diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 06ce4331679..540be48a1d7 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -7,9 +7,9 @@ If you believe you have discovered a vulnerability in EspoCRM, please contact us What reports we do not accept: - Executing PHP code by an extension, during extension installation or upgrade process. -- Exposing contacts through a target list, campaign or mass email, considering the user has access to them. +- Exposing contacts through a target list, campaign or mass email features, considering the user has access to these features. - SSRF in IMAP/SMTP with TOCTOU. -## Supported versions - -For severe vulnerabilities we provide fixes for 2 minor versions (the second number in the version string) back from the current stable version. +Submitting multiple unverified reports without a proper proof of concept +(for example, by simply copy-pasting LLM-generated output) may be considered abuse of the reporting process +and may result in the reporting account being blocked. diff --git a/.github/workflows/test-integration-pg.yml b/.github/workflows/test-integration-pg.yml index ff1ee559089..f2ee58aa3f5 100644 --- a/.github/workflows/test-integration-pg.yml +++ b/.github/workflows/test-integration-pg.yml @@ -42,9 +42,10 @@ jobs: ref: ${{ matrix.branches }} - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: '24' + cache: 'npm' - name: Setup PHP with Composer uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 6c69c796b69..33c82e79f68 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -36,9 +36,10 @@ jobs: ref: ${{ matrix.branches }} - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: '24' + cache: 'npm' - name: Setup PHP with Composer uses: shivammathur/setup-php@v2 diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 7b326b6735e..83cc5562a5e 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -30,8 +30,17 @@ namespace Espo; use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainerBuilder; use Espo\Core\Binding\BindingProcessor; use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; +use Espo\Core\Container; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\Cache\DataCacheServiceName as DataCacheServiceName; +use Espo\Core\Utils\Cache\FileCacheItemPool; +use Espo\Core\Utils\DataCache; +use Psr\Cache\CacheItemPoolInterface; /** * Default binding for the dependency injection framework. Custom binding should be set up in @@ -88,6 +97,8 @@ private function bindServices(Binder $binder): void 'fileManager' ); + $this->bindDataCacheServices($binder); + $binder->bindService( 'Espo\\ORM\\EntityManager', 'entityManager' @@ -143,6 +154,11 @@ private function bindServices(Binder $binder): void 'recordServiceContainer' ); + $binder->bindService( + 'Espo\\Core\\Hook\\DataProvider', + 'hookDataProvider' + ); + $binder->bindService( 'Espo\\Core\\HookManager', 'hookManager' @@ -173,16 +189,31 @@ private function bindServices(Binder $binder): void 'baseLanguage' ); + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'base'), + 'baseLanguage' + ); + $binder->bindService( NamedClassKey::create('Espo\\Core\\Utils\\Language', 'defaultLanguage'), 'defaultLanguage' ); + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'default'), + 'defaultLanguage' + ); + $binder->bindService( 'Espo\\Core\\Utils\\Language', 'language' ); + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'user'), + 'language' + ); + $binder->bindService( 'Espo\\Core\\Formula\\Manager', 'formulaManager' @@ -247,6 +278,31 @@ private function bindServices(Binder $binder): void 'Espo\\Core\\Session\\Session', 'session' ); + + $binder->bindService( + 'Espo\\Core\\Job\\JobManager', + 'jobManager' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\System\\SystemState', + 'systemState' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Event\\Configuration', + 'eventDispatcherConfiguration', + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Event\\EventTransport', + 'eventTransport' + ); + + $binder->bindService( + 'Espo\\Tools\\Pipeline\\PipelineDataProvider', + 'pipelineDataProvider' + ); } private function bindCore(Binder $binder): void @@ -265,6 +321,32 @@ private function bindCore(Binder $binder): void 'Espo\\Core\\Job\\JobScheduler\\Creator', 'Espo\\Core\\Job\\JobScheduler\\Creators\\EntityCreator', ); + + $binder->inContext('Espo\\Core\\Job\\JobManager', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\QueueProcessor', + 'Espo\\Core\\Job\\QueueProcessor\\QueueProcessors\\DefaultQueueProcessor', + ); + }); + + $binder->inContext('Espo\\Core\\Job\\Processing\\EnqueueDaemon', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\Processing\\Publisher', + 'Espo\\Core\\Job\\Processing\\RabbitMq\\Publisher', + ); + }); + + $binder->inContext('Espo\\Core\\Job\\Processing\\WorkerDaemon', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\Processing\\Consumer', + 'Espo\\Core\\Job\\Processing\\RabbitMq\\Consumer', + ); + }); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); } private function bindMisc(Binder $binder): void @@ -384,4 +466,32 @@ private function bindEmailAccount(Binder $binder): void 'Espo\\Core\\Mail\\Account\\GroupAccount\\StorageFactory' ); } + + private function bindDataCacheServices(Binder $binder): void + { + $binder->bindService( + DataCache::class, + DataCacheServiceName::APPLICATION + ); + + $binder->bindService( + QualifiedClassKey::create(DataCache::class, DataCache::QUALIFIER_SYSTEM), + DataCacheServiceName::SYSTEM + ); + + $binder->bindCallback( + QualifiedClassKey::create(DataCacheAccess::class, DataCache::QUALIFIER_SYSTEM), + function (InjectableFactory $injectableFactory, Container $container) { + return $injectableFactory->createWithBinding( + DataCacheAccess::class, + BindingContainerBuilder::create() + ->bindInstance( + DataCache::class, + $container->get(DataCacheServiceName::SYSTEM) + ) + ->build() + ); + } + ); + } } diff --git a/application/Espo/Classes/AppInfo/Container.php b/application/Espo/Classes/AppInfo/Container.php index 96b65394957..6424b7f529c 100644 --- a/application/Espo/Classes/AppInfo/Container.php +++ b/application/Espo/Classes/AppInfo/Container.php @@ -53,6 +53,7 @@ public function get(Params $params): string 'dataManager', 'metadata', 'user', + 'systemDataCache', ]; /** @var string[] $fileList */ diff --git a/application/Espo/Classes/ConsoleCommands/CronRun.php b/application/Espo/Classes/ConsoleCommands/CronRun.php new file mode 100644 index 00000000000..67f51ca51f1 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/CronRun.php @@ -0,0 +1,70 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\JobManager; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Utils\Config\SystemConfig; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class CronRun implements Command +{ + public function __construct( + private PrepareProcessor $prepareProcessor, + private JobManager $jobManager, + private SystemConfig $config, + ) {} + + public function run(Params $params, IO $io): void + { + if (!$this->config->isCronEnabled()) { + throw new RuntimeException("Cron cannot be run as 'cronDisabled' is set to true in the config."); + } + + if (!$params->hasFlag('force') && $this->config->isMaintenanceMode()) { + throw new RuntimeException("Cron cannot be run in maintenance mode. You can use --force flag."); + } + + try { + $this->prepareProcessor->process(); + } catch (TooFrequentRun $e) { + throw new RuntimeException('Too frequent run.', previous: $e); + } + + $this->jobManager->processMainQueue(); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobEnqueue.php b/application/Espo/Classes/ConsoleCommands/JobEnqueue.php new file mode 100644 index 00000000000..822645c91ad --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobEnqueue.php @@ -0,0 +1,122 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\EnqueueDaemon; +use Espo\Core\Job\QueueName; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobEnqueue implements Command +{ + public function __construct( + private EnqueueDaemon $enqueueDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $daemonParams = $this->prepareParams($params); + + $this->enqueueDaemon->run($daemonParams); + } + + private function prepareParams(Params $params): EnqueueDaemon\Params + { + $interval = $this->getInterval($params); + $limit = $this->getLimit($params); + $portion = $this->getPortion($params); + $queue = $params->getOption('queue'); + + if ($queue === QueueName::M0) { + throw new InvalidArgument("Queue 'm0' is not allowed. It is processed in the main queue."); + } + + return new EnqueueDaemon\Params( + interval: $interval, + limit: $limit, + portion: $portion, + queue: $queue, + ); + } + + private function getInterval(Params $params): ?float + { + $valueString = $params->getOption('interval'); + + if ($valueString === null) { + return null; + } + + if ( + filter_var($valueString, FILTER_VALIDATE_INT) !== false || + filter_var($valueString, FILTER_VALIDATE_FLOAT) !== false + ) { + return (float) $valueString; + } + + throw new RuntimeException("Bad interval."); + } + + private function getLimit(Params $params): ?int + { + $valueString = $params->getOption('limit'); + + if ($valueString === null) { + return null; + } + + if (filter_var($valueString, FILTER_VALIDATE_INT) !== false) { + return (int) $valueString; + } + + throw new RuntimeException("Bad limit."); + } + + private function getPortion(Params $params): ?int + { + $valueString = $params->getOption('portion'); + + if ($valueString === null) { + return null; + } + + if (filter_var($valueString, FILTER_VALIDATE_INT) !== false) { + return (int) $valueString; + } + + throw new RuntimeException("Bad portion."); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobPrepare.php b/application/Espo/Classes/ConsoleCommands/JobPrepare.php new file mode 100644 index 00000000000..1d1d538d700 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobPrepare.php @@ -0,0 +1,94 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\PrepareDaemon; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobPrepare implements Command +{ + public function __construct( + private PrepareDaemon $prepareDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $interval = $this->getInterval($params); + $limit = $this->getLimit($params); + $skipQueues = $params->hasFlag('sq'); + + $daemonParams = new PrepareDaemon\Params( + interval: $interval, + limit: $limit, + skipQueues: $skipQueues, + ); + + $this->prepareDaemon->run($daemonParams); + } + + private function getInterval(Params $params): ?float + { + $intervalString = $params->getOption('interval'); + + if ($intervalString === null) { + return null; + } + + if ( + filter_var($intervalString, FILTER_VALIDATE_INT) !== false || + filter_var($intervalString, FILTER_VALIDATE_FLOAT) !== false + ) { + return (float) $intervalString; + } else { + throw new RuntimeException("Bad interval."); + } + } + + private function getLimit(Params $params): ?int + { + $limitString = $params->getOption('limit'); + + if ($limitString === null) { + return null; + } + + if (filter_var($limitString, FILTER_VALIDATE_INT) !== false) { + return (int) $limitString; + } else { + throw new RuntimeException("Bad limit."); + } + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobWorker.php b/application/Espo/Classes/ConsoleCommands/JobWorker.php new file mode 100644 index 00000000000..2733b188deb --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobWorker.php @@ -0,0 +1,86 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\WorkerDaemon; +use Espo\Core\Job\QueueName; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobWorker implements Command +{ + public function __construct( + private WorkerDaemon $workerDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $daemonParams = $this->prepareParams($params); + + $this->workerDaemon->run($daemonParams); + } + + private function prepareParams(Params $params): WorkerDaemon\Params + { + $limit = $this->getLimit($params); + + $queue = $params->getOption('queue'); + + if ($queue === QueueName::M0) { + throw new InvalidArgument("Queue 'm0' is not allowed. It is processed in the main queue."); + } + + return new WorkerDaemon\Params( + limit: $limit, + queue: $queue, + ); + } + + private function getLimit(Params $params): ?int + { + $limitString = $params->getOption('limit'); + + if ($limitString === null) { + return null; + } + + if (filter_var($limitString, FILTER_VALIDATE_INT) !== false) { + return (int) $limitString; + } + + throw new RuntimeException("Bad limit."); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php index cec42b951f1..68e23dcd311 100644 --- a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php @@ -29,6 +29,7 @@ namespace Espo\Classes\FieldProcessing\CurrencyRecord; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\FieldProcessing\Loader; use Espo\Core\FieldProcessing\Loader\Params; use Espo\Core\Utils\Language; @@ -42,7 +43,8 @@ class Label implements Loader { public function __construct( - private Language $defaultLanguage + #[Qualify(Language::QUALIFIER_DEFAULT)] + private Language $defaultLanguage, ) {} public function process(Entity $entity, Params $params): void diff --git a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php index 3e3a57fc342..4141a9310c3 100644 --- a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php +++ b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php @@ -141,7 +141,6 @@ private function processFormUrl(LeadCapture $entity): void $siteUrl = $this->getSiteUrl(); if (!$entity->hasFormEnabled() || !$formId) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('formUrl', null); return; diff --git a/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php b/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php index baf64511142..c19df6657c9 100644 --- a/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php +++ b/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php @@ -115,12 +115,10 @@ private function controlEntityValues(EmailFilter $entity): void } if ($entity->getAction() !== EmailFilter::ACTION_MOVE_TO_FOLDER) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('emailFolderId', null); } if ($entity->getAction() !== EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('groupEmailFolderId', null); } } diff --git a/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php index 79155fdf9cd..8133609b2b0 100644 --- a/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php +++ b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php @@ -30,7 +30,6 @@ namespace Espo\Classes\RecordHooks\Team; use Espo\Core\Acl\Cache\Clearer; -use Espo\Core\DataManager; use Espo\Core\Record\Hook\UnlinkHook; use Espo\Entities\Team; use Espo\Entities\User; @@ -43,7 +42,6 @@ class ClearCacheAfterUnlink implements UnlinkHook { public function __construct( private Clearer $clearer, - private DataManager $dataManager ) {} public function process(Entity $entity, string $link, Entity $foreignEntity): void @@ -53,6 +51,5 @@ public function process(Entity $entity, string $link, Entity $foreignEntity): vo } $this->clearer->clearForUser($foreignEntity); - $this->dataManager->updateCacheTimestamp(); } } diff --git a/application/Espo/Classes/RecordHooks/User/AfterUpdate.php b/application/Espo/Classes/RecordHooks/User/AfterUpdate.php index 3026bda6e15..64c33481ebb 100644 --- a/application/Espo/Classes/RecordHooks/User/AfterUpdate.php +++ b/application/Espo/Classes/RecordHooks/User/AfterUpdate.php @@ -30,7 +30,6 @@ namespace Espo\Classes\RecordHooks\User; use Espo\Core\Acl\Cache\Clearer; -use Espo\Core\DataManager; use Espo\Core\Record\Hook\SaveHook; use Espo\Modules\Crm\Entities\Contact; use Espo\ORM\Entity; @@ -46,7 +45,6 @@ class AfterUpdate implements SaveHook public function __construct( private EntityManager $entityManager, private Clearer $clearer, - private DataManager $dataManager ) {} public function process(Entity $entity): void @@ -61,21 +59,12 @@ private function processCache(User $entity): void $entity->isAttributeChanged('rolesIds') || $entity->isAttributeChanged('teamsIds') || $entity->isAttributeChanged('type') || - $entity->isAttributeChanged('portalRolesIds') || - $entity->isAttributeChanged('portalsIds') - ) { - $this->clearer->clearForUser($entity); - $this->dataManager->updateCacheTimestamp(); - } - - if ( $entity->isAttributeChanged('portalRolesIds') || $entity->isAttributeChanged('portalsIds') || $entity->isAttributeChanged('contactId') || $entity->isAttributeChanged('accountsIds') ) { - $this->clearer->clearForAllPortalUsers(); - $this->dataManager->updateCacheTimestamp(); + $this->clearer->clearForUser($entity); } } diff --git a/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php b/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php index c36d22cdf1a..4f85cc73564 100644 --- a/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php +++ b/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php @@ -167,7 +167,6 @@ private function processEntityEventData(Webhook $entity): void return; } - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set('field', null); } diff --git a/application/Espo/Core/Acl/Cache/Clearer.php b/application/Espo/Core/Acl/Cache/Clearer.php index 6e2d5af982a..d819727246d 100644 --- a/application/Espo/Core/Acl/Cache/Clearer.php +++ b/application/Espo/Core/Acl/Cache/Clearer.php @@ -29,30 +29,39 @@ namespace Espo\Core\Acl\Cache; +use Espo\Core\Portal\Acl\Events\PortalUserRoleUpdate; +use Espo\Core\Acl\Events\UserRoleUpdate; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\System\SystemState; use Espo\Entities\Portal; use Espo\Entities\User; use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; -/** - * @todo Clear cache in AclManager. - */ class Clearer { - public function __construct(private FileManager $fileManager, private EntityManager $entityManager) - {} + public function __construct( + private FileManager $fileManager, + private EntityManager $entityManager, + private SystemState $systemState, + private EventDispatcher $eventDispatcher, + ) {} public function clearForAllInternalUsers(): void { $this->fileManager->removeInDir('data/cache/application/acl'); $this->fileManager->removeInDir('data/cache/application/aclMap'); + + $this->bumpSystemStateVersionNumber(); } public function clearForAllPortalUsers(): void { $this->fileManager->removeInDir('data/cache/application/aclPortal'); $this->fileManager->removeInDir('data/cache/application/aclPortalMap'); + + $this->bumpSystemStateVersionNumber(); } public function clearForUser(User $user): void @@ -67,6 +76,8 @@ public function clearForUser(User $user): void $this->fileManager->remove('data/cache/application/acl/' . $part); $this->fileManager->remove('data/cache/application/aclMap/' . $part); + + $this->eventDispatcher->dispatch(new UserRoleUpdate($user->getId())); } private function clearForPortalUser(User $user): void @@ -81,6 +92,18 @@ private function clearForPortalUser(User $user): void $this->fileManager->remove('data/cache/application/aclPortal/' . $part); $this->fileManager->remove('data/cache/application/aclPortalMap/' . $part); + + $event = new PortalUserRoleUpdate( + userId: $user->getId(), + portalId: $portal->getId(), + ); + + $this->eventDispatcher->dispatch($event); } } + + private function bumpSystemStateVersionNumber(): void + { + $this->systemState->bumpVersionNumber(); + } } diff --git a/application/Espo/Core/Acl/Events/UserRoleUpdate.php b/application/Espo/Core/Acl/Events/UserRoleUpdate.php new file mode 100644 index 00000000000..a0e62a82a6a --- /dev/null +++ b/application/Espo/Core/Acl/Events/UserRoleUpdate.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; +use UnexpectedValueException; + +/** + * @since 10.1.0 + */ +readonly class UserRoleUpdate implements CrossInstanceEvent +{ + public function __construct( + public string $userId, + ) {} + + public static function fromPayload(stdClass $payload): self + { + $userId = $payload->userId ?? null; + + if (!is_string($userId)) { + throw new UnexpectedValueException(); + } + + return new self( + userId: $userId, + ); + } + + public function toPayload(): stdClass + { + return (object) [ + 'userId' => $this->userId, + ]; + } +} diff --git a/application/Espo/Core/Acl/GlobalRestriction.php b/application/Espo/Core/Acl/GlobalRestriction.php index 4a6cc542446..82490288466 100644 --- a/application/Espo/Core/Acl/GlobalRestriction.php +++ b/application/Espo/Core/Acl/GlobalRestriction.php @@ -29,8 +29,7 @@ namespace Espo\Core\Acl; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Metadata; @@ -43,15 +42,15 @@ class GlobalRestriction { /** Totally forbidden. */ - public const TYPE_FORBIDDEN = 'forbidden'; + public const string TYPE_FORBIDDEN = 'forbidden'; /** Reading forbidden, writing allowed. */ - public const TYPE_INTERNAL = 'internal'; + public const string TYPE_INTERNAL = 'internal'; /** Forbidden for non-admin users. */ - public const TYPE_ONLY_ADMIN = 'onlyAdmin'; + public const string TYPE_ONLY_ADMIN = 'onlyAdmin'; /** Read-only for all users. */ - public const TYPE_READ_ONLY = 'readOnly'; + public const string TYPE_READ_ONLY = 'readOnly'; /** Read-only for non-admin users. */ - public const TYPE_NON_ADMIN_READ_ONLY = 'nonAdminReadOnly'; + public const string TYPE_NON_ADMIN_READ_ONLY = 'nonAdminReadOnly'; /** * @var array @@ -83,45 +82,21 @@ class GlobalRestriction self::TYPE_READ_ONLY, ]; - private ?stdClass $data = null; - private string $cacheKey = 'entityAcl'; public function __construct( private Metadata $metadata, - private DataCache $dataCache, private FieldUtil $fieldUtil, - SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $useCache = $systemConfig->useCache(); - - if ($useCache && $this->dataCache->has($this->cacheKey)) { - /** @var stdClass $cachedData */ - $cachedData = $this->dataCache->get($this->cacheKey); - - $this->data = $cachedData; - - return; - } - - if (!$this->data) { - $this->buildData(); - } - - if ($useCache) { - $this->storeCacheFile(); - } - } - - private function storeCacheFile(): void - { - assert($this->data !== null); - - $this->dataCache->store($this->cacheKey, $this->data); + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: fn () => $this->buildData(), + ); } - private function buildData(): void + private function buildData(): stdClass { /** @var string[] $scopeList */ $scopeList = array_keys($this->metadata->get(['entityDefs']) ?? []); @@ -211,7 +186,7 @@ private function buildData(): void } } - $this->data = $data; + return $data; } /** @@ -220,21 +195,9 @@ private function buildData(): void */ public function getScopeRestrictedFieldList(string $scope, string $type): array { - assert($this->data !== null); - - if (!property_exists($this->data, $scope)) { - return []; - } + $data = $this->dataCacheAccess->get(); - if (!property_exists($this->data->$scope, 'fields')) { - return []; - } - - if (!property_exists($this->data->$scope->fields, $type)) { - return []; - } - - return $this->data->$scope->fields->$type; + return $data->$scope->fields->$type ?? []; } /** @@ -243,21 +206,9 @@ public function getScopeRestrictedFieldList(string $scope, string $type): array */ public function getScopeRestrictedAttributeList(string $scope, string $type): array { - assert($this->data !== null); - - if (!property_exists($this->data, $scope)) { - return []; - } - - if (!property_exists($this->data->$scope, 'attributes')) { - return []; - } + $data = $this->dataCacheAccess->get(); - if (!property_exists($this->data->$scope->attributes, $type)) { - return []; - } - - return $this->data->$scope->attributes->$type; + return $data->$scope->attributes->$type ?? []; } /** @@ -266,20 +217,8 @@ public function getScopeRestrictedAttributeList(string $scope, string $type): ar */ public function getScopeRestrictedLinkList(string $scope, string $type): array { - assert($this->data !== null); - - if (!property_exists($this->data, $scope)) { - return []; - } - - if (!property_exists($this->data->$scope, 'links')) { - return []; - } - - if (!property_exists($this->data->$scope->links, $type)) { - return []; - } + $data = $this->dataCacheAccess->get(); - return $this->data->$scope->links->$type; + return $data->$scope->links->$type ?? []; } } diff --git a/application/Espo/Core/Acl/Map/Map.php b/application/Espo/Core/Acl/Map/Map.php index 55f5103926c..49f2ab823bd 100644 --- a/application/Espo/Core/Acl/Map/Map.php +++ b/application/Espo/Core/Acl/Map/Map.php @@ -30,10 +30,8 @@ namespace Espo\Core\Acl\Map; use Espo\Core\Acl\Table; -use Espo\Core\Utils\Config; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\ObjectUtil; - use stdClass; use RuntimeException; @@ -42,8 +40,6 @@ */ class Map { - private stdClass $data; - private string $cacheKey; /** @var array */ private $forbiddenFieldsCache = []; /** @var array */ @@ -54,28 +50,20 @@ class Map Table::LEVEL_NO, ]; + /** + * @param DataCacheAccess $dataCacheAccess + */ public function __construct( Table $table, private DataBuilder $dataBuilder, - private DataCache $dataCache, CacheKeyProvider $cacheKeyProvider, - Config\SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $this->cacheKey = $cacheKeyProvider->get(); - - if ($systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var stdClass $cachedData */ - $cachedData = $this->dataCache->get($this->cacheKey); - - $this->data = $cachedData; - } else { - $this->data = $this->dataBuilder->build($table); - - if ($systemConfig->useCache()) { - $this->dataCache->store($this->cacheKey, $this->data); - } - } + $this->dataCacheAccess->init( + key: $cacheKeyProvider->get(), + loader: fn () => $this->dataBuilder->build($table), + ); } /** @@ -83,7 +71,7 @@ public function __construct( */ public function getData(): stdClass { - return ObjectUtil::clone($this->data); + return ObjectUtil::clone($this->dataCacheAccess->get()); } /** @@ -114,7 +102,9 @@ public function getScopeForbiddenAttributeList( return $this->forbiddenAttributesCache[$key]; } - $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; + $data = $this->dataCacheAccess->get(); + + $fieldTableQuickAccess = $data->fieldTableQuickAccess; if ( !isset($fieldTableQuickAccess->$scope) || @@ -186,7 +176,9 @@ public function getScopeForbiddenFieldList( return $this->forbiddenFieldsCache[$key]; } - $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; + $data = $this->dataCacheAccess->get(); + + $fieldTableQuickAccess = $data->fieldTableQuickAccess; if ( !isset($fieldTableQuickAccess->$scope) || diff --git a/application/Espo/Core/Acl/Table/DefaultTable.php b/application/Espo/Core/Acl/Table/DefaultTable.php index 8c3aae51edb..afa155bf764 100644 --- a/application/Espo/Core/Acl/Table/DefaultTable.php +++ b/application/Espo/Core/Acl/Table/DefaultTable.php @@ -115,12 +115,18 @@ public function __construct( $this->cacheKey = $cacheKeyProvider->get(); + $cachedData = null; + if ($systemConfig->useCache() && $dataCache->has($this->cacheKey)) { - /** @var stdClass $cachedData */ + /** @var ?stdClass $cachedData */ $cachedData = $dataCache->get($this->cacheKey); - $this->data = $cachedData; - } else { + if ($cachedData !== null) { + $this->data = $cachedData; + } + } + + if ($cachedData === null) { $this->load(); if ($systemConfig->useCache()) { diff --git a/application/Espo/Core/AclManager.php b/application/Espo/Core/AclManager.php index 832018e1a01..f132700f728 100644 --- a/application/Espo/Core/AclManager.php +++ b/application/Espo/Core/AclManager.php @@ -29,9 +29,11 @@ namespace Espo\Core; +use Espo\Core\Acl\Events\UserRoleUpdate; use Espo\Core\Acl\OwnershipSharedChecker; use Espo\Core\Acl\Permission; use Espo\Core\Name\Field; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\ORM\Entity; use Espo\ORM\EntityManager; use Espo\Entities\User; @@ -119,12 +121,15 @@ public function __construct( MapFactory $mapFactory, protected GlobalRestriction $globalRestriction, protected OwnerUserFieldProvider $ownerUserFieldProvider, - protected EntityManager $entityManager + protected EntityManager $entityManager, + protected EventDispatcher $eventDispatcher, ) { $this->accessCheckerFactory = $accessCheckerFactory; $this->ownershipCheckerFactory = $ownershipCheckerFactory; $this->tableFactory = $tableFactory; $this->mapFactory = $mapFactory; + + $this->initEventHandling(); } /** @@ -782,4 +787,14 @@ public function checkUser(User $user, string $permission, User $target): bool return false; } + + protected function initEventHandling(): void + { + $this->eventDispatcher->subscribe(UserRoleUpdate::class, function (UserRoleUpdate $event) { + $userId = $event->userId; + + unset($this->tableHashMap[$userId]); + unset($this->mapHashMap[$userId]); + }); + } } diff --git a/application/Espo/Core/Action/Actions/Merge/Merger.php b/application/Espo/Core/Action/Actions/Merge/Merger.php index 605fca61ac8..36208fe128c 100644 --- a/application/Espo/Core/Action/Actions/Merge/Merger.php +++ b/application/Espo/Core/Action/Actions/Merge/Merger.php @@ -105,7 +105,7 @@ private function processInternal(Params $params, array $sourceIdList, stdClass $ $service->filterUpdateInput($clonedData); - $entity->set($clonedData); + $entity->setMultiple($clonedData); $this->unsetNotActualAttributes($entity); diff --git a/application/Espo/Core/ApplicationRunners/Cron.php b/application/Espo/Core/ApplicationRunners/Cron.php index c203f8f24ec..a074e54c644 100644 --- a/application/Espo/Core/ApplicationRunners/Cron.php +++ b/application/Espo/Core/ApplicationRunners/Cron.php @@ -29,8 +29,11 @@ namespace Espo\Core\ApplicationRunners; +use Espo\Core\Application\Exceptions\RunnerException; use Espo\Core\Application\Runner; +use Espo\Core\Job\Exceptions\TooFrequentRun; use Espo\Core\Job\JobManager; +use Espo\Core\Job\PrepareProcessor; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\Log; @@ -43,19 +46,32 @@ class Cron implements Runner use SetupSystemUser; public function __construct( + private PrepareProcessor $prepareProcessor, private JobManager $jobManager, private SystemConfig $config, - private Log $log + private Log $log, ) {} public function run(): void { if (!$this->config->isCronEnabled()) { - $this->log->warning("Cron is not run because it's disabled with 'cronDisabled' param."); + $this->log->warning("Cron is skipped as 'cronDisabled' is set to true in the config."); return; } - $this->jobManager->process(); + if ($this->config->isMaintenanceMode()) { + $this->log->warning("Cron run is skipped in maintenance mode."); + + return; + } + + try { + $this->prepareProcessor->process(); + } catch (TooFrequentRun $e) { + throw new RunnerException('Too frequent run.', previous: $e); + } + + $this->jobManager->processMainQueue(); } } diff --git a/application/Espo/Core/Authentication/ConfigDataProvider.php b/application/Espo/Core/Authentication/ConfigDataProvider.php index ec17647d1ad..ac2506fbe60 100644 --- a/application/Espo/Core/Authentication/ConfigDataProvider.php +++ b/application/Espo/Core/Authentication/ConfigDataProvider.php @@ -43,8 +43,11 @@ class ConfigDataProvider private const string USERNAME_FAILED_ATTEMPTS_PERIOD = '60 seconds'; private const int USERNAME_FAILED_ATTEMPT_DELAY = 2; - public function __construct(private Config $config, private Metadata $metadata) - {} + public function __construct( + private Config $config, + private Metadata $metadata, + private Config\SystemConfig $systemConfig, + ) {} /** * A period for max failed attempts checking. @@ -106,7 +109,7 @@ public function isAuthTokenSecretDisabled(): bool */ public function isMaintenanceMode(): bool { - return (bool) $this->config->get('maintenanceMode'); + return $this->systemConfig->isMaintenanceMode(); } /** diff --git a/application/Espo/Core/Authentication/Ldap/LdapLogin.php b/application/Espo/Core/Authentication/Ldap/LdapLogin.php index bba79390717..437263c543b 100644 --- a/application/Espo/Core/Authentication/Ldap/LdapLogin.php +++ b/application/Espo/Core/Authentication/Ldap/LdapLogin.php @@ -30,6 +30,7 @@ namespace Espo\Core\Authentication\Ldap; use Espo\Core\Api\Util; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver; use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver; use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver; @@ -73,6 +74,7 @@ public function __construct( private Config $config, private EntityManager $entityManager, private PasswordHash $passwordHash, + #[Qualify(Language::QUALIFIER_DEFAULT)] Language $defaultLanguage, private Log $log, private Espo $baseLogin, diff --git a/application/Espo/Core/Binding/Attributes/Qualify.php b/application/Espo/Core/Binding/Attributes/Qualify.php new file mode 100644 index 00000000000..ac113a96efb --- /dev/null +++ b/application/Espo/Core/Binding/Attributes/Qualify.php @@ -0,0 +1,45 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Binding\Attributes; + +use Attribute; + +/** + * Assigns a qualifier to a constructor parameter. + * + * @since 10.1.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +readonly class Qualify +{ + public function __construct( + public string $qualifier, + ) {} +} diff --git a/application/Espo/Core/Binding/Binder.php b/application/Espo/Core/Binding/Binder.php index 34d2dd27c50..d7d203b6494 100644 --- a/application/Espo/Core/Binding/Binder.php +++ b/application/Espo/Core/Binding/Binder.php @@ -30,6 +30,7 @@ namespace Espo\Core\Binding; use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; use LogicException; use Closure; @@ -42,18 +43,19 @@ public function __construct(private BindingData $data) * Bind an interface to an implementation. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string $implementationClassName An implementation class name. */ - public function bindImplementation(string|NamedClassKey $key, string $implementationClassName): self - { + public function bindImplementation( + string|NamedClassKey|QualifiedClassKey $key, + string $implementationClassName, + ): self { + $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromImplementationClassName($implementationClassName) - ); + $this->data->addGlobal($key, Binding::createFromImplementationClassName($implementationClassName)); return $this; } @@ -61,18 +63,16 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa /** * Bind an interface to a specific service. * - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param string $serviceName A service name. */ - public function bindService(string|NamedClassKey $key, string $serviceName): self + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self { $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromServiceName($serviceName) - ); + $this->data->addGlobal($key, Binding::createFromServiceName($serviceName)); return $this; } @@ -81,19 +81,17 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel * Bind an interface to a callback. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param Closure $callback A callback that will resolve a dependency. * @todo Change to Closure(...): T Once https://github.com/phpstan/phpstan/issues/8214 is implemented. */ - public function bindCallback(string|NamedClassKey $key, Closure $callback): self + public function bindCallback(string|NamedClassKey|QualifiedClassKey $key, Closure $callback): self { $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromCallback($callback) - ); + $this->data->addGlobal($key, Binding::createFromCallback($callback)); return $this; } @@ -102,19 +100,16 @@ public function bindCallback(string|NamedClassKey $key, Closure $callback): self * Bind an interface to a specific instance. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param T $instance An instance. - * @noinspection PhpDocSignatureInspection */ - public function bindInstance(string|NamedClassKey $key, object $instance): self + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self { $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromValue($instance) - ); + $this->data->addGlobal($key, Binding::createFromValue($instance)); return $this; } @@ -123,18 +118,16 @@ public function bindInstance(string|NamedClassKey $key, object $instance): self * Bind an interface to a factory. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string> $factoryClassName A factory class name. */ - public function bindFactory(string|NamedClassKey $key, string $factoryClassName): self + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self { $key = self::keyToString($key); $this->validateBindingKey($key); - $this->data->addGlobal( - $key, - Binding::createFromFactoryClassName($factoryClassName) - ); + $this->data->addGlobal($key, Binding::createFromFactoryClassName($factoryClassName)); return $this; } @@ -165,9 +158,9 @@ public function for(string $className): ContextualBinder } /** - * @param string|NamedClassKey $key + * @param string|NamedClassKey|QualifiedClassKey $key */ - private static function keyToString(string|NamedClassKey $key): string + private static function keyToString(string|NamedClassKey|QualifiedClassKey $key): string { return is_string($key) ? $key : $key->toString(); } @@ -181,5 +174,9 @@ private function validateBindingKey(string $key): void if ($key[0] === '$') { throw new LogicException("Can't binding a parameter name w/o an interface globally."); } + + if ($key[0] === '#') { + throw new LogicException("Can't binding a qualification name w/o an interface globally."); + } } } diff --git a/application/Espo/Core/Binding/Binding.php b/application/Espo/Core/Binding/Binding.php index b6f4dfdc530..7e8175fc3fa 100644 --- a/application/Espo/Core/Binding/Binding.php +++ b/application/Espo/Core/Binding/Binding.php @@ -33,20 +33,17 @@ class Binding { - public const IMPLEMENTATION_CLASS_NAME = 1; - public const CONTAINER_SERVICE = 2; - public const VALUE = 3; - public const CALLBACK = 4; - public const FACTORY_CLASS_NAME = 5; + public const int IMPLEMENTATION_CLASS_NAME = 1; + public const int CONTAINER_SERVICE = 2; + public const int VALUE = 3; + public const int CALLBACK = 4; + public const int FACTORY_CLASS_NAME = 5; private int $type; - /** @var mixed */ - private $value; - /** - * @param mixed $value - */ - private function __construct(int $type, $value) + private mixed $value; + + private function __construct(int $type, mixed $value) { $this->type = $type; $this->value = $value; @@ -57,10 +54,7 @@ public function getType(): int return $this->type; } - /** - * @return mixed - */ - public function getValue() + public function getValue(): mixed { return $this->value; } diff --git a/application/Espo/Core/Binding/BindingContainer.php b/application/Espo/Core/Binding/BindingContainer.php index 7ec045f2b97..f8be6bdf46b 100644 --- a/application/Espo/Core/Binding/BindingContainer.php +++ b/application/Espo/Core/Binding/BindingContainer.php @@ -29,6 +29,7 @@ namespace Espo\Core\Binding; +use Espo\Core\Binding\Attributes\Qualify; use ReflectionClass; use ReflectionParameter; use ReflectionNamedType; @@ -67,8 +68,7 @@ public function getByParam(?ReflectionClass $class, ReflectionParameter $param): throw new LogicException("Cannot get not existing binding."); } - /** @var Binding */ - return $this->getInternal($class, $param); + return $this->getInternal($class, $param) ?? throw new LogicException(); } /** @@ -104,74 +104,119 @@ public function getByInterface(string $interfaceName): Binding */ private function getInternal(?ReflectionClass $class, ReflectionParameter $param): ?Binding { - $className = null; + if ($class) { + $binding = $this->getInternalContextualNamed($class, $param); - $key = null; + if ($binding) { + return $binding; + } + } - if ($class) { - $className = $class->getName(); + $paramClassName = $this->getClassNameFromParameterType($param->getType()); - $key = '$' . $param->getName(); + if ($paramClassName === null) { + return null; } - $type = $param->getType(); + $qualifiedName = $this->getQualifiedName($param); - if ( - $className && - $key && - $this->data->hasContext($className, $key) - ) { - $binding = $this->data->getContext($className, $key); + if ($qualifiedName) { + $keyQualified = $paramClassName . ' #' . $qualifiedName; - $notMatching = - $type instanceof ReflectionNamedType && - !$type->isBuiltin() && - $binding->getType() === Binding::VALUE && - is_scalar($binding->getValue()); + $binding = $this->getInternalByClassNameKey($class?->getName(), $keyQualified); - if (!$notMatching) { + if ($binding) { return $binding; } } - $dependencyClassName = null; + $keyWithName = $paramClassName . ' $' . $param->getName(); - if ( - $type instanceof ReflectionNamedType && - !$type->isBuiltin() - ) { - $dependencyClassName = $type->getName(); + $binding = $this->getInternalByClassNameKey($class?->getName(), $keyWithName); + + if ($binding) { + return $binding; } - $key = null; - $keyWithParamName = null; + $key = $paramClassName; - if ($dependencyClassName) { - $key = $dependencyClassName; + $binding = $this->getInternalByClassNameKey($class?->getName(), $key); - $keyWithParamName = $key . ' $' . $param->getName(); + if ($binding) { + return $binding; } - if ($keyWithParamName) { - if ($className && $this->data->hasContext($className, $keyWithParamName)) { - return $this->data->getContext($className, $keyWithParamName); - } + return null; + } - if ($this->data->hasGlobal($keyWithParamName)) { - return $this->data->getGlobal($keyWithParamName); - } + private function getQualifiedName(ReflectionParameter $param): ?string + { + $qualifierClass = $param->getAttributes(Qualify::class)[0] ?? null; + + if (!$qualifierClass) { + return null; } - if ($key) { - if ($className && $this->data->hasContext($className, $key)) { - return $this->data->getContext($className, $key); - } + $qualifier = $qualifierClass->newInstance(); - if ($this->data->hasGlobal($key)) { - return $this->data->getGlobal($key); - } + return $qualifier->qualifier; + } + + /** + * @param ReflectionClass $class + */ + private function getInternalContextualNamed(ReflectionClass $class, ReflectionParameter $param): ?Binding + { + $key = '$' . $param->getName(); + + if (!$this->data->hasContext($class->getName(), $key)) { + return null; + } + + $type = $param->getType(); + + $binding = $this->data->getContext($class->getName(), $key); + + $notMatching = + $type instanceof ReflectionNamedType && + !$type->isBuiltin() && + $binding->getType() === Binding::VALUE && + is_scalar($binding->getValue()); + + if ($notMatching) { + return null; + } + + return $binding; + } + + /** + * @param ?class-string $className + */ + private function getInternalByClassNameKey(?string $className, string $key): ?Binding + { + if ($className && $this->data->hasContext($className, $key)) { + return $this->data->getContext($className, $key); + } + + if ($this->data->hasGlobal($key)) { + return $this->data->getGlobal($key); } return null; } + + private function getClassNameFromParameterType(mixed $type): ?string + { + $dependencyClassName = null; + + if ( + $type instanceof ReflectionNamedType && + !$type->isBuiltin() + ) { + $dependencyClassName = $type->getName(); + } + + return $dependencyClassName; + } } diff --git a/application/Espo/Core/Binding/BindingContainerBuilder.php b/application/Espo/Core/Binding/BindingContainerBuilder.php index b99b3c389af..a79c5a29ed9 100644 --- a/application/Espo/Core/Binding/BindingContainerBuilder.php +++ b/application/Espo/Core/Binding/BindingContainerBuilder.php @@ -31,6 +31,7 @@ use Closure; use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; class BindingContainerBuilder { @@ -47,10 +48,11 @@ public function __construct() * Bind an interface to an implementation. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string $implementationClassName An implementation class name. */ - public function bindImplementation(string|NamedClassKey $key, string $implementationClassName): self + public function bindImplementation(string|NamedClassKey|QualifiedClassKey $key, string $implementationClassName): self { $this->binder->bindImplementation($key, $implementationClassName); @@ -60,11 +62,12 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa /** * Bind an interface to a specific service. * - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param string $serviceName A service name. * @noinspection PhpUnused */ - public function bindService(string|NamedClassKey $key, string $serviceName): self + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self { $this->binder->bindService($key, $serviceName); @@ -75,12 +78,13 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel * Bind an interface to a callback. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param Closure $callback A callback that will resolve a dependency. * @todo Change to Closure(...): T Once https://github.com/phpstan/phpstan/issues/8214 is implemented. * @noinspection PhpUnused */ - public function bindCallback(string|NamedClassKey $key, Closure $callback): self + public function bindCallback(string|NamedClassKey|QualifiedClassKey $key, Closure $callback): self { $this->binder->bindCallback($key, $callback); @@ -91,11 +95,11 @@ public function bindCallback(string|NamedClassKey $key, Closure $callback): self * Bind an interface to a specific instance. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param T $instance An instance. - * @noinspection PhpDocSignatureInspection */ - public function bindInstance(string|NamedClassKey $key, object $instance): self + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self { $this->binder->bindInstance($key, $instance); @@ -106,11 +110,12 @@ public function bindInstance(string|NamedClassKey $key, object $instance): self * Bind an interface to a factory. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string> $factoryClassName A factory class name. * @noinspection PhpUnused */ - public function bindFactory(string|NamedClassKey $key, string $factoryClassName): self + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self { $this->binder->bindFactory($key, $factoryClassName); diff --git a/application/Espo/Core/Binding/ContextualBinder.php b/application/Espo/Core/Binding/ContextualBinder.php index 335edf8ce7d..e3cfd26fcfc 100644 --- a/application/Espo/Core/Binding/ContextualBinder.php +++ b/application/Espo/Core/Binding/ContextualBinder.php @@ -32,40 +32,38 @@ use Closure; use Espo\Core\Binding\Key\NamedClassKey; use Espo\Core\Binding\Key\NamedKey; +use Espo\Core\Binding\Key\QualifiedClassKey; use LogicException; class ContextualBinder { - private BindingData $data; - /** @var class-string */ - private string $className; - /** * @param class-string $className */ - public function __construct(BindingData $data, string $className) - { - $this->data = $data; - $this->className = $className; - } + public function __construct( + private BindingData $data, + private string $className, + ) {} /** * Bind an interface to an implementation. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string $implementationClassName An implementation class name. */ - public function bindImplementation(string|NamedClassKey $key, string $implementationClassName): self - { + public function bindImplementation( + string|NamedClassKey|QualifiedClassKey $key, + string $implementationClassName, + ): self { + $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromImplementationClassName($implementationClassName) - ); + $binding = Binding::createFromImplementationClassName($implementationClassName); + + $this->data->addContext($this->className, $key, $binding); return $this; } @@ -74,19 +72,16 @@ public function bindImplementation(string|NamedClassKey $key, string $implementa * Bind an interface to a specific service. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param string $serviceName A service name. */ - public function bindService(string|NamedClassKey $key, string $serviceName): self + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self { $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromServiceName($serviceName) - ); + $this->data->addContext($this->className, $key, Binding::createFromServiceName($serviceName)); return $this; } @@ -94,7 +89,8 @@ public function bindService(string|NamedClassKey $key, string $serviceName): sel /** * Bind an interface or parameter name to a specific value. * - * @param string|NamedKey|NamedClassKey $key Parameter name (`$name`) or interface with a parameter name. + * @param string|NamedKey|NamedClassKey $key + * A parameter name (`$name`) or an interface with a parameter name. * @param mixed $value A value of any type. */ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self @@ -102,11 +98,7 @@ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self $key = self::keyToString($key); $this->validateBindingKeyParameterName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromValue($value) - ); + $this->data->addContext($this->className, $key, Binding::createFromValue($value)); return $this; } @@ -115,20 +107,16 @@ public function bindValue(string|NamedKey|NamedClassKey $key, $value): self * Bind an interface to a specific instance. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param T $instance An instance. - * @noinspection PhpDocSignatureInspection */ - public function bindInstance(string|NamedClassKey $key, object $instance): self + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self { $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromValue($instance) - ); + $this->data->addContext($this->className, $key, Binding::createFromValue($instance)); return $this; } @@ -136,20 +124,17 @@ public function bindInstance(string|NamedClassKey $key, object $instance): self /** * Bind an interface or parameter name to a callback. * - * @param class-string|NamedClassKey|NamedKey $key An interface, parameter name or both. + * @param class-string|NamedClassKey|NamedKey|QualifiedClassKey $key + * An interface, parameter name or both. * @param Closure $callback A callback that will resolve a dependency. * @todo Change to Closure(...): mixed Once https://github.com/phpstan/phpstan/issues/8214 is implemented. */ - public function bindCallback(string|NamedClassKey|NamedKey $key, Closure $callback): self + public function bindCallback(string|NamedClassKey|NamedKey|QualifiedClassKey $key, Closure $callback): self { $key = self::keyToString($key); $this->validateBinding($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromCallback($callback) - ); + $this->data->addContext($this->className, $key, Binding::createFromCallback($callback)); return $this; } @@ -158,19 +143,16 @@ public function bindCallback(string|NamedClassKey|NamedKey $key, Closure $callba * Bind an interface to a factory. * * @template T of object - * @param class-string|NamedClassKey $key An interface or interface with a parameter name. + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. * @param class-string> $factoryClassName A factory class name. */ - public function bindFactory(string|NamedClassKey $key, string $factoryClassName): self + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self { $key = self::keyToString($key); - $this->validateBindingKeyNoParameterName($key); + $this->validateBindingKeyNoName($key); - $this->data->addContext( - $this->className, - $key, - Binding::createFromFactoryClassName($factoryClassName) - ); + $this->data->addContext($this->className, $key, Binding::createFromFactoryClassName($factoryClassName)); return $this; } @@ -182,13 +164,17 @@ private function validateBinding(string $key): void } } - private function validateBindingKeyNoParameterName(string $key): void + private function validateBindingKeyNoName(string $key): void { $this->validateBinding($key); if ($key[0] === '$') { throw new LogicException("Can't bind a parameter name w/o an interface."); } + + if ($key[0] === '#') { + throw new LogicException("Can't bind a qualification name w/o an interface."); + } } private function validateBindingKeyParameterName(string $key): void @@ -201,9 +187,9 @@ private function validateBindingKeyParameterName(string $key): void } /** - * @param string|NamedKey|NamedClassKey $key + * @param string|NamedKey|NamedClassKey|QualifiedClassKey $key */ - private static function keyToString(string|NamedKey|NamedClassKey $key): string + private static function keyToString(string|NamedKey|NamedClassKey|QualifiedClassKey $key): string { return is_string($key) ? $key : $key->toString(); } diff --git a/application/Espo/Core/Binding/Key/QualifiedClassKey.php b/application/Espo/Core/Binding/Key/QualifiedClassKey.php new file mode 100644 index 00000000000..9790b7f4448 --- /dev/null +++ b/application/Espo/Core/Binding/Key/QualifiedClassKey.php @@ -0,0 +1,69 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Binding\Key; + +/** + * A key for a class-type-hinted constructor parameter with a qualifier. + * Use it to bind to qualified constructor parameters. For example, one interface + * can be used with a qualifier and without it. In the latter case, + * the default binding it applied. While in the formed case, a special binding is applied. + * For example, a caching pool interface may be bound to two different services: + * core cache and application cache. + * + * @since 10.1.0 + * + * @template-covariant T of object + */ +class QualifiedClassKey +{ + /** + * @param class-string $className + */ + private function __construct(private string $className, private string $qualifier) + {} + + /** + * Create. + * + * @template TC of object + * @param class-string $className An interface. + * @param string $qualifier A qualification name. + * @return self + */ + public static function create(string $className, string $qualifier): self + { + return new self($className, $qualifier); + } + + public function toString(): string + { + return $this->className . ' #' . $this->qualifier; + } +} diff --git a/application/Espo/Core/Container/ContainerBuilder.php b/application/Espo/Core/Container/ContainerBuilder.php index c92be133d59..4be533ec51b 100644 --- a/application/Espo/Core/Container/ContainerBuilder.php +++ b/application/Espo/Core/Container/ContainerBuilder.php @@ -38,6 +38,8 @@ use Espo\Core\Binding\EspoBindingLoader; use Espo\Core\Loaders\ApplicationState as ApplicationStateLoader; +use Espo\Core\Utils\Cache\FileCacheItemPool; +use Espo\Core\Utils\Cache\DataCacheServiceName; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Config\ConfigFileManager; use Espo\Core\Utils\Config; @@ -61,8 +63,6 @@ class ContainerBuilder private string $configClassName = Config::class; /** @var class-string */ private string $fileManagerClassName = FileManager::class; - /** @var class-string */ - private string $dataCacheClassName = DataCache::class; /** @var class-string */ private string $moduleClassName = Module::class; private ?BindingLoader $bindingLoader = null; @@ -157,17 +157,6 @@ public function withFileManagerClassName(string $fileManagerClassName): self return $this; } - /** - * @param class-string $dataCacheClassName - * @noinspection PhpUnused - */ - public function withDataCacheClassName(string $dataCacheClassName): self - { - $this->dataCacheClassName = $dataCacheClassName; - - return $this; - } - public function build(): ContainerInterface { $this->services['applicationParams'] = $this->params ?? new ApplicationParams(); @@ -186,9 +175,11 @@ public function build(): ContainerInterface ) ); + $cacheItemPool = new FileCacheItemPool($fileManager); + /** @var DataCache $dataCache */ - $dataCache = $this->services['dataCache'] ?? ( - new $this->dataCacheClassName($fileManager) + $dataCache = $this->services[DataCacheServiceName::SYSTEM] ?? ( + new DataCache($cacheItemPool) ); $useCache = $config->get('useCache') ?? false; @@ -202,7 +193,7 @@ public function build(): ContainerInterface $this->services['config'] = $config; $this->services['fileManager'] = $fileManager; - $this->services['dataCache'] = $dataCache; + $this->services[DataCacheServiceName::SYSTEM] = $dataCache; $this->services['module'] = $module; $this->services['systemConfig'] = $systemConfig; diff --git a/application/Espo/Core/Container/Loader.php b/application/Espo/Core/Container/Loader.php index 64aea86d403..0e9b0898db5 100644 --- a/application/Espo/Core/Container/Loader.php +++ b/application/Espo/Core/Container/Loader.php @@ -34,8 +34,5 @@ */ interface Loader { - /** - * @return object - */ - public function load(); + public function load(): object; } diff --git a/application/Espo/Core/Currency/InternalRatesProvider.php b/application/Espo/Core/Currency/InternalRatesProvider.php index e52a2cd39e9..65a812a4e0e 100644 --- a/application/Espo/Core/Currency/InternalRatesProvider.php +++ b/application/Espo/Core/Currency/InternalRatesProvider.php @@ -30,10 +30,12 @@ namespace Espo\Core\Currency; use Espo\Core\Field\Date; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; use LogicException; +use RuntimeException; use stdClass; /** @@ -43,74 +45,60 @@ class InternalRatesProvider { private string $cacheKey = 'currencyRates'; - /** @var (stdClass&object{date: string, rates: stdClass})|null */ - private ?stdClass $data = null; + private ?Date $today = null; + private ?string $base = null; + /** + * @param DataCacheAccess $dataCacheAccess + */ public function __construct( - private DataCache $dataCache, - private SystemConfig $systemConfig, private DateTime $dateTime, private InternalRateEntryProvider $rateEntryProvider, - ) {} + private DataCacheAccess $dataCacheAccess, + private EventDispatcher $eventDispatcher, + ) { + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: function () { + if (!$this->today || $this->base === null) { + throw new LogicException(); + } + + return $this->buildData($this->today, $this->base); + }, + validityChecker: function (stdClass $data): bool { + if (!$this->today) { + throw new LogicException(); + } + + $date = $data->date ?? null; + + return $date === $this->today->toString(); + }, + ); + + $this->eventDispatcher->subscribe(CurrencyRateUpdate::class, function () { + $this->dataCacheAccess->reset(); + }); + } /** * @return array */ public function get(string $base): array { - $this->data ??= $this->getCachedData(); - $today = $this->dateTime->getToday(); - if (!$this->data || $this->data->date !== $today->toString()) { - $this->data = $this->buildData($today, $base); + $this->today = $today; + $this->base = $base; - $this->storeData(); - } - - if ($this->data === null) { - throw new LogicException(); - } - - return get_object_vars($this->data->rates); - } - - /** - * @return (stdClass&object{date: string, rates: stdClass})|null - */ - private function getCachedData(): ?stdClass - { - if (!$this->systemConfig->useCache()) { - return null; - } - - $cached = $this->dataCache->tryGet($this->cacheKey); - - if (!$cached instanceof stdClass) { - return null; - } - - if (!isset($cached->date) || !isset($cached->rates)) { - $this->dataCache->clear($this->cacheKey); - - return null; - } - - /** @var stdClass&object{date: string, rates: stdClass} */ - return $cached; - } - - private function storeData(): void - { - if (!$this->systemConfig->useCache()) { - return; - } + $data = $this->dataCacheAccess->get(); - if (!$this->data instanceof stdClass) { - throw new LogicException(); + if (!property_exists($data, 'rates')) { + throw new RuntimeException("Corrupted cache data in '$this->cacheKey'."); } - $this->dataCache->store($this->cacheKey, $this->data); + return get_object_vars($data->rates); } /** diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 98a00e531b0..a409586a799 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -29,11 +29,16 @@ namespace Espo\Core; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Error; +use Espo\Core\Hook\Control; use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Cache\DataCacheProvider; +use Espo\Core\Utils\Cache\Exceptions\PersistenceError; use Espo\Core\Utils\Database\Helper as DatabaseHelper; use Espo\Core\Utils\Database\Schema\RebuildMode; use Espo\Core\Utils\Database\Schema\SchemaManagerProxy; +use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Util; @@ -61,7 +66,6 @@ public function __construct( private ConfigWriter $configWriter, private Metadata $metadata, private OrmMetadataData $ormMetadataData, - private HookManager $hookManager, private SchemaManagerProxy $schemaManager, private Log $log, private Module $module, @@ -69,7 +73,11 @@ public function __construct( private ConfigMissingDefaultParamsSaver $configMissingDefaultParamsSaver, private FileManager $fileManager, private DatabaseParamsFactory $databaseParamsFactory, - private InjectableFactory $injectableFactory + private InjectableFactory $injectableFactory, + private Control $hookControl, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCache $systemDataCache, + private DataCacheProvider $dataCacheProvider, ) {} /** @@ -95,6 +103,7 @@ public function rebuild(?array $entityTypeList = null): void * Clear cache. * * @throws Error + * @todo Trigger an inter-process event. */ public function clearCache(): void { @@ -102,8 +111,30 @@ public function clearCache(): void $result = $this->fileManager->removeInDir($this->cachePath); + try { + $this->systemDataCache->clearAll(); + } catch (PersistenceError $e) { + throw new Error("Could not clear system cache.", previous: $e); + } + + try { + $dataCache = $this->dataCacheProvider->get(); + } catch (Throwable $e) { + $this->log->error("Could not get application data cache.", ['exception' => $e]); + + $dataCache = null; + } + + if ($dataCache && $this->systemDataCache !== $dataCache) { + try { + $dataCache->clearAll(); + } catch (PersistenceError $e) { + throw new Error("Could not clear application cache.", previous: $e); + } + } + if (!$result) { - throw new Error("Error while clearing cache"); + throw new Error("Error while clearing cache."); } $this->updateCacheTimestamp(); @@ -168,7 +199,7 @@ public function rebuildDatabase(?array $entityTypeList = null, string $mode = Re */ public function rebuildMetadata(): void { - $this->metadata->init(true); + $this->metadata->init(); $this->ormMetadataData->reload(); $this->entityManager->getMetadata()->updateData(); @@ -237,12 +268,12 @@ private function setCryptKeyConfigParameter(): void private function disableHooks(): void { - $this->hookManager->disable(); + $this->hookControl->disable(); } private function enableHooks(): void { - $this->hookManager->enable(); + $this->hookControl->enable(); } /** diff --git a/application/Espo/Core/Di/DataCacheSetter.php b/application/Espo/Core/Di/DataCacheSetter.php index 4c7c9707416..857b02565c5 100644 --- a/application/Espo/Core/Di/DataCacheSetter.php +++ b/application/Espo/Core/Di/DataCacheSetter.php @@ -31,6 +31,9 @@ use Espo\Core\Utils\DataCache; +/** + * @phpstan-ignore-next-line trait.unused + */ trait DataCacheSetter { /** diff --git a/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php b/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php index 33cae4b1efa..19e55cbe742 100644 --- a/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php +++ b/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php @@ -334,7 +334,7 @@ private function storeData(Entity $entity): void $entityEmailAddress = $this->entityManager->getNewEntity('EntityEmailAddress'); - $entityEmailAddress->set([ + $entityEmailAddress->setMultiple([ 'entityId' => $entity->getId(), 'entityType' => $entity->getEntityType(), 'emailAddressId' => $emailAddress->getId(), diff --git a/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php b/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php index 587db9b52b8..0905405e46d 100644 --- a/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php +++ b/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php @@ -87,7 +87,6 @@ private function processItem(Entity $entity, string $field): void ->findOne(); if (!$foreignEntity) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set($nameAttribute, null); return; diff --git a/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php b/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php index 6f91d6eaeb3..1c15330343e 100644 --- a/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php +++ b/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php @@ -336,7 +336,7 @@ private function storeData(Entity $entity): void $entityPhoneNumber = $this->entityManager->getNewEntity('EntityPhoneNumber'); - $entityPhoneNumber->set([ + $entityPhoneNumber->setMultiple([ 'entityId' => $entity->getId(), 'entityType' => $entity->getEntityType(), 'phoneNumberId' => $phoneNumber->getId(), diff --git a/application/Espo/Core/FieldProcessing/Relation/Saver.php b/application/Espo/Core/FieldProcessing/Relation/Saver.php index 5efa054164a..28409510652 100644 --- a/application/Espo/Core/FieldProcessing/Relation/Saver.php +++ b/application/Espo/Core/FieldProcessing/Relation/Saver.php @@ -190,7 +190,6 @@ private function processBelongsToHasOneItem(Entity $entity, string $name): void return; } - /** @noinspection PhpRedundantOptionalArgumentInspection */ $anotherEntity->set($idAttribute, null); $this->entityManager->saveEntity($anotherEntity, [ diff --git a/application/Espo/Core/Hook/Control.php b/application/Espo/Core/Hook/Control.php new file mode 100644 index 00000000000..aec35d74ac8 --- /dev/null +++ b/application/Espo/Core/Hook/Control.php @@ -0,0 +1,60 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Hook; + +use Espo\Core\Container; +use Espo\Core\HookManager; + +/** + * @internal + * + * Serves as a proxy for `HookManager`. + */ +class Control +{ + public function __construct( + private Container $container, + ) {} + + public function enable(): void + { + $this->getHookManager()->enable(); + } + + public function disable(): void + { + $this->getHookManager()->disable(); + } + + private function getHookManager(): HookManager + { + return $this->container->getByClass(HookManager::class); + } +} diff --git a/application/Espo/Core/Hook/DataProvider.php b/application/Espo/Core/Hook/DataProvider.php new file mode 100644 index 00000000000..db9f0e81eac --- /dev/null +++ b/application/Espo/Core/Hook/DataProvider.php @@ -0,0 +1,229 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Hook; + +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Module\PathProvider; +use Espo\Core\Utils\Util; +use LogicException; + +/** + * @internal + * @since 10.1.0 + * + * To be re-used between requests. + */ +class DataProvider +{ + private const int DEFAULT_ORDER = 9; + + /** @var ?array> */ + private $data = null; + + private string $cacheKey = 'hooks'; + + /** @var string[] */ + private $ignoredMethodList = [ + '__construct', + 'getDependencyList', + 'inject', + ]; + + public function __construct( + private SystemConfig $systemConfig, + private FileManager $fileManager, + private PathProvider $pathProvider, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCache $dataCache, + private Metadata $metadata, + ) {} + + /** + * @return array> + */ + public function get(): array + { + if (!$this->data) { + $this->load(); + } + + return $this->data ?? throw new LogicException(); + } + + private function load(): void + { + if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { + /** @var ?array> $cachedData */ + $cachedData = $this->dataCache->get($this->cacheKey); + + if ($cachedData !== null) { + $this->data = $cachedData; + + return; + } + } + + $data = $this->readHookData($this->pathProvider->getCustom() . 'Hooks'); + + foreach ($this->metadata->getModuleList() as $moduleName) { + $modulePath = $this->pathProvider->getModule($moduleName) . 'Hooks'; + + $data = $this->readHookData($modulePath, $data); + } + + $data = $this->readHookData($this->pathProvider->getCore() . 'Hooks', $data); + + $this->data = $this->sortHooks($data); + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($this->cacheKey, $this->data); + } + } + + /** + * @param string $hookDir + * @param array> $hookData + * @return array> + */ + private function readHookData(string $hookDir, array $hookData = []): array + { + if (!$this->fileManager->exists($hookDir)) { + return $hookData; + } + + /** @var array $fileList */ + $fileList = $this->fileManager->getFileList($hookDir, 1, '\.php$', true); + + foreach ($fileList as $scopeName => $hookFiles) { + $hookScopeDirPath = Util::concatPath($hookDir, $scopeName); + $normalizedScopeName = Util::normalizeScopeName($scopeName); + + foreach ($hookFiles as $hookFile) { + $hookFilePath = Util::concatPath($hookScopeDirPath, $hookFile); + $className = Util::getClassName($hookFilePath); + + $classMethods = get_class_methods($className); + + $hookMethods = array_diff($classMethods, $this->ignoredMethodList); + + /** @var string[] $hookMethods */ + $hookMethods = array_filter($hookMethods, function ($item) { + if (str_starts_with($item, 'set')) { + return false; + } + + return true; + }); + + foreach ($hookMethods as $hookType) { + $entityHookData = $hookData[$normalizedScopeName][$hookType] ?? []; + + if ($this->hookExists($className, $entityHookData)) { + continue; + } + + if ($this->hookClassIsSuppressed($className)) { + continue; + } + + $hookData[$normalizedScopeName][$hookType][] = [ + 'className' => $className, + 'order' => $className::$order ?? self::DEFAULT_ORDER, + ]; + } + } + } + + return $hookData; + } + + /** + * Check if hook exists in the list. + * + * @param class-string $className + * @param array $hookData + */ + private function hookExists(string $className, array $hookData): bool + { + $class = preg_replace('/^.*\\\(.*)$/', '$1', $className); + + foreach ($hookData as $item) { + if (preg_match('/\\\\'.$class.'$/', $item['className'])) { + return true; + } + } + + return false; + } + + /** + * Sort hooks by the order parameter. + * + * @param array> $hooks + * @return array> + */ + private function sortHooks(array $hooks): array + { + foreach ($hooks as &$scopeHooks) { + foreach ($scopeHooks as &$hookList) { + usort($hookList, [$this, 'cmpHooks']); + } + } + + return $hooks; + } + + /** + * @param class-string $className + */ + private function hookClassIsSuppressed(string $className): bool + { + $suppressList = $this->metadata->get(['app', 'hook', 'suppressClassNameList']) ?? []; + + return in_array($className, $suppressList); + } + + /** + * @param array $a + * @param array $b + */ + private function cmpHooks($a, $b): int + { + if ($a['order'] == $b['order']) { + return 0; + } + + return ($a['order'] < $b['order']) ? -1 : 1; + } +} diff --git a/application/Espo/Core/Hook/GeneralInvoker.php b/application/Espo/Core/Hook/GeneralInvoker.php index 6f49e2c6a89..0adf6a99825 100644 --- a/application/Espo/Core/Hook/GeneralInvoker.php +++ b/application/Espo/Core/Hook/GeneralInvoker.php @@ -52,15 +52,15 @@ */ class GeneralInvoker { - private const HOOK_BEFORE_SAVE = 'beforeSave'; - private const HOOK_AFTER_SAVE = 'afterSave'; - private const HOOK_LATE_AFTER_SAVE = 'lateAfterSave'; - private const HOOK_BEFORE_REMOVE = 'beforeRemove'; - private const HOOK_AFTER_REMOVE = 'afterRemove'; - private const HOOK_LATE_AFTER_REMOVE = 'lateAfterRemove'; - private const HOOK_AFTER_RELATE = 'afterRelate'; - private const HOOK_AFTER_UNRELATE = 'afterUnrelate'; - private const HOOK_AFTER_MASS_RELATE = 'afterMassRelate'; + private const string HOOK_BEFORE_SAVE = 'beforeSave'; + private const string HOOK_AFTER_SAVE = 'afterSave'; + private const string HOOK_LATE_AFTER_SAVE = 'lateAfterSave'; + private const string HOOK_BEFORE_REMOVE = 'beforeRemove'; + private const string HOOK_AFTER_REMOVE = 'afterRemove'; + private const string HOOK_LATE_AFTER_REMOVE = 'lateAfterRemove'; + private const string HOOK_AFTER_RELATE = 'afterRelate'; + private const string HOOK_AFTER_UNRELATE = 'afterUnrelate'; + private const string HOOK_AFTER_MASS_RELATE = 'afterMassRelate'; /** * @param object $hook A hook object. @@ -74,7 +74,7 @@ public function invoke( string $name, mixed $subject, array $options, - array $hookData + array $hookData, ): void { if ($name === self::HOOK_BEFORE_SAVE && $hook instanceof BeforeSave) { @@ -151,11 +151,11 @@ public function invoke( } $hook->afterRelate( - $subject, - $relationName, - $relatedEntity, - $columnData, - RelateOptions::fromAssoc($options) + entity: $subject, + relationName: $relationName, + relatedEntity: $relatedEntity, + columnData: $columnData, + options: RelateOptions::fromAssoc($options), ); return; @@ -174,10 +174,10 @@ public function invoke( } $hook->afterUnrelate( - $subject, - $relationName, - $relatedEntity, - UnrelateOptions::fromAssoc($options) + entity: $subject, + relationName: $relationName, + relatedEntity: $relatedEntity, + options: UnrelateOptions::fromAssoc($options), ); return; @@ -197,11 +197,11 @@ public function invoke( } $hook->afterMassRelate( - $subject, - $relationName, - $query, - $columnData, - MassRelateOptions::fromAssoc($options) + entity: $subject, + relationName: $relationName, + query: $query, + columnData: $columnData, + options: MassRelateOptions::fromAssoc($options), ); return; diff --git a/application/Espo/Core/HookManager.php b/application/Espo/Core/HookManager.php index 9e857bc7662..a8a73adafff 100644 --- a/application/Espo/Core/HookManager.php +++ b/application/Espo/Core/HookManager.php @@ -29,14 +29,9 @@ namespace Espo\Core; +use Espo\Core\Hook\DataProvider; use Espo\Core\Hook\GeneralInvoker; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; -use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Log; -use Espo\Core\Utils\Metadata; -use Espo\Core\Utils\Module\PathProvider; -use Espo\Core\Utils\Util; /** * Runs hooks. E.g. beforeSave, afterSave. Hooks can be located in a folder @@ -51,32 +46,22 @@ */ class HookManager { - private const DEFAULT_ORDER = 9; - /** @var ?array> */ private $data = null; + private bool $isDisabled = false; + /** @var array */ private $hookListHash = []; + /** @var array */ private $hooks; - private string $cacheKey = 'hooks'; - /** @var string[] */ - private $ignoredMethodList = [ - '__construct', - 'getDependencyList', - 'inject', - ]; public function __construct( private InjectableFactory $injectableFactory, - private FileManager $fileManager, - private Metadata $metadata, - private DataCache $dataCache, private Log $log, - private PathProvider $pathProvider, private GeneralInvoker $generalInvoker, - private SystemConfig $systemConfig, + private DataProvider $dataProvider, ) {} /** @@ -91,20 +76,20 @@ public function process( string $hookName, mixed $injection = null, array $options = [], - array $hookData = [] + array $hookData = [], ): void { if ($this->isDisabled) { return; } - if (!isset($this->data)) { + if ($this->data === null) { $this->loadHooks(); } $hookList = $this->getHookList($scope, $hookName); - if (empty($hookList)) { + if ($hookList === []) { return; } @@ -116,11 +101,11 @@ public function process( $hook = $this->hooks[$className]; $this->generalInvoker->invoke( - $hook, - $hookName, - $injection, - $options, - $hookData + hook: $hook, + name: $hookName, + subject: $injection, + options: $options, + hookData: $hookData, ); } } @@ -143,32 +128,7 @@ public function enable(): void private function loadHooks(): void { - if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var array> $cachedData */ - $cachedData = $this->dataCache->get($this->cacheKey); - - $this->data = $cachedData; - - return; - } - - $metadata = $this->metadata; - - $data = $this->readHookData($this->pathProvider->getCustom() . 'Hooks'); - - foreach ($metadata->getModuleList() as $moduleName) { - $modulePath = $this->pathProvider->getModule($moduleName) . 'Hooks'; - - $data = $this->readHookData($modulePath, $data); - } - - $data = $this->readHookData($this->pathProvider->getCore() . 'Hooks', $data); - - $this->data = $this->sortHooks($data); - - if ($this->systemConfig->useCache()) { - $this->dataCache->store($this->cacheKey, $this->data); - } + $this->data = $this->dataProvider->get(); } /** @@ -183,89 +143,6 @@ private function createHookByClassName(string $className): object return $this->injectableFactory->create($className); } - /** - * @param string $hookDir - * @param array> $hookData - * @return array> - */ - private function readHookData(string $hookDir, array $hookData = []): array - { - if (!$this->fileManager->exists($hookDir)) { - return $hookData; - } - - /** @var array $fileList */ - $fileList = $this->fileManager->getFileList($hookDir, 1, '\.php$', true); - - foreach ($fileList as $scopeName => $hookFiles) { - $hookScopeDirPath = Util::concatPath($hookDir, $scopeName); - $normalizedScopeName = Util::normalizeScopeName($scopeName); - - foreach ($hookFiles as $hookFile) { - $hookFilePath = Util::concatPath($hookScopeDirPath, $hookFile); - $className = Util::getClassName($hookFilePath); - - $classMethods = get_class_methods($className); - - $hookMethods = array_diff($classMethods, $this->ignoredMethodList); - - /** @var string[] $hookMethods */ - $hookMethods = array_filter($hookMethods, function ($item) { - if (str_starts_with($item, 'set')) { - return false; - } - - return true; - }); - - foreach ($hookMethods as $hookType) { - $entityHookData = $hookData[$normalizedScopeName][$hookType] ?? []; - - if ($this->hookExists($className, $entityHookData)) { - continue; - } - - if ($this->hookClassIsSuppressed($className)) { - continue; - } - - $hookData[$normalizedScopeName][$hookType][] = [ - 'className' => $className, - 'order' => $className::$order ?? self::DEFAULT_ORDER, - ]; - } - } - } - - return $hookData; - } - - /** - * @param class-string $className - */ - private function hookClassIsSuppressed(string $className): bool - { - $suppressList = $this->metadata->get(['app', 'hook', 'suppressClassNameList']) ?? []; - - return in_array($className, $suppressList); - } - - /** - * Sort hooks by the order parameter. - * - * @param array> $hooks - * @return array> - */ - private function sortHooks(array $hooks): array - { - foreach ($hooks as &$scopeHooks) { - foreach ($scopeHooks as &$hookList) { - usort($hookList, [$this, 'cmpHooks']); - } - } - - return $hooks; - } /** * Get sorted hook list. @@ -286,7 +163,7 @@ private function getHookList(string $scope, string $hookName): array if (isset($this->data[$scope][$hookName])) { $hookList = array_merge($hookList, $this->data[$scope][$hookName]); - usort($hookList, array($this, 'cmpHooks')); + usort($hookList, [$this, 'cmpHooks']); } $normalizedList = []; @@ -301,25 +178,6 @@ private function getHookList(string $scope, string $hookName): array return $this->hookListHash[$key]; } - /** - * Check if hook exists in the list. - * - * @param class-string $className - * @param array $hookData - */ - private function hookExists(string $className, array $hookData): bool - { - $class = preg_replace('/^.*\\\(.*)$/', '$1', $className); - - foreach ($hookData as $item) { - if (preg_match('/\\\\'.$class.'$/', $item['className'])) { - return true; - } - } - - return false; - } - /** * @param array $a * @param array $b diff --git a/application/Espo/Core/InjectableFactory.php b/application/Espo/Core/InjectableFactory.php index 6cc09787a18..e88938aa843 100644 --- a/application/Espo/Core/InjectableFactory.php +++ b/application/Espo/Core/InjectableFactory.php @@ -34,8 +34,9 @@ use Espo\Core\Binding\BindingContainer; use Espo\Core\Binding\Binding; use Espo\Core\Binding\Factory; - +use Espo\Core\Container\Container; use ReflectionClass; +use ReflectionException; use ReflectionParameter; use ReflectionFunction; use ReflectionNamedType; @@ -114,6 +115,7 @@ public function createResolved(string $interfaceName, ?BindingContainer $binding null; if (!$binding) { + /** @noinspection PhpUnhandledExceptionInspection */ $class = new ReflectionClass($interfaceName); if ($class->isInterface()) { @@ -145,6 +147,7 @@ public function createResolved(string $interfaceName, ?BindingContainer $binding throw new RuntimeException("Class `$interfaceName` resolved to another type."); } + /** @noinspection PhpIncompatibleReturnTypeInspection */ return $obj; } @@ -157,18 +160,22 @@ public function createResolved(string $interfaceName, ?BindingContainer $binding private function createInternal( string $className, ?array $with = null, - ?BindingContainer $bindingContainer = null + ?BindingContainer $bindingContainer = null, ): object { if (!class_exists($className)) { - throw new RuntimeException("InjectableFactory: Class '$className' does not exist."); + throw new RuntimeException("Class '$className' does not exist."); } $class = new ReflectionClass($className); $injectionList = $this->getConstructorInjectionList($class, $with, $bindingContainer); - $obj = $class->newInstanceArgs($injectionList); + try { + $obj = $class->newInstanceArgs($injectionList); + } catch (ReflectionException $e) { + throw new RuntimeException("Reflection error.", previous: $e); + } $this->applyAwareInjections($class, $obj); @@ -178,7 +185,7 @@ private function createInternal( /** * @param ReflectionClass $class * @param ?array $with - * @return mixed[] + * @return array[] */ private function getConstructorInjectionList( ReflectionClass $class, @@ -225,6 +232,7 @@ private function getMethodParamInjection( $type = $param->getType(); + /** @noinspection PhpConditionCheckedByNextConditionInspection */ if ( $type && $type instanceof ReflectionNamedType && @@ -290,17 +298,21 @@ class_exists($badClassName); } /** - * @return mixed[] + * @return array[] */ private function getCallbackInjectionList(callable $callback): array { $injectionList = []; if (!$callback instanceof Closure) { - $callback = Closure::fromCallable($callback); + $callback = $callback(...); } - $function = new ReflectionFunction($callback); + try { + $function = new ReflectionFunction($callback); + } catch (ReflectionException $e) { + throw new RuntimeException("Reflection error.", previous: $e); + } foreach ($function->getParameters() as $param) { $injectionList[] = $this->getMethodParamInjection(null, $param); @@ -372,9 +384,8 @@ private function areDependencyClassesMatching( /** * @param ReflectionClass $class - * @param string[] $ignoreList */ - private function applyAwareInjections(ReflectionClass $class, object $obj, array $ignoreList = []): void + private function applyAwareInjections(ReflectionClass $class, object $obj): void { foreach ($class->getInterfaces() as $interface) { $interfaceName = $interface->getShortName(); @@ -385,10 +396,6 @@ private function applyAwareInjections(ReflectionClass $class, object $obj, array $name = lcfirst(substr($interfaceName, 0, -5)); - if (in_array($name, $ignoreList)) { - continue; - } - if (!$this->classHasDependencySetter($class, $name, true)) { continue; } @@ -432,6 +439,7 @@ private function classHasDependencySetter( $type = $params[0]->getType(); + /** @noinspection PhpConditionCheckedByNextConditionInspection */ if ( $type && $type instanceof ReflectionNamedType && diff --git a/application/Espo/Core/Job/CronUtil.php b/application/Espo/Core/Job/CronUtil.php new file mode 100644 index 00000000000..7da33f3574e --- /dev/null +++ b/application/Espo/Core/Job/CronUtil.php @@ -0,0 +1,84 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job; + +use Espo\Core\Utils\File\Manager as FileManager; +use RuntimeException; + +/** + * @internal + */ +class CronUtil +{ + protected string $lastRunTimeFile = 'data/cache/application/cronLastRunTime.php'; + + public function __construct( + private FileManager $fileManager, + private ConfigDataProvider $configDataProvider, + ) {} + + public function checkLastRunTime(): bool + { + $currentTime = time(); + $lastRunTime = $this->getLastRunTime(); + + $cronMinInterval = $this->configDataProvider->getCronMinInterval(); + + if ($currentTime > ($lastRunTime + $cronMinInterval)) { + return true; + } + + return false; + } + + private function getLastRunTime(): int + { + if ($this->fileManager->isFile($this->lastRunTimeFile)) { + try { + $data = $this->fileManager->getPhpContents($this->lastRunTimeFile); + } catch (RuntimeException) { + $data = null; + } + + if (is_array($data) && isset($data['time'])) { + return (int) $data['time']; + } + } + + return time() - $this->configDataProvider->getCronMinInterval() - 1; + } + + public function updateLastRunTime(): void + { + $data = ['time' => time()]; + + $this->fileManager->putPhpContents($this->lastRunTimeFile, $data, false, true); + } +} diff --git a/application/Espo/Core/Job/Exceptions/TooFrequentRun.php b/application/Espo/Core/Job/Exceptions/TooFrequentRun.php new file mode 100644 index 00000000000..bf10989d49c --- /dev/null +++ b/application/Espo/Core/Job/Exceptions/TooFrequentRun.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Exceptions; + +use Exception; + +class TooFrequentRun extends Exception +{} diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php index ff34db690e1..e0cdfba02d1 100644 --- a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php @@ -34,4 +34,6 @@ class ProcessJobQueueE0 extends AbstractQueueJob { protected string $queue = QueueName::E0; + + public const string NAME = 'ProcessJobQueueE0'; } diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php index 8bba7bc922d..3b059e05495 100644 --- a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php @@ -34,4 +34,6 @@ class ProcessJobQueueQ0 extends AbstractQueueJob { protected string $queue = QueueName::Q0; + + public const string NAME = 'ProcessJobQueueQ0'; } diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php index 800dd385286..bdf1d6313cc 100644 --- a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php @@ -34,4 +34,6 @@ class ProcessJobQueueQ1 extends AbstractQueueJob { protected string $queue = QueueName::Q1; + + public const string NAME = 'ProcessJobQueueQ1'; } diff --git a/application/Espo/Core/Job/JobManager.php b/application/Espo/Core/Job/JobManager.php index ed9fb64ceee..d6cec719623 100644 --- a/application/Espo/Core/Job/JobManager.php +++ b/application/Espo/Core/Job/JobManager.php @@ -30,11 +30,9 @@ namespace Espo\Core\Job; use Espo\Core\Job\QueueProcessor\Params; -use Espo\Core\Utils\File\Manager as FileManager; -use Espo\Core\Utils\Log; +use Espo\Core\Job\QueueProcessor\QueueProcessors\SequentialQueueProcessor; use Espo\Entities\Job as JobEntity; -use RuntimeException; use Throwable; /** @@ -42,61 +40,23 @@ */ class JobManager { - private bool $useProcessPool = false; - protected string $lastRunTimeFile = 'data/cache/application/cronLastRunTime.php'; - public function __construct( - private FileManager $fileManager, private JobRunner $jobRunner, - private Log $log, - private ScheduleProcessor $scheduleProcessor, - private QueueUtil $queueUtil, - private AsyncPoolFactory $asyncPoolFactory, private QueueProcessor $queueProcessor, - private ConfigDataProvider $configDataProvider - ) { - if ($this->configDataProvider->runInParallel()) { - if ($this->asyncPoolFactory->isSupported()) { - $this->useProcessPool = true; - } else { - $this->log->warning("Enabled `jobRunInParallel` parameter requires pcntl and posix extensions."); - } - } - } - - /** - * Process jobs. Jobs will be created according scheduling. Then pending jobs will be processed. - * This method supposed to be called on every Cron run or loop iteration of the Daemon. - */ - public function process(): void - { - if (!$this->checkLastRunTime()) { - $this->log->info('JobManager: Skip job processing. Too frequent execution.'); - - return; - } - - $this->updateLastRunTime(); - $this->queueUtil->markJobsFailed(); - $this->queueUtil->updateFailedJobAttempts(); - $this->scheduleProcessor->process(); - $this->queueUtil->removePendingJobDuplicates(); - $this->processMainQueue(); - } + private ConfigDataProvider $configDataProvider, + private SequentialQueueProcessor $sequentialQueueProcessor, + ) {} /** * Process pending jobs from a specific queue. Jobs within a queue are processed one by one. */ public function processQueue(string $queue, int $limit): void { - $params = Params - ::create() + $params = Params::create() ->withQueue($queue) - ->withLimit($limit) - ->withUseProcessPool(false) - ->withNoLock(true); + ->withLimit($limit); - $this->queueProcessor->process($params); + $this->sequentialQueueProcessor->process($params); } /** @@ -104,31 +64,27 @@ public function processQueue(string $queue, int $limit): void */ public function processGroup(string $group, int $limit): void { - $params = Params - ::create() + $params = Params::create() ->withGroup($group) - ->withLimit($limit) - ->withUseProcessPool(false) - ->withNoLock(true); + ->withLimit($limit); - $this->queueProcessor->process($params); + $this->sequentialQueueProcessor->process($params); } - private function processMainQueue(): void + /** + * Process the main job queue. + */ + public function processMainQueue(): void { $limit = $this->configDataProvider->getMaxPortion(); - $params = Params - ::create() - ->withUseProcessPool($this->useProcessPool) + $params = Params::create() ->withLimit($limit); - $subQueueParams = [ + $params = $params->withSubQueueParamsList([ $params->withWeight(0.5), $params->withQueue(QueueName::M0)->withWeight(0.5), - ]; - - $params = $params->withSubQueueParamsList($subQueueParams); + ]); $this->queueProcessor->process($params); } @@ -150,48 +106,4 @@ public function runJob(JobEntity $job): void { $this->jobRunner->runThrowingException($job); } - - /** - * @todo Move to a separate class. - */ - private function getLastRunTime(): int - { - if ($this->fileManager->isFile($this->lastRunTimeFile)) { - try { - $data = $this->fileManager->getPhpContents($this->lastRunTimeFile); - } catch (RuntimeException) { - $data = null; - } - - if (is_array($data) && isset($data['time'])) { - return (int) $data['time']; - } - } - - return time() - $this->configDataProvider->getCronMinInterval() - 1; - } - - /** - * @todo Move to a separate class. - */ - private function updateLastRunTime(): void - { - $data = ['time' => time()]; - - $this->fileManager->putPhpContents($this->lastRunTimeFile, $data, false, true); - } - - private function checkLastRunTime(): bool - { - $currentTime = time(); - $lastRunTime = $this->getLastRunTime(); - - $cronMinInterval = $this->configDataProvider->getCronMinInterval(); - - if ($currentTime > ($lastRunTime + $cronMinInterval)) { - return true; - } - - return false; - } } diff --git a/application/Espo/Core/Job/Preparator/CollectionHelper.php b/application/Espo/Core/Job/Preparator/CollectionHelper.php index 84587e7c7ca..13b74e72e54 100644 --- a/application/Espo/Core/Job/Preparator/CollectionHelper.php +++ b/application/Espo/Core/Job/Preparator/CollectionHelper.php @@ -66,11 +66,13 @@ public function prepare(Collection $collection, Data $data, DateTimeImmutable $e private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $executeTime): void { $running = $this->entityManager - ->getRDBRepository(Job::ENTITY_TYPE) + ->getRDBRepositoryByClass(Job::class) + // Reduces the chance of race condition. + ->forUpdate() ->select(Attribute::ID) ->where([ 'scheduledJobId' => $data->getId(), - 'status' => [ + Job::FIELD_STATUS => [ Status::RUNNING, Status::READY, ], @@ -84,10 +86,10 @@ private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $exec } $countPending = $this->entityManager - ->getRDBRepository(Job::ENTITY_TYPE) + ->getRDBRepositoryByClass(Job::class) ->where([ 'scheduledJobId' => $data->getId(), - 'status' => Status::PENDING, + Job::FIELD_STATUS => Status::PENDING, 'targetType' => $entity->getEntityType(), 'targetId' => $entity->getId(), ]) @@ -97,9 +99,9 @@ private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $exec return; } - $job = $this->entityManager->getNewEntity(Job::ENTITY_TYPE); + $job = $this->entityManager->getRDBRepositoryByClass(Job::class)->getNew(); - $job->set([ + $job->setMultiple([ 'name' => $data->getName(), 'scheduledJobId' => $data->getId(), 'executeTime' => $executeTime->format(DateTime::SYSTEM_DATE_TIME_FORMAT), diff --git a/application/Espo/Core/Job/PrepareProcessor.php b/application/Espo/Core/Job/PrepareProcessor.php new file mode 100644 index 00000000000..3f1141163c9 --- /dev/null +++ b/application/Espo/Core/Job/PrepareProcessor.php @@ -0,0 +1,74 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job; + +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\PrepareProcessor\Params; + +/** + * @since 10.1.0 + */ +class PrepareProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private ScheduleProcessor $scheduleProcessor, + private CronUtil $cronUtil, + ) {} + + /** + * Jobs are be created according scheduling of scheduled jobs. + * This method is meant to be called on every Cron run or loop iteration of the Daemon. + * + * @throws TooFrequentRun + */ + public function process(Params $params = new Params()): void + { + if (!$this->cronUtil->checkLastRunTime()) { + throw new TooFrequentRun('JobManager: Skip job processing. Too frequent execution.'); + } + + $this->cronUtil->updateLastRunTime(); + + $this->processPrepare($params); + } + + private function processPrepare(Params $params): void + { + $scheduleParams = new ScheduleProcessor\Params( + skipQueues: $params->skipQueues, + ); + + $this->queueUtil->markJobsFailed(); + $this->queueUtil->updateFailedJobAttempts(); + $this->scheduleProcessor->process($scheduleParams); + $this->queueUtil->removePendingJobDuplicates(); + } +} diff --git a/application/Espo/Core/Job/PrepareProcessor/Params.php b/application/Espo/Core/Job/PrepareProcessor/Params.php new file mode 100644 index 00000000000..fcb0628ee5e --- /dev/null +++ b/application/Espo/Core/Job/PrepareProcessor/Params.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\PrepareProcessor; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Consumer.php b/application/Espo/Core/Job/Processing/Consumer.php new file mode 100644 index 00000000000..6904d1b3ec3 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Consumer.php @@ -0,0 +1,42 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing; + +use Espo\Core\Job\Processing\Consumer\Params; + +/** + * @since 10.1.0 + */ +interface Consumer +{ + public function start(Params $params): void; + + public function stop(): void; +} diff --git a/application/Espo/Core/Job/Processing/Consumer/Params.php b/application/Espo/Core/Job/Processing/Consumer/Params.php new file mode 100644 index 00000000000..6da9945fae8 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Consumer/Params.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\Consumer; + +readonly class Params +{ + public function __construct( + public ?int $limit = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/EnqueueDaemon.php b/application/Espo/Core/Job/Processing/EnqueueDaemon.php new file mode 100644 index 00000000000..4597d3376ba --- /dev/null +++ b/application/Espo/Core/Job/Processing/EnqueueDaemon.php @@ -0,0 +1,217 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing; + +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Job\QueueName; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\Utils\Log; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use RuntimeException; +use Throwable; + +/** + * @since 10.1.0 + * + * @internal + */ +class EnqueueDaemon +{ + private const float INTERVAL = 1.0; + + private bool $stopped = false; + + public function __construct( + private Picker $picker, + private ConfigDataProvider $configDataProvider, + private Publisher $publisher, + private EntityManager $entityManager, + private QueueUtil $queueUtil, + private Log $log, + private ExitSetup $exitSetup, + private ExitPolicy $exitPolicy, + ) {} + + public function run(EnqueueDaemon\Params $params): void + { + $interval = $this->getInterval($params); + $limit = $params->limit; + + $pickerParams = $this->getPickerParams($params); + $publisherParams = $this->preparePublisherParams($params); + + $this->publisher->initialize($publisherParams); + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $count = 0; + + while (true) { + $jobs = $this->picker->pick($pickerParams); + + foreach ($jobs as $job) { + $this->processJob($job); + + $count ++; + + if ($this->toForceExit() || $this->toExit($limit, $count)) { + break 2; + } + } + + usleep($interval); + + if ($this->toForceExit()) { + break; + } + } + + $this->publisher->close(); + } + + private function isStopped(): bool + { + return $this->stopped; + } + + private function getPickerParams(EnqueueDaemon\Params $daemonParams): Params + { + $limit = $daemonParams->portion ?? $this->configDataProvider->getMaxPortion(); + + $params = Params::create() + ->withLimit($limit); + + if ($daemonParams->queue !== null) { + return $params->withQueue($daemonParams->queue); + } + + return $params->withSubQueueParamsList([ + $params->withWeight(0.5), + $params->withQueue(QueueName::M0)->withWeight(0.5), + ]); + } + + private function getInterval(EnqueueDaemon\Params $params): int + { + $interval = $params->interval ?? self::INTERVAL; + + return (int) ($interval * 1000000); + } + + private function prepareJob(Job $job): void + { + $job + // Needed for failing not started. + ->setStartedAtNow() + ->setStatus(Status::READY); + + $this->entityManager->saveEntity($job); + } + + private function processJob(Job $job): void + { + $this->entityManager->getTransactionManager()->run(function () use ($job) { + $this->processJobInternal($job); + }); + } + + private function toSkip(Job $job): bool + { + if ($job->getStatus() !== Status::PENDING) { + return true; + } + + if ($this->queueUtil->isScheduledJobRunning($job)) { + return true; + } + + return false; + } + + private function fetchLocked(Job $job): ?Job + { + return $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->forUpdate() + ->where([Attribute::ID => $job->getId()]) + ->findOne(); + } + + private function processJobInternal(Job $job): void + { + $job = $this->fetchLocked($job); + + if (!$job || $this->toSkip($job)) { + return; + } + + $this->prepareJob($job); + + try { + $this->publisher->publish($job); + } catch (Throwable $e) { + $this->log->error("Enqueue: Could not publish job {id}.", [ + 'id' => $job->getId(), + 'exception' => $e, + ]); + + throw new RuntimeException(previous: $e); + } + } + + private function toExit(?int $limit, int $count): bool + { + return $limit && $count >= $limit; + } + + /** + * @phpstan-impure + */ + private function toForceExit(): bool + { + return $this->isStopped() || $this->exitPolicy->toExit(); + } + + private function preparePublisherParams(EnqueueDaemon\Params $params): Publisher\Params + { + return new Publisher\Params( + queue: $params->queue, + ); + } +} diff --git a/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php b/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php new file mode 100644 index 00000000000..0c7830009f2 --- /dev/null +++ b/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\EnqueueDaemon; + +readonly class Params +{ + /** + * @param ?float $interval Interval in seconds. + */ + public function __construct( + public ?float $interval = null, + public ?int $limit = null, + public ?int $portion = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php b/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php new file mode 100644 index 00000000000..85cd7592a23 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\Exceptions; + +use Exception; + +class TickFailure extends Exception +{} diff --git a/application/Espo/Core/Job/Processing/JobProvider.php b/application/Espo/Core/Job/Processing/JobProvider.php new file mode 100644 index 00000000000..7972843989f --- /dev/null +++ b/application/Espo/Core/Job/Processing/JobProvider.php @@ -0,0 +1,65 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing; + +use Espo\Core\Job\Job\Status; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Exception; + +class JobProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + /** + * @throws Exception + */ + public function get(string $id): Job + { + $job = $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->forUpdate() + ->where([Attribute::ID => $id]) + ->findOne(); + + if (!$job) { + throw new Exception("Job $id not found."); + } + + if ($job->getStatus() !== Status::READY) { + throw new Exception("Job $id is not in status Ready."); + } + + return $job; + } +} diff --git a/application/Espo/Core/Job/Processing/PrepareDaemon.php b/application/Espo/Core/Job/Processing/PrepareDaemon.php new file mode 100644 index 00000000000..61b0497a7ef --- /dev/null +++ b/application/Espo/Core/Job/Processing/PrepareDaemon.php @@ -0,0 +1,113 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing; + +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\ExitSetup; + +/** + * @since 10.1.0 + */ +class PrepareDaemon +{ + private const float INTERVAL = 10.0; + + private bool $stopped = false; + + public function __construct( + private ExitSetup $exitSetup, + private ExitPolicy $exitPolicy, + private PrepareProcessor $prepareProcessor, + ) {} + + public function run(PrepareDaemon\Params $params): void + { + $interval = $this->getInterval($params); + + $prepareParams = new PrepareProcessor\Params( + skipQueues: $params->skipQueues, + ); + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $count = 0; + + while (true) { + try { + $this->prepareProcessor->process($prepareParams); + } catch (TooFrequentRun) { + continue; + } + + $count ++; + + if ($this->toForceExit() || $this->toExit($params, $count)) { + break; + } + + usleep($interval); + + if ($this->toForceExit()) { + break; + } + } + + $this->stopped = false; + } + + private function getInterval(PrepareDaemon\Params $params): int + { + $interval = $params->interval ?? self::INTERVAL; + + return (int) ($interval * 1000000); + } + + private function isStopped(): bool + { + return $this->stopped; + } + + private function toExit(PrepareDaemon\Params $params, int $count): bool + { + return $params->limit && $count >= $params->limit; + } + + /** + * @phpstan-impure + */ + private function toForceExit(): bool + { + return $this->isStopped() || $this->exitPolicy->toExit(); + } +} diff --git a/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php b/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php new file mode 100644 index 00000000000..e8b847dce8d --- /dev/null +++ b/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php @@ -0,0 +1,42 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\PrepareDaemon; + +readonly class Params +{ + /** + * @param ?float $interval Interval in seconds. + */ + public function __construct( + public ?float $interval = null, + public ?int $limit = null, + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Publisher.php b/application/Espo/Core/Job/Processing/Publisher.php new file mode 100644 index 00000000000..8d8de6ff5fb --- /dev/null +++ b/application/Espo/Core/Job/Processing/Publisher.php @@ -0,0 +1,45 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing; + +use Espo\Core\Job\Processing\Publisher\Params; +use Espo\Entities\Job; + +/** + * @since 10.1.0 + */ +interface Publisher +{ + public function initialize(Params $params): void; + + public function publish(Job $job): void; + + public function close(): void; +} diff --git a/application/Espo/Core/Job/Processing/Publisher/Params.php b/application/Espo/Core/Job/Processing/Publisher/Params.php new file mode 100644 index 00000000000..8ead955443e --- /dev/null +++ b/application/Espo/Core/Job/Processing/Publisher/Params.php @@ -0,0 +1,37 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\Publisher; + +readonly class Params +{ + public function __construct( + public ?string $queue, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php b/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php new file mode 100644 index 00000000000..2dd94e472a6 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php @@ -0,0 +1,58 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\RabbitMq; + +use Espo\Core\Utils\Config; +use Exception; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use RuntimeException; + +class ConnectionFactory +{ + public function __construct( + private Config $config, + ) {} + + public function create(): AMQPStreamConnection + { + $host = $this->config->get('rabbitMq.host'); + $port = $this->config->get('rabbitMq.port'); + $user = $this->config->get('rabbitMq.user'); + $password = $this->config->get('rabbitMq.password'); + + try { + $connection = new AMQPStreamConnection($host, $port, $user, $password); + } catch (Exception $e) { + throw new RuntimeException("Connection error.", previous: $e); + } + + return $connection; + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php new file mode 100644 index 00000000000..79575564d64 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php @@ -0,0 +1,204 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\RabbitMq; + +use Espo\Core\Job\JobRunner; +use Espo\Core\Job\Processing\Exceptions\TickFailure; +use Espo\Core\Job\Processing\JobProvider; +use Espo\Core\Job\Processing\Consumer as ConsumerInterface; +use Espo\Core\Job\Processing\Consumer\Params; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\Ticker; +use Espo\Core\Utils\Log; +use Exception; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Exception\AMQPTimeoutException; +use PhpAmqpLib\Message\AMQPMessage; +use RuntimeException; +use Throwable; + +class Consumer implements ConsumerInterface +{ + private bool $stopped = false; + + private const float ITERATION_TIMEOUT = 1.0; + + public function __construct( + private ConnectionFactory $connectionFactory, + private JobRunner $jobRunner, + private Log $log, + private JobProvider $jobProvider, + private ExitPolicy $exitPolicy, + private Ticker $ticker, + ) {} + + public function start(Params $params): void + { + if ($this->stopped) { + $this->stopped = false; + + return; + } + + $queue = Util::composeQueueName($params->queue); + + $connection = $this->connectionFactory->create(); + $channel = $this->prepareChannel($connection, $queue); + + $this->setupConsume($channel, $queue); + + $count = 0; + + while ($channel->is_consuming()) { + try { + $this->ticker->tick(); + } catch (TickFailure $e) { + $this->log->warning("Tick failure.", ['exception' => $e]); + + break; + } + + try { + $channel->wait(timeout: self::ITERATION_TIMEOUT); + } catch (AMQPTimeoutException) { + if ($this->toStop($params, $count)) { + break; + } + + continue; + } + + $count ++; + + if ($this->toStop($params, $count)) { + break; + } + } + + $channel->close(); + $this->closeConnection($connection); + + $this->stopped = false; + } + + public function stop(): void + { + $this->stopped = true; + } + + private function prepareChannel(AMQPStreamConnection $connection, string $queue): AMQPChannel + { + $channel = $connection->channel(); + + $channel->queue_declare( + queue: $queue, + durable: true, + auto_delete: false, + ); + + $channel->basic_qos( + prefetch_size: 0, + prefetch_count: 1, + a_global: false, + ); + + return $channel; + } + + private function closeConnection(AMQPStreamConnection $connection): void + { + try { + $connection->close(); + } catch (Exception $e) { + throw new RuntimeException("Connection closing error.", previous: $e); + } + } + + private function getJobId(AMQPMessage $message): string + { + $payload = json_decode($message->getBody()); + + if (!is_object($payload)) { + throw new RuntimeException("Bad payload."); + } + + $id = $payload->id ?? null; + + if (!is_string($id)) { + throw new RuntimeException("No string ID."); + } + + return $id; + } + + private function setupConsume(AMQPChannel $channel, string $queue): void + { + $channel->basic_consume( + queue: $queue, + callback: function (AMQPMessage $message) { + try { + $id = $this->getJobId($message); + } catch (Throwable $e) { + $this->log->error("Worker: Could not get job ID.", ['exception' => $e]); + + $message->nack(); + + return; + } + + try { + $job = $this->jobProvider->get($id); + + $this->jobRunner->run($job); + } catch (Throwable $e) { + $this->log->error("Worker: Job {id} failed.", [ + 'exception' => $e, + 'id' => $id, + ]); + + $message->nack(); + + return; + } + + $message->ack(); + }, + ); + } + + private function toStop(Params $params, int $count): bool + { + return + $this->stopped || + $params->limit && $count >= $params->limit || + $this->exitPolicy->toExit(); + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php b/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php new file mode 100644 index 00000000000..789b78cca83 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php @@ -0,0 +1,117 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\RabbitMq; + +use Espo\Core\Job\Processing\Publisher as PublisherInterface; +use Espo\Core\Job\Processing\Publisher\Params; +use Espo\Core\Utils\Json; +use Espo\Entities\Job; +use Espo\ORM\Name\Attribute; +use Exception; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Message\AMQPMessage; +use RuntimeException; + +class Publisher implements PublisherInterface +{ + public const string QUEUE = 'espo.jobs'; + + private ?AMQPStreamConnection $connection = null; + private ?AMQPChannel $channel = null; + private string $queue = self::QUEUE; + + public function __construct( + private ConnectionFactory $connectionFactory, + ) {} + + public function initialize(Params $params): void + { + $this->queue = Util::composeQueueName($params->queue); + + $this->connection = $this->connectionFactory->create(); + $this->channel = $this->prepareChannel($this->connection); + } + + public function publish(Job $job): void + { + if (!$this->channel) { + throw new RuntimeException("No channel."); + } + + $message = $this->prepareMessage($job); + + $this->channel->basic_publish( + msg: $message, + routing_key: $this->queue, + ); + } + + public function close(): void + { + $this->channel?->close(); + + if ($this->connection) { + $this->closeConnection($this->connection); + } + + $this->channel = null; + $this->connection = null; + } + + private function prepareChannel(AMQPStreamConnection $connection): AMQPChannel + { + $channel = $connection->channel(); + + $channel->queue_declare( + queue: $this->queue, + durable: true, + auto_delete: false, + ); + + return $channel; + } + + private function closeConnection(AMQPStreamConnection $connection): void + { + try { + $connection->close(); + } catch (Exception $e) { + throw new RuntimeException("Connection closing error.", previous: $e); + } + } + + private function prepareMessage(Job $job): AMQPMessage + { + $payload = Json::encode([Attribute::ID => $job->getId()]); + + return new AMQPMessage($payload, ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]); + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Util.php b/application/Espo/Core/Job/Processing/RabbitMq/Util.php new file mode 100644 index 00000000000..b54df686fae --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Util.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\RabbitMq; + +class Util +{ + public static function composeQueueName(?string $queue): string + { + $name = Publisher::QUEUE; + + if ($queue !== null) { + $name .= '.' . $queue; + } + + return $name; + } +} diff --git a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php new file mode 100644 index 00000000000..b656ad153a0 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php @@ -0,0 +1,50 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\Util; + +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\StateConfig; + +class ExitPolicy +{ + private int $cacheTimestamp; + + public function __construct( + private StateConfig $stateConfig, + private Config\StateConfigDirect $stateConfigDirect, + ) { + $this->cacheTimestamp = $this->stateConfig->getCacheTimestamp(); + } + + public function toExit(): bool + { + return $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp(); + } +} diff --git a/application/Espo/Core/Job/Processing/Util/ExitSetup.php b/application/Espo/Core/Job/Processing/Util/ExitSetup.php new file mode 100644 index 00000000000..5063d354f1a --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/ExitSetup.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\Util; + +use Closure; + +class ExitSetup +{ + public function setup(Closure $handler): void + { + if (!extension_loaded('pcntl')) { + return; + } + + pcntl_async_signals(true); + pcntl_signal(SIGTERM, fn () => $handler()); + pcntl_signal(SIGINT, fn () => $handler()); + } +} diff --git a/application/Espo/Core/Job/Processing/Util/Ticker.php b/application/Espo/Core/Job/Processing/Util/Ticker.php new file mode 100644 index 00000000000..5d4fe093ed8 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/Ticker.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\Util; + +use Espo\Core\Job\Processing\Exceptions\TickFailure; +use Espo\Core\Utils\Event\EventTransport; +use Espo\Core\Utils\Event\Exceptions\TransportNotConnected; + +class Ticker +{ + public function __construct( + private EventTransport $transport + ) {} + + /** + * @throws TickFailure + */ + public function tick(): void + { + try { + $this->transport->tick(); + } catch (TransportNotConnected $e) { + throw new TickFailure(previous: $e); + } + } +} diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon.php b/application/Espo/Core/Job/Processing/WorkerDaemon.php new file mode 100644 index 00000000000..e079ffe6ca6 --- /dev/null +++ b/application/Espo/Core/Job/Processing/WorkerDaemon.php @@ -0,0 +1,71 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing; + +use Espo\Core\Job\Processing\Consumer\Params; +use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Utils\Event\Configuration; + +/** + * @since 10.1.0 + * @internal + */ +class WorkerDaemon +{ + public function __construct( + private Consumer $consumer, + private ExitSetup $exitSetup, + private Configuration $eventConfiguration, + ) {} + + public function run(WorkerDaemon\Params $params): void + { + $consumerParams = $this->prepareParams($params); + + $this->setupExit(); + + $this->eventConfiguration->setSubscribeToCrossInstanceEvents(true); + + $this->consumer->start($consumerParams); + } + + private function setupExit(): void + { + $this->exitSetup->setup(fn () => $this->consumer->stop()); + } + + private function prepareParams(WorkerDaemon\Params $params): Params + { + return new Params( + limit: $params->limit, + queue: $params->queue, + ); + } +} diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php b/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php new file mode 100644 index 00000000000..6a06a30f914 --- /dev/null +++ b/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\Processing\WorkerDaemon; + +readonly class Params +{ + public function __construct( + public ?int $limit = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/QueueName.php b/application/Espo/Core/Job/QueueName.php index ca8bc4371b4..ec2b36f4e38 100644 --- a/application/Espo/Core/Job/QueueName.php +++ b/application/Espo/Core/Job/QueueName.php @@ -32,19 +32,19 @@ class QueueName { /** - * Executes as soon as possible. Non-parallel. + * Executes as soon as possible. Non-parallel by default. */ - public const Q0 = 'q0'; + public const string Q0 = 'q0'; /** - * Executes every minute. Non-parallel. + * Executes every minute. Non-parallel by default. */ - public const Q1 = 'q1'; + public const string Q1 = 'q1'; /** - * Executes as soon as possible. For email processing. Non-parallel. + * Executes as soon as possible. For email processing. Non-parallel by default. */ - public const E0 = 'e0'; + public const string E0 = 'e0'; /** * Executes in the main queue pool in parallel. Along with jobs without specified queue. @@ -54,5 +54,5 @@ class QueueName * * @since 9.2.0 */ - const M0 = 'm0'; + const string M0 = 'm0'; } diff --git a/application/Espo/Core/Job/QueueProcessor.php b/application/Espo/Core/Job/QueueProcessor.php index cd105eb6078..07f579ea2e8 100644 --- a/application/Espo/Core/Job/QueueProcessor.php +++ b/application/Espo/Core/Job/QueueProcessor.php @@ -29,117 +29,12 @@ namespace Espo\Core\Job; -use Espo\Core\Job\QueueProcessor\Picker; -use Espo\Entities\Job as JobEntity; use Espo\Core\Job\QueueProcessor\Params; -use Espo\Core\ORM\EntityManager; -use Espo\Core\Utils\System; -use Espo\Core\Job\Job\Status; -use Spatie\Async\Pool as AsyncPool; - -class QueueProcessor +/** + * @since 10.1.0 + */ +interface QueueProcessor { - private bool $noTableLocking; - - public function __construct( - private QueueUtil $queueUtil, - private JobRunner $jobRunner, - private AsyncPoolFactory $asyncPoolFactory, - private EntityManager $entityManager, - private Picker $picker, - ConfigDataProvider $configDataProvider - ) { - $this->noTableLocking = $configDataProvider->noTableLocking(); - } - - public function process(Params $params): void - { - $pool = $params->useProcessPool() ? - $this->asyncPoolFactory->create() : null; - - foreach ($this->picker->pick($params) as $job) { - $this->processJob($params, $job, $pool); - } - - $pool?->wait(); - } - - private function processJob(Params $params, JobEntity $job, ?AsyncPool $pool = null): void - { - $noLock = $params->noLock(); - $lockTable = $job->getScheduledJobId() && !$noLock && !$this->noTableLocking; - - if ($lockTable) { - // MySQL doesn't allow to lock non-existent rows. We resort to locking an entire table. - $this->entityManager->getLocker()->lockExclusive(JobEntity::ENTITY_TYPE); - } - - $skip = $this->toSkip($noLock, $job); - - if ($skip) { - if ($lockTable) { - $this->entityManager->getLocker()->rollback(); - } - - return; - } - - $this->prepareJob($job, $pool); - - $this->entityManager->saveEntity($job); - - if ($lockTable) { - $this->entityManager->getLocker()->commit(); - } - - $this->runJob($job, $pool); - } - - private function toSkip(bool $noLock, JobEntity $job): bool - { - $skip = !$noLock && !$this->queueUtil->isJobPending($job->getId()); - - if ( - !$skip && - $job->getScheduledJobId() && - $this->queueUtil->isScheduledJobRunning( - $job->getScheduledJobId(), - $job->getTargetId(), - $job->getTargetType(), - $job->getTargetGroup() - ) - ) { - $skip = true; - } - - return $skip; - } - - private function prepareJob(JobEntity $job, ?AsyncPool $pool): void - { - $job->setStartedAtNow(); - - if ($pool) { - $job->setStatus(Status::READY); - - return; - } - - $job->setStatus(Status::RUNNING); - $job->setPid(System::getPid()); - } - - private function runJob(JobEntity $job, ?AsyncPool $pool): void - { - if (!$pool) { - $this->jobRunner->run($job); - - return; - } - - $task = new JobTask($job->getId()); - - $pool->add($task); - } + public function process(Params $params): void; } diff --git a/application/Espo/Core/Job/QueueProcessor/Params.php b/application/Espo/Core/Job/QueueProcessor/Params.php index a6bc9e05f1b..ce025e4fe49 100644 --- a/application/Espo/Core/Job/QueueProcessor/Params.php +++ b/application/Espo/Core/Job/QueueProcessor/Params.php @@ -31,8 +31,6 @@ class Params { - private bool $useProcessPool = false; - private bool $noLock = false; private ?string $queue = null; private ?string $group = null; private int $limit = 0; @@ -40,22 +38,6 @@ class Params private ?array $subQueueParamsList = null; private float $weight = 1.0; - public function withUseProcessPool(bool $useProcessPool): self - { - $obj = clone $this; - $obj->useProcessPool = $useProcessPool; - - return $obj; - } - - public function withNoLock(bool $noLock): self - { - $obj = clone $this; - $obj->noLock = $noLock; - - return $obj; - } - public function withQueue(?string $queue): self { $obj = clone $this; @@ -99,16 +81,6 @@ public function withSubQueueParamsList(?array $subQueueParamsList): self return $obj; } - public function useProcessPool(): bool - { - return $this->useProcessPool; - } - - public function noLock(): bool - { - return $this->noLock; - } - public function getQueue(): ?string { return $this->queue; diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php new file mode 100644 index 00000000000..4859d3c5234 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php @@ -0,0 +1,74 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\AsyncPoolFactory; +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Utils\Log; +use Espo\Core\Job\QueueProcessor\Params; + +/** + * @internal + */ +class DefaultQueueProcessor implements QueueProcessor +{ + public function __construct( + private ProcessPoolQueueProcessor $processPoolQueueProcessor, + private SequentialQueueProcessor $sequentialQueueProcessor, + private AsyncPoolFactory $asyncPoolFactory, + private Log $log, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params): void + { + if ($this->inParallel()) { + $this->processPoolQueueProcessor->process($params); + } else { + $this->sequentialQueueProcessor->process($params); + } + } + + private function inParallel(): bool + { + if (!$this->configDataProvider->runInParallel()) { + return false; + } + + if (!$this->asyncPoolFactory->isSupported()) { + $this->log->warning("Enabled `jobRunInParallel` parameter requires pcntl and posix extensions."); + + return false; + } + + return true; + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php new file mode 100644 index 00000000000..0579833688f --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php @@ -0,0 +1,132 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\AsyncPoolFactory; +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\JobTask; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\ORM\EntityManager; +use Espo\Entities\Job; +use Spatie\Async\Pool as AsyncPool; + +class ProcessPoolQueueProcessor implements QueueProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private AsyncPoolFactory $asyncPoolFactory, + private EntityManager $entityManager, + private Picker $picker, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params): void + { + $noLock = $this->skipLock($params); + + $pool = $this->asyncPoolFactory->create(); + + foreach ($this->picker->pick($params) as $job) { + $this->processJob($noLock, $job, $pool); + } + + $pool->wait(); + } + + private function processJob(bool $noLock, Job $job, AsyncPool $pool): void + { + $lockTable = !$noLock && $job->getScheduledJobId(); + + if ($lockTable) { + // MySQL doesn't allow to lock non-existent rows. We resort to locking an entire table. + $this->entityManager->getLocker()->lockExclusive(Job::ENTITY_TYPE); + } + + $skip = $this->toSkip($noLock, $job); + + if ($skip) { + if ($lockTable) { + $this->entityManager->getLocker()->rollback(); + } + + return; + } + + $this->prepareJob($job); + + if ($lockTable) { + $this->entityManager->getLocker()->commit(); + } + + $this->runJob($job, $pool); + } + + private function toSkip(bool $noLock, Job $job): bool + { + if (!$noLock && !$this->queueUtil->isJobPending($job)) { + return true; + } + + if ($this->queueUtil->isScheduledJobRunning($job)) { + return true; + } + + return false; + } + + private function prepareJob(Job $job): void + { + $job + // Needed for failing not started. + ->setStartedAtNow() + ->setStatus(Status::READY); + + $this->entityManager->saveEntity($job); + } + + private function runJob(Job $job, AsyncPool $pool): void + { + $task = new JobTask($job->getId()); + + $pool->add($task); + } + + private function skipLock(Params $params): bool + { + return + $params->getGroup() !== null || + $params->getQueue() !== null || + $this->configDataProvider->noTableLocking(); + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php new file mode 100644 index 00000000000..a8507cacd59 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php @@ -0,0 +1,83 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\JobRunner; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\System; +use Espo\Entities\Job; + +class SequentialQueueProcessor implements QueueProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private JobRunner $jobRunner, + private EntityManager $entityManager, + private Picker $picker, + ) {} + + public function process(Params $params): void + { + foreach ($this->picker->pick($params) as $job) { + $this->processJob($job); + } + } + + private function processJob(Job $job): void + { + if ($this->toSkip($job)) { + return; + } + + $this->prepareJob($job); + + $this->jobRunner->run($job); + } + + private function toSkip(Job $job): bool + { + return $this->queueUtil->isScheduledJobRunning($job); + } + + private function prepareJob(Job $job): void + { + $job + ->setStartedAtNow() + ->setStatus(Status::RUNNING) + ->setPid(System::getPid()); + + $this->entityManager->saveEntity($job); + } +} diff --git a/application/Espo/Core/Job/QueueUtil.php b/application/Espo/Core/Job/QueueUtil.php index 88d9056cc54..458565f767f 100644 --- a/application/Espo/Core/Job/QueueUtil.php +++ b/application/Espo/Core/Job/QueueUtil.php @@ -37,32 +37,30 @@ use Espo\Core\Utils\System; use Espo\Core\Job\Job\Status; use Espo\Entities\Job as JobEntity; - -use DateTime; use Espo\ORM\Collection; use Espo\ORM\Name\Attribute; +use DateTime; use Exception; use LogicException; class QueueUtil { - private const NOT_EXISTING_PROCESS_PERIOD = 300; - private const READY_NOT_STARTED_PERIOD = 60; + private const int NOT_EXISTING_PROCESS_PERIOD = 300; + private const int READY_NOT_STARTED_PERIOD = 600; public function __construct( private Config $config, private EntityManager $entityManager, private ScheduleUtil $scheduleUtil, - private MetadataProvider $metadataProvider + private MetadataProvider $metadataProvider, ) {} - public function isJobPending(string $id): bool + public function isJobPending(JobEntity $job): bool { - /** @var ?JobEntity $job */ $job = $this->entityManager ->getRDBRepositoryByClass(JobEntity::class) - ->select([Attribute::ID, 'status']) - ->where([Attribute::ID => $id]) + ->select([Attribute::ID, JobEntity::FIELD_STATUS]) + ->where([Attribute::ID => $job->getId()]) ->forUpdate() ->findOne(); @@ -111,16 +109,20 @@ public function getPendingJobs(Params $params): Collection return $builder->sth()->find(); } - public function isScheduledJobRunning( - string $scheduledJobId, - ?string $targetId = null, - ?string $targetType = null, - ?string $targetGroup = null - ): bool { + public function isScheduledJobRunning(JobEntity $job): bool + { + $scheduledJobId = $job->getScheduledJobId(); + $targetId = $job->getTargetId(); + $targetType = $job->getTargetType(); + $targetGroup = $job->getTargetGroup(); + + if (!$scheduledJobId) { + return false; + } $where = [ 'scheduledJobId' => $scheduledJobId, - 'status' => [ + JobEntity::FIELD_STATUS => [ Status::RUNNING, Status::READY, ], @@ -474,7 +476,7 @@ public function updateFailedJobAttempts(): void ]) ->find(); - foreach ($jobCollection as $job) { + foreach ($jobCollection as $job) { $failedAttempts = $job->getFailedAttempts(); $attempts = $job->getAttempts(); diff --git a/application/Espo/Core/Job/ScheduleProcessor.php b/application/Espo/Core/Job/ScheduleProcessor.php index 403bd2ca7fc..e8e79cc6654 100644 --- a/application/Espo/Core/Job/ScheduleProcessor.php +++ b/application/Espo/Core/Job/ScheduleProcessor.php @@ -31,6 +31,7 @@ use DateTimeZone; use Espo\Core\Job\Preparator\Data as PreparatorData; +use Espo\Core\Job\ScheduleProcessor\Params; use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\DateTime as DateTimeUtil; use Espo\Core\Utils\Log; @@ -66,15 +67,15 @@ public function __construct( private ScheduleUtil $scheduleUtil, private PreparatorFactory $preparatorFactory, private MetadataProvider $metadataProvider, - private ConfigDataProvider $configDataProvider + private ConfigDataProvider $configDataProvider, ) {} - public function process(): void + public function process(Params $params = new Params()): void { - $activeScheduledJobList = $this->scheduleUtil->getActiveScheduledJobList(); + $activeScheduledJobs = $this->scheduleUtil->getActiveScheduledJobs($params); $runningScheduledJobIdList = $this->queueUtil->getRunningScheduledJobIdList(); - foreach ($activeScheduledJobList as $scheduledJob) { + foreach ($activeScheduledJobs as $scheduledJob) { try { $isRunning = in_array($scheduledJob->getId(), $runningScheduledJobIdList); diff --git a/application/Espo/Core/Job/ScheduleProcessor/Params.php b/application/Espo/Core/Job/ScheduleProcessor/Params.php new file mode 100644 index 00000000000..7fcc0914905 --- /dev/null +++ b/application/Espo/Core/Job/ScheduleProcessor/Params.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Job\ScheduleProcessor; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/ScheduleUtil.php b/application/Espo/Core/Job/ScheduleUtil.php index 7413fdb5335..71ee7632ea3 100644 --- a/application/Espo/Core/Job/ScheduleUtil.php +++ b/application/Espo/Core/Job/ScheduleUtil.php @@ -29,14 +29,22 @@ namespace Espo\Core\Job; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueE0; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueQ0; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueQ1; +use Espo\Core\Job\ScheduleProcessor\Params; +use Espo\Core\Name\Field; use Espo\Core\Utils\DateTime as DateTimeUtil; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\ORM\Collection; use Espo\ORM\EntityManager; -use Espo\Entities\ScheduledJob as ScheduledJobEntity; +use Espo\Entities\ScheduledJob; use Espo\Entities\ScheduledJobLogRecord as ScheduledJobLogRecordEntity; use Espo\ORM\Name\Attribute; +/** + * @internal + */ class ScheduleUtil { public function __construct(private EntityManager $entityManager) @@ -45,24 +53,33 @@ public function __construct(private EntityManager $entityManager) /** * Get active scheduled job list. * - * @return Collection + * @return Collection */ - public function getActiveScheduledJobList(): Collection + public function getActiveScheduledJobs(Params $params): Collection { - /** @var Collection $collection */ $collection = $this->entityManager - ->getRDBRepository(ScheduledJobEntity::ENTITY_TYPE) + ->getRDBRepositoryByClass(ScheduledJob::class) ->select([ Attribute::ID, - 'scheduling', - 'job', - 'name', + ScheduledJob::FIELD_SCHEDULING, + ScheduledJob::FIELD_JOB, + Field::NAME, ]) ->where([ - 'status' => ScheduledJobEntity::STATUS_ACTIVE, + ScheduledJob::FIELD_STATUS => ScheduledJob::STATUS_ACTIVE, ]) ->find(); + if ($params->skipQueues) { + $collection = $collection->filter(function (ScheduledJob $entity) { + return !in_array($entity->getJob(), [ + ProcessJobQueueE0::NAME, + ProcessJobQueueQ0::NAME, + ProcessJobQueueQ1::NAME, + ]); + }); + } + return $collection; } @@ -81,8 +98,8 @@ public function addLogRecord( $runTime = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); } - /** @var ScheduledJobEntity|null $scheduledJob */ - $scheduledJob = $this->entityManager->getEntityById(ScheduledJobEntity::ENTITY_TYPE, $scheduledJobId); + /** @var ScheduledJob|null $scheduledJob */ + $scheduledJob = $this->entityManager->getEntityById(ScheduledJob::ENTITY_TYPE, $scheduledJobId); if (!$scheduledJob) { return; @@ -94,7 +111,7 @@ public function addLogRecord( $scheduledJobLog = $this->entityManager->getNewEntity(ScheduledJobLogRecordEntity::ENTITY_TYPE); - $scheduledJobLog->set([ + $scheduledJobLog->setMultiple([ 'scheduledJobId' => $scheduledJobId, 'name' => $scheduledJob->getName(), 'status' => $status, diff --git a/application/Espo/Core/Loaders/DateTime.php b/application/Espo/Core/Loaders/DateTime.php index 29027796199..bcceb517d8d 100644 --- a/application/Espo/Core/Loaders/DateTime.php +++ b/application/Espo/Core/Loaders/DateTime.php @@ -32,19 +32,23 @@ use Espo\Core\Container\Loader; use Espo\Core\Utils\Config; use Espo\Core\Utils\DateTime as DateTimeService; +use Espo\Core\Utils\DateTime\Clock; class DateTime implements Loader { - public function __construct(private Config $config) - {} + public function __construct( + private Config $config, + private Clock $clock, + ) {} public function load(): DateTimeService { return new DateTimeService( - $this->config->get('dateFormat'), - $this->config->get('timeFormat'), - $this->config->get('timeZone'), - $this->config->get('language') + dateFormat: $this->config->get('dateFormat'), + timeFormat: $this->config->get('timeFormat'), + timeZone: $this->config->get('timeZone'), + language: $this->config->get('language'), + clock: $this->clock, ); } } diff --git a/application/Espo/Core/Loaders/EventDispatcher.php b/application/Espo/Core/Loaders/EventDispatcher.php new file mode 100644 index 00000000000..703166f0cde --- /dev/null +++ b/application/Espo/Core/Loaders/EventDispatcher.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Loaders; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Event\EventDispatcher as EventDispatcherService; +use Espo\Core\Utils\Event\OriginProvider; + +class EventDispatcher implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): EventDispatcherService + { + $originProvider = $this->injectableFactory->create(OriginProvider::class); + + return $this->injectableFactory->createWithBinding( + EventDispatcherService::class, + BindingContainerBuilder::create() + ->bindInstance(OriginProvider::class, $originProvider) + ->build() + ); + } +} diff --git a/application/Espo/Core/Loaders/HookDataProvider.php b/application/Espo/Core/Loaders/HookDataProvider.php new file mode 100644 index 00000000000..5cd5ffdb30c --- /dev/null +++ b/application/Espo/Core/Loaders/HookDataProvider.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Hook\DataProvider; +use Espo\Core\InjectableFactory; + +/** + * @noinspection PhpUnused + */ +class HookDataProvider implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): DataProvider + { + return $this->injectableFactory->create(DataProvider::class); + } +} diff --git a/application/Espo/Core/Loaders/Loader.php b/application/Espo/Core/Loaders/Loader.php index 88dad1468bc..6933c1bf136 100644 --- a/application/Espo/Core/Loaders/Loader.php +++ b/application/Espo/Core/Loaders/Loader.php @@ -33,7 +33,7 @@ /** * @deprecated Since v6.2.0. Use `Espo\Core\Container\Loader`. + * @todo Remove in v11.0. */ interface Loader extends BaseLoader -{ -} +{} diff --git a/application/Espo/Core/Loaders/Metadata.php b/application/Espo/Core/Loaders/Metadata.php index bbc8653f0ae..0cf435b513b 100644 --- a/application/Espo/Core/Loaders/Metadata.php +++ b/application/Espo/Core/Loaders/Metadata.php @@ -31,18 +31,15 @@ use Espo\Core\Container\Loader; use Espo\Core\InjectableFactory; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Metadata as MetadataService; class Metadata implements Loader { - public function __construct(private InjectableFactory $injectableFactory, private Config $config) + public function __construct(private InjectableFactory $injectableFactory) {} public function load(): MetadataService { - return $this->injectableFactory->createWith(MetadataService::class, [ - 'useCache' => $this->config->get('useCache') ?? false, - ]); + return $this->injectableFactory->create(MetadataService::class); } } diff --git a/application/Espo/Core/Loaders/SystemState.php b/application/Espo/Core/Loaders/SystemState.php new file mode 100644 index 00000000000..2a3c332018a --- /dev/null +++ b/application/Espo/Core/Loaders/SystemState.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\System\SystemState as SystemStateService; + +/** + * @noinspection PhpUnused + */ +class SystemState implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): SystemStateService + { + return $this->injectableFactory->create(SystemStateService::class); + } +} diff --git a/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php b/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php index 3d950404e71..048cd1d373a 100644 --- a/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php +++ b/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php @@ -49,7 +49,6 @@ public function __construct( ) {} /** - * @todo Test. * @inheritDoc * @noinspection PhpRedundantCatchClauseInspection */ @@ -172,7 +171,6 @@ public function getUidsFromUid(int $id): array } /** - * @todo Test. * @inheritDoc * @noinspection PhpRedundantCatchClauseInspection */ diff --git a/application/Espo/Core/Mail/Importer/DefaultImporter.php b/application/Espo/Core/Mail/Importer/DefaultImporter.php index de82eb87034..fb1cdda9806 100644 --- a/application/Espo/Core/Mail/Importer/DefaultImporter.php +++ b/application/Espo/Core/Mail/Importer/DefaultImporter.php @@ -86,6 +86,7 @@ public function __construct( private JobSchedulerFactory $jobSchedulerFactory, private ParentFinder $parentFinder, private AutoReplyDetector $autoReplyDetector, + private EmailSaver $emailSaver, ) { $this->notificator = $notificatorFactory->createByClass(Email::class); $this->filtersMatcher = new FiltersMatcher(); @@ -224,7 +225,8 @@ public function import(Message $message, Data $data): ?Email $email->setStatus(Email::STATUS_ARCHIVED); - $this->processFinalTransactionalSave($email); + $this->emailSaver->save($email); + $this->processAttachmentSave($inlineAttachmentList, $email); return $email; @@ -591,20 +593,6 @@ private function processFilters(Email $email, iterable $filterList, bool $skipBo return false; } - private function processFinalTransactionalSave(Email $email): void - { - $this->entityManager->getTransactionManager()->start(); - - $this->entityManager - ->getRDBRepositoryByClass(Email::class) - ->forUpdate() - ->where([Attribute::ID => $email->getId()]) - ->findOne(); - - $this->entityManager->saveEntity($email, [Email::SAVE_OPTION_IS_BEING_IMPORTED => true]); - - $this->entityManager->getTransactionManager()->commit(); - } /** * @param Attachment[] $inlineAttachmentList diff --git a/application/Espo/Core/Mail/Importer/EmailSaver.php b/application/Espo/Core/Mail/Importer/EmailSaver.php new file mode 100644 index 00000000000..f29460d628b --- /dev/null +++ b/application/Espo/Core/Mail/Importer/EmailSaver.php @@ -0,0 +1,85 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Mail\Importer; + +use Espo\Entities\Email; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use PDOException; + +/** + * @internal + */ +class EmailSaver +{ + private const int SAVE_RETRY_COUNT = 2; + + public function __construct( + private EntityManager $entityManager, + ) {} + + public function save(Email $email): void + { + for ($i = 0; $i < self::SAVE_RETRY_COUNT; $i ++) { + try { + $this->saveInternal($email); + } catch (PDOException $e) { + $code = (int) ($e->errorInfo[1] ?? '0'); + + // Handles a snapshot isolation conflict. + if ($code === 1020) { + if ($i === self::SAVE_RETRY_COUNT - 1) { + throw $e; + } + + continue; + } + + throw $e; + } + + break; + } + } + + private function saveInternal(Email $email): void + { + $this->entityManager->getTransactionManager()->run(function () use ($email) { + $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->forUpdate() + ->select(Attribute::ID) + ->where([Attribute::ID => $email->getId()]) + ->findOne(); + + $this->entityManager->saveEntity($email, [Email::SAVE_OPTION_IS_BEING_IMPORTED => true]); + }); + } +} diff --git a/application/Espo/Core/ORM/Entity.php b/application/Espo/Core/ORM/Entity.php index f4939ff2a5d..baa3280f33a 100644 --- a/application/Espo/Core/ORM/Entity.php +++ b/application/Espo/Core/ORM/Entity.php @@ -98,7 +98,6 @@ public function loadParentNameField(string $field): void $toSetFetched = !$this->isNew() && !$this->isAttributeChanged($idAttribute); if (!$parentId || !$parentType) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $this->set($nameAttribute, null); if ($toSetFetched) { diff --git a/application/Espo/Core/ORM/EntityManagerProxy.php b/application/Espo/Core/ORM/EntityManagerProxy.php index 54802f739ee..ad40dd0d735 100644 --- a/application/Espo/Core/ORM/EntityManagerProxy.php +++ b/application/Espo/Core/ORM/EntityManagerProxy.php @@ -30,6 +30,7 @@ namespace Espo\Core\ORM; use Espo\ORM\Entity; +use Espo\ORM\Executor\QueryExecutor; use Espo\ORM\Metadata; use Espo\ORM\Repository\RDBRepository; use Espo\ORM\Repository\Repository; @@ -129,4 +130,14 @@ public function getRepositoryByClass(string $className): Repository { return $this->getEntityManager()->getRepositoryByClass($className); } + + /** + * Get the query executor. + * + * @since 10.1.0 + */ + public function getQueryExecutor(): QueryExecutor + { + return $this->getEntityManager()->getQueryExecutor(); + } } diff --git a/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php new file mode 100644 index 00000000000..49355e37048 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php @@ -0,0 +1,73 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Portal\Acl\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; +use UnexpectedValueException; + +/** + * @since 10.1.0 + */ +class PortalUserRoleUpdate implements CrossInstanceEvent +{ + public function __construct( + public string $userId, + public string $portalId, + ) {} + + public static function fromPayload(stdClass $payload): self + { + $userId = $payload->userId ?? null; + + if (!is_string($userId)) { + throw new UnexpectedValueException("No user ID."); + } + + $portalId = $payload->portalId ?? null; + + if (!is_string($portalId)) { + throw new UnexpectedValueException("No portal ID."); + } + + return new self( + userId: $userId, + portalId: $portalId, + ); + } + + public function toPayload(): stdClass + { + return (object) [ + 'userId' => $this->userId, + 'portalId' => $this->portalId, + ]; + } +} diff --git a/application/Espo/Core/Portal/AclManager.php b/application/Espo/Core/Portal/AclManager.php index e79a10a4a5f..3d9ab994d67 100644 --- a/application/Espo/Core/Portal/AclManager.php +++ b/application/Espo/Core/Portal/AclManager.php @@ -30,12 +30,12 @@ namespace Espo\Core\Portal; use Espo\Core\Acl\Permission; +use Espo\Core\Portal\Acl\Events\PortalUserRoleUpdate; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\ORM\Entity; use Espo\ORM\EntityManager; - use Espo\Entities\Portal; use Espo\Entities\User; - use Espo\Core\Acl\GlobalRestriction; use Espo\Core\Acl\Map\Map; use Espo\Core\Acl\OwnerUserFieldProvider; @@ -70,7 +70,8 @@ public function __construct( GlobalRestriction $globalRestriction, OwnerUserFieldProvider $ownerUserFieldProvider, EntityManager $entityManager, - InternalAclManager $internalAclManager + InternalAclManager $internalAclManager, + EventDispatcher $eventDispatcher, ) { $this->accessCheckerFactory = $accessCheckerFactory; $this->ownershipCheckerFactory = $ownershipCheckerFactory; @@ -80,6 +81,9 @@ public function __construct( $this->ownerUserFieldProvider = $ownerUserFieldProvider; $this->entityManager = $entityManager; $this->internalAclManager = $internalAclManager; + $this->eventDispatcher = $eventDispatcher; + + $this->initEventHandling(); } public function setPortal(Portal $portal): void @@ -338,4 +342,18 @@ public function get(User $user, string $permission): string { return $this->getPermissionLevel($user, $permission); } + + protected function initEventHandling(): void + { + $this->eventDispatcher->subscribe(PortalUserRoleUpdate::class, function (PortalUserRoleUpdate $event) { + if ($this->portal?->getId() !== $event->portalId) { + return; + } + + $userId = $event->userId; + + unset($this->tableHashMap[$userId]); + unset($this->mapHashMap[$userId]); + }); + } } diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemState.php b/application/Espo/Core/Rebuild/Actions/AddSystemState.php new file mode 100644 index 00000000000..05618f9092a --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemState.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Entities\SystemState; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class AddSystemState implements RebuildAction +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(): void + { + $entity = $this->entityManager->getEntityById(SystemState::ENTITY_TYPE, SystemState::ID_VALUE); + + if ($entity) { + return; + } + + $this->entityManager->createEntity(SystemState::ENTITY_TYPE, [Attribute::ID => SystemState::ID_VALUE]); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/VersionUpdate.php b/application/Espo/Core/Rebuild/Actions/VersionUpdate.php new file mode 100644 index 00000000000..16f0651a6ac --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/VersionUpdate.php @@ -0,0 +1,63 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Upgrades\Migration\VersionDataProvider; +use Espo\Core\Utils\Config\ConfigWriter; + +/** + * @noinspection PhpUnused + */ +class VersionUpdate implements RebuildAction +{ + private const string DEV_VERSION = '@@version'; + + public function __construct( + private VersionDataProvider $versionDataProvider, + private ConfigWriter $configWriter, + ) {} + + public function process(): void + { + $coreVersion = $this->versionDataProvider->getTargetVersion(); + $stateVersion = $this->versionDataProvider->getPreviousVersion(); + + if ($coreVersion === self::DEV_VERSION || $stateVersion === self::DEV_VERSION) { + return; + } + + if (version_compare($stateVersion, $coreVersion) > 0) { + $this->configWriter->set('version', $coreVersion); + + $this->configWriter->save();; + } + } +} diff --git a/application/Espo/Core/Repositories/Database.php b/application/Espo/Core/Repositories/Database.php index 4b1f773549b..a6400377b73 100644 --- a/application/Espo/Core/Repositories/Database.php +++ b/application/Espo/Core/Repositories/Database.php @@ -56,12 +56,12 @@ */ class Database extends RDBRepository { - private const ATTR_ID = 'id'; - private const ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; - private const ATTR_MODIFIED_BY_ID = Field::MODIFIED_BY . 'Id'; - private const ATTR_MODIFIED_BY_NAME = Field::MODIFIED_BY . 'Name'; - private const ATTR_CREATED_AT = Field::CREATED_AT; - private const ATTR_MODIFIED_AT = Field::MODIFIED_AT; + private const string ATTR_ID = 'id'; + private const string ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; + private const string ATTR_MODIFIED_BY_ID = Field::MODIFIED_BY . 'Id'; + private const string ATTR_MODIFIED_BY_NAME = Field::MODIFIED_BY . 'Name'; + private const string ATTR_CREATED_AT = Field::CREATED_AT; + private const string ATTR_MODIFIED_AT = Field::MODIFIED_AT; /** * Disables hook processing. @@ -117,12 +117,6 @@ protected function getMetadata() /** @phpstan-ignore-line */ return $this->metadata; } - /** - * @deprecated Will be removed. - */ - public function handleSelectParams(&$params) /** @phpstan-ignore-line */ - {} - /** * @param TEntity $entity * @param array $options @@ -183,6 +177,8 @@ final protected function lateAfterRemove(Entity $entity, array $options): void * @param TEntity $entity * @param array $options * @return void + * + * @todo Add final in v11.0. */ protected function beforeRemove(Entity $entity, array $options = []) { @@ -222,6 +218,8 @@ protected function beforeRemove(Entity $entity, array $options = []) * @param TEntity $entity * @param array $options * @return void + * + * @todo Add final in v11.0. */ protected function afterRemove(Entity $entity, array $options = []) { @@ -240,6 +238,8 @@ protected function afterRemove(Entity $entity, array $options = []) * @param array $params * @param array $options * @return void + * + * @todo Add final in v11.0. */ protected function afterMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) { @@ -366,7 +366,7 @@ protected function beforeSave(Entity $entity, array $options = []) protected function afterSave(Entity $entity, array $options = []) { if (!empty($this->restoreData)) { - $entity->set($this->restoreData); + $entity->setMultiple($this->restoreData); $this->restoreData = null; } @@ -478,6 +478,7 @@ private function processCreatedAndModifiedFieldsSaveNew(Entity $entity, array $o /** * @pparam TEntity $entity * @return mixed + * @noinspection PhpSameParameterValueInspection */ private function getAttributeParam(Entity $entity, string $attribute, string $param) { @@ -499,6 +500,7 @@ private function getAttributeParam(Entity $entity, string $attribute, string $pa /** * @param TEntity $entity * @return mixed + * @noinspection PhpSameParameterValueInspection */ private function getRelationParam(Entity $entity, string $relation, string $param) { diff --git a/application/Espo/Core/Repositories/Event.php b/application/Espo/Core/Repositories/Event.php index ee8ba153ffd..ed8089eaf75 100644 --- a/application/Espo/Core/Repositories/Event.php +++ b/application/Espo/Core/Repositories/Event.php @@ -85,7 +85,6 @@ protected function beforeSave(Entity $entity, array $options = []) $entity->set(Meeting::FIELD_DATE_START, $dateStart); } else { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set(Meeting::FIELD_DATE_START_DATE, null); } } @@ -107,7 +106,6 @@ protected function beforeSave(Entity $entity, array $options = []) $dateEnd = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); $entity->set(Meeting::FIELD_DATE_END, $dateEnd); } else { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set(Meeting::FIELD_DATE_END_DATE, null); } } diff --git a/application/Espo/Core/Upgrades/Actions/Base.php b/application/Espo/Core/Upgrades/Actions/Base.php index f84a436469b..5cafa8e67fa 100644 --- a/application/Espo/Core/Upgrades/Actions/Base.php +++ b/application/Espo/Core/Upgrades/Actions/Base.php @@ -83,7 +83,7 @@ abstract class Base public function __construct( private Container $container, - private ActionManager $actionManager + private ActionManager $actionManager, ) { $this->params = $actionManager->getParams(); @@ -1007,9 +1007,8 @@ protected function enableMaintenanceMode(): void } $actualParams = [ - 'maintenanceMode' => $config->get('maintenanceMode'), - 'cronDisabled' => $config->get('cronDisabled'), - 'useCache' => $config->get('useCache'), + 'maintenanceMode' => $this->getSystemConfig()->isMaintenanceMode(), + 'useCache' => $this->getSystemConfig()->useCache(), ]; if ($configParamName) { @@ -1025,12 +1024,6 @@ protected function enableMaintenanceMode(): void $save = true; } - if (!$actualParams['cronDisabled']) { - $configWriter->set('cronDisabled', true); - - $save = true; - } - if ($actualParams['useCache']) { $configWriter->set('useCache', false); diff --git a/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php new file mode 100644 index 00000000000..5c889f027e1 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php @@ -0,0 +1,147 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Upgrades\Migrations\V10_1; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Theme\Direction; +use Espo\Entities\LeadCapture; +use Espo\Entities\Portal; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +class AfterUpgrade implements Script +{ + private const LEGACY_THEME = 'EspoRtl'; + private const THEME = 'Espo'; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private ConfigWriter $configWriter, + ) {} + + public function run(): void + { + $this->updateConfig(); + $this->updatePreferences(); + $this->updatePortals(); + $this->updateLeadCaptures(); + } + + private function updateConfig(): void + { + if ($this->config->get('theme') !== self::LEGACY_THEME) { + return; + } + + $this->configWriter->set('theme', self::THEME); + $this->configWriter->set('themeParams', $this->prepareThemeParams($this->config->get('themeParams'))); + $this->configWriter->save(); + } + + private function updatePreferences(): void + { + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + User::ATTR_TYPE => [ + User::TYPE_ADMIN, + User::TYPE_REGULAR, + User::TYPE_PORTAL, + ], + ]) + ->find(); + + foreach ($users as $user) { + $preferences = $this->entityManager + ->getRepositoryByClass(Preferences::class) + ->getById($user->getId()); + + if (!$preferences || $preferences->get('theme') !== self::LEGACY_THEME) { + continue; + } + + $this->updateThemeEntity($preferences); + } + } + + private function updatePortals(): void + { + $portals = $this->entityManager + ->getRDBRepositoryByClass(Portal::class) + ->where(['theme' => self::LEGACY_THEME]) + ->find(); + + foreach ($portals as $portal) { + $this->updateThemeEntity($portal); + } + } + + private function updateLeadCaptures(): void + { + $leadCaptures = $this->entityManager + ->getRDBRepositoryByClass(LeadCapture::class) + ->where(['formTheme' => self::LEGACY_THEME]) + ->find(); + + foreach ($leadCaptures as $leadCapture) { + $leadCapture->set('formTheme', self::THEME); + $this->entityManager->saveEntity($leadCapture); + } + } + + private function updateThemeEntity(Entity $entity): void + { + $entity->set('theme', self::THEME); + $entity->set('themeParams', $this->prepareThemeParams($entity->get('themeParams'))); + + $this->entityManager->saveEntity($entity); + } + + private function prepareThemeParams(mixed $themeParams): object + { + if (is_object($themeParams)) { + $themeParams = get_object_vars($themeParams); + } + + if (!is_array($themeParams)) { + $themeParams = []; + } + + $themeParams['navbar'] ??= 'top'; + $themeParams['direction'] = Direction::Rtl->value; + + return (object) $themeParams; + } +} diff --git a/application/Espo/Core/Utils/Address/CountryDataProvider.php b/application/Espo/Core/Utils/Address/CountryDataProvider.php index 28eda25154a..201ad00eaaf 100644 --- a/application/Espo/Core/Utils/Address/CountryDataProvider.php +++ b/application/Espo/Core/Utils/Address/CountryDataProvider.php @@ -30,27 +30,29 @@ namespace Espo\Core\Utils\Address; use Espo\Core\Name\Field; -use Espo\Core\Utils\Config; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Entities\AddressCountry; use Espo\ORM\EntityManager; use Espo\ORM\Query\Part\Order; class CountryDataProvider { - /** @var ?array{list: string[], preferredList: string[]} */ - private ?array $data = null; - private bool $useCache; - - private const CACHE_KEY = 'addressCountryData'; - private const LIMIT = 500; + private const string CACHE_KEY = 'addressCountryData'; + private const int LIMIT = 500; + /** + * @param DataCacheAccess $dataCacheAccess + */ public function __construct( - private DataCache $dataCache, private EntityManager $entityManager, - Config\SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, ) { - $this->useCache = $systemConfig->useCache(); + $dataCacheAccess->init( + key: self::CACHE_KEY, + loader: function () { + return $this->load(); + }, + ); } /** @@ -58,11 +60,7 @@ public function __construct( */ public function get(): array { - if ($this->data === null) { - $this->data = $this->load(); - } - - return $this->data; + return $this->dataCacheAccess->get(); } /** @@ -70,19 +68,6 @@ public function get(): array */ private function load(): array { - if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) { - $list = $this->dataCache->get(self::CACHE_KEY); - - if ( - is_array($list) && - is_array($list['list'] ?? null) && - is_array($list['preferredList'] ?? null) - ) { - /** @var array{list: string[], preferredList: string[]} */ - return $list; - } - } - $list = []; $preferredList = []; @@ -103,13 +88,6 @@ private function load(): array } } - if ($this->useCache) { - $this->dataCache->store(self::CACHE_KEY, [ - 'list' => $list, - 'preferredList' => $preferredList, - ]); - } - return [ 'list' => $list, 'preferredList' => $preferredList, diff --git a/application/Espo/Core/Utils/Autoload.php b/application/Espo/Core/Utils/Autoload.php index d1a87248260..ceccb65cd00 100644 --- a/application/Espo/Core/Utils/Autoload.php +++ b/application/Espo/Core/Utils/Autoload.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Autoload\Loader; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\File\Manager as FileManager; @@ -46,6 +47,7 @@ class Autoload public function __construct( private Metadata $metadata, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private FileManager $fileManager, private Loader $loader, @@ -75,9 +77,11 @@ private function init(): void /** @var ?array $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return; + return; + } } $this->data = $this->loadData(); diff --git a/application/Espo/Core/Utils/Autoload/NamespaceLoader.php b/application/Espo/Core/Utils/Autoload/NamespaceLoader.php index 3a3f2dd8a40..cc94fd1f891 100644 --- a/application/Espo/Core/Utils/Autoload/NamespaceLoader.php +++ b/application/Espo/Core/Utils/Autoload/NamespaceLoader.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\Autoload; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; @@ -68,6 +69,7 @@ class NamespaceLoader private ClassLoader $classLoader; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private FileManager $fileManager, private Log $log, @@ -240,12 +242,10 @@ private function getVendorNamespaces(string $path): array /** @var ?array $cachedData */ $cachedData = $this->dataCache->get($this->cacheKey); - $this->vendorNamespaces = $cachedData; + $this->vendorNamespaces = $cachedData ?? []; } } - assert($this->vendorNamespaces !== null); - if (!array_key_exists($path, $this->vendorNamespaces)) { $vendorPath = $this->findVendorPath($path); diff --git a/application/Espo/Core/Utils/Cache/CacheItem.php b/application/Espo/Core/Utils/Cache/CacheItem.php new file mode 100644 index 00000000000..90729faddaf --- /dev/null +++ b/application/Espo/Core/Utils/Cache/CacheItem.php @@ -0,0 +1,81 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache; + +use DateInterval; +use DateTimeInterface; +use Psr\Cache\CacheItemInterface; + +class CacheItem implements CacheItemInterface +{ + public function __construct( + private string $key, + private mixed $value, + private bool $isHit = false, + ) {} + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->isHit; + } + + public function set(mixed $value): static + { + $this->value = $value; + + return $this; + } + + /** + * Not supported. + */ + public function expiresAt(?DateTimeInterface $expiration): static + { + return $this; + } + + /** + * Not supported. + */ + public function expiresAfter(DateInterval|int|null $time): static + { + return $this; + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheAccess.php b/application/Espo/Core/Utils/Cache/DataCacheAccess.php new file mode 100644 index 00000000000..2aa2bd49b11 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheAccess.php @@ -0,0 +1,172 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache; + +use Closure; +use Espo\Core\Utils\Cache\Exceptions\ReadError; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Log; +use LogicException; +use stdClass; + +/** + * @internal + * @since 10.1.0 + * @template T of array | stdClass = array | stdClass + * @todo Test. + */ +class DataCacheAccess +{ + /** @var T|null */ + private mixed $data = null; + + private ?string $key = null; + + /** @var (Closure(): T)|null */ + private ?Closure $loader = null; + + /** @var (Closure(T): bool)|null */ + private ?Closure $validityChecker = null; + + public function __construct( + private DataCache $dataCache, + private SystemConfig $systemConfig, + private Log $log, + ) {} + + /** + * @param Closure(): T $loader + * @param (Closure(T): bool)|null $validityChecker + */ + public function init(string $key, Closure $loader, ?Closure $validityChecker = null): void + { + $this->key = $key; + $this->loader = $loader; + $this->validityChecker = $validityChecker; + + $this->data = null; + } + + public function reset(): void + { + $this->data = null; + } + + /** + * @param T $data + */ + public function set(mixed $data): void + { + $this->data = $data; + } + + public function store(): void + { + if (!$this->key) { + throw new LogicException("Not initialized."); + } + + if ($this->data === null) { + throw new LogicException("Data not set."); + } + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($this->key, $this->data); + } + } + + /** + * @return T + */ + public function get(): mixed + { + if ($this->data && $this->validityChecker && !($this->validityChecker)($this->data)) { + $this->data = null; + } + + if ($this->data !== null) { + return $this->data; + } + + $key = $this->key; + $loader = $this->loader; + + if (!$key || !$loader) { + throw new LogicException("Not initialized."); + } + + if ($this->systemConfig->useCache() && $this->dataCache->has($key)) { + $this->loadFromCache(); + } + + if ($this->data === null) { + $this->data = $loader(); + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($key, $this->data); + } + } + + return $this->data; + } + + private function loadFromCache(): void + { + $key = $this->key ?? throw new LogicException(); + + try { + $data = $this->dataCache->get($key); + } catch (ReadError $e) { + $this->log->warning("Corrupted cache data by key '{key}'.", [ + 'exception' => $e, + 'key' => $key, + ]); + + $this->dataCache->clear($key); + + return; + } + + if ($data === null) { + return; + } + + /** @var T $data */ + + if ($this->validityChecker && !($this->validityChecker)($data)) { + $this->data = null; + + return; + } + + $this->data = $data; + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheProvider.php b/application/Espo/Core/Utils/Cache/DataCacheProvider.php new file mode 100644 index 00000000000..4037e197fd3 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheProvider.php @@ -0,0 +1,52 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache; + +use Espo\Core\Container; +use Espo\Core\Utils\DataCache; + +/** + * May be needed when data cache is corrupted not allowing to load the DataCache service + * when clearing cache. + * + * @internal + * @since 10.1.0 + */ +class DataCacheProvider +{ + public function __construct( + private Container $container, + ) {} + + public function get(): DataCache + { + return $this->container->getByClass(DataCache::class); + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php b/application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php new file mode 100644 index 00000000000..35d71062530 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheServiceLoader.php @@ -0,0 +1,56 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache; + +use Espo\Core\Container; +use Espo\Core\Container\Loader; +use Espo\Core\Utils\DataCache; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class DataCacheServiceLoader implements Loader +{ + public function __construct( + private Container $container, + ) {} + + public function load(): DataCache + { + $service = $this->container->get(DataCacheServiceName::SYSTEM); + + if (!$service instanceof DataCache) { + throw new RuntimeException("Unexpected 'dataCache' service instance."); + } + + return $service; + } +} diff --git a/application/Espo/Core/Utils/Cache/DataCacheServiceName.php b/application/Espo/Core/Utils/Cache/DataCacheServiceName.php new file mode 100644 index 00000000000..219c3942be6 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/DataCacheServiceName.php @@ -0,0 +1,39 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache; + +/** + * @since 10.1.0 + */ +class DataCacheServiceName +{ + public const string SYSTEM = 'systemDataCache'; + public const string APPLICATION = 'dataCache'; +} diff --git a/application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php b/application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php new file mode 100644 index 00000000000..5be77e21efe --- /dev/null +++ b/application/Espo/Core/Utils/Cache/Exceptions/InvalidArgument.php @@ -0,0 +1,39 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache\Exceptions; + +use Psr\Cache\InvalidArgumentException as InvalidArgumentExceptionInterface; +use RuntimeException; + +/** + * @since 10.1.0 + */ +class InvalidArgument extends RuntimeException implements InvalidArgumentExceptionInterface +{} diff --git a/application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php b/application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php new file mode 100644 index 00000000000..73fb7d54814 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/Exceptions/PersistenceError.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache\Exceptions; + +use RuntimeException; + +/** + * @since 10.1.0 + */ +class PersistenceError extends RuntimeException +{} diff --git a/application/Espo/Core/Utils/Cache/Exceptions/ReadError.php b/application/Espo/Core/Utils/Cache/Exceptions/ReadError.php new file mode 100644 index 00000000000..d0c32568696 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/Exceptions/ReadError.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache\Exceptions; + +use RuntimeException; + +/** + * @since 10.1.0 + */ +class ReadError extends RuntimeException +{} diff --git a/application/Espo/Core/Utils/Cache/FileCacheItemPool.php b/application/Espo/Core/Utils/Cache/FileCacheItemPool.php new file mode 100644 index 00000000000..b3802368982 --- /dev/null +++ b/application/Espo/Core/Utils/Cache/FileCacheItemPool.php @@ -0,0 +1,176 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Cache; + +use Espo\Core\Utils\Cache\Exceptions\InvalidArgument; +use Espo\Core\Utils\File\Exceptions\FileError; +use Espo\Core\Utils\File\Manager as FileManager; +use InvalidArgumentException; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +/** + * Supports only array and stdClass values. + * + * @since 10.1.0 + */ +class FileCacheItemPool implements CacheItemPoolInterface +{ + private string $cacheDir = 'data/cache/application/'; + + public function __construct(private FileManager $fileManager) + {} + + /** + * @throws InvalidArgument + */ + public function getItem(string $key): CacheItem + { + $file = $this->getFile($key); + + try { + $value = $this->fileManager->getPhpSafeContents($file); + } catch (FileError) { + return new CacheItem( + key: $key, + value: null, + isHit: false, + ); + } + + return new CacheItem( + key: $key, + value: $value, + isHit: true, + ); + } + + /** + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + $values = []; + + foreach ($keys as $key) { + $values[] = $this->getItem($key); + } + + return $values; + } + + /** + * @throws InvalidArgument + */ + public function hasItem(string $key): bool + { + $file = $this->getFile($key); + + return $this->fileManager->isFile($file); + } + + /** + * @inheritDoc + */ + public function clear(): bool + { + return $this->fileManager->removeInDir($this->cacheDir); + } + + /** + * @inheritDoc + * @throws InvalidArgument + */ + public function deleteItem(string $key): bool + { + $file = $this->getFile($key); + + return $this->fileManager->removeFile($file); + } + + /** + * @inheritDoc + */ + public function deleteItems(array $keys): bool + { + $result = true; + + foreach ($keys as $key) { + $result &= $this->deleteItem($key); + } + + return (bool) $result; + } + + /** + * Persists a cache immediately. + * + * @todo Throw an error in DataCache if false. + */ + public function save(CacheItemInterface $item): bool + { + $file = $this->getFile($item->getKey()); + + $result = $this->fileManager->putPhpContents($file, $item->get(), true, true); + + if ($result === false) { + return false; + } + + return true; + } + + /** + * Sets a cache item to be persisted later. + */ + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function commit(): bool + { + return true; + } + + private function getFile(string $key): string + { + if ( + $key === '' || + preg_match('/[^a-zA-Z0-9_\/\-]/i', $key) || + $key[0] === '/' || + str_ends_with($key, '/') + ) { + throw new InvalidArgumentException("Bad cache key."); + } + + return $this->cacheDir . $key . '.php'; + } +} diff --git a/application/Espo/Core/Utils/Client/ActionRenderer.php b/application/Espo/Core/Utils/Client/ActionRenderer.php index 8e5ab08bac9..075e768f94a 100644 --- a/application/Espo/Core/Utils/Client/ActionRenderer.php +++ b/application/Espo/Core/Utils/Client/ActionRenderer.php @@ -33,6 +33,7 @@ use Espo\Core\Utils\Client\ActionRenderer\Params; use Espo\Core\Utils\Json; use Espo\Core\Utils\ClientManager; +use Espo\Core\Utils\Theme\Direction; /** * Renders a front-end page that executes a controller action. Utilized by entry points. @@ -56,6 +57,7 @@ public function write(Response $response, Params $params): void scripts: $params->getScripts(), pageTitle: $params->getPageTitle(), theme: $params->getTheme(), + direction: $params->getDirection(), ); $securityParams = new SecurityParams( @@ -78,6 +80,7 @@ private function render( array $scripts, ?string $pageTitle, ?string $theme, + ?Direction $direction, ): string { $encodedData = Json::encode($data); @@ -99,6 +102,7 @@ private function render( scripts: $scripts, pageTitle: $pageTitle, theme: $theme, + direction: $direction, ); return $this->clientManager->render($params); diff --git a/application/Espo/Core/Utils/Client/ActionRenderer/Params.php b/application/Espo/Core/Utils/Client/ActionRenderer/Params.php index bca03a896e6..971b48a48ae 100644 --- a/application/Espo/Core/Utils/Client/ActionRenderer/Params.php +++ b/application/Espo/Core/Utils/Client/ActionRenderer/Params.php @@ -30,6 +30,7 @@ namespace Espo\Core\Utils\Client\ActionRenderer; use Espo\Core\Utils\Client\Script; +use Espo\Core\Utils\Theme\Direction; /** * Immutable. @@ -45,6 +46,7 @@ class Params private array $scripts = []; private ?string $pageTitle = null; private ?string $theme = null; + private ?Direction $direction = null; /** * @param ?array $data @@ -130,6 +132,14 @@ public function withTheme(?string $theme): self return $obj; } + public function withDirection(?Direction $direction): self + { + $obj = clone $this; + $obj->direction = $direction; + + return $obj; + } + public function getController(): string { return $this->controller; @@ -186,4 +196,9 @@ public function getTheme(): ?string { return $this->theme; } + + public function getDirection(): ?Direction + { + return $this->direction; + } } diff --git a/application/Espo/Core/Utils/Client/RenderParams.php b/application/Espo/Core/Utils/Client/RenderParams.php index 974401e70b1..c21ad515be7 100644 --- a/application/Espo/Core/Utils/Client/RenderParams.php +++ b/application/Espo/Core/Utils/Client/RenderParams.php @@ -29,6 +29,8 @@ namespace Espo\Core\Utils\Client; +use Espo\Core\Utils\Theme\Direction; + readonly class RenderParams { /** @@ -36,11 +38,13 @@ * @param Script[] $scripts Scripts to include on the page. * @param ?string $pageTitle A page title. Since 9.1.0. * @param ?string $theme A page theme name. + * @param ?Direction $direction A document direction. */ public function __construct( public ?string $runScript = null, public array $scripts = [], public ?string $pageTitle = null, public ?string $theme = null, + public ?Direction $direction = null, ) {} } diff --git a/application/Espo/Core/Utils/ClientManager.php b/application/Espo/Core/Utils/ClientManager.php index c52a53a5e3d..b245a0527d7 100644 --- a/application/Espo/Core/Utils/ClientManager.php +++ b/application/Espo/Core/Utils/ClientManager.php @@ -41,6 +41,7 @@ use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Theme\Direction; use Espo\Core\Utils\Theme\MetadataProvider as ThemeMetadataProvider; use Slim\Psr7\Response as Psr7Response; use Slim\ResponseEmitter; @@ -179,6 +180,7 @@ public function render(RenderParams $params): string additionalScripts: $params->scripts, pageTitle: $params->pageTitle, theme: $params->theme, + direction: $params->direction, ); } @@ -193,6 +195,7 @@ private function renderInternal( array $additionalScripts = [], ?string $pageTitle = null, ?string $theme = null, + ?Direction $direction = null, ): string { $runScript ??= $this->runScript; @@ -248,6 +251,8 @@ private function renderInternal( $this->themeMetadataProvider->getStylesheet($theme) : $this->themeManager->getStylesheet(); + $direction ??= $this->themeManager->getDirection(); + $data = [ 'applicationId' => $this->applicationId, 'apiUrl' => $this->apiUrl, @@ -256,6 +261,7 @@ private function renderInternal( 'appTimestamp' => $appTimestamp, 'loaderCacheTimestamp' => Json::encode($loaderCacheTimestamp), 'stylesheet' => $stylesheet, + 'direction' => $direction->value, 'theme' => Json::encode($theme), 'runScript' => $runScript, 'basePath' => $this->basePath, diff --git a/application/Espo/Core/Utils/Config/StateConfig.php b/application/Espo/Core/Utils/Config/StateConfig.php new file mode 100644 index 00000000000..373b56ab9f9 --- /dev/null +++ b/application/Espo/Core/Utils/Config/StateConfig.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Config; + +use Espo\Core\Utils\Config; + +/** + * @since 10.1.0 + */ +class StateConfig +{ + public function __construct( + private Config $config, + ) {} + + public function getCacheTimestamp(): int + { + return $this->config->get('cacheTimestamp') ?? 0; + } +} diff --git a/application/Espo/Core/Utils/Config/StateConfigDirect.php b/application/Espo/Core/Utils/Config/StateConfigDirect.php new file mode 100644 index 00000000000..b67f51cf1ba --- /dev/null +++ b/application/Espo/Core/Utils/Config/StateConfigDirect.php @@ -0,0 +1,52 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Config; + +/** + * @internal + * @since 10.1.0 + */ +class StateConfigDirect +{ + private string $stateConfigPath = 'data/state.php'; + + private const string CACHE_TIMESTAMP_KEY = 'cacheTimestamp'; + + public function __construct( + private ConfigFileManager $configFileManager, + ) {} + + public function getCacheTimestamp(): int + { + $data = $this->configFileManager->getPhpContents($this->stateConfigPath); + + return $data[self::CACHE_TIMESTAMP_KEY] ?? 0; + } +} diff --git a/application/Espo/Core/Utils/Config/SystemConfig.php b/application/Espo/Core/Utils/Config/SystemConfig.php index d77cd671d0d..f99e4a2fb34 100644 --- a/application/Espo/Core/Utils/Config/SystemConfig.php +++ b/application/Espo/Core/Utils/Config/SystemConfig.php @@ -68,4 +68,14 @@ public function isCronEnabled(): bool { return !$this->config->get('cronDisabled'); } + + /** + * A maintenance mode. Only admin can log in. + * + * @since 10.1.0 + */ + public function isMaintenanceMode(): bool + { + return (bool) $this->config->get('maintenanceMode'); + } } diff --git a/application/Espo/Core/Utils/DataCache.php b/application/Espo/Core/Utils/DataCache.php index f7509c8c90b..ba308b0580f 100644 --- a/application/Espo/Core/Utils/DataCache.php +++ b/application/Espo/Core/Utils/DataCache.php @@ -29,60 +29,82 @@ namespace Espo\Core\Utils; -use Espo\Core\Utils\File\Exceptions\FileError; -use Espo\Core\Utils\File\Manager as FileManager; - -use InvalidArgumentException; -use RuntimeException; +use Espo\Core\Utils\Cache\CacheItem; +use Espo\Core\Utils\Cache\Exceptions\InvalidArgument; +use Espo\Core\Utils\Cache\Exceptions\PersistenceError; +use Espo\Core\Utils\Cache\Exceptions\ReadError; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException; use stdClass; class DataCache { - protected string $cacheDir = 'data/cache/application/'; + /** + * DI qualifier. Can be applied to the DataCache and DataCacheAccess dependencies. + * + * @since 10.1.0 + */ + public const string QUALIFIER_SYSTEM = 'system'; - public function __construct(protected FileManager $fileManager) - {} + public function __construct( + private CacheItemPoolInterface $pool, + ) {} /** * Whether is cached. */ public function has(string $key): bool { - $cacheFile = $this->getCacheFile($key); - - return $this->fileManager->isFile($cacheFile); + try { + return $this->pool->hasItem($key); + } catch (InvalidArgumentException $e) { + throw new InvalidArgument(previous: $e); + } } /** - * Get a stored value. + * Get a stored value. Returns null if not hit. * - * @return array|stdClass - * @throws FileError + * @return array|stdClass|null + * @throws ReadError If data is corrupted. */ - public function get(string $key) + public function get(string $key): array|stdClass|null { - $cacheFile = $this->getCacheFile($key); + try { + $item = $this->pool->getItem($key); + } catch (InvalidArgumentException $e) { + throw new InvalidArgument(previous: $e); + } + + if (!$item->isHit()) { + return null; + } - return $this->fileManager->getPhpSafeContents($cacheFile); + $value = $item->get(); + + if (!is_array($value) && !$value instanceof stdClass) { + throw new ReadError("Bad cache data by key '$key'."); + } + + /** @var array|stdClass */ + return $value; } /** - * Try to get a stored value. Returns null if does not exist. + * Try to get a stored value. Does not throw ReadError. * * @return array|stdClass|null * @since 9.3.0 */ - public function tryGet(string $key) + public function tryGet(string $key): array|stdClass|null { if (!$this->has($key)) { return null; } - $cacheFile = $this->getCacheFile($key); - try { - return $this->fileManager->getPhpSafeContents($cacheFile); - } catch (FileError) { + return $this->get($key); + } catch (ReadError) { return null; } } @@ -91,58 +113,57 @@ public function tryGet(string $key) * Store in cache. * * @param array|stdClass $data + * @throws PersistenceError */ public function store(string $key, $data): void { /** @phpstan-var mixed $data */ if (!$this->checkDataIsValid($data)) { - throw new InvalidArgumentException("Bad cache data type."); + throw new InvalidArgument("Bad cache data type."); } - $cacheFile = $this->getCacheFile($key); + $item = new CacheItem( + key: $key, + value: $data, + ); - $result = $this->fileManager->putPhpContents($cacheFile, $data, true, true); + $result = $this->pool->save($item); if ($result === false) { - throw new RuntimeException("Could not store '$key'."); + throw new PersistenceError("Could not store '$key'."); } } /** * Removes in cache. + * + * @throws InvalidArgument */ public function clear(string $key): void { - $cacheFile = $this->getCacheFile($key); - - $this->fileManager->removeFile($cacheFile); + $this->pool->deleteItem($key); } /** - * @param mixed $data - * @return bool + * Clears all cache. + * + * @since 10.1.0 + * @throws PersistenceError */ - private function checkDataIsValid($data) + public function clearAll(): void { - $isInvalid = - !is_array($data) && - !$data instanceof stdClass; + $result = $this->pool->clear(); - return !$isInvalid; + if ($result === false) { + throw new PersistenceError("Could not clear cache."); + } } - private function getCacheFile(string $key): string + private function checkDataIsValid(mixed $data): bool { - if ( - $key === '' || - preg_match('/[^a-zA-Z0-9_\/\-]/i', $key) || - $key[0] === '/' || - str_ends_with($key, '/') - ) { - throw new InvalidArgumentException("Bad cache key."); - } + $isInvalid = !is_array($data) && !$data instanceof stdClass; - return $this->cacheDir . $key . '.php'; + return !$isInvalid; } } diff --git a/application/Espo/Core/Utils/DateTime.php b/application/Espo/Core/Utils/DateTime.php index cae75fc7017..ca45e2f42dd 100644 --- a/application/Espo/Core/Utils/DateTime.php +++ b/application/Espo/Core/Utils/DateTime.php @@ -37,6 +37,7 @@ use DateTime as DateTimeStd; use DateTimeImmutable; use DateTimeZone; +use Espo\Core\Utils\DateTime\Clock; use Exception; use RuntimeException; @@ -46,8 +47,8 @@ */ class DateTime { - public const SYSTEM_DATE_TIME_FORMAT = 'Y-m-d H:i:s'; - public const SYSTEM_DATE_FORMAT = 'Y-m-d'; + public const string SYSTEM_DATE_TIME_FORMAT = 'Y-m-d H:i:s'; + public const string SYSTEM_DATE_FORMAT = 'Y-m-d'; private string $dateFormat; private string $timeFormat; @@ -58,7 +59,8 @@ public function __construct( ?string $dateFormat = 'YYYY-MM-DD', ?string $timeFormat = 'HH:mm', ?string $timeZone = 'UTC', - ?string $language = 'en_US' + ?string $language = 'en_US', + private ?Clock $clock = null, ) { $this->dateFormat = $dateFormat ?? 'YYYY-MM-DD'; $this->timeFormat = $timeFormat ?? 'HH:mm'; @@ -168,8 +170,8 @@ public function getTodayString(?string $timezone = null, ?string $format = null) throw new RuntimeException($e->getMessage()); } - $dateTime = new DateTimeStd(); - $dateTime->setTimezone($tz); + $dateTime = $this->getNowInternal() + ->setTimezone($tz); $carbon = Carbon::instance($dateTime); $carbon->locale($this->language); @@ -191,9 +193,8 @@ public function getNowString(?string $timezone = null, ?string $format = null): throw new RuntimeException($e->getMessage()); } - $dateTime = new DateTimeStd(); - - $dateTime->setTimezone($tz); + $dateTime = $this->getNowInternal() + ->setTimezone($tz); $carbon = Carbon::instance($dateTime); @@ -257,7 +258,7 @@ public function getTimezone(): DateTimeZone */ public function getToday(): Date { - $string = (new DateTimeImmutable) + $string = $this->getNowInternal() ->setTimezone($this->timezone) ->format(self::SYSTEM_DATE_FORMAT); @@ -271,7 +272,7 @@ public function getToday(): Date */ public function getNow(): DateTimeField { - return DateTimeField::createNow() + return DateTimeField::fromDateTime($this->getNowInternal()) ->withTimezone($this->timezone); } @@ -307,4 +308,9 @@ public function convertSystemDateTimeToGlobal(string $string): string { return $this->convertSystemDateTime($string); } + + private function getNowInternal(): DateTimeImmutable + { + return $this->clock?->now() ?? new DateTimeImmutable(); + } } diff --git a/application/Espo/Core/Utils/EmailFilterManager.php b/application/Espo/Core/Utils/EmailFilterManager.php index 26059a78d65..cf3fca53eb5 100644 --- a/application/Espo/Core/Utils/EmailFilterManager.php +++ b/application/Espo/Core/Utils/EmailFilterManager.php @@ -32,6 +32,7 @@ use Espo\Core\ORM\EntityManager; use Espo\Core\Mail\FiltersMatcher; use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\User\UserStateProvider; use Espo\Entities\Email; use Espo\Entities\EmailFilter; use Espo\Entities\User; @@ -49,13 +50,17 @@ class EmailFilterManager private array $data = []; private bool $useCache; - private const CACHE_KEY = 'emailFilters'; + /** @var array */ + private array $cacheVersionMap = []; + + private const string CACHE_KEY = 'emailFilters'; public function __construct( private EntityManager $entityManager, private FiltersMatcher $filtersMatcher, private DataCache $dataCache, SystemConfig $systemConfig, + private UserStateProvider $userStateProvider, ) { $this->useCache = $systemConfig->useCache(); } @@ -72,16 +77,24 @@ public function getMatchingFilter(Email $email, string $userId): ?EmailFilter */ private function get(string $userId): array { - if (array_key_exists($userId, $this->data)) { + if (array_key_exists($userId, $this->data) && $this->cacheIsRelevant($userId)) { return $this->data[$userId]; } + unset($this->data[$userId]); + $cacheKey = $this->composeCacheKey($userId); if ($this->useCache && $this->dataCache->has($cacheKey)) { - $this->data[$userId] = $this->loadFromCache($cacheKey); + $cached = $this->loadFromCache($cacheKey); - return $this->data[$userId]; + if ($cached !== null) { + $this->data[$userId] = $cached; + + $this->setCacheVersionNumber($userId); + + return $this->data[$userId]; + } } $this->data[$userId] = $this->fetch($userId); @@ -90,6 +103,8 @@ private function get(string $userId): array $this->storeToCache($userId); } + $this->setCacheVersionNumber($userId); + return $this->data[$userId]; } @@ -125,13 +140,17 @@ private function fetch(string $userId): array } /** - * @return EmailFilter[] + * @return ?EmailFilter[] */ - private function loadFromCache(string $cacheKey): array + private function loadFromCache(string $cacheKey): ?array { /** @var stdClass[] $dataList */ $dataList = $this->dataCache->get($cacheKey); + if ($dataList === null) { + return null; + } + /** @var EmailFilter[] $list */ $list = []; @@ -163,4 +182,20 @@ private function storeToCache(string $userId): void $this->dataCache->store($cacheKey, $dataList); } + + private function setCacheVersionNumber(string $userId): void + { + $this->cacheVersionMap[$userId] = $this->userStateProvider->getEmailFiltersVersionNumber($userId); + } + + private function cacheIsRelevant(string $userId): bool + { + $versionNumber = $this->cacheVersionMap[$userId] ?? null; + + if ($versionNumber === null) { + return false; + } + + return $versionNumber === $this->userStateProvider->getEmailFiltersVersionNumber($userId); + } } diff --git a/application/Espo/Core/Utils/Event/BypassEventTransport.php b/application/Espo/Core/Utils/Event/BypassEventTransport.php new file mode 100644 index 00000000000..7ce1bcd1572 --- /dev/null +++ b/application/Espo/Core/Utils/Event/BypassEventTransport.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use Closure; + +class BypassEventTransport implements EventTransport +{ + public function subscribe(Closure $callback): void + {} + + public function publish(Envelope $envelope): void + {} + + public function tick(): void + {} +} diff --git a/application/Espo/Core/Utils/Event/Configuration.php b/application/Espo/Core/Utils/Event/Configuration.php new file mode 100644 index 00000000000..fb83e9d720e --- /dev/null +++ b/application/Espo/Core/Utils/Event/Configuration.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +/** + * @internal + */ +class Configuration +{ + public function __construct( + private bool $subscribeToCrossInstanceEvents = false, + ) {} + + public function subscribeToCrossInstanceEvents(): bool + { + return $this->subscribeToCrossInstanceEvents; + } + + /** + * @internal + */ + public function setSubscribeToCrossInstanceEvents(bool $value): self + { + $this->subscribeToCrossInstanceEvents = $value; + + return $this; + } +} diff --git a/application/Espo/Core/Utils/Event/Context.php b/application/Espo/Core/Utils/Event/Context.php new file mode 100644 index 00000000000..59471cddc81 --- /dev/null +++ b/application/Espo/Core/Utils/Event/Context.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +/** + * @since 10.1.0 + */ +readonly class Context +{ + /** + * @internal + */ + public function __construct( + public bool $isLocal, + public string $origin, + ) {} +} diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEvent.php b/application/Espo/Core/Utils/Event/CrossInstanceEvent.php new file mode 100644 index 00000000000..9a803dcea8c --- /dev/null +++ b/application/Espo/Core/Utils/Event/CrossInstanceEvent.php @@ -0,0 +1,39 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use stdClass; + +interface CrossInstanceEvent extends Event +{ + public static function fromPayload(stdClass $payload): self; + + public function toPayload(): stdClass; +} diff --git a/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php new file mode 100644 index 00000000000..44aab6c5517 --- /dev/null +++ b/application/Espo/Core/Utils/Event/CrossInstanceEventDispatcher.php @@ -0,0 +1,138 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use Closure; +use RuntimeException; +use Throwable; + +class CrossInstanceEventDispatcher +{ + /** + * @var array, (Closure(CrossInstanceEvent, Context): void)[]> + */ + private array $callbacks = []; + + private bool $isTransportSubscribed = false; + + public function __construct( + private EventTransport $transport, + private OriginProvider $originProvider, + ) {} + + /** + * @param class-string $className + * @param Closure(CrossInstanceEvent, Context): void $callback + */ + public function subscribe(string $className, Closure $callback): void + { + $this->ensureSubscribeTransport(); + + $this->callbacks[$className] ??= []; + $this->callbacks[$className][] = $callback; + } + + /** + * @param class-string $className + * @param Closure(CrossInstanceEvent, Context): void $callback + */ + public function unsubscribe(string $className, Closure $callback): void + { + if (!array_key_exists($className, $this->callbacks)) { + return; + } + + $list = &$this->callbacks[$className]; + + $index = array_search($callback, $list); + + if ($index !== false) { + unset($list[$index]); + + $list = array_values($list); + } + } + + public function dispatch(CrossInstanceEvent $event): void + { + $envelope = new Envelope( + eventClassName: $event::class, + payload: $event->toPayload(), + origin: $this->originProvider->get(), + ); + + $this->transport->publish($envelope); + } + + private function ensureSubscribeTransport(): void + { + if ($this->isTransportSubscribed) { + return; + } + + $this->transport->subscribe(fn (Envelope $envelope) => $this->transportCallback($envelope)); + + $this->isTransportSubscribed = true; + } + + private function transportCallback(Envelope $envelope): void + { + if ($envelope->origin === $this->originProvider->get()) { + return; + } + + $className = $envelope->eventClassName; + + if (!is_subclass_of($className, CrossInstanceEvent::class)) { + throw new RuntimeException("Non-valid event class name."); + } + + $callbacks = $this->callbacks[$className] ?? []; + + if ($callbacks === []) { + return; + } + + try { + $event = $className::fromPayload($envelope->payload); + } catch (Throwable $e) { + throw new RuntimeException("Could not hydrate event '$className'.", previous: $e); + } + + $context = new Context( + isLocal: false, + origin: $envelope->origin, + ); + + foreach ($callbacks as $callback) { + $callback($event, $context); + } + } +} diff --git a/application/Espo/Core/Utils/Event/Envelope.php b/application/Espo/Core/Utils/Event/Envelope.php new file mode 100644 index 00000000000..7d2c08dfd6f --- /dev/null +++ b/application/Espo/Core/Utils/Event/Envelope.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use stdClass; + +readonly class Envelope +{ + /** + * @param class-string $eventClassName + */ + public function __construct( + public string $eventClassName, + public stdClass $payload, + public string $origin, + ) {} +} diff --git a/application/Espo/Core/Utils/Event/Event.php b/application/Espo/Core/Utils/Event/Event.php new file mode 100644 index 00000000000..d80eb1b0c23 --- /dev/null +++ b/application/Espo/Core/Utils/Event/Event.php @@ -0,0 +1,36 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +/** + * @since 10.1.0 + */ +interface Event +{} diff --git a/application/Espo/Core/Utils/Event/EventDispatcher.php b/application/Espo/Core/Utils/Event/EventDispatcher.php new file mode 100644 index 00000000000..2cff79e73bf --- /dev/null +++ b/application/Espo/Core/Utils/Event/EventDispatcher.php @@ -0,0 +1,118 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use Closure; + +/** + * @since 10.1.0 + */ +class EventDispatcher +{ + /** + * @var array, (Closure(Event, Context): void)[]> + */ + private array $callbacks = []; + + public function __construct( + private OriginProvider $originProvider, + private CrossInstanceEventDispatcher $crossInstanceDispatcher, + private Configuration $configuration, + ) {} + + /** + * @template T of Event + * @param class-string $className + * @param Closure(T, Context): void $callback + */ + public function subscribe(string $className, Closure $callback): void + { + /** @var Closure(Event, Context): void $callback */ + + $this->callbacks[$className] ??= []; + $this->callbacks[$className][] = $callback; + + if ( + $this->configuration->subscribeToCrossInstanceEvents() && + is_subclass_of($className, CrossInstanceEvent::class) + ) { + $this->crossInstanceDispatcher->subscribe($className, $callback); + } + } + + /** + * @template T of Event + * @param class-string $className + * @param Closure(T, Context): void $callback + */ + public function unsubscribe(string $className, Closure $callback): void + { + /** @var Closure(Event, Context): void $callback */ + + if (!array_key_exists($className, $this->callbacks)) { + return; + } + + $list = &$this->callbacks[$className]; + + $index = array_search($callback, $list); + + if ($index !== false) { + unset($list[$index]); + + $list = array_values($list); + } + + if ( + $this->configuration->subscribeToCrossInstanceEvents() && + is_subclass_of($className, CrossInstanceEvent::class) + ) { + $this->crossInstanceDispatcher->unsubscribe($className, $callback); + } + } + + public function dispatch(Event $event): void + { + $callbacks = $this->callbacks[$event::class] ?? []; + + $localContext = new Context( + isLocal: true, + origin: $this->originProvider->get(), + ); + + foreach ($callbacks as $callback) { + $callback($event, $localContext); + } + + if ($event instanceof CrossInstanceEvent) { + $this->crossInstanceDispatcher->dispatch($event); + } + } +} diff --git a/application/Espo/Core/Utils/Event/EventTransport.php b/application/Espo/Core/Utils/Event/EventTransport.php new file mode 100644 index 00000000000..cf83c345dd7 --- /dev/null +++ b/application/Espo/Core/Utils/Event/EventTransport.php @@ -0,0 +1,51 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use Closure; +use Espo\Core\Utils\Event\Exceptions\TransportNotConnected; + +/** + * @since 10.1.0 + */ +interface EventTransport +{ + /** + * @param Closure(Envelope): void $callback + */ + public function subscribe(Closure $callback): void; + + public function publish(Envelope $envelope): void; + + /** + * @throws TransportNotConnected + */ + public function tick(): void; +} diff --git a/application/Espo/Core/Utils/Event/EventTransportLoader.php b/application/Espo/Core/Utils/Event/EventTransportLoader.php new file mode 100644 index 00000000000..0851b9ae132 --- /dev/null +++ b/application/Espo/Core/Utils/Event/EventTransportLoader.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Event\Redis\RedisEventTransport; + +/** + * @noinspection PhpUnused + */ +class EventTransportLoader implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Config $config, + ) {} + + public function load(): EventTransport + { + if ($this->config->get('eventTransport') === 'Redis') { + return $this->injectableFactory->create(RedisEventTransport::class); + } + + return $this->injectableFactory->create(BypassEventTransport::class); + } +} diff --git a/application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php b/application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php new file mode 100644 index 00000000000..9fe8c30fd5d --- /dev/null +++ b/application/Espo/Core/Utils/Event/Exceptions/TransportNotConnected.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event\Exceptions; + +use Exception; + +class TransportNotConnected extends Exception +{} diff --git a/application/Espo/Core/Utils/Event/OriginProvider.php b/application/Espo/Core/Utils/Event/OriginProvider.php new file mode 100644 index 00000000000..e98297ecf4f --- /dev/null +++ b/application/Espo/Core/Utils/Event/OriginProvider.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event; + +use Espo\Core\Utils\Util; + +/** + * @since 10.1.0 + */ +class OriginProvider +{ + private ?string $value = null; + + public function get(): string + { + $this->value ??= Util::generateId(); + + return $this->value; + } +} diff --git a/application/Espo/Core/Utils/Event/Redis/ClientProvider.php b/application/Espo/Core/Utils/Event/Redis/ClientProvider.php new file mode 100644 index 00000000000..708847ce472 --- /dev/null +++ b/application/Espo/Core/Utils/Event/Redis/ClientProvider.php @@ -0,0 +1,84 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event\Redis; + +use Espo\Core\Utils\Config; +use Predis\Client; +use Predis\CommunicationException; + +class ClientProvider +{ + private ?Client $client = null; + + public function __construct( + private Config $config, + ) {} + + public function get(): Client + { + if (!$this->client) { + $scheme = $this->config->get('redis.scheme') ?? null; + $port = $this->config->get('redis.port') ?? null; + $host = $this->config->get('redis.host') ?? null; + + $params = []; + + if ($scheme !== null) { + $params['scheme'] = $scheme; + } + + if ($host !== null) { + $params['host'] = $host; + } + + if ($port !== null) { + $params['port'] = $port; + } + + $this->client = new Client($params); + } + + return $this->client; + } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function reconnect(): void + { + try { + $this->client?->disconnect(); + } catch (CommunicationException) {} + + $client = $this->get(); + + $client->connect(); + } +} diff --git a/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php new file mode 100644 index 00000000000..1be3883814c --- /dev/null +++ b/application/Espo/Core/Utils/Event/Redis/RedisEventTransport.php @@ -0,0 +1,202 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Event\Redis; + +use Closure; +use Espo\Core\Utils\Event\CrossInstanceEvent; +use Espo\Core\Utils\Event\Envelope; +use Espo\Core\Utils\Event\EventTransport; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Log; +use LogicException; +use Predis\Connection\ConnectionException; +use RuntimeException; +use stdClass; +use Throwable; + +class RedisEventTransport implements EventTransport +{ + private const string STREAM_NAME = 'espocrm:events'; + + private const int COUNT = 50; + private const int MAX_STREAM_LENGTH = 10000; + + /** + * @var ?(Closure(Envelope): void) $callback + */ + private ?Closure $callback = null; + + private ?string $lastId = null; + + public function __construct( + private ClientProvider $clientProvider, + private Log $log, + ) {} + + public function subscribe(Closure $callback): void + { + $this->callback = $callback; + } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function publish(Envelope $envelope): void + { + $json = Json::encode([ + 'eventClassName' => $envelope->eventClassName, + 'payload' => $envelope->payload, + 'origin' => $envelope->origin, + ]); + + try { + $this->publishRaw($json); + } catch (ConnectionException $e) { + $this->log->info("Redis connection lost, reconnecting.", ['exception' => $e]); + + $this->clientProvider->reconnect(); + + $this->publishRaw($json); + } + } + + public function tick(): void + { + $client = $this->clientProvider->get(); + + if ($this->lastId === null) { + $response = $client->xread(1, null, [self::STREAM_NAME], '+'); + + $this->lastId = '0'; + + foreach (($response[self::STREAM_NAME] ?? []) as $item) { + [$messageId] = $this->getMessageData($item); + + $this->lastId = $messageId; + + break; + } + } + + $response = $client->xread(self::COUNT, null, [self::STREAM_NAME], $this->lastId); + + $jsonItems = []; + + foreach (($response[self::STREAM_NAME] ?? []) as $item) { + [$messageId, $json] = $this->getMessageData($item); + + $this->lastId = $messageId; + + $jsonItems[] = $json; + } + + if (!$this->callback) { + return; + } + + foreach ($jsonItems as $json) { + $this->processMessageItem($json); + } + } + + private function processMessageItem(string $json): void + { + if (!$this->callback) { + throw new LogicException(); + } + + $data = Json::decode($json); + + /** @var ?class-string $eventClassName */ + $eventClassName = $data->eventClassName ?? null; + $payload = $data->payload ?? null; + $origin = $data->origin ?? null; + + if (!is_string($eventClassName)) { + throw new RuntimeException(); + } + + if (!$payload instanceof stdClass) { + throw new RuntimeException(); + } + + if (!is_string($origin)) { + throw new RuntimeException(); + } + + $envelope = new Envelope( + eventClassName: $eventClassName, + payload: $payload, + origin: $origin, + ); + + try { + ($this->callback)($envelope); + } catch (Throwable $e) { + $this->log->error("Event callback error, {eventClassName}.", [ + 'exception' => $e, + 'eventClassName' => $eventClassName, + 'origin' => $origin, + ]); + } + } + + private function publishRaw(string $json): void + { + $client = $this->clientProvider->get(); + + $options = [ + 'trim' => ['MAXLEN', self::MAX_STREAM_LENGTH], + ]; + + // Magic method is used. + $client->xadd(self::STREAM_NAME, ['data' => $json], '*', $options); + } + + /** + * @param array $item + * @return array{string, string} + */ + private function getMessageData(mixed $item): array + { + $messageId = $item[0] ?? null; + $json = $item[1][1] ?? null; + + if ($messageId === null) { + throw new RuntimeException("Bad message data, no ID."); + } + + if (!is_string($json)) { + throw new RuntimeException("Bad message data."); + } + + return [$messageId, $json]; + } +} diff --git a/application/Espo/Core/Utils/File/ClassMap.php b/application/Espo/Core/Utils/File/ClassMap.php index 5545b313dd0..687cdea2e9d 100644 --- a/application/Espo/Core/Utils/File/ClassMap.php +++ b/application/Espo/Core/Utils/File/ClassMap.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\File; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Config; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\File\Manager as FileManager; @@ -43,6 +44,7 @@ class ClassMap public function __construct( private FileManager $fileManager, private Module $module, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private PathProvider $pathProvider, private Config\SystemConfig $systemConfig, diff --git a/application/Espo/Core/Utils/Language.php b/application/Espo/Core/Utils/Language.php index 17752e8ff57..fe215bfa55f 100644 --- a/application/Espo/Core/Utils/Language.php +++ b/application/Espo/Core/Utils/Language.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Resource\Reader as ResourceReader; use Espo\Core\Utils\Resource\Reader\Params as ResourceReaderParams; @@ -39,6 +40,27 @@ class Language { + /** + * DI qualifier. The language selected as the system default. + * + * @since 10.1.0 + */ + public const string QUALIFIER_DEFAULT = 'default'; + + /** + * DI qualifier. The base language, en_US. + * + * @since 10.1.0 + */ + public const string QUALIFIER_BASE = 'base'; + + /** + * DI qualifier. The language selected in the current user's preferences. + * + * @since 10.1.0 + */ + public const string QUALIFIER_USER = 'user'; + /** @var array> */ private $data = []; /** @var array> */ @@ -59,6 +81,7 @@ public function __construct( ?string $language, private FileManager $fileManager, private ResourceReader $resourceReader, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, protected bool $useCache = false, protected bool $noCustom = false, @@ -430,34 +453,35 @@ private function getLanguageData(string $language, bool $reload = false): array $cacheKey = $this->getCacheKey($language); - if (!$this->useCache || !$this->dataCache->has($cacheKey) || $reload) { - $readerParams = ResourceReaderParams - ::create() - ->withNoCustom($this->noCustom); - - $path = str_replace('{language}', $language, $this->resourcePath); + if (!$reload && $this->useCache && $this->dataCache->has($cacheKey)) { + /** @var ?array $cachedData */ + $cachedData = $this->dataCache->get($cacheKey); - $data = $this->resourceReader->readAsArray($path, $readerParams); + if ($cachedData !== null) { + $this->data[$language] = $cachedData; - if ($language !== $this->defaultLanguage && !$this->noFallback) { - /** @var array> $data */ - $data = Util::merge($this->getDefaultLanguageData($reload), $data); + return $cachedData; } + } - $this->data[$language] = $data; + $readerParams = ResourceReaderParams::create() + ->withNoCustom($this->noCustom); - if ($this->useCache) { - $this->dataCache->store($cacheKey, $data); - } + $path = str_replace('{language}', $language, $this->resourcePath); + + $data = $this->resourceReader->readAsArray($path, $readerParams); + + if ($language !== $this->defaultLanguage && !$this->noFallback) { + /** @var array> $data */ + $data = Util::merge($this->getDefaultLanguageData($reload), $data); } - if ($this->useCache) { - /** @var array $cachedData */ - $cachedData = $this->dataCache->get($cacheKey); + $this->data[$language] = $data; - $this->data[$language] = $cachedData; + if ($this->useCache) { + $this->dataCache->store($cacheKey, $data); } - return $this->data[$language] ?? []; + return $data; } } diff --git a/application/Espo/Core/Utils/Metadata.php b/application/Espo/Core/Utils/Metadata.php index a906ca1a6fb..76c6365745c 100644 --- a/application/Espo/Core/Utils/Metadata.php +++ b/application/Espo/Core/Utils/Metadata.php @@ -29,10 +29,11 @@ namespace Espo\Core\Utils; +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Metadata\Builder; use Espo\Core\Utils\Metadata\BuilderHelper; - use stdClass; use LogicException; use RuntimeException; @@ -42,55 +43,51 @@ */ class Metadata { - /** @var ?array */ - private ?array $data = null; - private ?stdClass $objData = null; - private string $cacheKey = 'metadata'; private string $objCacheKey = 'objMetadata'; private string $customPath = 'custom/Espo/Custom/Resources/metadata'; /** @var array> */ private $deletedData = []; + /** @var array> */ private $changedData = []; + /** + * @param DataCacheAccess> $data + * @param DataCacheAccess $objectData + */ public function __construct( private FileManager $fileManager, - private DataCache $dataCache, private Module $module, private Builder $builder, private BuilderHelper $builderHelper, - private bool $useCache = false - ) {} + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCacheAccess $data, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCacheAccess $objectData, + ) { + + $this->objectData->init( + key: $this->objCacheKey, + loader: fn () => $this->builder->build(), + ); + + $this->data->init( + key: $this->cacheKey, + loader: fn () => $this->getObjectConvertedToAssoc(), + ); + } /** * Init metadata. + * + * @internal */ - public function init(bool $reload = false): void + public function init(): void { - if (!$this->useCache) { - $reload = true; - } - - if ($this->dataCache->has($this->cacheKey) && !$reload) { - /** @var array $data */ - $data = $this->dataCache->get($this->cacheKey); - - $this->data = $data; - - return; - } - - $this->clearVars(); - - $objData = $this->getObjData($reload); - - $this->data = Util::objectToArray($objData); - - if ($this->useCache) { - $this->dataCache->store($this->cacheKey, $this->data); - } + $this->reloadObject(); + $this->reload(); } /** @@ -100,13 +97,7 @@ public function init(bool $reload = false): void */ private function getData(): array { - if (empty($this->data) || !is_array($this->data)) { - $this->init(); - } - - assert($this->data !== null); - - return $this->data; + return $this->data->get(); } /** @@ -118,40 +109,9 @@ private function getData(): array */ public function get($key = null, $default = null) { - return Util::getValueByKey($this->getData(), $key, $default); - } - - private function objInit(bool $reload = false): void - { - if (!$this->useCache) { - $reload = true; - } - - if ($this->dataCache->has($this->objCacheKey) && !$reload) { - /** @var stdClass $data */ - $data = $this->dataCache->get($this->objCacheKey); - - $this->objData = $data; - - return; - } - - $this->objData = $this->builder->build(); - - if ($this->useCache) { - $this->dataCache->store($this->objCacheKey, $this->objData); - } - } - - private function getObjData(bool $reload = false): stdClass - { - if (!isset($this->objData) || $reload) { - $this->objInit($reload); - } + $data = $this->data->get(); - assert($this->objData !== null); - - return $this->objData; + return Util::getValueByKey($data, $key, $default); } /** @@ -163,18 +123,17 @@ private function getObjData(bool $reload = false): stdClass */ public function getObjects($key = null, $default = null) { - $objData = $this->getObjData(); - - return Util::getValueByKey($objData, $key, $default); + return Util::getValueByKey($this->getAll(), $key, $default); } + /** + * Important. Do not modify without cloning. + */ public function getAll(): stdClass { - return $this->getObjData(); + return $this->objectData->get(); } - - /** * Get metadata definition in custom directory. * @@ -183,7 +142,7 @@ public function getAll(): stdClass */ public function getCustom(string $key1, string $key2, $default = null) { - $filePath = $this->customPath . "/$key1/$key2.json"; + $filePath = "$this->customPath/$key1/$key2.json"; if (!$this->fileManager->isFile($filePath)) { return $default; @@ -213,13 +172,13 @@ public function saveCustom(string $key1, string $key2, $data): void } } - $filePath = $this->customPath . "/$key1/$key2.json"; + $filePath = "$this->customPath/$key1/$key2.json"; $changedData = Json::encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $this->fileManager->putContents($filePath, $changedData); - $this->init(true); + $this->init(); } /** @@ -264,11 +223,13 @@ private function setInternal(string $key1, string $key2, $data, bool $allowEmpty /** @var array> $mergedChangedData */ $mergedChangedData = Util::merge($this->changedData, $newData); + /** @var array $mergedData */ $mergedData = Util::merge($this->getData(), $newData); $this->changedData = $mergedChangedData; - $this->data = $mergedData; + + $this->data->set($mergedData); if (is_array($data)) { $this->undelete($key1, $key2, $data); @@ -294,24 +255,27 @@ public function delete(string $key1, string $key2, $unsets = null): void $unsetList = $unsets; foreach ($unsetList as $unsetItem) { - if (preg_match('/fields\.([^.]+)/', $unsetItem, $matches)) { - $field = $matches[1]; - $fieldPath = [$key1, $key2, 'fields', $field]; - - // @todo Revise the need. Additional fields are supposed to exist only in the build? - $additionalFields = $this->builderHelper->getAdditionalFields( - field: $field, - params: $this->get($fieldPath, []), - defs: $defs, - ); + if (!preg_match('/fields\.([^.]+)/', $unsetItem, $matches)) { + continue; + } + + $field = $matches[1]; + $fieldPath = [$key1, $key2, 'fields', $field]; - if (is_array($additionalFields)) { - foreach ($additionalFields as $additionalFieldName => $additionalFieldParams) { - $unsets[] = 'fields.' . $additionalFieldName; - } + // @todo Revise the need. Additional fields are supposed to exist only in the build? + $additionalFields = $this->builderHelper->getAdditionalFields( + field: $field, + params: $this->get($fieldPath, []), + defs: $defs, + ); + + if (is_array($additionalFields)) { + foreach ($additionalFields as $additionalFieldName => $additionalFieldParams) { + $unsets[] = 'fields.' . $additionalFieldName; } } } + break; } @@ -343,7 +307,8 @@ public function delete(string $key1, string $key2, $unsets = null): void /** @var array $data */ $data = Util::unsetInArray($this->getData(), $metadataUnsetData, true); - $this->data = $data; + + $this->data->set($data); } /** @@ -351,13 +316,15 @@ public function delete(string $key1, string $key2, $unsets = null): void */ private function undelete(string $key1, string $key2, $data): void { - if (isset($this->deletedData[$key1][$key2])) { - foreach ($this->deletedData[$key1][$key2] as $unsetIndex => $unsetItem) { - $value = Util::getValueByKey($data, $unsetItem); + if (!isset($this->deletedData[$key1][$key2])) { + return; + } - if (isset($value)) { - unset($this->deletedData[$key1][$key2][$unsetIndex]); - } + foreach ($this->deletedData[$key1][$key2] as $unsetIndex => $unsetItem) { + $value = Util::getValueByKey($data, $unsetItem); + + if (isset($value)) { + unset($this->deletedData[$key1][$key2][$unsetIndex]); } } } @@ -370,7 +337,7 @@ public function clearChanges(): void $this->changedData = []; $this->deletedData = []; - $this->init(true); + $this->init(); } /** @@ -389,7 +356,7 @@ public function save(): bool continue; } - $filePath = $path . "/$key1/$key2.json"; + $filePath = "$path/$key1/$key2.json"; $result &= $this->fileManager->mergeJsonContents($filePath, $data); } @@ -403,7 +370,7 @@ public function save(): bool continue; } - $filePath = $path . "/$key1/$key2.json"; + $filePath = "$path/$key1/$key2.json"; $rowResult = $this->fileManager->unsetJsonContents($filePath, $unsetData); @@ -443,8 +410,29 @@ public function getScopeModuleName(string $scopeName): ?string return $this->get(['scopes', $scopeName, 'module']); } - private function clearVars(): void + private function reloadObject(): void + { + $data = $this->builder->build(); + + $this->objectData->set($data); + $this->objectData->store(); + } + + private function reload(): void { - $this->data = null; + $data = $this->getObjectConvertedToAssoc(); + + $this->data->set($data); + $this->data->store(); + } + + /** + * @return array + */ + private function getObjectConvertedToAssoc(): array + { + $data = $this->objectData->get(); + + return Util::objectToArray($data); } } diff --git a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php index 77a19426700..ada514fc089 100644 --- a/application/Espo/Core/Utils/Metadata/OrmMetadataData.php +++ b/application/Espo/Core/Utils/Metadata/OrmMetadataData.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\Metadata; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\InjectableFactory; use Espo\Core\Utils\Config; use Espo\Core\Utils\Database\Orm\Converter; @@ -44,6 +45,7 @@ class OrmMetadataData private ?Converter $converter = null; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private InjectableFactory $injectableFactory, Config\SystemConfig $systemConfig, @@ -88,12 +90,14 @@ private function getDataInternal(bool $reload = false): array } if ($this->useCache && $this->dataCache->has($this->cacheKey) && !$reload) { - /** @var array> $data */ + /** @var ?array> $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return $this->data; + return $this->data; + } } $this->data = $this->getConverter()->process(); diff --git a/application/Espo/Core/Utils/Module.php b/application/Espo/Core/Utils/Module.php index 7085fd5dc18..2e823a6d2a1 100644 --- a/application/Espo/Core/Utils/Module.php +++ b/application/Espo/Core/Utils/Module.php @@ -87,12 +87,14 @@ private function init(): void $this->dataCache && $this->dataCache->has($this->cacheKey) ) { - /** @var array> $data */ + /** @var ?array> $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return; + return; + } } $this->data = $this->loadData(); @@ -173,7 +175,6 @@ public function getList(): array /** * @todo Use event-dispatcher class (passed via constructor). - * `$this->clearCacheEventDispatcher->subscribe(...);` */ public function clearCache(): void { diff --git a/application/Espo/Core/Utils/Route.php b/application/Espo/Core/Utils/Route.php index aebfe2d6cf2..9d0d0a6262f 100644 --- a/application/Espo/Core/Utils/Route.php +++ b/application/Espo/Core/Utils/Route.php @@ -31,6 +31,7 @@ use Espo\Core\Api\Action; use Espo\Core\Api\Route as RouteItem; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\File\Manager as FileManager; @@ -56,6 +57,7 @@ class Route public function __construct( private Metadata $metadata, private FileManager $fileManager, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private PathProvider $pathProvider, private SystemConfig $systemConfig, @@ -97,9 +99,11 @@ private function init(): void /** @var ?(RouteArrayShape[]) $data */ $data = $this->dataCache->get($this->cacheKey); - $this->data = $data; + if ($data !== null) { + $this->data = $data; - return; + return; + } } $this->data = $this->unify(); diff --git a/application/Espo/Core/Utils/ScheduledJob/Populator.php b/application/Espo/Core/Utils/ScheduledJob/Populator.php index 5faded12d01..e9705f3dc40 100644 --- a/application/Espo/Core/Utils/ScheduledJob/Populator.php +++ b/application/Espo/Core/Utils/ScheduledJob/Populator.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils\ScheduledJob; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Utils\Language; use Espo\Core\Utils\Metadata; use Espo\Entities\ScheduledJob; @@ -43,6 +44,7 @@ class Populator public function __construct( private EntityManager $entityManager, private Metadata $metadata, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, ) {} diff --git a/application/Espo/Core/Utils/System/SystemState.php b/application/Espo/Core/Utils/System/SystemState.php new file mode 100644 index 00000000000..fee6cc5c706 --- /dev/null +++ b/application/Espo/Core/Utils/System/SystemState.php @@ -0,0 +1,70 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\System; + +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\SystemState as State; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\UpdateBuilder; + +/** + * @since 10.1.0 + */ +class SystemState +{ + public function __construct( + private EntityManagerProxy $entityManager, + ) {} + + public function getVersionNumber(): int + { + $entity = $this->entityManager + ->getRDBRepositoryByClass(State::class) + ->getById(State::ID_VALUE); + + return $entity?->getVersionNumber() ?? 0; + } + + public function bumpVersionNumber(): void + { + $query = UpdateBuilder::create() + ->in(State::ENTITY_TYPE) + ->set([ + State::FIELD_VERSION_NUMBER => Expr::add(Expr::column(State::FIELD_VERSION_NUMBER), 1), + ]) + ->where([ + Attribute::ID => State::ID_VALUE, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Core/Utils/Theme/Direction.php b/application/Espo/Core/Utils/Theme/Direction.php new file mode 100644 index 00000000000..02b98c4680f --- /dev/null +++ b/application/Espo/Core/Utils/Theme/Direction.php @@ -0,0 +1,36 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\Theme; + +enum Direction: string +{ + case Ltr = 'ltr'; + case Rtl = 'rtl'; +} diff --git a/application/Espo/Core/Utils/ThemeManager.php b/application/Espo/Core/Utils/ThemeManager.php index 9c76cbfddda..95355096749 100644 --- a/application/Espo/Core/Utils/ThemeManager.php +++ b/application/Espo/Core/Utils/ThemeManager.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Utils\Theme\Direction; use Espo\Core\Utils\Theme\MetadataProvider; class ThemeManager @@ -53,6 +54,32 @@ public function getStylesheet(): string return $this->metadataProvider->getStylesheet($this->getName()); } + public function getDirection(): Direction + { + $rawDirection = $this->config->get('themeParams.direction'); + $direction = is_string($rawDirection) ? Direction::tryFrom($rawDirection) : null; + + if ($direction) { + return $direction; + } + + $rawDirection = $this->metadata->get( + ['themes', $this->getName(), 'params', 'direction', 'default'] + ); + + if (!is_string($rawDirection)) { + return Direction::Ltr; + } + + $direction = Direction::tryFrom($rawDirection); + + if (!$direction) { + return Direction::Ltr; + } + + return $direction; + } + public function getLogoSrc(): string { return $this->metadata->get(['themes', $this->getName(), 'logo']) ?? $this->defaultLogoSrc; diff --git a/application/Espo/Core/Utils/User/UserStateProvider.php b/application/Espo/Core/Utils/User/UserStateProvider.php new file mode 100644 index 00000000000..b6ee139824e --- /dev/null +++ b/application/Espo/Core/Utils/User/UserStateProvider.php @@ -0,0 +1,106 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Utils\User; + +use Espo\Entities\User; +use Espo\Entities\UserState; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\UpdateBuilder; + +/** + * @since 10.1.0 + * @internal + */ +class UserStateProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function getEmailFiltersVersionNumber(string $userId): int + { + $userState = $this->entityManager + ->getRDBRepositoryByClass(UserState::class) + ->where([UserState::ATTR_USER_ID => $userId]) + ->findOne(); + + return $userState?->getEmailFiltersVersionNumber() ?? 0; + } + + public function bumpEmailFiltersVersionNumber(string $userId): void + { + $this->prepare($userId); + + $column = UserState::FIELD_EMAIL_FILTERS_VERSION_NUMBER; + + $query = UpdateBuilder::create() + ->in(UserState::ENTITY_TYPE) + ->set([ + $column => Expr::add(Expr::column($column), 1), + ]) + ->where([ + UserState::ATTR_USER_ID => $userId, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + private function prepare(string $userId): void + { + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return; + } + + $userState = $this->entityManager + ->getRDBRepositoryByClass(UserState::class) + ->select(Attribute::ID) + ->where([UserState::ATTR_USER_ID => $user->getId()]) + ->findOne(); + + if ($userState) { + return; + } + + $userState = $this->entityManager->getRDBRepositoryByClass(UserState::class)->getNew(); + + $userState->set(UserState::ATTR_USER_ID, $user->getId()); + + $this->entityManager + ->getMapper() + ->insertOnDuplicateUpdate($userState, [UserState::ATTR_USER_ID]); + + $userState->setAsFetched(); + } +} diff --git a/application/Espo/Core/Webhook/Events/UpdateGlobal.php b/application/Espo/Core/Webhook/Events/UpdateGlobal.php new file mode 100644 index 00000000000..a5e79309b17 --- /dev/null +++ b/application/Espo/Core/Webhook/Events/UpdateGlobal.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Webhook\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +class UpdateGlobal implements CrossInstanceEvent +{ + public static function fromPayload(stdClass $payload): CrossInstanceEvent + { + return new self(); + } + + public function toPayload(): stdClass + { + return (object) []; + } +} diff --git a/application/Espo/Core/Webhook/Manager.php b/application/Espo/Core/Webhook/Manager.php index cbe01671334..1830cb640aa 100644 --- a/application/Espo/Core/Webhook/Manager.php +++ b/application/Espo/Core/Webhook/Manager.php @@ -32,16 +32,16 @@ use Espo\Core\Name\Field; use Espo\Core\ORM\Entity; use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Cache\DataCacheAccess; use Espo\Core\Utils\Config; -use Espo\Core\Utils\Config\SystemConfig; -use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Event\Context; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Log; +use Espo\Core\Webhook\Events\UpdateGlobal; use Espo\Entities\Webhook; use Espo\Entities\WebhookEventQueueItem; - use Espo\ORM\Name\Attribute; -use RuntimeException; use stdClass; /** @@ -63,45 +63,30 @@ class Manager Field::VERSION_NUMBER, ]; - /** @var ?array */ - private $data = null; - + /** + * @param DataCacheAccess> $dataCacheAccess + */ public function __construct( private Config $config, - private DataCache $dataCache, private EntityManager $entityManager, private FieldUtil $fieldUtil, private Log $log, - private SystemConfig $systemConfig, + private DataCacheAccess $dataCacheAccess, + private EventDispatcher $eventDispatcher, ) { - $this->loadData(); - } - private function loadData(): void - { - if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { - /** @var array $data */ - $data = $this->dataCache->get($this->cacheKey); + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: fn () => $this->buildData(), + ); - $this->data = $data; - } - - if (is_null($this->data)) { - $this->data = $this->buildData(); - - if ($this->systemConfig->useCache()) { - $this->storeDataToCache(); + $this->eventDispatcher->subscribe(UpdateGlobal::class, function (UpdateGlobal $event, Context $context) { + if ($context->isLocal) { + return; } - } - } - private function storeDataToCache(): void - { - if ($this->data === null) { - throw new RuntimeException("No data to store."); - } - - $this->dataCache->store($this->cacheKey, $this->data); + $this->dataCacheAccess->reset(); + }); } /** @@ -135,11 +120,14 @@ private function buildData(): array */ public function addEvent(string $event): void { - $this->data[$event] = true; + $data = $this->dataCacheAccess->get(); - if ($this->systemConfig->useCache()) { - $this->storeDataToCache(); - } + $data[$event] = true; + + $this->dataCacheAccess->set($data); + $this->dataCacheAccess->store(); + + $this->eventDispatcher->dispatch(new UpdateGlobal()); } /** @@ -160,16 +148,21 @@ public function removeEvent(string $event): void return; } - unset($this->data[$event]); + $data = $this->dataCacheAccess->get(); - if ($this->systemConfig->useCache()) { - $this->storeDataToCache(); - } + unset($data[$event]); + + $this->dataCacheAccess->set($data); + $this->dataCacheAccess->store(); + + $this->eventDispatcher->dispatch(new UpdateGlobal()); } private function eventExists(string $event): bool { - return isset($this->data[$event]); + $data = $this->dataCacheAccess->get(); + + return isset($data[$event]); } private function logDebugEvent(string $event, Entity $entity): void diff --git a/application/Espo/Entities/Integration.php b/application/Espo/Entities/Integration.php index cc7a7604438..377f3e6b97c 100644 --- a/application/Espo/Entities/Integration.php +++ b/application/Espo/Entities/Integration.php @@ -30,6 +30,7 @@ namespace Espo\Entities; use Espo\Core\ORM\Entity; +use Espo\ORM\Entity\EmptyValue; use Espo\ORM\Name\Attribute; use stdClass; @@ -86,7 +87,7 @@ public function setMultiple(array|stdClass $valueMap): static return $this->set($valueMap); } - public function set($attribute, $value = null): static + public function set($attribute, $value = new EmptyValue()): static { if (is_object($attribute)) { $attribute = get_object_vars($attribute); @@ -98,6 +99,10 @@ public function set($attribute, $value = null): static return $this; } + if ($value instanceof EmptyValue) { + $value = null; + } + $name = $attribute; if ($name === Attribute::ID) { diff --git a/application/Espo/Entities/Job.php b/application/Espo/Entities/Job.php index 27047295f38..aa553551b5d 100644 --- a/application/Espo/Entities/Job.php +++ b/application/Espo/Entities/Job.php @@ -42,12 +42,17 @@ class Job extends Entity { public const ENTITY_TYPE = 'Job'; + /** + * @since v10.1.0 + */ + public const string FIELD_STATUS = 'status'; + /** * Get a status. */ public function getStatus(): string { - return $this->get('status'); + return $this->get(self::FIELD_STATUS); } /** @@ -171,7 +176,7 @@ public function getFailedAttempts(): int */ public function setStatus(string $status): self { - return $this->set('status', $status); + return $this->set(self::FIELD_STATUS, $status); } /** diff --git a/application/Espo/Entities/ScheduledJob.php b/application/Espo/Entities/ScheduledJob.php index ffd55c1d9e0..d2d223df08b 100644 --- a/application/Espo/Entities/ScheduledJob.php +++ b/application/Espo/Entities/ScheduledJob.php @@ -38,6 +38,15 @@ class ScheduledJob extends Entity public const string STATUS_ACTIVE = 'Active'; + /** + * @since 10.1.0 + */ + public const string FIELD_SCHEDULING = 'scheduling'; + /** + * @since 10.1.0 + */ + public const string FIELD_STATUS = 'status'; + /** * @since 10.0.0 */ @@ -50,7 +59,7 @@ public function getName(): ?string public function getScheduling(): ?string { - return $this->get('scheduling'); + return $this->get(self::FIELD_SCHEDULING); } public function getJob(): ?string @@ -63,7 +72,7 @@ public function getJob(): ?string */ public function setActive(): self { - return $this->set('status', self::STATUS_ACTIVE); + return $this->set(self::FIELD_STATUS, self::STATUS_ACTIVE); } /** @@ -79,7 +88,7 @@ public function setName(string $name): self */ public function setScheduling(string $scheduling): self { - return $this->set('scheduling', $scheduling); + return $this->set(self::FIELD_SCHEDULING, $scheduling); } /** diff --git a/application/Espo/Entities/SystemState.php b/application/Espo/Entities/SystemState.php new file mode 100644 index 00000000000..dfff37a5c8e --- /dev/null +++ b/application/Espo/Entities/SystemState.php @@ -0,0 +1,50 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Entities; + +use Espo\Core\ORM\Entity; + +/** + * @internal + * @since 10.1.0 + */ +class SystemState extends Entity +{ + public const string ENTITY_TYPE = 'SystemState'; + + public const string ID_VALUE = '1'; + + public const string FIELD_VERSION_NUMBER = 'versionNumber'; + + public function getVersionNumber(): int + { + return $this->get(self::FIELD_VERSION_NUMBER) ?? 0; + } +} diff --git a/application/Espo/Entities/UserState.php b/application/Espo/Entities/UserState.php new file mode 100644 index 00000000000..41499f560e1 --- /dev/null +++ b/application/Espo/Entities/UserState.php @@ -0,0 +1,50 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Entities; + +use Espo\Core\ORM\Entity; + +/** + * @internal + * @since 10.1.0 + */ +class UserState extends Entity +{ + public const string ENTITY_TYPE = 'UserState'; + + public const string FIELD_EMAIL_FILTERS_VERSION_NUMBER = 'emailFiltersVersionNumber'; + + public const string ATTR_USER_ID = 'userId'; + + public function getEmailFiltersVersionNumber(): int + { + return $this->get(self::FIELD_EMAIL_FILTERS_VERSION_NUMBER) ?? 0; + } +} diff --git a/application/Espo/EntryPoints/LeadCaptureForm.php b/application/Espo/EntryPoints/LeadCaptureForm.php index f7f967642a7..13db872498f 100644 --- a/application/Espo/EntryPoints/LeadCaptureForm.php +++ b/application/Espo/EntryPoints/LeadCaptureForm.php @@ -63,7 +63,7 @@ public function run(Request $request, Response $response): void throw new BadRequest("No ID."); } - [$leadCapture, $data, $captchaScript] = $this->service->getData($id); + [$leadCapture, $data, $captchaScript, $direction] = $this->service->getData($id); $params = new ActionRenderer\Params( controller: 'controllers/lead-capture-form', @@ -74,7 +74,8 @@ public function run(Request $request, Response $response): void $params = $params ->withFrameAncestors($leadCapture->getFormFrameAncestors()) ->withPageTitle($leadCapture->getFormTitle()) - ->withTheme($leadCapture->getFormTheme()); + ->withTheme($leadCapture->getFormTheme()) + ->withDirection($direction); if ($captchaScript) { $params = $params->withScripts([new Script(source: $captchaScript)]); diff --git a/application/Espo/Hooks/EmailFilter/CacheClearing.php b/application/Espo/Hooks/EmailFilter/CacheClearing.php index 60fd6f9c5cd..228fa251025 100644 --- a/application/Espo/Hooks/EmailFilter/CacheClearing.php +++ b/application/Espo/Hooks/EmailFilter/CacheClearing.php @@ -32,6 +32,7 @@ use Espo\Core\Hook\Hook\AfterRemove; use Espo\Core\Hook\Hook\AfterSave; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\User\UserStateProvider; use Espo\Entities\EmailFilter; use Espo\Entities\User; use Espo\ORM\Entity; @@ -44,9 +45,12 @@ */ class CacheClearing implements AfterSave, AfterRemove { - private const CACHE_KEY = 'emailFilters'; + private const string CACHE_KEY = 'emailFilters'; - public function __construct(private DataCache $dataCache) {} + public function __construct( + private DataCache $dataCache, + private UserStateProvider $userStateProvider, + ) {} /** * @param EmailFilter $entity @@ -73,6 +77,8 @@ private function processEntity(EmailFilter $entity): void $cacheKey = $this->composeCacheKey($entity->getParentId()); $this->dataCache->clear($cacheKey); + + $this->userStateProvider->bumpEmailFiltersVersionNumber($entity->getParentId()); } private function composeCacheKey(string $userId): string diff --git a/application/Espo/Hooks/Pipeline/CreateStages.php b/application/Espo/Hooks/Pipeline/CreateStages.php index 35f9755f075..57be4cabe02 100644 --- a/application/Espo/Hooks/Pipeline/CreateStages.php +++ b/application/Espo/Hooks/Pipeline/CreateStages.php @@ -29,6 +29,7 @@ namespace Espo\Hooks\Pipeline; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Hook\Hook\AfterSave; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\Utils\Language; @@ -47,6 +48,7 @@ class CreateStages implements AfterSave { public function __construct( private EntityManager $entityManager, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private Defs $defs, private EnumOptionsProvider $enumOptionsProvider, diff --git a/application/Espo/Hooks/User/DeleteUserState.php b/application/Espo/Hooks/User/DeleteUserState.php new file mode 100644 index 00000000000..1ff77b66d5c --- /dev/null +++ b/application/Espo/Hooks/User/DeleteUserState.php @@ -0,0 +1,60 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Hooks\User; + +use Espo\Core\Hook\Hook\AfterRemove; +use Espo\Entities\User; +use Espo\Entities\UserState; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\DeleteBuilder; +use Espo\ORM\Repository\Option\RemoveOptions; + +/** + * @implements AfterRemove + */ +class DeleteUserState implements AfterRemove +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function afterRemove(Entity $entity, RemoveOptions $options): void + { + $query = DeleteBuilder::create() + ->from(UserState::ENTITY_TYPE) + ->where([ + UserState::ATTR_USER_ID => $entity->getId() + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php b/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php index 795a9ddc489..4a408752921 100644 --- a/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php +++ b/application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php @@ -30,6 +30,7 @@ namespace Espo\Modules\Crm\Tools\Campaign; use Espo\Core\Acl; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; @@ -61,22 +62,13 @@ class MailMergeService 'users', ]; - private EntityManager $entityManager; - private Acl $acl; - private Language $defaultLanguage; - private MailMergeGenerator $generator; - public function __construct( - EntityManager $entityManager, - Acl $acl, - Language $defaultLanguage, - MailMergeGenerator $generator - ) { - $this->entityManager = $entityManager; - $this->acl = $acl; - $this->defaultLanguage = $defaultLanguage; - $this->generator = $generator; - } + private EntityManager $entityManager, + private Acl $acl, + #[Qualify(Language::QUALIFIER_DEFAULT)] + private Language $defaultLanguage, + private MailMergeGenerator $generator, + ) {} /** * @return string An attachment ID. diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php b/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php index 7ed57b7259d..b050e3f44b0 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php @@ -29,6 +29,7 @@ namespace Espo\Modules\Crm\Tools\MassEmail; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers; use Espo\Core\Field\DateTime; use Espo\Core\Mail\ConfigDataProvider; @@ -73,6 +74,7 @@ class SendingProcessor public function __construct( private Config $config, private EntityManager $entityManager, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private EmailSender $emailSender, private Log $log, diff --git a/application/Espo/ORM/BaseEntity.php b/application/Espo/ORM/BaseEntity.php index c6655219e63..8170ea38fcc 100644 --- a/application/Espo/ORM/BaseEntity.php +++ b/application/Espo/ORM/BaseEntity.php @@ -35,11 +35,11 @@ use Espo\ORM\Defs\Params\AttributeParam; use Espo\ORM\Defs\Params\EntityParam; use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity\EmptyValue; use Espo\ORM\Name\Attribute; use Espo\ORM\Relation\EmptyRelations; use Espo\ORM\Relation\Relations; use Espo\ORM\Type\AttributeType; -use Espo\ORM\Type\RelationType; use Espo\ORM\Value\ValueAccessorFactory; use Espo\ORM\Value\ValueAccessor; @@ -47,7 +47,6 @@ use InvalidArgumentException; use RuntimeException; -use const E_USER_DEPRECATED; use const JSON_THROW_ON_ERROR; class BaseEntity implements Entity @@ -164,7 +163,7 @@ public function reset(): void * @param string|stdClass|array $attribute * @param mixed $value */ - public function set($attribute, $value = null): static + public function set($attribute, $value = new EmptyValue()): static { $arg = $attribute; @@ -185,6 +184,10 @@ public function set($attribute, $value = null): static if (is_string($arg)) { $name = $arg; + if ($value instanceof EmptyValue) { + $value = null; + } + if ($name == Attribute::ID) { $this->id = $value; } diff --git a/application/Espo/ORM/Entity.php b/application/Espo/ORM/Entity.php index 0356fa143b0..31650bf20d8 100644 --- a/application/Espo/ORM/Entity.php +++ b/application/Espo/ORM/Entity.php @@ -89,7 +89,7 @@ public function reset(): void; * @param string|stdClass|array $attribute * @param mixed $value */ - public function set($attribute, $value = null): static; + public function set($attribute, $value): static; /** * Set multiple attributes. diff --git a/application/Espo/ORM/Entity/EmptyValue.php b/application/Espo/ORM/Entity/EmptyValue.php new file mode 100644 index 00000000000..2b4cd37be58 --- /dev/null +++ b/application/Espo/ORM/Entity/EmptyValue.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\ORM\Entity; + +/** + * @internal + * + * Prevents inspection firing on a null value. + */ +class EmptyValue +{} diff --git a/application/Espo/ORM/EntityCollection.php b/application/Espo/ORM/EntityCollection.php index bd650fa2178..b846b8c450b 100644 --- a/application/Espo/ORM/EntityCollection.php +++ b/application/Espo/ORM/EntityCollection.php @@ -252,7 +252,7 @@ protected function buildEntityFromArray(array $dataArray): Entity /** @var TEntity $entity */ $entity = $this->entityFactory->create($this->entityType); - $entity->set($dataArray); + $entity->setMultiple($dataArray); if ($this->isFetched) { $entity->setAsFetched(); diff --git a/application/Espo/ORM/EntityManager.php b/application/Espo/ORM/EntityManager.php index a3ec089acd2..fb451c6ac3e 100644 --- a/application/Espo/ORM/EntityManager.php +++ b/application/Espo/ORM/EntityManager.php @@ -303,7 +303,7 @@ public function refreshEntity(Entity $entity): void } } - $entity->set($fetchedMap); + $entity->setMultiple($fetchedMap); $entity->setAsFetched(); } @@ -316,7 +316,7 @@ public function refreshEntity(Entity $entity): void public function createEntity(string $entityType, $data = [], array $options = []): Entity { $entity = $this->getNewEntity($entityType); - $entity->set($data); + $entity->setMultiple($data); $this->saveEntity($entity, $options); return $entity; diff --git a/application/Espo/ORM/Mapper/BaseMapper.php b/application/Espo/ORM/Mapper/BaseMapper.php index 9ae9521629a..501fa23e7a8 100644 --- a/application/Espo/ORM/Mapper/BaseMapper.php +++ b/application/Espo/ORM/Mapper/BaseMapper.php @@ -1172,7 +1172,6 @@ private function removeRelation( $where[$typeKey] = $foreignEntityType; } - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set($typeKey, null); $entity->setFetched($typeKey, null); } @@ -1583,7 +1582,7 @@ private function toValueMap(Entity $entity, bool $onlyStorable = true): array */ private function populateEntityFromRow(Entity $entity, $data): void { - $entity->set($data); + $entity->setMultiple($data); } /** diff --git a/application/Espo/ORM/Repository/RDBRelation.php b/application/Espo/ORM/Repository/RDBRelation.php index 9bad9707f2e..edb832696de 100644 --- a/application/Espo/ORM/Repository/RDBRelation.php +++ b/application/Espo/ORM/Repository/RDBRelation.php @@ -98,7 +98,7 @@ public function __construct( /** * Create a select builder. * - * @return Builder + * @return Builder> * * @since 9.2.5 */ @@ -228,7 +228,7 @@ public function count(): int * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return Builder + * @return Builder> */ public function join($target, ?string $alias = null, $conditions = null): Builder { @@ -242,7 +242,7 @@ public function join($target, ?string $alias = null, $conditions = null): Builde * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return Builder + * @return Builder> */ public function leftJoin($target, ?string $alias = null, $conditions = null): Builder { @@ -252,7 +252,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): Bu /** * Set DISTINCT parameter. * - * @return Builder + * @return Builder> */ public function distinct(): Builder { @@ -262,7 +262,7 @@ public function distinct(): Builder /** * Set to return STH collection. Recommended for fetching large number of records. * - * @return Builder + * @return Builder> */ public function sth(): Builder { @@ -279,7 +279,7 @@ public function sth(): Builder * * @param WhereItem|array|string $clause A key or where clause. * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return Builder + * @return Builder> */ public function where($clause = [], $value = null): Builder { @@ -296,7 +296,7 @@ public function where($clause = [], $value = null): Builder * * @param WhereItem|array|string $clause A key or where clause. * @param array|string|null $value A value. Should be omitted if the first argument is not string. - * @return Builder + * @return Builder> */ public function having($clause = [], $value = null): Builder { @@ -316,7 +316,7 @@ public function having($clause = [], $value = null): Builder * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. - * @return Builder + * @return Builder> */ public function order($orderBy = Attribute::ID, $direction = null): Builder { @@ -326,7 +326,7 @@ public function order($orderBy = Attribute::ID, $direction = null): Builder /** * Apply OFFSET and LIMIT. * - * @return Builder + * @return Builder> */ public function limit(?int $offset = null, ?int $limit = null): Builder { @@ -346,7 +346,7 @@ public function limit(?int $offset = null, ?int $limit = null): Builder * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. - * @return Builder + * @return Builder> */ public function select($select = [], ?string $alias = null): Builder { @@ -363,7 +363,7 @@ public function select($select = [], ?string $alias = null): Builder * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return Builder + * @return Builder> */ public function group($groupBy): Builder { @@ -373,7 +373,7 @@ public function group($groupBy): Builder /** * @deprecated Use `group` method. * @param Expression|Expression[]|string|string[] $groupBy - * @return Builder + * @return Builder> */ public function groupBy($groupBy): Builder { @@ -387,7 +387,7 @@ public function groupBy($groupBy): Builder * `->columnsWhere(['column' => $value])` * * @param WhereItem|array $clause Where clause. - * @return Builder + * @return Builder> */ public function columnsWhere($clause): Builder { diff --git a/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php b/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php index 61b041a06d5..ea8b03faf9f 100644 --- a/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php +++ b/application/Espo/ORM/Repository/RDBRelationSelectBuilder.php @@ -49,11 +49,13 @@ use LogicException; use RuntimeException; use InvalidArgumentException; +use UnexpectedValueException; /** * Builds select parameters for related records for RDB repository. * * @template TEntity of Entity = Entity + * @template TCollection of EntityCollection|SthCollection = EntityCollection */ class RDBRelationSelectBuilder { @@ -128,7 +130,7 @@ private function getMapper(): RDBMapper * `->columnsWhere(['column' => $value])` * * @param WhereItem|array $clause Where clause. - * @return self + * @return self */ public function columnsWhere($clause): self { @@ -187,7 +189,7 @@ private function applyMiddleAliasToWhere(array $where): array /** * Find related records by a criteria. * - * @return EntityCollection|SthCollection + * @return TCollection */ public function find(): EntityCollection|SthCollection { @@ -198,7 +200,7 @@ public function find(): EntityCollection|SthCollection if ($related instanceof Collection) { /** @var Collection $related */ - /** @var EntityCollection|SthCollection */ + /** @var TCollection */ return $this->handleReturnCollection($related); } @@ -211,6 +213,7 @@ public function find(): EntityCollection|SthCollection $collection[] = $related; } + /** @var TCollection */ return $collection; } @@ -259,7 +262,7 @@ public function count(): int * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return self + * @return self */ public function join($target, ?string $alias = null, $conditions = null): self { @@ -275,7 +278,7 @@ public function join($target, ?string $alias = null, $conditions = null): self * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return self + * @return self */ public function leftJoin($target, ?string $alias = null, $conditions = null): self { @@ -287,7 +290,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): se /** * Set DISTINCT parameter. * - * @return self + * @return self */ public function distinct(): self { @@ -299,7 +302,7 @@ public function distinct(): self /** * Return STH collection. Recommended for fetching large number of records. * - * @return self + * @return self> */ public function sth(): self { @@ -318,7 +321,7 @@ public function sth(): self * * @param WhereItem|array|string $clause A key or where clause. * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return self + * @return self */ public function where($clause = [], $value = null): self { @@ -347,7 +350,7 @@ public function where($clause = [], $value = null): self * * @param WhereItem|array|string $clause A key or where clause. * @param array|string|null $value A value. Should be omitted if the first argument is not string. - * @return self + * @return self */ public function having($clause = [], $value = null): self { @@ -369,7 +372,7 @@ public function having($clause = [], $value = null): self * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction Select::ORDER_ASC|Select::ORDER_DESC. - * @return self + * @return self */ public function order($orderBy = Attribute::ID, $direction = null): self { @@ -381,7 +384,7 @@ public function order($orderBy = Attribute::ID, $direction = null): self /** * Apply OFFSET and LIMIT. * - * @return self + * @return self */ public function limit(?int $offset = null, ?int $limit = null): self { @@ -403,7 +406,7 @@ public function limit(?int $offset = null, ?int $limit = null): self * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. - * @return self + * @return self */ public function select($select, ?string $alias = null): self { @@ -422,7 +425,7 @@ public function select($select, ?string $alias = null): self * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return self + * @return self */ public function group($groupBy): self { @@ -434,7 +437,7 @@ public function group($groupBy): self /** * @deprecated Use `group` method. * @param Expression|Expression[]|string|string[] $groupBy - * @return self + * @return self */ public function groupBy($groupBy): self { @@ -508,10 +511,14 @@ private function isManyMany(): bool /** * @param Collection $collection - * @return Collection + * @return EntityCollection|SthCollection */ - private function handleReturnCollection(Collection $collection): Collection + private function handleReturnCollection(Collection $collection): EntityCollection|SthCollection { + if (!$collection instanceof EntityCollection && !$collection instanceof SthCollection) { + throw new UnexpectedValueException(); + } + if (!$collection instanceof SthCollection) { return $collection; } @@ -520,7 +527,7 @@ private function handleReturnCollection(Collection $collection): Collection return $collection; } - /** @var Collection */ + /** @var EntityCollection */ return $this->entityManager->getCollectionFactory()->createFromSthCollection($collection); } diff --git a/application/Espo/ORM/Repository/RDBRepository.php b/application/Espo/ORM/Repository/RDBRepository.php index b19ae2a4841..7187c74b1fd 100644 --- a/application/Espo/ORM/Repository/RDBRepository.php +++ b/application/Espo/ORM/Repository/RDBRepository.php @@ -293,9 +293,9 @@ private function removeInternal(Entity $entity, array $options = []): void /** * Find records. * - * @return EntityCollection|SthCollection + * @return EntityCollection */ - public function find(): EntityCollection|SthCollection + public function find(): EntityCollection { return $this->createBuilder()->find(); } @@ -372,7 +372,7 @@ public function sum(string $attribute) /** * Clone an existing query for a further modification and usage by 'find' or 'count' methods. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function clone(Select $query): RDBSelectBuilder { @@ -380,7 +380,7 @@ public function clone(Select $query): RDBSelectBuilder throw new RuntimeException("Can't clone a query of a different entity type."); } - /** @var RDBSelectBuilder $builder */ + /** @var RDBSelectBuilder> $builder */ $builder = new RDBSelectBuilder($this->entityManager, $this->entityType, $query); return $builder; @@ -393,7 +393,7 @@ public function clone(Select $query): RDBSelectBuilder * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function join($target, ?string $alias = null, $conditions = null): RDBSelectBuilder { @@ -407,7 +407,7 @@ public function join($target, ?string $alias = null, $conditions = null): RDBSel * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function leftJoin($target, ?string $alias = null, $conditions = null): RDBSelectBuilder { @@ -417,7 +417,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): RD /** * Set DISTINCT parameter. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function distinct(): RDBSelectBuilder { @@ -427,7 +427,7 @@ public function distinct(): RDBSelectBuilder /** * Lock selected rows. To be used within a transaction. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function forUpdate(): RDBSelectBuilder { @@ -437,7 +437,7 @@ public function forUpdate(): RDBSelectBuilder /** * Set to return STH collection. Recommended fetching large number of records. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function sth(): RDBSelectBuilder { @@ -453,8 +453,8 @@ public function sth(): RDBSelectBuilder * * `where(string $key, string $value)` * * @param WhereItem|array|string $clause A key or where clause. - * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. + * @return RDBSelectBuilder> */ public function where($clause = [], $value = null): RDBSelectBuilder { @@ -470,8 +470,8 @@ public function where($clause = [], $value = null): RDBSelectBuilder * * `having(string $key, string $value)` * * @param WhereItem|array|string $clause A key or where clause. - * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @param array|scalar|null $value A value. Should be omitted if the first argument is not string. + * @return RDBSelectBuilder> */ public function having($clause = [], $value = null): RDBSelectBuilder { @@ -491,7 +491,7 @@ public function having($clause = [], $value = null): RDBSelectBuilder * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function order($orderBy = Attribute::ID, $direction = null): RDBSelectBuilder { @@ -501,7 +501,7 @@ public function order($orderBy = Attribute::ID, $direction = null): RDBSelectBui /** * Apply OFFSET and LIMIT. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function limit(?int $offset = null, ?int $limit = null): RDBSelectBuilder { @@ -521,7 +521,7 @@ public function limit(?int $offset = null, ?int $limit = null): RDBSelectBuilder * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is not an array. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function select($select = [], ?string $alias = null): RDBSelectBuilder { @@ -538,7 +538,7 @@ public function select($select = [], ?string $alias = null): RDBSelectBuilder * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function group($groupBy): RDBSelectBuilder { @@ -548,20 +548,21 @@ public function group($groupBy): RDBSelectBuilder /** * Create a select builder. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder> * * @since 9.2.5 */ public function createBuilder(): RDBSelectBuilder { - /** @var RDBSelectBuilder $builder */ + /** @var RDBSelectBuilder> $builder */ $builder = new RDBSelectBuilder($this->entityManager, $this->entityType); return $builder; } /** - * Use hooks instead. + * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -573,6 +574,7 @@ protected function beforeSave(Entity $entity, array $options = []) /** * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -584,6 +586,7 @@ protected function afterSave(Entity $entity, array $options = []) /** * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -595,6 +598,7 @@ protected function beforeRemove(Entity $entity, array $options = []) /** * @deprecated Use hooks instead. + * @todo Change signature in v11.0. Add void. * * @param array $options * @return void @@ -617,6 +621,7 @@ protected function getMapper(): RDBMapper /** * @deprecated As of v6.0. Use hooks instead. + * @todo Remove in v11.0. * @phpstan-ignore-next-line */ protected function beforeRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) @@ -624,6 +629,7 @@ protected function beforeRelate(Entity $entity, $relationName, $foreign, $data = /** * @deprecated As of v6.0. Use hooks instead. + * @todo Change signature in v11.0. Add void. * @phpstan-ignore-next-line */ protected function afterRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) @@ -631,6 +637,7 @@ protected function afterRelate(Entity $entity, $relationName, $foreign, $data = /** * @deprecated As of v6.0. Use hooks instead. + * @todo Remove in v11.0. * @phpstan-ignore-next-line */ protected function beforeUnrelate(Entity $entity, $relationName, $foreign, array $options = []) @@ -638,6 +645,7 @@ protected function beforeUnrelate(Entity $entity, $relationName, $foreign, array /** * @deprecated As of v6.0. Use hooks instead. + * @todo Change signature in v11.0. Add void. * @phpstan-ignore-next-line */ protected function afterUnrelate(Entity $entity, $relationName, $foreign, array $options = []) @@ -645,6 +653,7 @@ protected function afterUnrelate(Entity $entity, $relationName, $foreign, array /** * @deprecated As of v6.0. Use hooks instead. + * @todo Remove in v11.0. * @phpstan-ignore-next-line */ protected function beforeMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) @@ -652,6 +661,7 @@ protected function beforeMassRelate(Entity $entity, $relationName, array $params /** * @deprecated As of v6.0. Use hooks instead. + * @todo Change signature in v11.0. Add void. * @phpstan-ignore-next-line */ protected function afterMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) @@ -718,7 +728,7 @@ private function saveSetRelationHasOne(Entity $entity, string $name, ?Entity $re ->findOne(); if (!$entity->isNew()) { - $entity->setFetched($idAttribute, $previous ? $previous->getId() : null); + $entity->setFetched($idAttribute, $previous?->getId()); } if ($previous) { diff --git a/application/Espo/ORM/Repository/RDBSelectBuilder.php b/application/Espo/ORM/Repository/RDBSelectBuilder.php index d2e8db4679f..30d1f41ba79 100644 --- a/application/Espo/ORM/Repository/RDBSelectBuilder.php +++ b/application/Espo/ORM/Repository/RDBSelectBuilder.php @@ -52,6 +52,7 @@ * Builds select parameters for an RDB repository. Contains 'find' methods. * * @template TEntity of Entity + * @template TCollection of EntityCollection|SthCollection = EntityCollection */ class RDBSelectBuilder { @@ -93,15 +94,16 @@ protected function getMapper(): Mapper } /** - * @return EntityCollection|SthCollection + * @return TCollection */ - public function find(): EntityCollection|SthCollection + public function find(): Collection { $query = $this->builder->build(); /** @var Collection $collection */ $collection = $this->getMapper()->select($query); + /** @var TCollection */ return $this->handleReturnCollection($collection); } @@ -197,7 +199,7 @@ public function sum(string $attribute) * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function join($target, ?string $alias = null, $conditions = null): self { @@ -214,7 +216,7 @@ public function join($target, ?string $alias = null, $conditions = null): self * @param string|null $alias An alias. * @param WhereItem|array|null $conditions Join conditions. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function leftJoin($target, ?string $alias = null, $conditions = null): self { @@ -226,7 +228,7 @@ public function leftJoin($target, ?string $alias = null, $conditions = null): se /** * Set DISTINCT parameter. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function distinct(): self { @@ -238,7 +240,7 @@ public function distinct(): self /** * Lock selected rows. To be used within a transaction. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function forUpdate(): self { @@ -251,8 +253,7 @@ public function forUpdate(): self /** * Set to return STH collection. Recommended for fetching large number of records. * - * @todo Remove. - * @return RDBSelectBuilder + * @return RDBSelectBuilder> */ public function sth(): self { @@ -271,7 +272,7 @@ public function sth(): self * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function where($clause = [], $value = null): self { @@ -290,7 +291,7 @@ public function where($clause = [], $value = null): self * * @param WhereItem|array|string $clause A key or where clause. * @param mixed[]|scalar|null $value A value. Should be omitted if the first argument is not string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function having($clause = [], $value = null): self { @@ -312,7 +313,7 @@ public function having($clause = [], $value = null): self * An attribute to order by or an array or order items. * Passing an array will reset a previously set order. * @param (Order::ASC|Order::DESC)|bool|null $direction A direction. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function order($orderBy = Attribute::ID, $direction = null): self { @@ -324,7 +325,7 @@ public function order($orderBy = Attribute::ID, $direction = null): self /** * Apply OFFSET and LIMIT. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function limit(?int $offset = null, ?int $limit = null): self { @@ -349,7 +350,7 @@ public function limit(?int $offset = null, ?int $limit = null): self * @param Selection|Selection[]|Expression|Expression[]|string[]|string|array $select * An array of expressions or one expression. * @param string|null $alias An alias. Actual if the first parameter is a string. - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function select($select, ?string $alias = null): self { @@ -368,7 +369,7 @@ public function select($select, ?string $alias = null): self * * `groupBy([$expr1, $expr2, ...])` * * @param Expression|Expression[]|string|string[] $groupBy - * @return RDBSelectBuilder + * @return RDBSelectBuilder */ public function group($groupBy): self { @@ -380,7 +381,7 @@ public function group($groupBy): self /** * @deprecated Use `group` method. * - * @return RDBSelectBuilder + * @return RDBSelectBuilder * @param Expression|Expression[]|string|string[] $groupBy */ public function groupBy($groupBy): self diff --git a/application/Espo/ORM/SthCollection.php b/application/Espo/ORM/SthCollection.php index 8117dbfecc0..7e4dbb79758 100644 --- a/application/Espo/ORM/SthCollection.php +++ b/application/Espo/ORM/SthCollection.php @@ -85,7 +85,7 @@ public function getIterator(): Traversable while ($row = $this->fetchRow()) { $entity = $this->entityManager->getEntityFactory()->create($this->entityType); - $entity->set($row); + $entity->setMultiple($row); $entity->setAsFetched(); $this->prepareEntity($entity); diff --git a/application/Espo/ORM/Value/ValueAccessor.php b/application/Espo/ORM/Value/ValueAccessor.php index 840be59a6c7..37daa74d36f 100644 --- a/application/Espo/ORM/Value/ValueAccessor.php +++ b/application/Espo/ORM/Value/ValueAccessor.php @@ -66,6 +66,6 @@ public function set(string $field, ?object $value): void { $attributeValueMap = $this->extractor->extract($this->entity->getEntityType(), $field, $value); - $this->entity->set($attributeValueMap); + $this->entity->setMultiple($attributeValueMap); } } diff --git a/application/Espo/Repositories/Email.php b/application/Espo/Repositories/Email.php index 224ac5cc4a3..fc0d4019d14 100644 --- a/application/Espo/Repositories/Email.php +++ b/application/Espo/Repositories/Email.php @@ -558,7 +558,6 @@ private function processBeforeSaveFrom(EmailEntity $entity): void $from = trim($entity->getFromAddress() ?? ''); if (!$from) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $entity->set(self::ATTR_FROM_EMAIL_ADDRESS_ID, null); return; diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index cf636d188b6..fc5f47df812 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -191,7 +191,10 @@ 'b2cMode' => false, 'restrictedMode' => false, 'theme' => 'Espo', - 'themeParams' => (object) ['navbar' => 'side'], + 'themeParams' => (object) [ + 'navbar' => 'side', + 'direction' => 'ltr', + ], 'massEmailMaxPerHourCount' => 100, 'massEmailMaxPerBatchCount' => null, 'massEmailVerp' => false, diff --git a/application/Espo/Resources/defaults/systemConfig.php b/application/Espo/Resources/defaults/systemConfig.php index e93d8be953f..06b9d890b6c 100644 --- a/application/Espo/Resources/defaults/systemConfig.php +++ b/application/Espo/Resources/defaults/systemConfig.php @@ -120,6 +120,9 @@ 'passwordRecoveryInternalIntervalPeriod', 'cleanupAppLog', 'cleanupAppLogPeriod', + 'rabbitMq', + 'redis', + 'eventTransport', ], 'adminItems' => [ 'devMode', diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 58fb751335a..4833878c0c7 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -978,7 +978,6 @@ "Dark": "Dark", "Light": "Light", "Espo": "Espo", - "EspoRtl": "RTL", "Sakura": "Sakura", "Violet": "Violet", "Hazyblue": "Hazyblue", @@ -988,6 +987,10 @@ "side": "Side Navbar", "top": "Top Navbar" }, + "themeDirections": { + "ltr": "LTR", + "rtl": "RTL" + }, "fieldValidations": { "required": "Required", "maxCount": "Max Count", diff --git a/application/Espo/Resources/i18n/en_US/Job.json b/application/Espo/Resources/i18n/en_US/Job.json index b874985d834..4227e67424b 100644 --- a/application/Espo/Resources/i18n/en_US/Job.json +++ b/application/Espo/Resources/i18n/en_US/Job.json @@ -16,7 +16,8 @@ "group": "Group", "className": "Class Name", "targetGroup": "Target Group", - "job": "Job" + "job": "Job", + "pid": "PID" }, "options": { "status": { diff --git a/application/Espo/Resources/i18n/ur_IN/Global.json b/application/Espo/Resources/i18n/ur_IN/Global.json index 08d0fb99b61..7d01b729e10 100644 --- a/application/Espo/Resources/i18n/ur_IN/Global.json +++ b/application/Espo/Resources/i18n/ur_IN/Global.json @@ -825,7 +825,6 @@ "Sakura": "ساکورا", "Violet": "وایلیٹ", "Hazyblue": "ہیزی بلو", - "EspoRtl": "rtl", "Glass": "گلاس", "Light": "روشنی" }, @@ -920,4 +919,4 @@ "tabs": { "Stream": "ندی" } -} \ No newline at end of file +} diff --git a/application/Espo/Resources/layouts/Job/detail.json b/application/Espo/Resources/layouts/Job/detail.json index 0d603e97904..eaa71cd3b8b 100644 --- a/application/Espo/Resources/layouts/Job/detail.json +++ b/application/Espo/Resources/layouts/Job/detail.json @@ -1,25 +1,25 @@ [ { "rows": [ - [{"name":"name"}, {"name": "status"}], - [{"name":"queue"}, {"name":"number"}], - [{"name":"group"}, false] + [{"name": "name"}, {"name": "status"}], + [{"name": "queue"}, {"name":"number"}], + [{"name": "group"}, false] ] }, { "rows": [ - [{"name":"executeTime"}, {"name": "createdAt"}], - [{"name":"startedAt"}, {"name": "modifiedAt"}], - [{"name":"executedAt"}, false], - [{"name":"attempts"}, false], - [{"name":"failedAttempts"}, false] + [{"name": "executeTime"}, {"name": "createdAt"}], + [{"name": "startedAt"}, {"name": "modifiedAt"}], + [{"name": "executedAt"}, false], + [{"name": "attempts"}, {"name": "pid"}], + [{"name": "failedAttempts"}, false] ] }, { "rows": [ - [{"name":"scheduledJob"}, {"name":"targetType"}], - [{"name":"className"}, {"name":"targetId"}], - [{"name":"job"}, false], + [{"name": "scheduledJob"}, {"name": "targetType"}], + [{"name": "className"}, {"name": "targetId"}], + [{"name": "job"}, false], [{"name": "data"}] ] } diff --git a/application/Espo/Resources/layouts/Job/detailSmall.json b/application/Espo/Resources/layouts/Job/detailSmall.json index 0d603e97904..3170d1c8f34 100644 --- a/application/Espo/Resources/layouts/Job/detailSmall.json +++ b/application/Espo/Resources/layouts/Job/detailSmall.json @@ -1,25 +1,25 @@ [ { "rows": [ - [{"name":"name"}, {"name": "status"}], - [{"name":"queue"}, {"name":"number"}], - [{"name":"group"}, false] + [{"name": "name"}, {"name": "status"}], + [{"name": "queue"}, {"name": "number"}], + [{"name": "group"}, false] ] }, { "rows": [ - [{"name":"executeTime"}, {"name": "createdAt"}], - [{"name":"startedAt"}, {"name": "modifiedAt"}], - [{"name":"executedAt"}, false], - [{"name":"attempts"}, false], - [{"name":"failedAttempts"}, false] + [{"name": "executeTime"}, {"name": "createdAt"}], + [{"name": "startedAt"}, {"name": "modifiedAt"}], + [{"name": "executedAt"}, false], + [{"name": "attempts"}, {"name": "pid"}], + [{"name": "failedAttempts"}, false] ] }, { "rows": [ - [{"name":"scheduledJob"}, {"name":"targetType"}], - [{"name":"className"}, {"name":"targetId"}], - [{"name":"job"}, false], + [{"name": "scheduledJob"}, {"name": "targetType"}], + [{"name": "className"}, {"name": "targetId"}], + [{"name": "job"}, false], [{"name": "data"}] ] } diff --git a/application/Espo/Resources/layouts/Job/filters.json b/application/Espo/Resources/layouts/Job/filters.json index b8f873d0163..aa7226d5a80 100644 --- a/application/Espo/Resources/layouts/Job/filters.json +++ b/application/Espo/Resources/layouts/Job/filters.json @@ -6,5 +6,6 @@ "executedAt", "queue", "group", - "className" + "className", + "pid" ] diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index 2decade2036..e80327636d0 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -8,8 +8,13 @@ }, { "rows": [ - [{"name": "useCache"}, {"name": "useWebSocket"}], - [{"name": "maintenanceMode"}, {"name": "cronDisabled"}] + [{"name": "useCache"}, {"name": "maintenanceMode"}], + [false, {"name": "cronDisabled"}] + ] + }, + { + "rows": [ + [{"name": "useWebSocket"}, false] ] }, { diff --git a/application/Espo/Resources/metadata/app/consoleCommands.json b/application/Espo/Resources/metadata/app/consoleCommands.json index e89648a3856..c0eee85511a 100644 --- a/application/Espo/Resources/metadata/app/consoleCommands.json +++ b/application/Espo/Resources/metadata/app/consoleCommands.json @@ -106,5 +106,44 @@ "migrationVersionStep": { "listed": false, "noSystemUser": true + }, + "cron:run": { + "className": "Espo\\Classes\\ConsoleCommands\\CronRun", + "listed": true, + "allowedFlags": [ + "force" + ], + "allowedOptions": [] + }, + "job:prepare": { + "className": "Espo\\Classes\\ConsoleCommands\\JobPrepare", + "listed": false, + "allowedFlags": [ + "sq" + ], + "allowedOptions": [ + "interval", + "limit" + ] + }, + "job:enqueue": { + "className": "Espo\\Classes\\ConsoleCommands\\JobEnqueue", + "listed": false, + "allowedFlags": [], + "allowedOptions": [ + "interval", + "limit", + "portion", + "queue" + ] + }, + "job:worker": { + "className": "Espo\\Classes\\ConsoleCommands\\JobWorker", + "listed": false, + "allowedFlags": [], + "allowedOptions": [ + "limit", + "queue" + ] } } diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 8af3f9a59bc..592ac7ecd17 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -2,6 +2,9 @@ "authTokenManager": { "className": "Espo\\Core\\Authentication\\AuthToken\\EspoManager" }, + "dataCache": { + "loaderClassName": "Espo\\Core\\Utils\\Cache\\DataCacheServiceLoader" + }, "ormMetadataData": { "className": "Espo\\Core\\Utils\\Metadata\\OrmMetadataData" }, @@ -91,5 +94,14 @@ }, "session": { "className": "Espo\\Core\\Session\\DefaultSession" + }, + "eventDispatcherConfiguration": { + "className": "Espo\\Core\\Utils\\Event\\Configuration" + }, + "eventTransport": { + "loaderClassName": "Espo\\Core\\Utils\\Event\\EventTransportLoader" + }, + "pipelineDataProvider": { + "className": "Espo\\Tools\\Pipeline\\PipelineDataProvider" } } diff --git a/application/Espo/Resources/metadata/app/rebuild.json b/application/Espo/Resources/metadata/app/rebuild.json index 68a7cb23a9b..a00b9701146 100644 --- a/application/Espo/Resources/metadata/app/rebuild.json +++ b/application/Espo/Resources/metadata/app/rebuild.json @@ -1,7 +1,9 @@ { "actionClassNameList": [ "Espo\\Core\\Rebuild\\Actions\\AddSystemUser", + "Espo\\Core\\Rebuild\\Actions\\AddSystemState", "Espo\\Core\\Rebuild\\Actions\\AddSystemData", + "Espo\\Core\\Rebuild\\Actions\\VersionUpdate", "Espo\\Core\\Rebuild\\Actions\\ScheduledJobs", "Espo\\Core\\Rebuild\\Actions\\ConfigMetadataCheck", "Espo\\Core\\Rebuild\\Actions\\GenerateInstanceId", diff --git a/application/Espo/Resources/metadata/entityAcl/SystemState.json b/application/Espo/Resources/metadata/entityAcl/SystemState.json new file mode 100644 index 00000000000..e96a4427208 --- /dev/null +++ b/application/Espo/Resources/metadata/entityAcl/SystemState.json @@ -0,0 +1,3 @@ +{ + "systemWriteForbidden": true +} diff --git a/application/Espo/Resources/metadata/entityAcl/UserState.json b/application/Espo/Resources/metadata/entityAcl/UserState.json new file mode 100644 index 00000000000..e96a4427208 --- /dev/null +++ b/application/Espo/Resources/metadata/entityAcl/UserState.json @@ -0,0 +1,3 @@ +{ + "systemWriteForbidden": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/Job.json b/application/Espo/Resources/metadata/entityDefs/Job.json index 410c09448b7..e06a79d30ca 100644 --- a/application/Espo/Resources/metadata/entityDefs/Job.json +++ b/application/Espo/Resources/metadata/entityDefs/Job.json @@ -75,7 +75,8 @@ "hasSeconds": true }, "pid": { - "type": "int" + "type": "int", + "disableFormatting": true }, "attempts": { "type": "int" diff --git a/application/Espo/Resources/metadata/entityDefs/SystemState.json b/application/Espo/Resources/metadata/entityDefs/SystemState.json new file mode 100644 index 00000000000..d98c2d4cb82 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/SystemState.json @@ -0,0 +1,15 @@ +{ + "fields": { + "id": { + "type": "id", + "dbType": "string", + "maxLength": 1 + }, + "versionNumber": { + "type": "int", + "dbType": "bigint", + "default": 0 + } + }, + "noDeletedAttribute": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/UserState.json b/application/Espo/Resources/metadata/entityDefs/UserState.json new file mode 100644 index 00000000000..fb87d1b809b --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/UserState.json @@ -0,0 +1,32 @@ +{ + "fields": { + "id": { + "type": "id", + "dbType": "integer", + "autoincrement": true + }, + "user": { + "type": "link" + }, + "emailFiltersVersionNumber": { + "type": "int", + "dbType": "bigint", + "default": 0 + } + }, + "links": { + "user": { + "type": "belongsTo", + "entity": "User" + } + }, + "indexes": { + "userId": { + "unique": true, + "columns": [ + "userId" + ] + } + }, + "noDeletedAttribute": true +} diff --git a/application/Espo/Resources/metadata/themes/Espo.json b/application/Espo/Resources/metadata/themes/Espo.json index 23f161550a7..fa735191b12 100644 --- a/application/Espo/Resources/metadata/themes/Espo.json +++ b/application/Espo/Resources/metadata/themes/Espo.json @@ -10,6 +10,14 @@ "side", "top" ] + }, + "direction": { + "type": "enum", + "default": "ltr", + "options": [ + "ltr", + "rtl" + ] } }, "mappedParams": { diff --git a/application/Espo/Resources/metadata/themes/EspoRtl.json b/application/Espo/Resources/metadata/themes/EspoRtl.json deleted file mode 100644 index f76e989533d..00000000000 --- a/application/Espo/Resources/metadata/themes/EspoRtl.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "stylesheet": "client/css/espo/espo-rtl.css", - "stylesheetIframe": "client/css/espo/espo-rtl-iframe.css", - "logo": "client/img/logo-light.svg", - "params": { - "navbar": { - "type": "enum", - "default": "top", - "options": [ - "top", - "side" - ] - } - } -} diff --git a/application/Espo/Tools/App/Language/AclDependencyProvider.php b/application/Espo/Tools/App/Language/AclDependencyProvider.php index 617eb0e0403..09acdc5f94c 100644 --- a/application/Espo/Tools/App/Language/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Language/AclDependencyProvider.php @@ -29,8 +29,8 @@ namespace Espo\Tools\App\Language; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\ORM\Type\FieldType; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\Metadata; @@ -38,7 +38,7 @@ class AclDependencyProvider { - private const CACHE_KEY = 'languageAclDependency'; + private const string CACHE_KEY = 'languageAclDependency'; /** @var string[] */ private array $enumFieldTypeList = [ @@ -53,6 +53,7 @@ class AclDependencyProvider private bool $useCache; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private Metadata $metadata, private Defs $ormDefs, @@ -79,10 +80,12 @@ public function get(): array private function loadData(): array { if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) { - /** @var array[] $raw */ + /** @var ?array[] $raw */ $raw = $this->dataCache->get(self::CACHE_KEY); - return $this->buildFromRaw($raw); + if ($raw !== null) { + return $this->buildFromRaw($raw); + } } return $this->buildData(); diff --git a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php index eeb20d4c558..635c213c058 100644 --- a/application/Espo/Tools/App/Metadata/AclDependencyProvider.php +++ b/application/Espo/Tools/App/Metadata/AclDependencyProvider.php @@ -29,6 +29,7 @@ namespace Espo\Tools\App\Metadata; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Config; use Espo\Core\Utils\DataCache; @@ -37,7 +38,7 @@ class AclDependencyProvider { - private const CACHE_KEY = 'metadataAclDependency'; + private const string CACHE_KEY = 'metadataAclDependency'; /** @var string[] */ private array $enumFieldTypeList = [ @@ -52,6 +53,7 @@ class AclDependencyProvider private bool $useCache; public function __construct( + #[Qualify(DataCache::QUALIFIER_SYSTEM)] private DataCache $dataCache, private Metadata $metadata, private Defs $ormDefs, @@ -78,10 +80,12 @@ public function get(): array private function loadData(): array { if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) { - /** @var array[] $raw */ + /** @var ?array[] $raw */ $raw = $this->dataCache->get(self::CACHE_KEY); - return $this->buildFromRaw($raw); + if ($raw !== null) { + return $this->buildFromRaw($raw); + } } return $this->buildData(); diff --git a/application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php b/application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php new file mode 100644 index 00000000000..b8b55b39bf3 --- /dev/null +++ b/application/Espo/Tools/Currency/Events/CurrencyRateUpdate.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Currency\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +/** + * @since 10.1.0 + */ +readonly class CurrencyRateUpdate implements CrossInstanceEvent +{ + public function toPayload(): stdClass + { + return (object) []; + } + + public static function fromPayload(stdClass $payload): self + { + return new self(); + } +} diff --git a/application/Espo/Tools/Currency/RateEntryProvider.php b/application/Espo/Tools/Currency/RateEntryProvider.php index 351fbb06afe..ac798cd6c5c 100644 --- a/application/Espo/Tools/Currency/RateEntryProvider.php +++ b/application/Espo/Tools/Currency/RateEntryProvider.php @@ -33,10 +33,12 @@ use Espo\Core\Currency\InternalRateEntryProvider; use Espo\Core\Field\Date; use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Entities\CurrencyRecord; use Espo\Entities\CurrencyRecordRate; use Espo\ORM\EntityManager; use Espo\ORM\Query\Part\Order; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; use Espo\Tools\Currency\Exceptions\NotEnabled; use WeakMap; @@ -53,8 +55,13 @@ public function __construct( private EntityManager $entityManager, private DateTime $dateTime, private InternalRateEntryProvider $internalRateEntryProvider, + private EventDispatcher $eventDispatcher, ) { $this->map = new WeakMap(); + + $this->eventDispatcher->subscribe(CurrencyRateUpdate::class, function () { + $this->map = new WeakMap(); + }); } public function getCurrentRateEntry(CurrencyRecord $record): ?CurrencyRecordRate diff --git a/application/Espo/Tools/Currency/SyncManager.php b/application/Espo/Tools/Currency/SyncManager.php index 6442b90c9a3..00a047d3020 100644 --- a/application/Espo/Tools/Currency/SyncManager.php +++ b/application/Espo/Tools/Currency/SyncManager.php @@ -33,10 +33,12 @@ use Espo\Core\Utils\Config\ConfigWriter; use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Entities\CurrencyRecord; use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; use Espo\ORM\Query\UpdateBuilder; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; use Espo\Tools\Currency\Exceptions\NotEnabled; /** @@ -54,6 +56,7 @@ public function __construct( private RateEntryProvider $rateEntryProvider, private DataCache $dataCache, private SystemConfig $systemConfig, + private EventDispatcher $eventDispatcher, ) {} public function sync(): void @@ -127,6 +130,7 @@ public function refreshCache(): void $this->syncToConfigInTransaction(); }); + $this->dispatchUpdateEvent(); $this->clearCache(); } @@ -154,6 +158,7 @@ public function updateCode(string $code): void $this->configWriter->set('currencyRates', $rates); $this->configWriter->save(); + $this->dispatchUpdateEvent(); $this->clearCache(); } @@ -165,4 +170,9 @@ private function clearCache(): void $this->dataCache->clear($this->cacheKey); } + + private function dispatchUpdateEvent(): void + { + $this->eventDispatcher->dispatch(new CurrencyRateUpdate()); + } } diff --git a/application/Espo/Tools/Dashboard/Service.php b/application/Espo/Tools/Dashboard/Service.php index da7d1878bad..e3fe86624f5 100644 --- a/application/Espo/Tools/Dashboard/Service.php +++ b/application/Espo/Tools/Dashboard/Service.php @@ -130,7 +130,7 @@ public function deployTemplateToTeam(string $id, string $teamId, bool $append = private function applyTemplate(Entity $preferences, DashboardTemplate $template, bool $append): void { if (!$append) { - $preferences->set([ + $preferences->setMultiple([ 'dashboardLayout' => $template->get('layout'), 'dashletsOptions' => $template->get('dashletsOptions'), ]); @@ -168,7 +168,7 @@ private function applyTemplate(Entity $preferences, DashboardTemplate $template, $dashletsOptions->$id = $item; } - $preferences->set([ + $preferences->setMultiple([ 'dashboardLayout' => $dashboardLayout, 'dashletsOptions' => $dashletsOptions, ]); diff --git a/application/Espo/Tools/EmailTemplate/Processor.php b/application/Espo/Tools/EmailTemplate/Processor.php index 8dc976a6e12..c222f8ee6f8 100644 --- a/application/Espo/Tools/EmailTemplate/Processor.php +++ b/application/Espo/Tools/EmailTemplate/Processor.php @@ -104,11 +104,14 @@ public function process(EmailTemplate $template, Params $params, Data $data): Re } } + $pairs = []; + foreach ($entityHash as $type => $entity) { $subject = $this->processText( type: $type, entity: $entity, text: $subject, + pairs: $pairs, user: $user, skipAcl: !$params->applyAcl(), isHtml: $template->isHtml(), @@ -120,17 +123,18 @@ public function process(EmailTemplate $template, Params $params, Data $data): Re type: $type, entity: $entity, text: $body, + pairs: $pairs, user: $user, skipAcl: !$params->applyAcl(), isHtml: $template->isHtml(), ); } - $subject = $this->processPlaceholders($subject, $data); - $body = $this->processPlaceholders($body, $data); + $this->processPlaceholders($pairs, $data); + $this->processCleanup($pairs); - $subject = $this->processCleanup($subject); - $body = $this->processCleanup($body); + $subject = strtr($subject, $pairs); + $body = strtr($body, $pairs); $attachmentList = $params->copyAttachments() ? $this->copyAttachments($template) : []; @@ -142,22 +146,27 @@ public function process(EmailTemplate $template, Params $params, Data $data): Re attachmentList: $attachmentList, ); } + /** + * @param array $pairs + */ - private function processPlaceholders(string $text, Data $data): string + private function processPlaceholders(array &$pairs, Data $data): void { foreach ($this->placeholdersProvider->get() as [$key, $placeholder]) { - $value = $placeholder->get($data); + $from = '{' . $key . '}'; - $text = str_replace('{' . $key . '}', $value, $text); + $pairs[$from] = $placeholder->get($data); } - - return $text; } + /** + * @param array $pairs + */ private function processText( string $type, Entity $entity, string $text, + array &$pairs, User $user, bool $skipLinks = false, ?string $prefixLink = null, @@ -208,7 +217,13 @@ private function processText( $variableName = "$prefixLink.$attribute"; } - $text = str_replace("{{$type}.$variableName}", $value, $text); + $placeholder = "{{$type}.$variableName}"; + + if (array_key_exists($placeholder, $pairs)) { + continue; + } + + $pairs[$placeholder] = $value; } if (!$skipLinks && $entity->hasId()) { @@ -216,6 +231,7 @@ private function processText( type: $type, entity: $entity, text: $text, + pairs: $pairs, user: $user, skipAcl: $skipAcl, isHtml: $isHtml, @@ -225,10 +241,14 @@ private function processText( return $text; } + /** + * @param array $pairs + */ private function processLinks( string $type, Entity $entity, string $text, + array &$pairs, User $user, bool $skipAcl, bool $isHtml, @@ -292,6 +312,7 @@ private function processLinks( type: $type, entity: $relatedEntity, text: $text, + pairs: $pairs, user: $user, skipLinks: true, prefixLink: $relation, @@ -464,14 +485,17 @@ private function prepare(Data $data, User $user, Params $params): array return [$entityHash, $data]; } - private function processCleanup(string $text): string + /** + * @param array $pairs + */ + private function processCleanup(array &$pairs): void { - $pairs = []; - foreach ($this->stripPlaceholderList as $item) { + if (array_key_exists($item, $pairs)) { + continue; + } + $pairs[$item] = ''; } - - return strtr($text, $pairs); } } diff --git a/application/Espo/Tools/EntityManager/EntityManager.php b/application/Espo/Tools/EntityManager/EntityManager.php index 1dab8900d01..3f49139adba 100644 --- a/application/Espo/Tools/EntityManager/EntityManager.php +++ b/application/Espo/Tools/EntityManager/EntityManager.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Forbidden; @@ -67,7 +68,9 @@ class EntityManager public function __construct( private Metadata $metadata, + #[Qualify(Language::QUALIFIER_USER)] private Language $language, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private FileManager $fileManager, private Config $config, diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php index 46e6420257a..09947900996 100644 --- a/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/CategoriesUpdateHook.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager\Hook\Hooks; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\DataManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; @@ -63,6 +64,7 @@ class CategoriesUpdateHook implements UpdateHook public function __construct( private InjectableFactory $injectableFactory, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private Metadata $metadata, private DataManager $dataManager, diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php index d28d1c7705e..374462f6532 100644 --- a/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/EventCreateHook.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager\Hook\Hooks; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Templates\Entities\Event; use Espo\Core\Utils\Language; use Espo\Tools\EntityManager\Hook\CreateHook; @@ -37,6 +38,7 @@ class EventCreateHook implements CreateHook { public function __construct( + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private Language $language ) {} diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php index 5d286c76c39..bb5149f75a0 100644 --- a/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/EventDeleteHook.php @@ -29,6 +29,7 @@ namespace Espo\Tools\EntityManager\Hook\Hooks; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Templates\Entities\Event; use Espo\Core\Utils\Language; use Espo\Tools\EntityManager\Hook\DeleteHook; @@ -37,6 +38,7 @@ class EventDeleteHook implements DeleteHook { public function __construct( + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private Language $language ) {} diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index ec52540d287..8939181cf78 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -29,6 +29,7 @@ namespace Espo\Tools\FieldManager; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Language; @@ -65,6 +66,7 @@ public function __construct( private InjectableFactory $injectableFactory, private Metadata $metadata, private Language $language, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private MetadataHelper $metadataHelper, private NameUtil $nameUtil diff --git a/application/Espo/Tools/LabelManager/LabelManager.php b/application/Espo/Tools/LabelManager/LabelManager.php index 0535c6cfc25..8e8992d3c04 100644 --- a/application/Espo/Tools/LabelManager/LabelManager.php +++ b/application/Espo/Tools/LabelManager/LabelManager.php @@ -42,13 +42,11 @@ class LabelManager implements Di\DefaultLanguageAware, Di\MetadataAware, - Di\FileManagerAware, - Di\DataCacheAware + Di\FileManagerAware { use Di\DefaultLanguageSetter; use Di\MetadataSetter; use Di\FileManagerSetter; - use Di\DataCacheSetter; /** @var string[] */ protected $ignoreList = [ diff --git a/application/Espo/Tools/Layout/CustomLayoutService.php b/application/Espo/Tools/Layout/CustomLayoutService.php index 0540e0b24c2..a85f4d2e5af 100644 --- a/application/Espo/Tools/Layout/CustomLayoutService.php +++ b/application/Espo/Tools/Layout/CustomLayoutService.php @@ -29,6 +29,7 @@ namespace Espo\Tools\Layout; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\DataManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; @@ -46,6 +47,7 @@ class CustomLayoutService public function __construct( private Metadata $metadata, private FileManager $fileManager, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private LayoutProvider $layoutProvider, private DataManager $dataManager diff --git a/application/Espo/Tools/LeadCapture/CaptureService.php b/application/Espo/Tools/LeadCapture/CaptureService.php index d2de2c1f25f..6d3f9a7cf73 100644 --- a/application/Espo/Tools/LeadCapture/CaptureService.php +++ b/application/Espo/Tools/LeadCapture/CaptureService.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Field\Link; use Espo\Core\FieldValidation\Exceptions\ValidationError; @@ -70,6 +71,7 @@ class CaptureService public function __construct( private EntityManager $entityManager, private FieldUtil $fieldUtil, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private HookManager $hookManager, private Log $log, diff --git a/application/Espo/Tools/LeadCapture/ConfirmationSender.php b/application/Espo/Tools/LeadCapture/ConfirmationSender.php index 9cc26282f3b..6c03cd6fdff 100644 --- a/application/Espo/Tools/LeadCapture/ConfirmationSender.php +++ b/application/Espo/Tools/LeadCapture/ConfirmationSender.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\Error; use Espo\Core\Mail\Account\GroupAccount\AccountFactory; use Espo\Core\Mail\EmailSender; @@ -54,6 +55,7 @@ class ConfirmationSender public function __construct( private EntityManager $entityManager, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private EmailSender $emailSender, private AccountFactory $accountFactory, diff --git a/application/Espo/Tools/LeadCapture/FormService.php b/application/Espo/Tools/LeadCapture/FormService.php index 3536a3cc308..3a899208e3f 100644 --- a/application/Espo/Tools/LeadCapture/FormService.php +++ b/application/Espo/Tools/LeadCapture/FormService.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\NotFound; use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Address\CountryDataProvider; @@ -36,6 +37,7 @@ use Espo\Core\Utils\DataCache; use Espo\Core\Utils\Language; use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Theme\Direction; use Espo\Core\Utils\Theme\MetadataProvider as ThemeMetadataProvider; use Espo\Core\Utils\ThemeManager; use Espo\Entities\Integration; @@ -48,12 +50,13 @@ class FormService { - private const CACHE_KEY_PREFIX = 'leadCaptureForm'; + private const string CACHE_KEY_PREFIX = 'leadCaptureForm'; public function __construct( private EntityManager $entityManager, private Config $config, private Metadata $metadata, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private CountryDataProvider $countryDataProvider, private LanguageService $languageService, @@ -62,10 +65,11 @@ public function __construct( private ThemeManager $themeManager, private Config\SystemConfig $systemConfig, private ThemeMetadataProvider $themeMetadataProvider, + private ThemeDirectionDetector $themeDirectionDetector, ) {} /** - * @return array{LeadCapture, array, ?string} + * @return array{LeadCapture, array, ?string, Direction} * @throws NotFound */ public function getData(string $id): array @@ -78,7 +82,9 @@ public function getData(string $id): array $data['captchaKey'] = $captchaKey; - return [$leadCapture, $data, $captchaScript]; + $direction = $this->themeDirectionDetector->detect($leadCapture); + + return [$leadCapture, $data, $captchaScript, $direction]; } /** @@ -89,7 +95,11 @@ private function getDataInternal(LeadCapture $leadCapture): array $cacheKey = $this->getCacheKey($leadCapture); if ($this->systemConfig->useCache() && $this->dataCache->has($cacheKey)) { - return $this->getFromCache($cacheKey); + $cached = $this->getFromCache($cacheKey); + + if ($cached !== null) { + return $cached; + } } $data = $this->prepareData($leadCapture); @@ -387,11 +397,11 @@ private function getCacheKey(LeadCapture $leadCapture): string } /** - * @return array + * @return ?array */ - private function getFromCache(string $cacheKey): array + private function getFromCache(string $cacheKey): ?array { - /** @var array */ + /** @var ?array */ return $this->dataCache->get($cacheKey); } diff --git a/application/Espo/Tools/LeadCapture/ThemeDirectionDetector.php b/application/Espo/Tools/LeadCapture/ThemeDirectionDetector.php new file mode 100644 index 00000000000..54dc605cd38 --- /dev/null +++ b/application/Espo/Tools/LeadCapture/ThemeDirectionDetector.php @@ -0,0 +1,61 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\LeadCapture; + +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\Utils\Language; +use Espo\Core\Utils\Theme\Direction; +use Espo\Entities\LeadCapture; + +class ThemeDirectionDetector +{ + private const array RTL_LANGUAGE_CODE_LIST = [ + 'ar', + 'fa', + 'he', + 'ur', + ]; + + public function __construct( + #[Qualify(Language::QUALIFIER_DEFAULT)] + private Language $language, + ) {} + + public function detect(LeadCapture $leadCapture): Direction + { + $language = $leadCapture->getFormLanguage() ?? $this->language->getLanguage(); + + $languageCode = strtolower(substr($language, 0, 2)); + + return in_array($languageCode, self::RTL_LANGUAGE_CODE_LIST, true) ? + Direction::Rtl : + Direction::Ltr; + } +} diff --git a/application/Espo/Tools/LinkManager/LinkManager.php b/application/Espo/Tools/LinkManager/LinkManager.php index 0a1deed335e..8c7597d9584 100644 --- a/application/Espo/Tools/LinkManager/LinkManager.php +++ b/application/Espo/Tools/LinkManager/LinkManager.php @@ -29,6 +29,7 @@ namespace Espo\Tools\LinkManager; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\DataManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Conflict; @@ -69,6 +70,7 @@ class LinkManager public function __construct( private Metadata $metadata, private Language $language, + #[Qualify(Language::QUALIFIER_BASE)] private Language $baseLanguage, private DataManager $dataManager, private LinkHookProcessor $linkHookProcessor, diff --git a/application/Espo/Tools/MassUpdate/Processor.php b/application/Espo/Tools/MassUpdate/Processor.php index 3a8f608fe47..01724a16169 100644 --- a/application/Espo/Tools/MassUpdate/Processor.php +++ b/application/Espo/Tools/MassUpdate/Processor.php @@ -192,7 +192,7 @@ private function processEntity( return false; } - $entity->set($values); + $entity->setMultiple($values); try { $service->processValidation($entity, $values); diff --git a/application/Espo/Tools/OpenApi/Provider.php b/application/Espo/Tools/OpenApi/Provider.php index 09f2b2a556a..0752d37e52e 100644 --- a/application/Espo/Tools/OpenApi/Provider.php +++ b/application/Espo/Tools/OpenApi/Provider.php @@ -90,11 +90,13 @@ private function getData(Params $params): stdClass if ($this->dataCache->has($cacheKey)) { $data = $this->dataCache->get($cacheKey); - if (!$data instanceof stdClass) { + if ($data !== null && !$data instanceof stdClass) { throw new RuntimeException("Corrupted OpenAPI spec cache file."); } - return $data; + if ($data !== null) { + return $data; + } } $data = $this->buildData($params); diff --git a/application/Espo/Tools/Pdf/MassService.php b/application/Espo/Tools/Pdf/MassService.php index fafde7f9db9..a853d46115c 100644 --- a/application/Espo/Tools/Pdf/MassService.php +++ b/application/Espo/Tools/Pdf/MassService.php @@ -31,6 +31,7 @@ use DateTime; use Espo\Core\Acl; +use Espo\Core\Binding\Attributes\Qualify; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; @@ -66,6 +67,7 @@ public function __construct( private DataLoaderManager $dataLoaderManager, private SelectBuilderFactory $selectBuilderFactory, private Builder $builder, + #[Qualify(Language::QUALIFIER_DEFAULT)] private Language $defaultLanguage, private JobSchedulerFactory $jobSchedulerFactory, private FileStorageManager $fileStorageManager, diff --git a/application/Espo/Tools/Pipeline/CacheClearer.php b/application/Espo/Tools/Pipeline/CacheClearer.php index 0e877c70a58..f95d94a81ae 100644 --- a/application/Espo/Tools/Pipeline/CacheClearer.php +++ b/application/Espo/Tools/Pipeline/CacheClearer.php @@ -30,7 +30,9 @@ namespace Espo\Tools\Pipeline; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\WebSocket\Submission; +use Espo\Tools\Pipeline\Events\UpdateGlobal; class CacheClearer { @@ -39,11 +41,13 @@ class CacheClearer public function __construct( private DataCache $dataCache, private Submission $submission, + private EventDispatcher $eventDispatcher, ) {} public function clear(): void { $this->dataCache->clear(self::CACHE_KEY); + $this->eventDispatcher->dispatch(new UpdateGlobal()); $this->submission->submit('appParamsUpdate'); } } diff --git a/application/Espo/Tools/Pipeline/Events/UpdateGlobal.php b/application/Espo/Tools/Pipeline/Events/UpdateGlobal.php new file mode 100644 index 00000000000..2449944ecb2 --- /dev/null +++ b/application/Espo/Tools/Pipeline/Events/UpdateGlobal.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Pipeline\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; + +class UpdateGlobal implements CrossInstanceEvent +{ + public static function fromPayload(stdClass $payload): CrossInstanceEvent + { + return new self(); + } + + public function toPayload(): stdClass + { + return (object) []; + } +} diff --git a/application/Espo/Tools/Pipeline/PipelineDataProvider.php b/application/Espo/Tools/Pipeline/PipelineDataProvider.php index 19852ff7d25..5f6b0c347de 100644 --- a/application/Espo/Tools/Pipeline/PipelineDataProvider.php +++ b/application/Espo/Tools/Pipeline/PipelineDataProvider.php @@ -31,6 +31,7 @@ use Espo\Core\Utils\Config\SystemConfig; use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Event\EventDispatcher; use Espo\Core\Utils\Log; use Espo\Core\Utils\Metadata; use Espo\Entities\Pipeline; @@ -38,6 +39,7 @@ use Espo\ORM\EntityManager; use Espo\Tools\Pipeline\Data\PipelineData; use Espo\Tools\Pipeline\Data\StageData; +use Espo\Tools\Pipeline\Events\UpdateGlobal; use stdClass; use Throwable; @@ -46,26 +48,40 @@ class PipelineDataProvider private const int LIMIT = 100; private const string CACHE_KEY = 'pipelines'; + /** + * @var ?array + */ + private ?array $data = null; + public function __construct( private Metadata $metadata, private EntityManager $entityManager, private SystemConfig $systemConfig, private DataCache $dataCache, private Log $log, - ) {} + EventDispatcher $eventDispatcher, + ) { + $eventDispatcher->subscribe(UpdateGlobal::class, function () { + $this->data = null; + }); + } /** * @return array */ public function get(): array { + if ($this->data !== null) { + return $this->data; + } + $data = null; $store = false; if ($this->systemConfig->useCache()) { $data = $this->getFromCache(); - if (!$data) { + if ($data === null) { $store = true; } } @@ -76,6 +92,8 @@ public function get(): array $this->storeCache($data); } + $this->data = $data; + return $data; } @@ -282,6 +300,10 @@ private function tryGetFromCache(): ?array { $data = $this->dataCache->get(self::CACHE_KEY); + if ($data === null) { + return null; + } + if (!$data instanceof stdClass) { $this->log->warning("Bad pipeline cache."); diff --git a/application/Espo/Tools/UserSecurity/Service.php b/application/Espo/Tools/UserSecurity/Service.php index c97be0a2c40..6346573dba9 100644 --- a/application/Espo/Tools/UserSecurity/Service.php +++ b/application/Espo/Tools/UserSecurity/Service.php @@ -172,7 +172,6 @@ public function getTwoFactorUserSetupData(string $id, stdClass $data): stdClass } $userData->set('auth2FA', false); - /** @noinspection PhpRedundantOptionalArgumentInspection */ $userData->set('auth2FAMethod', null); $this->entityManager->saveEntity($userData); @@ -233,7 +232,6 @@ public function update(string $id, stdClass $data): stdClass } if (!$userData->get('auth2FA')) { - /** @noinspection PhpRedundantOptionalArgumentInspection */ $userData->set('auth2FAMethod', null); } diff --git a/client/modules/crm/src/views/calendar/calendar.js b/client/modules/crm/src/views/calendar/calendar.js index 50e0fcbc197..385890a8054 100644 --- a/client/modules/crm/src/views/calendar/calendar.js +++ b/client/modules/crm/src/views/calendar/calendar.js @@ -896,6 +896,7 @@ class CalendarView extends View { /** @type {CalendarOptions & Object.} */ const options = { + direction: this.getThemeManager().getDirection(), scrollTime: this.scrollHour + ':00', headerToolbar: false, slotLabelFormat: slotLabelFormat, diff --git a/client/modules/crm/src/views/calendar/timeline.js b/client/modules/crm/src/views/calendar/timeline.js index d6605b60794..0edc82e6502 100644 --- a/client/modules/crm/src/views/calendar/timeline.js +++ b/client/modules/crm/src/views/calendar/timeline.js @@ -595,6 +595,7 @@ class TimelineView extends View { const itemsDataSet = new DataSet(eventList); this.timeline = new Timeline($timeline.get(0), itemsDataSet, this.groupsDataSet, { + rtl: this.getThemeManager().getDirection() === 'rtl', dataAttributes: 'all', start: this.start.toDate(), end: this.end.toDate(), diff --git a/client/res/templates/record/detail.tpl b/client/res/templates/record/detail.tpl index 0f3b285fdd6..83e3235702e 100644 --- a/client/res/templates/record/detail.tpl +++ b/client/res/templates/record/detail.tpl @@ -7,7 +7,7 @@ role="group" >{{{buttons}}} {{#if navigateButtonsEnabled}} -
+
{{{editSideButtons}}}
diff --git a/client/res/templates/record/edit.tpl b/client/res/templates/record/edit.tpl index 479f666799c..2788d3ca8a2 100644 --- a/client/res/templates/record/edit.tpl +++ b/client/res/templates/record/edit.tpl @@ -7,7 +7,7 @@ role="group" >{{{buttons}}}
{{{sideButtons}}}
diff --git a/client/src/app.js b/client/src/app.js index 97c6184fabb..8e831ddc871 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -487,6 +487,7 @@ class App { this.loadStylesheet(); } + this.applyThemeDirection(); this.applyUserStyle(); if (this.anotherUser) { @@ -966,6 +967,8 @@ class App { this.preferences.clear(); this.acl.clear(); + this.applyThemeDirection(); + if (!silent) { this.storage.clear('user', 'auth'); this.storage.clear('user', 'anotherUser'); @@ -1023,6 +1026,16 @@ class App { element.setAttribute('href', path); } + /** + * @private + */ + applyThemeDirection() { + const direction = this.themeManager.getDirection(); + + document.documentElement.dir = direction; + document.body.dataset.direction = direction; + } + /** * @private */ diff --git a/client/src/theme-manager.js b/client/src/theme-manager.js index 2cbab3c425d..be7f3d61474 100644 --- a/client/src/theme-manager.js +++ b/client/src/theme-manager.js @@ -176,6 +176,17 @@ class ThemeManager { return link; } + /** + * Get the normalized document direction. + * + * @returns {'ltr'|'rtl'} + */ + getDirection() { + const direction = this.getParam('direction'); + + return direction === 'rtl' ? 'rtl' : 'ltr'; + } + /** * Get a theme parameter. * diff --git a/client/src/utils.ts b/client/src/utils.ts index 4314b79840e..78ccf4459d8 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -196,9 +196,13 @@ const Utils = { fired = true; Espo.loader.require(handler, Handler => { - const handler = new Handler(view); + const handlerInstance = new Handler(view); - handler[method].call(handler, data, event); + if (!(method in handlerInstance)) { + throw new Error(`No method '${method}' in action handler '${handler}'.`); + } + + handlerInstance[method].call(handlerInstance, data, event); }); } else if ( // @ts-ignore diff --git a/client/src/views/admin/user-interface.js b/client/src/views/admin/user-interface.js index e19eeeff0bc..ff57aa0de70 100644 --- a/client/src/views/admin/user-interface.js +++ b/client/src/views/admin/user-interface.js @@ -41,9 +41,13 @@ export default class extends SettingsEditRecordView { this.listenTo(this.model, 'change:scopeColorsDisabled', () => this.controlColorsField()); this.on('save', initialAttributes => { + const themeParams = this.model.get('themeParams') || {}; + const initialThemeParams = initialAttributes.themeParams || {}; + if ( this.model.get('theme') !== initialAttributes.theme || - (this.model.get('themeParams').navbar || {}) !== (initialAttributes.themeParams).navbar + themeParams.navbar !== initialThemeParams.navbar || + themeParams.direction !== initialThemeParams.direction ) { this.setConfirmLeaveOut(false); diff --git a/client/src/views/fields/link-multiple-with-status.js b/client/src/views/fields/link-multiple-with-status.js index 4c6a0189db5..ecbb07eb3ba 100644 --- a/client/src/views/fields/link-multiple-with-status.js +++ b/client/src/views/fields/link-multiple-with-status.js @@ -86,7 +86,10 @@ class LinkMultipleWithStatusFieldView extends LinkMultipleFieldView { iconStyle = `fas fa-times text-${style} small`; } - return ` ` + + const space = ' ' + + return `` + + space + super.getDetailLinkHtml(id, name); } } diff --git a/client/src/views/fields/wysiwyg.ts b/client/src/views/fields/wysiwyg.ts index 0fbf5ec19ea..0ad33295a4e 100644 --- a/client/src/views/fields/wysiwyg.ts +++ b/client/src/views/fields/wysiwyg.ts @@ -455,7 +455,8 @@ class WysiwygFieldView < } // noinspection HtmlRequiredTitleElement - const documentHtml = `${headHtml}${bodyHtml}` + const direction = this.getThemeManager().getDirection(); + const documentHtml = `${headHtml}${bodyHtml}` // @ts-ignore documentElement.write(documentHtml); diff --git a/client/src/views/lead-capture/fields/form-theme.js b/client/src/views/lead-capture/fields/form-theme.js index d634222348e..0380c67fb78 100644 --- a/client/src/views/lead-capture/fields/form-theme.js +++ b/client/src/views/lead-capture/fields/form-theme.js @@ -32,14 +32,9 @@ export default class FormThemeFieldView extends EnumFieldView { setupOptions() { const list = Object.keys(this.getMetadata().get('themes') || {}) - .sort((v1, v2) => { - if (v2 === 'EspoRtl') { - return -1; - } - - return this.translate(v1, 'theme') - .localeCompare(this.translate(v2, 'theme')); - }); + .sort( + (v1, v2) => this.translate(v1, 'theme').localeCompare(this.translate(v2, 'theme')) + ); this.params.options = ['', ...list]; } diff --git a/client/src/views/preferences/fields/theme.js b/client/src/views/preferences/fields/theme.js index d2dde8b8dc5..8b2d0aa96d8 100644 --- a/client/src/views/preferences/fields/theme.js +++ b/client/src/views/preferences/fields/theme.js @@ -32,13 +32,7 @@ export default class extends ThemeSettingsFieldView { setupOptions() { this.params.options = Object.keys(this.getMetadata().get('themes') || {}) - .sort((v1, v2) => { - if (v2 === 'EspoRtl') { - return -1; - } - - return this.translate(v1, 'themes').localeCompare(this.translate(v2, 'themes')); - }); + .sort((v1, v2) => this.translate(v1, 'themes').localeCompare(this.translate(v2, 'themes'))); this.params.options.unshift(''); } @@ -56,8 +50,16 @@ export default class extends ThemeSettingsFieldView { afterRenderDetail() { const navbar = this.getNavbarValue() || this.getDefaultNavbar(); + const direction = this.getDirectionValue() || this.getDefaultDirection(); + + [ + [navbar, 'themeNavbars'], + [direction, 'themeDirections'], + ].forEach(([value, category]) => { + if (!value) { + return; + } - if (navbar) { this.$el .append(' ') .append( @@ -65,8 +67,8 @@ export default class extends ThemeSettingsFieldView { ) .append(' ') .append( - $('').text(this.translate(navbar, 'themeNavbars')) + $('').text(this.translate(value, category)) ) - } + }); } } diff --git a/client/src/views/preferences/record/edit.js b/client/src/views/preferences/record/edit.js index 38ef63bce78..5eab1027a93 100644 --- a/client/src/views/preferences/record/edit.js +++ b/client/src/views/preferences/record/edit.js @@ -150,6 +150,7 @@ class PreferencesEditRecordView extends EditRecordView { this.model.get('language') !== initialAttributes.language || this.model.get('theme') !== initialAttributes.theme || (this.model.get('themeParams') || {}).navbar !== (initialAttributes.themeParams || {}).navbar || + (this.model.get('themeParams') || {}).direction !== (initialAttributes.themeParams || {}).direction || this.model.get('pageContentWidth') !== initialAttributes.pageContentWidth ) { this.setConfirmLeaveOut(false); diff --git a/client/src/views/record/search.ts b/client/src/views/record/search.ts index 709a3603b3b..1e5142212c3 100644 --- a/client/src/views/record/search.ts +++ b/client/src/views/record/search.ts @@ -1035,9 +1035,18 @@ class SearchView extends View { this.currentFilterLabelList!.push(filterLabel); this.filtersButton.classList - .remove('btn-default', 'btn-primary', 'btn-danger', 'btn-success', 'btn-info', 'btn-info'); + .remove(...[ + 'text-primary', + 'text-danger', + 'text-success', + 'text-info', + 'text-warning', + ]); - this.filtersButton.classList.add(`btn-${filterStyle}`); + + if (filterStyle !== 'default') { + this.filtersButton.classList.add(`text-${filterStyle}`); + } presetName = presetName || ''; diff --git a/client/src/views/settings/fields/theme.js b/client/src/views/settings/fields/theme.js index 577e4e00d7e..47b7a7c38cd 100644 --- a/client/src/views/settings/fields/theme.js +++ b/client/src/views/settings/fields/theme.js @@ -34,7 +34,7 @@ export default class ThemeSettingsFieldView extends EnumFieldView { // language=Handlebars editTemplateContent = ` -
+
+
+
{{#if navbarOptionList.length}}
{{/if}} + {{#if directionOptionList.length}} +
+ +
+ {{/if}}
` @@ -68,6 +77,14 @@ export default class ThemeSettingsFieldView extends EnumFieldView { data.navbarTranslatedOptions[item] = this.translate(item, 'themeNavbars'); }); + data.directionOptionList = this.getDirectionOptionList(); + data.direction = this.getDirectionValue() || this.getDefaultDirection(); + + data.directionTranslatedOptions = {}; + data.directionOptionList.forEach(item => { + data.directionTranslatedOptions[item] = this.translate(item, 'themeDirections'); + }); + return data; } @@ -88,10 +105,13 @@ export default class ThemeSettingsFieldView extends EnumFieldView { afterRenderEdit() { this.$navbar = this.$el.find('[data-name="themeNavbar"]'); + this.$direction = this.$el.find('[data-name="themeDirection"]'); this.$navbar.on('change', () => this.trigger('change')); + this.$direction.on('change', () => this.trigger('change')); Select.init(this.$navbar); + Select.init(this.$direction); } /** @@ -152,6 +172,64 @@ export default class ThemeSettingsFieldView extends EnumFieldView { return defs.default || null; } + /** + * @protected + * @return {string} + */ + getDirectionValue() { + const params = this.model.get('themeParams') || {}; + + return params.direction; + } + + /** + * @protected + * @return {Record|null} + */ + getDirectionDefs() { + if (!this.themeManager) { + return null; + } + + const params = this.themeManager.getParam('params'); + + if (!params || !params.direction) { + return null; + } + + return Espo.Utils.cloneDeep(params.direction); + } + + /** + * @private + * @return {string[]} + */ + getDirectionOptionList() { + const defs = this.getDirectionDefs(); + + if (!defs) { + return []; + } + + const optionList = defs.options || []; + + if (!optionList.length || optionList.length === 1) { + return []; + } + + return optionList; + } + + /** + * @protected + * @return {string|null} + */ + getDefaultDirection() { + const defs = this.getDirectionDefs() || {}; + + return defs.default || null; + } + /** * @private */ @@ -178,14 +256,9 @@ export default class ThemeSettingsFieldView extends EnumFieldView { setupOptions() { this.params.options = Object.keys(this.getMetadata().get('themes') || {}) - .sort((v1, v2) => { - if (v2 === 'EspoRtl') { - return -1; - } - - return this.translate(v1, 'theme') - .localeCompare(this.translate(v2, 'theme')); - }); + .sort( + (v1, v2) => this.translate(v1, 'theme').localeCompare(this.translate(v2, 'theme')) + ); } fetch() { @@ -197,6 +270,10 @@ export default class ThemeSettingsFieldView extends EnumFieldView { params.navbar = this.$navbar.val(); } + if (this.$direction.length) { + params.direction = this.$direction.val(); + } + data.themeParams = params; return data; diff --git a/client/src/views/site/master.js b/client/src/views/site/master.js index 2df1ccf8b1c..c59173cd4c5 100644 --- a/client/src/views/site/master.js +++ b/client/src/views/site/master.js @@ -118,6 +118,7 @@ class MasterSiteView extends View { body.dataset[param] = this.getThemeManager().getParam(param); } + body.dataset.direction = this.getThemeManager().getDirection(); body.dataset.isDark = this.getThemeManager().getParam('isDark') ?? false; body.dataset.themeName = this.getThemeManager().getName(); diff --git a/composer.json b/composer.json index 8d0cc768326..4e7b0f0db45 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "symfony/routing": "^7", "cboden/ratchet": "0.4.x-dev#9ac6a412a26d8bae6541ee0b65ffaf78e316b1a6", "react/zmq": "^0.4.0", - "guzzlehttp/psr7": "^2.9.0", + "guzzlehttp/psr7": "^2.13", "michelf/php-markdown": "^1.9", "robthree/twofactorauth": "^1.8", "nesbot/carbon": "^3.11.0", @@ -55,8 +55,11 @@ "lasserafn/php-initial-avatar-generator": "dev-update-image-lib#a46ab8f1427f93c5b37957e739205da7fcca0290", "directorytree/imapengine": "^1.19", "zbateson/mail-mime-parser": "^3.0", - "guzzlehttp/guzzle": "^7.10", - "devtheorem/php-handlebars": "^1.0" + "guzzlehttp/guzzle": "^7.15", + "devtheorem/php-handlebars": "^1.0", + "php-amqplib/php-amqplib": "^3.7", + "predis/predis": "^3.5", + "psr/cache": "^3.0" }, "require-dev": { "phpunit/phpunit": "^11.5", diff --git a/composer.lock b/composer.lock index c74ad23b0bb..c146d8b6f5d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4971184fe7b94063ee84e91227012bc", + "content-hash": "34ac75e2293db87de4f484db7ff3c921", "packages": [ { "name": "async-aws/core", @@ -1659,26 +1659,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.12.1", + "version": "7.15.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.1", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1686,8 +1686,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5.1", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1767,7 +1767,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" }, "funding": [ { @@ -1783,20 +1783,20 @@ "type": "tidelift" } ], - "time": "2026-06-18T14:12:49+00:00" + "time": "2026-07-26T23:23:20+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1851,7 +1851,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1867,20 +1867,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.1", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -1889,7 +1889,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1970,7 +1970,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.1" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1986,7 +1986,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T09:49:37+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "illuminate/collections", @@ -3990,6 +3990,87 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "php-amqplib/php-amqplib", + "version": "v3.7.4", + "source": { + "type": "git", + "url": "https://github.com/php-amqplib/php-amqplib.git", + "reference": "381b6f7c600e0e0c7463cdd7f7a1a3bc6268e5fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/381b6f7c600e0e0c7463cdd7f7a1a3bc6268e5fd", + "reference": "381b6f7c600e0e0c7463cdd7f7a1a3bc6268e5fd", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-sockets": "*", + "php": "^7.2||^8.0", + "phpseclib/phpseclib": "^2.0|^3.0" + }, + "conflict": { + "php": "7.4.0 - 7.4.1" + }, + "replace": { + "videlalvaro/php-amqplib": "self.version" + }, + "require-dev": { + "ext-curl": "*", + "nategood/httpful": "^0.2.20", + "phpunit/phpunit": "^7.5|^9.5", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpAmqpLib\\": "PhpAmqpLib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Alvaro Videla", + "role": "Original Maintainer" + }, + { + "name": "Raúl Araya", + "email": "nubeiro@gmail.com", + "role": "Maintainer" + }, + { + "name": "Luke Bakken", + "email": "luke@bakken.io", + "role": "Maintainer" + }, + { + "name": "Ramūnas Dronga", + "email": "github@ramuno.lt", + "role": "Maintainer" + } + ], + "description": "Formerly videlalvaro/php-amqplib. This library is a pure PHP implementation of the AMQP protocol. It's been tested against RabbitMQ.", + "homepage": "https://github.com/php-amqplib/php-amqplib/", + "keywords": [ + "message", + "queue", + "rabbitmq" + ], + "support": { + "issues": "https://github.com/php-amqplib/php-amqplib/issues", + "source": "https://github.com/php-amqplib/php-amqplib/tree/v3.7.4" + }, + "time": "2025-11-23T17:00:56+00:00" + }, { "name": "php-di/invoker", "version": "2.3.7", @@ -4120,16 +4201,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.7.0", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8" + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", - "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "shasum": "" }, "require": { @@ -4151,7 +4232,7 @@ "maennchen/zipstream-php": "^2.1 || ^3.0", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": "^8.1", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { @@ -4165,7 +4246,7 @@ "phpstan/phpstan": "^1.1 || ^2.0", "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^10.5 || ^11.0", "squizlabs/php_codesniffer": "^3.7", "tecnickcom/tcpdf": "^6.5" }, @@ -4223,9 +4304,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" }, - "time": "2026-04-20T02:42:17+00:00" + "time": "2026-07-12T19:17:39+00:00" }, { "name": "phpseclib/phpseclib", @@ -4418,6 +4499,69 @@ }, "time": "2026-01-08T08:57:40+00:00" }, + { + "name": "predis/predis", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/5c996db191ee2d9bafe651f454b1fca16754271b", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-06-11T16:56:53+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -6147,16 +6291,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -6194,7 +6338,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6214,7 +6358,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", diff --git a/frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less deleted file mode 100644 index 2d6aad80829..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/alerts-rtl.less +++ /dev/null @@ -1,18 +0,0 @@ -// -// RTL Alerts -// -------------------------------------------------- - - -// Dismissable alerts -// -// Expand the left padding and account for the close button's positioning. - -.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0. -.alert-dismissible { - - // Adjust close link position - .close { - right: auto; - left: -21px; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less deleted file mode 100644 index ba0f4292e08..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/badges-rtl.less +++ /dev/null @@ -1,21 +0,0 @@ -// -// RTL Badges -// -------------------------------------------------- - - -// Base class -.badge { - - .nav-pills > li > a > & { - margin-left: 0px; - margin-right: 3px; - } - - .list-group-item > & { - float: left; - } - .list-group-item > & + & { - margin-left: 5px; - margin-right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less deleted file mode 100644 index d04b46496ff..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-flipped.less +++ /dev/null @@ -1,64 +0,0 @@ -// -------------------------------------------------- -// Deprecated flips for Bootstrap RTL v3.x -// Morteza Ansarinia -// http://github.com/morteza/bootstrap-rtl -// -------------------------------------------------- - -// Regular, semantically correct Bootstrap RTL -// Core variables and mixins imported from the latest original bootstrap -@import "../bootstrap/less/variables.less"; -@import "variables-rtl.less"; -@import "mixins-flipped.less"; - -// ---- Single side border-radius ---- - -.border-right-radius(@radius) { - border-bottom-left-radius: @radius; - border-top-left-radius: @radius; -} -.border-left-radius(@radius) { - border-bottom-right-radius: @radius; - border-top-right-radius: @radius; -} - -// Reset and dependencies -@import "normalize-rtl.less"; - - -// Core CSS - -@import "type-rtl.less"; - -@import "grid-rtl.less"; -@import "tables-rtl.less"; -@import "forms-rtl.less"; - - -// Components -//@import "component-animations.less"; -@import "dropdowns-rtl.less"; -@import "button-groups-rtl.less"; -@import "input-groups-rtl.less"; -@import "navs-rtl.less"; -@import "navbar-rtl.less"; - -@import "badges-rtl.less"; -@import "alerts-rtl.less"; -@import "progress-bars-rtl.less"; -@import "media-rtl.less"; -@import "list-group-rtl.less"; -@import "panels-rtl.less"; -@import "responsive-embed-rtl.less"; - -@import "close-rtl.less"; - - -@import "modals-rtl.less"; - -@import "popovers-rtl.less"; -@import "carousel-rtl.less"; - - -@import "utilities-rtl.less"; - -@import "flipped.less"; diff --git a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less deleted file mode 100644 index 725256f633e..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/bootstrap-rtl.less +++ /dev/null @@ -1,49 +0,0 @@ -// -------------------------------------------------- -// Right-to-Left (RTL) Theme for Bootstrap 3.x -// Morteza Ansarinia -// http://github.com/morteza/bootstrap-rtl -// -------------------------------------------------- - -// Core variables and mixins imported from the latest original bootstrap -@import "../../espo/bootstrap/variables.less"; -@import "variables-rtl.less"; -@import "mixins-rtl.less"; - -// Reset and dependencies -@import "normalize-rtl.less"; -//@import "print.less"; -//@import "glyphicons.less"; - -// Core CSS -//@import "scaffolding.less"; -@import "type-rtl.less"; -//@import "code.less"; -@import "grid-rtl.less"; -@import "tables-rtl.less"; -@import "forms-rtl.less"; -//@import "buttons.less"; - -// Components -//@import "component-animations.less"; -@import "dropdowns-rtl.less"; -@import "button-groups-rtl.less"; -@import "input-groups-rtl.less"; -@import "navs-rtl.less"; -@import "navbar-rtl.less"; -@import "badges-rtl.less"; -@import "alerts-rtl.less"; -@import "progress-bars-rtl.less"; -@import "media-rtl.less"; -@import "list-group-rtl.less"; -@import "panels-rtl.less"; -@import "responsive-embed-rtl.less"; -@import "close-rtl.less"; - -// Components w/ JavaScript -@import "modals-rtl.less"; -@import "popovers-rtl.less"; -@import "carousel-rtl.less"; - -// Utility classes -@import "utilities-rtl.less"; -//@import "responsive-utilities.less"; diff --git a/frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less deleted file mode 100644 index f7064008cae..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/button-groups-rtl.less +++ /dev/null @@ -1,94 +0,0 @@ -// -// RTL Button groups -// -------------------------------------------------- - -// Make the div behave like a button -.btn-group, -.btn-group-vertical { - > .btn { - float: right; - } -} - -// Prevent double borders when buttons are next to each other -.btn-group { - .btn + .btn, - .btn + .btn-group, - .btn-group + .btn, - .btn-group + .btn-group { - margin-right: -1px; - margin-left: 0px; - } -} - -// Optional: Group multiple button groups together for a toolbar -.btn-toolbar { - margin-right: -5px; // Offset the first child's margin - margin-left: 0px; - - .btn-group, - .input-group { - float: right; - } - > .btn, - > .btn-group, - > .input-group { - margin-right: 5px; - margin-left: 0px; - } -} - -// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match -.btn-group > .btn:first-child { - margin-right: 0; - &:not(:last-child):not(.dropdown-toggle) { - .border-left-radius(0); - } -} -// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it -.btn-group > .btn:last-child:not(:first-child), -.btn-group > .dropdown-toggle:not(:first-child) { - .border-right-radius(0); -} - -// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group) -.btn-group > .btn-group { - float: right; -} - -.btn-group.btn-group-justified > .btn, -.btn-group.btn-group-justified > .btn-group { - float: none; -} - -.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { - border-radius: 0; -} -.btn-group > .btn-group:first-child { - > .btn:last-child, - > .dropdown-toggle { - .border-left-radius(0); - } -} -.btn-group > .btn-group:last-child > .btn:first-child { - .border-right-radius(0); -} - -// Reposition the caret -.btn .caret { - margin-right: 0; -} - -// Vertical button groups -// ---------------------- - -.btn-group-vertical { - - > .btn + .btn, - > .btn + .btn-group, - > .btn-group + .btn, - > .btn-group + .btn-group { - margin-top: -1px; - margin-right: 0; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less deleted file mode 100644 index e5650ae1f66..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/carousel-rtl.less +++ /dev/null @@ -1,76 +0,0 @@ -// -// RTL Carousel -// -------------------------------------------------- - - -// Left/right controls for nav -// --------------------------- - -.carousel-control { - right: 0; - bottom: 0; - - // Set gradients for backgrounds - &.left { - right: auto; - left: 0; - #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001)); - } - &.right { - left: auto; - right: 0; - #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5)); - } - - .icon-prev, - .glyphicon-chevron-left { - left: 50%; - right: auto; - margin-right: -10px; - } - .icon-next, - .glyphicon-chevron-right { - right: 50%; - left: auto; - margin-left: -10px; - } -} - -// Optional indicator pips -// -// Add an unordered list with the following class and add a list item for each -// slide your carousel holds. - -.carousel-indicators { - right: 50%; - left: 0; - margin-right: -30%; - margin-left: 0; - padding-left: 0; - -} - -// Scale up controls for tablets and up -@media screen and (min-width: @screen-sm-min) { - - // Scale up the controls a smidge - .carousel-control { - .glyphicon-chevron-left, - .icon-prev { - margin-left: 0; - margin-right: -15px; - } - .glyphicon-chevron-right, - .icon-next { - margin-left: 0; - margin-right: -15px; - } - } - - // Show and left align the captions - .carousel-caption { - left: 20%; - right: 20%; - padding-bottom: 30px; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less deleted file mode 100644 index b61439574c4..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/close-rtl.less +++ /dev/null @@ -1,8 +0,0 @@ -// -// RTL Close icons -// -------------------------------------------------- - - -.close { - float: left; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less deleted file mode 100644 index 808252c7fbf..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/dropdowns-rtl.less +++ /dev/null @@ -1,63 +0,0 @@ -// -// RTL Dropdown menus -// -------------------------------------------------- - -// Dropdown arrow/caret -.caret { - margin-right: 2px; - margin-left: 0; -} - -// The dropdown menu (ul) -.dropdown-menu { - right: auto; - left: 0; - float: left; - text-align: right; // Ensures proper alignment if parent has it changed (e.g., modal footer) - - // Aligns the dropdown menu to right - // - // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]` - &.pull-right { - left: 0; - right: auto; - float: right; - } -} - -// Menu positioning -// -// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown -// menu with the parent. -.dropdown-menu-right { - left: auto; // Reset the default from `.dropdown-menu` - right: 0; -} -// With v3, we enabled auto-flipping if you have a dropdown within a right -// aligned nav component. To enable the undoing of that, we provide an override -// to restore the default dropdown menu alignment. -// -// This is only for left-aligning a dropdown menu within a `.navbar-right` or -// `.pull-right` nav component. -.dropdown-menu-left { - left: 0; - right: auto; -} - -// Component alignment -// -// Reiterate per navbar.less and the modified component alignment there. - -@media (min-width: @grid-float-breakpoint) { - .navbar-right { - .dropdown-menu { - .dropdown-menu-right(); - } - // Necessary for overrides of the default right aligned menu. - // Will remove come v4 in all likelihood. - .dropdown-menu-left { - .dropdown-menu-left(); - } - } -} - diff --git a/frontend/less/espo-rtl/bootstrap-rtl/flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/flipped.less deleted file mode 100644 index 4d5a57a6d6a..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/flipped.less +++ /dev/null @@ -1,83 +0,0 @@ -// --------------------------------------------- -// Flipped classes -// --------------------------------------------- - -// ---- Utilities ---- -.pull-right { - float: left !important; -} -.pull-left { - float: right !important; -} - -//TODO carousel -//TODO Glyphicons - -// ---- Dropdown ---- -.dropdown-menu-left { - left: auto; - right: 0; -} - -// ---- Media ---- -.media-right, -.media > .pull-right { - padding-right: 10px; - padding-left: initial; -} - -.media-left, -.media > .pull-left { - padding-left: 10px; - padding-right: initial; -} - -// ---- Navbar ---- -@media (min-width: @grid-float-breakpoint) { - .navbar-left { .pull-left(); } - .navbar-right { - .pull-right(); - - ~ .navbar-right { - margin-left: 0; - margin-right: initial; - } - } -} - -// ---- Tooltip ---- -.tooltip { - &.top-left .tooltip-arrow { - left: @tooltip-arrow-width; - } - &.top-right .tooltip-arrow { - right: @tooltip-arrow-width; - } - &.right .tooltip-arrow { - right: 0; - border-left-color: @tooltip-arrow-color; - } - &.left .tooltip-arrow { - left: 0; - border-right-color: @tooltip-arrow-color; - } - &.bottom-left .tooltip-arrow { - left: @tooltip-arrow-width; - } - &.bottom-right .tooltip-arrow { - right: @tooltip-arrow-width; - } -} - -// ---- Type ---- -.text-left { text-align: right; } -.text-right { text-align: left; } - -//DEPRECATED -blockquote.pull-right { - padding-left: 15px; - padding-right: 0; - border-left: 5px solid @blockquote-border-color; - border-right: 0; - text-align: left; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less deleted file mode 100644 index dce7fbad075..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/forms-rtl.less +++ /dev/null @@ -1,102 +0,0 @@ -// -// RTL Forms -// -------------------------------------------------- - - -.radio, -.checkbox { - - label { - padding-right: 20px; - padding-left: initial; - } -} -.radio input[type="radio"], -.radio-inline input[type="radio"], -.checkbox input[type="checkbox"], -.checkbox-inline input[type="checkbox"] { - margin-right: -20px; - margin-left: auto; -} - -// Radios and checkboxes on same line -.radio-inline, -.checkbox-inline { - padding-right: 20px; - padding-left: 0; -} -.radio-inline + .radio-inline, -.checkbox-inline + .checkbox-inline { - margin-right: 10px; // space out consecutive inline controls - margin-left: 0; -} - -.has-feedback { - - // Ensure icons don't overlap text - .form-control { - padding-left: calc(var(--input-height-base) * 1.25); - padding-right: 12px; - } -} -// Feedback icon (requires .glyphicon classes) -.form-control-feedback { - left: 0; - right: auto; -} - -// Inline forms -// -// Make forms appear inline(-block) by adding the `.form-inline` class. Inline -// forms begin stacked on extra small (mobile) devices and then go inline when -// viewports reach <768px. -// -// Requires wrapping inputs and labels with `.form-group` for proper display of -// default HTML form controls and our custom form controls (e.g., input groups). -// -// Heads up! This is mixin-ed into `.navbar-form` in navbars.less. - -.form-inline { - - // Kick in the inline - @media (min-width: @screen-sm-min) { - - label { - padding-right: 0; - padding-left: initial; - } - - .radio input[type="radio"], - .checkbox input[type="checkbox"] { - margin-right: 0; - margin-left: auto; - } - - } -} - - -// Horizontal forms -// -// Horizontal forms are built on grid classes and allow you to create forms with -// labels on the left and inputs on the right. - -.form-horizontal { - - // Reset spacing and right align labels, but scope to media queries so that - // labels on narrow viewports stack the same as a default form example. - @media (min-width: @screen-sm-min) { - .control-label { - text-align: left; - } - } - - // Validation states - // - // Reposition the icon because it's now within a grid column and columns have - // `position: relative;` on them. Also accounts for the grid gutter padding. - .has-feedback .form-control-feedback { - left: var(--grid-gutter-width-half); - right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less deleted file mode 100644 index e482bcbf6de..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/grid-rtl.less +++ /dev/null @@ -1,45 +0,0 @@ -// -// RTL Grid system -// -------------------------------------------------- - -// Columns -// -// Common styles for small and large grid columns - -.make-rtl-grid-columns(); - - -// Extra small grid -// -// Columns, offsets, pushes, and pulls for extra small devices like -// smartphones. - -.make-rtl-grid(xs); - - -// Small grid -// -// Columns, offsets, pushes, and pulls for the small device range, from phones -// to tablets. - -@media (min-width: @screen-sm-min) { - .make-rtl-grid(sm); -} - - -// Medium grid -// -// Columns, offsets, pushes, and pulls for the desktop device range. - -@media (min-width: @screen-md-min) { - .make-rtl-grid(md); -} - - -// Large grid -// -// Columns, offsets, pushes, and pulls for the large desktop device range. - -@media (min-width: @screen-lg-min) { - .make-rtl-grid(lg); -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less deleted file mode 100644 index f04c21d2bf6..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/input-groups-rtl.less +++ /dev/null @@ -1,75 +0,0 @@ -// -// Input groups -// -------------------------------------------------- - -// Base styles -// ------------------------- -.input-group { - .form-control { - // IE9 fubars the placeholder attribute in text inputs and the arrows on - // select elements in input groups. To fix it, we float the input. Details: - // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855 - float: right; - } -} - -// Reset rounded corners -.input-group .form-control:first-child, -.input-group-addon:first-child, -.input-group-btn:first-child > .btn, -.input-group-btn:first-child > .btn-group > .btn, -.input-group-btn:first-child > .dropdown-toggle, -.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), -.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { - .border-right-radius(var(--border-radius)); - .border-left-radius(0); -} -.input-group-addon:first-child { - border-left: 0px; - border-right: 1px solid; -} - -.input-group .form-control:last-child, -.input-group-addon:last-child, -.input-group-btn:last-child > .btn, -.input-group-btn:last-child > .btn-group > .btn, -.input-group-btn:last-child > .dropdown-toggle, -.input-group-btn:first-child > .btn:not(:first-child), -.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { - .border-right-radius(0); -} -.input-group-addon:last-child { - border-left-width: 1px; - border-left-style: solid; - border-right: 0px; -} - -// Button input groups -// ------------------------- -.input-group-btn { - - // Negative margin for spacing, position for bringing hovered/focused/actived - // element above the siblings. - > .btn { - + .btn { - margin-right: -1px; - margin-left: auto; - } - } - - // Negative margin to only have a 1px border between the two - &:first-child { - > .btn, - > .btn-group { - margin-left: -1px; - margin-right: auto; - } - } - &:last-child { - > .btn, - > .btn-group { - margin-right: -1px; - margin-left: auto; - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less deleted file mode 100644 index 67734f7e446..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/list-group-rtl.less +++ /dev/null @@ -1,14 +0,0 @@ -// -// List groups -// -------------------------------------------------- - - -// Base class -// -// Easily usable on
    ,
      , or
      . - -.list-group { - padding-right: 0; // reset padding because ul and ol - padding-left: initial; -} - diff --git a/frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less deleted file mode 100644 index 9dd84247ff8..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/media-rtl.less +++ /dev/null @@ -1,47 +0,0 @@ -// RTL Media objects -// -------------------------------------------------- - - -// Media image alignment -// ------------------------- - -.media { - > .pull-left { - margin-right: 10px; - &.flip { - margin-right: 0; - margin-left: 10px; - } - } - > .pull-right { - margin-left: 10px; - &.flip { - margin-left: 0; - margin-right: 10px; - } - } -} - -.media-right, -.media > .pull-right { - padding-right: 10px; - padding-left: initial; -} - -.media-left, -.media > .pull-left { - padding-left: 10px; - padding-right: initial; -} - - -// Media list variation -// ------------------------- - -// Undo default ul/ol styles -.media-list { - padding-right: 0; - padding-left: initial; - list-style: none; -} - diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less deleted file mode 100644 index 96b617f3b30..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins-flipped.less +++ /dev/null @@ -1,31 +0,0 @@ -// Mixins -// -------------------------------------------------- - -// Utilities -@import "../bootstrap/less/mixins/hide-text.less"; -@import "../bootstrap/less/mixins/opacity.less"; -@import "../bootstrap/less/mixins/image.less"; -@import "../bootstrap/less/mixins/reset-filter.less"; -@import "../bootstrap/less/mixins/resize.less"; -@import "../bootstrap/less/mixins/responsive-visibility.less"; -@import "../bootstrap/less/mixins/size.less"; -@import "../bootstrap/less/mixins/tab-focus.less"; -@import "../bootstrap/less/mixins/text-overflow.less"; -@import "../bootstrap/less/mixins/vendor-prefixes.less"; - -// Components -@import "../bootstrap/less/mixins/buttons.less"; -@import "../bootstrap/less/mixins/panels.less"; -@import "../bootstrap/less/mixins/nav-divider.less"; -@import "../bootstrap/less/mixins/forms.less"; -@import "../bootstrap/less/mixins/progress-bar.less"; - -// Skins -@import "mixins/border-radius-flipped.less"; //FLIPPED -@import "mixins/gradients-rtl.less"; // RTL - -// Layout -@import "../bootstrap/less/mixins/clearfix.less"; -@import "../bootstrap/less/mixins/center-block.less"; -@import "mixins/grid-framework-rtl.less"; // RTL -@import "mixins/grid-rtl.less"; // RTL diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less deleted file mode 100644 index 855d26b1275..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins-rtl.less +++ /dev/null @@ -1,31 +0,0 @@ -// Mixins -// -------------------------------------------------- - -// Utilities -@import "../../espo/bootstrap/mixins/hide-text.less"; -@import "../../espo/bootstrap/mixins/opacity.less"; -@import "../../espo/bootstrap/mixins/image.less"; -@import "../../espo/bootstrap/mixins/reset-filter.less"; -@import "../../espo/bootstrap/mixins/resize.less"; -@import "../../espo/bootstrap/mixins/responsive-visibility.less"; -@import "../../espo/bootstrap/mixins/size.less"; -@import "../../espo/bootstrap/mixins/tab-focus.less"; -@import "../../espo/bootstrap/mixins/text-overflow.less"; -@import "../../espo/bootstrap/mixins/vendor-prefixes.less"; - -// Components -@import "../../espo/bootstrap/mixins/buttons.less"; -@import "../../espo/bootstrap/mixins/panels.less"; -@import "../../espo/bootstrap/mixins/nav-divider.less"; -@import "../../espo/bootstrap/mixins/forms.less"; -@import "../../espo/bootstrap/mixins/progress-bar.less"; - -// Skins -@import "../../espo/bootstrap/mixins/border-radius.less"; -@import "mixins/gradients-rtl.less"; // RTL - -// Layout -@import "../../espo/bootstrap/mixins/clearfix.less"; -@import "../../espo/bootstrap/mixins/center-block.less"; -@import "mixins/grid-framework-rtl.less"; // RTL -@import "mixins/grid-rtl.less"; // RTL diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less deleted file mode 100644 index bddaeca3b85..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/border-radius-flipped.less +++ /dev/null @@ -1,10 +0,0 @@ -// Single side flipped border-radius - -.border-right-radius(@radius) { - border-bottom-left-radius: @radius; - border-top-left-radius: @radius; -} -.border-left-radius(@radius) { - border-bottom-right-radius: @radius; - border-top-right-radius: @radius; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less deleted file mode 100644 index 3a69ad97978..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/gradients-rtl.less +++ /dev/null @@ -1,24 +0,0 @@ -// RTL Gradients - -#gradient { - - // Horizontal gradient, from right to left - // - // Creates two color stops, start and end, by specifying a color and position for each color stop. - // Color stops are not available in IE9 and below. - .horizontal(@start-color: #333; @end-color: #555; @start-percent: 0%; @end-percent: 100%) { - background-image: -webkit-linear-gradient(left, color-stop(@start-color @start-percent), color-stop(@end-color @end-percent)); // Safari 5.1-6, Chrome 10+ - background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12 - background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+ - background-repeat: repeat-x; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down - } - - .horizontal-three-colors(@start-color: #c3325f; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #00b3ee) { - background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color); - background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color); - background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color); - background-repeat: no-repeat; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less deleted file mode 100644 index 3d36c62a2d5..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-framework-rtl.less +++ /dev/null @@ -1,96 +0,0 @@ -// RTL Framework grid generation -// -// Used only by Bootstrap to generate the correct number of grid classes given -// any value of `@grid-columns`. - -.make-rtl-grid-columns() { - // Common styles for all sizes of grid columns, widths 1-12 - .col(@index) { // initial - @item: ~".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}"; - .col((@index + 1), @item); - } - .col(@index, @list) when (@index =< @grid-columns) { // general; "=<" isn't a typo - @item: ~".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}"; - .col((@index + 1), ~"@{list}, @{item}"); - } - .col(@index, @list) when (@index > @grid-columns) { // terminal - @{list} { - position: relative; - // Prevent columns from collapsing when empty - min-height: 1px; - // Inner gutter via padding - padding-left: var(--grid-gutter-width-half); - padding-right: var(--grid-gutter-width-half); - } - } - .col(1); // kickstart it -} - -.float-rtl-grid-columns(@class) { - .col(@index) { // initial - @item: ~".col-@{class}-@{index}"; - .col((@index + 1), @item); - } - .col(@index, @list) when (@index =< @grid-columns) { // general - @item: ~".col-@{class}-@{index}"; - .col((@index + 1), ~"@{list}, @{item}"); - } - .col(@index, @list) when (@index > @grid-columns) { // terminal - @{list} { - float: right; - } - } - .col(1); // kickstart it -} - -.calc-rtl-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) { - .col-@{class}-@{index} { - width: percentage((@index / @grid-columns)); - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) { - .col-@{class}-push-@{index} { - right: percentage((@index / @grid-columns)); - left: 0; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) { - .col-@{class}-push-0 { - right: auto; - left: 0; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) { - .col-@{class}-pull-@{index} { - left: percentage((@index / @grid-columns)); - right: auto; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) { - .col-@{class}-pull-0 { - left: auto; - right: auto; - } -} -.calc-rtl-grid-column(@index, @class, @type) when (@type = offset) { - .col-@{class}-offset-@{index} { - margin-right: percentage((@index / @grid-columns)); - margin-left: 0; - } -} - -// Basic looping in LESS -.loop-rtl-grid-columns(@index, @class, @type) when (@index >= 0) { - .calc-rtl-grid-column(@index, @class, @type); - // next iteration - .loop-rtl-grid-columns((@index - 1), @class, @type); -} - -// Create grid for specific class -.make-rtl-grid(@class) { - .float-rtl-grid-columns(@class); - .loop-rtl-grid-columns(@grid-columns, @class, width); - .loop-rtl-grid-columns(@grid-columns, @class, pull); - .loop-rtl-grid-columns(@grid-columns, @class, push); - .loop-rtl-grid-columns(@grid-columns, @class, offset); -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less deleted file mode 100644 index 58ef91ef80d..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/mixins/grid-rtl.less +++ /dev/null @@ -1,98 +0,0 @@ -// RTL Grid system -// -// Generate semantic rtl grid columns with these mixins. - -// Generate the extra small columns -.make-xs-column(@columns) { - float: right; -} -.make-xs-column-offset(@columns) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; -} -.make-xs-column-push(@columns) { - right: percentage((@columns / @grid-columns)); - left: auto; -} -.make-xs-column-pull(@columns) { - left: percentage((@columns / @grid-columns)); - right: auto; -} - -// Generate the small columns -.make-sm-column(@columns) { - - @media (min-width: @screen-sm-min) { - float: right; - } -} -.make-sm-column-offset(@columns) { - @media (min-width: @screen-sm-min) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; - } -} -.make-sm-column-push(@columns) { - @media (min-width: @screen-sm-min) { - right: percentage((@columns / @grid-columns)); - left: auto; - } -} -.make-sm-column-pull(@columns) { - @media (min-width: @screen-sm-min) { - left: percentage((@columns / @grid-columns)); - right: auto; - } -} - -// Generate the medium columns -.make-md-column(@columns) { - - @media (min-width: @screen-md-min) { - float: right; - } -} -.make-md-column-offset(@columns) { - @media (min-width: @screen-md-min) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; - } -} -.make-md-column-push(@columns) { - @media (min-width: @screen-md-min) { - right: percentage((@columns / @grid-columns)); - left: auto; - } -} -.make-md-column-pull(@columns) { - @media (min-width: @screen-md-min) { - left: percentage((@columns / @grid-columns)); - right: auto; - } -} - -// Generate the large columns -.make-lg-column(@columns) { - - @media (min-width: @screen-lg-min) { - float: right; - } -} -.make-lg-column-offset(@columns) { - @media (min-width: @screen-lg-min) { - margin-right: percentage((@columns / @grid-columns)); - margin-left: 0; - } -} -.make-lg-column-push(@columns) { - @media (min-width: @screen-lg-min) { - right: percentage((@columns / @grid-columns)); - left: auto; - } -} -.make-lg-column-pull(@columns) { - @media (min-width: @screen-lg-min) { - left: percentage((@columns / @grid-columns)); - right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less deleted file mode 100644 index 0a373c8385d..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/modals-rtl.less +++ /dev/null @@ -1,27 +0,0 @@ -// -// RTL Modals -// -------------------------------------------------- - -// Footer (for actions) -.modal-footer { - text-align: left; // right align buttons - - &.flip { - text-align: right; - } - // Properly space out buttons - .btn + .btn { - margin-left: auto; - margin-right: 5px; - } - // but override that for button groups - .btn-group .btn + .btn { - margin-right: -1px; - margin-left: auto; - } - // and override it for block buttons as well - .btn-block + .btn-block { - margin-right: 0; - margin-left: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less deleted file mode 100644 index fe4c0405142..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/navbar-rtl.less +++ /dev/null @@ -1,83 +0,0 @@ -// -// RTL Navbars -// -------------------------------------------------- - - -// Navbar heading -// -// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy -// styling of responsive aspects. - -.navbar-header { - - @media (min-width: @grid-float-breakpoint) { - float: right; - } -} - -// Brand/project name - -.navbar-brand { - float: right; -} - - -// Navbar toggle -// -// Custom button for toggling the `.navbar-collapse`, powered by the collapse -// JavaScript plugin. - -.navbar-toggle { - float: left; -} - - -// Navbar nav links -// -// Builds on top of the `.nav` components with its own modifier class to make -// the nav the full height of the horizontal nav (above 768px). - -.navbar-nav { - - @media (max-width: @grid-float-breakpoint-max) { - // Dropdowns get custom display when collapsed - .open .dropdown-menu { - > li > a, - .dropdown-header { - padding: 5px 25px 5px 15px; - } - } - } - - // Uncollapse the nav - @media (min-width: @grid-float-breakpoint) { - float: right; - - > li { - float: right !important; - } - - } -} - -@media (min-width: @grid-float-breakpoint) { - .navbar-left { - float: right !important; - &.flip { - float: right !important; - } - } - - .navbar-right { - float: left !important; - - &.flip { - float: left !important; - } - - .dropdown-menu { - left: 0; - right: auto; - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less deleted file mode 100644 index ad43b11de8a..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/navs-rtl.less +++ /dev/null @@ -1,86 +0,0 @@ -// -// Navs -// -------------------------------------------------- - - -// Base class -// -------------------------------------------------- - -.nav { - padding-right: 0; // Override default ul/ol - padding-left: initial; -} - - -// Tabs -// ------------------------- - -// Give the tabs something to sit on -.nav-tabs { - > li { - float: right; - - // Actual tabs (as links) - > a { - margin-left: auto; - margin-right: -2px; - } - } -} - - -// Pills -// ------------------------- -.nav-pills { - > li { - float: right; - - + li { - margin-right: 2px; - margin-left: auto; - } - } -} - - -// Stacked pills -.nav-stacked { - > li { - float: none; - + li { - margin-right: 0; // no need for this gap between nav items - margin-left: auto; - } - } -} - - -// Nav variations -// -------------------------------------------------- - -// Justified nav links -// ------------------------- - -.nav-justified { - - //To fix the overridden style by line 21 - > li{ - float: none; - } - - > .dropdown .dropdown-menu { - right: auto; - } -} - -// Move borders to anchors instead of bottom of list -// -// Mixin for adding on top the shared `.nav-justified` styles for our tabs -.nav-tabs-justified { - - > li > a { - // Override margin from .nav-tabs - margin-left: 0; - margin-right: auto; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less deleted file mode 100644 index c1250b9e7e3..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/normalize-rtl.less +++ /dev/null @@ -1,15 +0,0 @@ -// -// 1. Set direction to RTL -// - -html { - direction: rtl; -} - -// -// Remove default margin. -// - -body { - direction: rtl; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less deleted file mode 100644 index 13e1128b146..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/panels-rtl.less +++ /dev/null @@ -1,70 +0,0 @@ -// -// RTL Panels -// -------------------------------------------------- - -// Tables in panels -// -// Place a non-bordered `.table` within a panel (not within a `.panel-body`) and -// watch it go full width. - -.panel { - // Add border top radius for first one - > .table:first-child, - > .table-responsive:first-child > .table:first-child { - - > thead:first-child, - > tbody:first-child { - > tr:first-child { - td:first-child, - th:first-child { - //border-top-right-radius: (@panel-border-radius); - //border-top-left-radius: 0; - } - td:last-child, - th:last-child { - //border-top-left-radius: (@panel-border-radius); - //border-top-right-radius: 0; - } - } - } - } - // Add border bottom radius for last one - > .table:last-child, - > .table-responsive:last-child > .table:last-child { - - > tbody:last-child, - > tfoot:last-child { - > tr:last-child { - td:first-child, - th:first-child { - //border-bottom-left-radius: (@panel-border-radius); - //border-top-right-radius: 0; - } - td:last-child, - th:last-child { - //border-bottom-right-radius: (@panel-border-radius); - //border-top-left-radius: 0; - } - } - } - } - > .table-bordered, - > .table-responsive > .table-bordered { - > thead, - > tbody, - > tfoot { - > tr { - > th:first-child, - > td:first-child { - border-right: 0; - border-left: none; - } - > th:last-child, - > td:last-child { - border-right: none; - border-left: 0; - } - } - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less deleted file mode 100644 index 8de29cb1376..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/popovers-rtl.less +++ /dev/null @@ -1,27 +0,0 @@ -.popover { - left: auto; - text-align: right; -} - -.popover { - &.top > .arrow { - right: 50%; - left: auto; - margin-right: var(--popover-arrow-outer-width-negative); - margin-left: auto; - &:after { - margin-right: var(--popover-arrow-width-negative); - margin-left: auto; - } - } - &.bottom > .arrow { - right: 50%; - left: auto; - margin-right: var(--popover-arrow-outer-width-negative); - margin-left: auto; - &:after { - margin-right: var(--popover-arrow-width-negative); - margin-left: auto; - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less deleted file mode 100644 index 9b5b9f76e0c..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/progress-bars-rtl.less +++ /dev/null @@ -1,8 +0,0 @@ -// -// RTL Progress bars -// -------------------------------------------------- - -// Bar of progress -.progress-bar { - float: right; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less deleted file mode 100644 index 87bc416db04..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/responsive-embed-rtl.less +++ /dev/null @@ -1,15 +0,0 @@ -// RTL Embeds responsive -// -// Credit: Nicolas Gallagher and SUIT CSS. - -.embed-responsive { - - .embed-responsive-item, - iframe, - embed, - object { - right: 0; - left: auto; - } - -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less deleted file mode 100644 index 956e6ade469..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/tables-rtl.less +++ /dev/null @@ -1,47 +0,0 @@ -// -// Tables -// -------------------------------------------------- - -//TODO -caption { - text-align: right; -} - -th { - text-align: right; -} - -// Responsive tables -// -// Wrap your tables in `.table-responsive` and we'll make them mobile friendly -// by enabling horizontal scrolling. Only applies <768px. Everything above that -// will display normally. - -.table-responsive { - @media screen and (max-width: @screen-xs-max) { - - // Special overrides for the bordered tables - > .table-bordered { - border: 0; - - // Nuke the appropriate borders so that the parent can handle them - > thead, - > tbody, - > tfoot { - > tr { - > th:first-child, - > td:first-child { - border-right: 0; - border-left: initial; - } - > th:last-child, - > td:last-child { - border-left: 0; - border-right: initial; - } - } - } - - } - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less deleted file mode 100644 index 0b4af781f66..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/type-rtl.less +++ /dev/null @@ -1,45 +0,0 @@ -// -// RTL Typography -// -------------------------------------------------- - -// Flipped Alignment -.flip.text-left { text-align: right; } -.flip.text-right { text-align: left; } - -// List options - -// Unstyled keeps list items block level, just removes default browser padding and list-style -.list-unstyled { - padding-right: 0; - padding-left: initial; -} - -// Inline turns list items into inline-block -.list-inline { - .list-unstyled(); - margin-right: -5px; - margin-left: 0; -} - -dd { - margin-right: 0; // Undo browser default - margin-left: initial; -} - -// Blockquotes -blockquote { - border-right: 5px solid @blockquote-border-color; - border-left: 0; -} - -// Opposite alignment of blockquote -// -// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0. -.blockquote-reverse, -blockquote.pull-left { - padding-left: 15px; - padding-right: 0; - border-left: 5px solid @blockquote-border-color; - border-right: 0; - text-align: left; -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less deleted file mode 100644 index eaf8db421c0..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/utilities-rtl.less +++ /dev/null @@ -1,15 +0,0 @@ -// -// Temporary RTL style to fix bugs rapidly. -// They will move to some place more appropriate later. -// -------------------------------------------------- - -.pull-right { - &.flip { - float: left !important; - } -} -.pull-left { - &.flip { - float: right !important; - } -} diff --git a/frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less b/frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less deleted file mode 100644 index 13afd053b7c..00000000000 --- a/frontend/less/espo-rtl/bootstrap-rtl/variables-rtl.less +++ /dev/null @@ -1,8 +0,0 @@ -// -// RTL Variables -// -------------------------------------------------- - - -//== Media Objects on pull-left/pull-right + flip -//REMOVED @media-pull-margin: 10px; - diff --git a/frontend/less/espo-rtl/iframe/main.less b/frontend/less/espo-rtl/iframe/main.less deleted file mode 100644 index 265a4f60220..00000000000 --- a/frontend/less/espo-rtl/iframe/main.less +++ /dev/null @@ -1,7 +0,0 @@ -@import "../../espo/bootstrap/variables.less"; -@import "../../espo/value-variables.less"; -@import "../../espo/variables.less"; -@import "../variables.less"; -@import "../../espo/root-variables.less"; -@import "../../espo/fonts.less"; -@import "../../espo/iframe/iframe.less"; diff --git a/frontend/less/espo-rtl/main.less b/frontend/less/espo-rtl/main.less deleted file mode 100644 index e19b4618835..00000000000 --- a/frontend/less/espo-rtl/main.less +++ /dev/null @@ -1,19 +0,0 @@ -@import "../espo/init.less"; -@import "bootstrap-rtl/bootstrap-rtl.less"; -@import "../espo/value-variables.less"; -@import "../espo/variables.less"; -@import "variables.less"; -@import "../espo/root-variables.less"; -@import "../espo/mixins.less"; -@import "../espo/misc.less"; -@import "../espo/layout-top.less"; -@import "../espo/layout-side.less"; -@import "../espo/custom.less"; -@import "layout-top.less"; -@import "layout-side.less"; -@import "custom.less"; -@import "bootstrap-rtl/utilities-rtl.less"; - -body { - --theme-name: EspoRtl; -} diff --git a/frontend/less/espo-rtl/variables.less b/frontend/less/espo-rtl/variables.less deleted file mode 100644 index 15e61e24afc..00000000000 --- a/frontend/less/espo-rtl/variables.less +++ /dev/null @@ -1,3 +0,0 @@ -@border-radius-value: var(--6px); -@panel-border-radius-value: var(--6px); -@dropdown-border-radius-value: var(--4px); diff --git a/frontend/less/espo/bootstrap/buttons.less b/frontend/less/espo/bootstrap/buttons.less index f4750965bd3..c772f55a77d 100644 --- a/frontend/less/espo/bootstrap/buttons.less +++ b/frontend/less/espo/bootstrap/buttons.less @@ -1,16 +1,16 @@ .btn { - display: inline-block; - margin-bottom: 0; // For input.btn - font-weight: normal; - text-align: center; - vertical-align: middle; - touch-action: manipulation; - cursor: pointer; - background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 - border: var(--1px) solid transparent; - white-space: nowrap; - .button-size(var(--padding-base-vertical); var(--padding-base-horizontal); var(--font-size-base); var(--line-height-base)); - .user-select(none); + display: inline-block; + margin-bottom: 0; // For input.btn + font-weight: normal; + text-align: center; + vertical-align: middle; + touch-action: manipulation; + cursor: pointer; + background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 + border: var(--1px) solid transparent; + white-space: nowrap; + .button-size(var(--padding-base-vertical); var(--padding-base-horizontal); var(--font-size-base); var(--line-height-base)); + user-select: none; &, &:active:hover, // Espo-fix @@ -31,9 +31,9 @@ &.disabled, &[disabled], fieldset[disabled] & { - cursor: @cursor-disabled; - .opacity(.65); - .box-shadow(none); + cursor: @cursor-disabled; + opacity: 0.65; + box-shadow: none; } a& { diff --git a/frontend/less/espo/bootstrap/dropdowns.less b/frontend/less/espo/bootstrap/dropdowns.less index 46618ace66e..bf42c46bb97 100644 --- a/frontend/less/espo/bootstrap/dropdowns.less +++ b/frontend/less/espo/bootstrap/dropdowns.less @@ -104,7 +104,6 @@ text-decoration: none; background-color: transparent; background-image: none; // Remove CSS gradient - .reset-filter(); cursor: @cursor-disabled; } } diff --git a/frontend/less/espo/bootstrap/forms.less b/frontend/less/espo/bootstrap/forms.less index f5b57e1b47b..9a296b0ced2 100644 --- a/frontend/less/espo/bootstrap/forms.less +++ b/frontend/less/espo/bootstrap/forms.less @@ -62,14 +62,14 @@ input[type="range"] { // Make multiple select elements height not fixed select[multiple], select[size] { - height: auto; + height: auto; } // Focus for file, radio, and checkbox input[type="file"]:focus, input[type="radio"]:focus, input[type="checkbox"]:focus { - .tab-focus(); + .tab-focus(); } // Adjust output element @@ -83,46 +83,46 @@ output { .form-control { - display: block; - width: 100%; - //height: var(--input-height-base); // Make inputs at least the height of their button counterpart (base line-height + padding + border) - padding: var(--padding-base-vertical) var(--padding-base-horizontal); - font-size: var(--font-size-base); - line-height: var(--line-height-base); - color: var(--input-color); - background-color: var(--input-bg); - background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 - - // Placeholder - .placeholder(); - - // Unstyle the caret on ``s in IE10+. + &::-ms-expand { + border: 0; + background-color: transparent; + } - // Disabled and read-only inputs - // - // HTML5 says that controls under a fieldset > legend:first-child won't be - // disabled if the fieldset is disabled. Due to implementation difficulty, we - // don't honor that edge case; we style them as disabled anyway. - &[disabled], - &[readonly], - fieldset[disabled] & { - background-color: var(--input-bg-disabled); - opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655 - } + // Disabled and read-only inputs + // + // HTML5 says that controls under a fieldset > legend:first-child won't be + // disabled if the fieldset is disabled. Due to implementation difficulty, we + // don't honor that edge case; we style them as disabled anyway. + &[disabled], + &[readonly], + fieldset[disabled] & { + background-color: var(--input-bg-disabled); + opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655 + } - &[disabled], - fieldset[disabled] & { - cursor: @cursor-disabled; - } + &[disabled], + fieldset[disabled] & { + cursor: @cursor-disabled; + } - // Reset height for `textarea`s - textarea& { - height: auto; - } + // Reset height for `textarea`s + textarea& { + height: auto; + } } @@ -134,7 +134,7 @@ output { // https://github.com/twbs/bootstrap/issues/11586. input[type="search"] { - -webkit-appearance: none; + -webkit-appearance: none; } diff --git a/frontend/less/espo/bootstrap/mixins.less b/frontend/less/espo/bootstrap/mixins.less index d06b82e31c2..4f4d82b1408 100644 --- a/frontend/less/espo/bootstrap/mixins.less +++ b/frontend/less/espo/bootstrap/mixins.less @@ -2,13 +2,7 @@ // -------------------------------------------------- // Utilities -@import "mixins/hide-text.less"; -@import "mixins/opacity.less"; -@import "mixins/image.less"; -@import "mixins/reset-filter.less"; -@import "mixins/resize.less"; @import "mixins/responsive-visibility.less"; -@import "mixins/size.less"; @import "mixins/tab-focus.less"; @import "mixins/reset-text.less"; @import "mixins/text-overflow.less"; @@ -23,7 +17,6 @@ // Skins @import "mixins/border-radius.less"; -@import "mixins/gradients.less"; // Layout @import "mixins/clearfix.less"; diff --git a/frontend/less/espo/bootstrap/mixins/border-radius.less b/frontend/less/espo/bootstrap/mixins/border-radius.less index ca05dbf4570..5b101297d61 100644 --- a/frontend/less/espo/bootstrap/mixins/border-radius.less +++ b/frontend/less/espo/bootstrap/mixins/border-radius.less @@ -1,18 +1,16 @@ -// Single side border-radius - .border-top-radius(@radius) { - border-top-right-radius: @radius; - border-top-left-radius: @radius; + border-top-right-radius: @radius; + border-top-left-radius: @radius; } .border-right-radius(@radius) { - border-bottom-right-radius: @radius; - border-top-right-radius: @radius; + border-bottom-right-radius: @radius; + border-top-right-radius: @radius; } .border-bottom-radius(@radius) { - border-bottom-right-radius: @radius; - border-bottom-left-radius: @radius; + border-bottom-right-radius: @radius; + border-bottom-left-radius: @radius; } .border-left-radius(@radius) { - border-bottom-left-radius: @radius; - border-top-left-radius: @radius; + border-bottom-left-radius: @radius; + border-top-left-radius: @radius; } diff --git a/frontend/less/espo/bootstrap/mixins/buttons.less b/frontend/less/espo/bootstrap/mixins/buttons.less index f7733b68f56..2a0dc1b0360 100644 --- a/frontend/less/espo/bootstrap/mixins/buttons.less +++ b/frontend/less/espo/bootstrap/mixins/buttons.less @@ -1,7 +1,6 @@ -// Button sizes .button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height) { - padding: @padding-vertical @padding-horizontal; - font-size: @font-size; - line-height: @line-height; + padding: @padding-vertical @padding-horizontal; + font-size: @font-size; + line-height: @line-height; } diff --git a/frontend/less/espo/bootstrap/mixins/center-block.less b/frontend/less/espo/bootstrap/mixins/center-block.less index d18d6de9ed6..b3a5ea8ffea 100644 --- a/frontend/less/espo/bootstrap/mixins/center-block.less +++ b/frontend/less/espo/bootstrap/mixins/center-block.less @@ -1,7 +1,5 @@ -// Center-align a block level element - .center-block() { - display: block; - margin-left: auto; - margin-right: auto; + display: block; + margin-left: auto; + margin-right: auto; } diff --git a/frontend/less/espo/bootstrap/mixins/clearfix.less b/frontend/less/espo/bootstrap/mixins/clearfix.less index 3f7a3820c1c..6f8a14476d7 100644 --- a/frontend/less/espo/bootstrap/mixins/clearfix.less +++ b/frontend/less/espo/bootstrap/mixins/clearfix.less @@ -1,6 +1,3 @@ -// Clearfix -// -// For modern browsers // 1. The space content is one way to avoid an Opera bug when the // contenteditable attribute is included anywhere else in the document. // Otherwise it causes space to appear at the top and bottom of elements @@ -11,12 +8,13 @@ // Source: http://nicolasgallagher.com/micro-clearfix-hack/ .clearfix() { - &:before, - &:after { - content: " "; // 1 - display: table; // 2 - } - &:after { - clear: both; - } + &:before, + &:after { + content: " "; + display: table; + } + + &:after { + clear: both; + } } diff --git a/frontend/less/espo/bootstrap/mixins/forms.less b/frontend/less/espo/bootstrap/mixins/forms.less index a9a13c7ea43..dd16fbb86d0 100644 --- a/frontend/less/espo/bootstrap/mixins/forms.less +++ b/frontend/less/espo/bootstrap/mixins/forms.less @@ -1,21 +1,15 @@ -// Form control sizing -// -// Relative text size, padding, and border-radii changes for form controls. For -// horizontal sizing, wrap controls in the predefined grid classes. `