Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,380 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\ExportCustom;
use GuzzleHttp\Psr7\Utils;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\Core\Utils\DateTime;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Util;
use Espo\Entities\Attachment;
use Espo\ORM\EntityManager;
use Espo\Tools\EntityManager\NameUtil;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use ZipArchive;
class ExportCustom
{
private string $minVersion = '8.0.0';
/** @var string[] */
private array $metadataFolderList = [
'scopes',
'entityDefs',
'clientDefs',
'recordDefs',
'selectDefs',
'aclDefs',
'entityAcl',
'formula',
];
public function __construct(
private Metadata $metadata,
private FileManager $fileManager,
private NameUtil $nameUtil,
private FileStorageManager $fileStorageManager,
private EntityManager $entityManager
) {}
public function process(Params $params): Result
{
$this->validate($params);
$data = $this->createData($params);
$this->createDir($data);
$this->copy($data);
$this->fixMetadata($params, $data);
$this->filterLayouts($data);
$this->createControllers($params, $data);
$this->createModuleJson($data);
$this->createManifest($params, $data);
$attachmentId = $this->createAttachment($data);
$this->cleanup($data);
return new Result($attachmentId);
}
private function cleanup(Data $data): void
{
$this->fileManager->removeInDir($data->getDir(), true);
$this->fileManager->removeFile($data->getDir() . '.zip');
}
private function createDir(Data $data): void
{
$this->fileManager->mkdir($data->getDestDir(), 0755);
}
private function copy(Data $data): void
{
$customDir = 'custom/Espo/Custom';
$this->fileManager->copy(
$customDir . '/Resources',
$data->getDestDir() . '/Resources',
true
);
}
private function fixMetadata(Params $params, Data $data): void
{
foreach ($data->customEntityTypeList as $scope) {
$file = $data->getDestDir() . '/Resources/metadata/scopes/' . $scope . '.json';
if (!$this->fileManager->exists($file)) {
throw new RuntimeException("File $file does not exist.");
}
$defs = Json::decode($this->fileManager->getContents($file));
unset($defs->isCustom);
$defs->module = $params->getModule();
$defs->isCustomExported = true;
$this->fileManager->putJsonContents($file, $defs);
}
$dir = $data->getDestDir() . '/Resources/metadata/entityDefs';
/** @var string[] $files */
$files = $this->fileManager->getFileList($dir);
$files = array_filter($files, fn ($file) => str_ends_with($file, '.json'));
foreach ($files as $file) {
$fullFile = $dir . '/' . $file;
$defs = Json::decode($this->fileManager->getContents($fullFile));
foreach (get_object_vars($defs->fields ?? (object) []) as $key => $value) {
unset($defs->fields->$key->isCustom);
}
foreach (get_object_vars($defs->links ?? (object) []) as $key => $value) {
unset($defs->links->$key->isCustom);
}
$this->fileManager->putJsonContents($fullFile, $defs);
}
$metadataDir = $data->getDestDir() . '/Resources/metadata';
/** @var string[] $list */
$list = $this->fileManager->getFileList($metadataDir);
foreach ($list as $item) {
if (in_array($item, $this->metadataFolderList)) {
continue;
}
$file = $metadataDir . '/' . $item;
if ($this->fileManager->isFile($file)) {
$this->fileManager->removeFile($file);
continue;
}
$this->fileManager->removeInDir($file, true);
}
}
private function filterLayouts(Data $data): void
{
$dir = $data->getDestDir() . '/Resources/layouts';
/** @var string[] $list */
$list = $this->fileManager->getFileList($dir);
foreach ($list as $item) {
if (in_array($item, $data->customEntityTypeList)) {
continue;
}
$file = $dir . '/' . $item;
if ($this->fileManager->isFile($file)) {
$this->fileManager->removeFile($file);
continue;
}
$this->fileManager->removeInDir($file, true);
}
}
private function createControllers(Params $params, Data $data): void
{
foreach ($data->customEntityTypeList as $scope) {
$this->createController($params, $data, $scope);
}
}
private function createModuleJson(Data $data): void
{
$file = $data->getDestDir() . '/Resources/module.json';
$defs = (object) ['order' => 30];
$this->fileManager->putJsonContents($file, $defs);
}
private function createController(Params $params, Data $data, string $scope): void
{
$shortClassName = Util::normalizeClassName($scope);
$module = $params->getModule();
$contents = "<" . "?" . "php\n\n".
"namespace Espo\\Modules\\$module\\Controllers;\n\n".
"class $shortClassName extends \\Espo\\Core\\Templates\\Controllers\\Base\n".
"{}\n";
$file = "{$data->getDestDir()}/Controllers/$shortClassName.php";
$this->fileManager->putContents($file, $contents);
}
private function createManifest(Params $params, Data $data): void
{
$file = $data->getDir() . '/manifest.json';
$defs = (object) [
'name' => $params->getName(),
'version' => $params->getVersion(),
'author' => $params->getAuthor(),
'skipBackup' => true,
'releaseDate' => DateTime::getSystemTodayString(),
'description' => $params->getDescription(),
'acceptableVersions' => ['>=' . $this->minVersion]
];
$this->fileManager->putJsonContents($file, $defs);
}
private function createAttachment(Data $data): string
{
$zipFile = $this->createZip($data);
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew();
$attachment
->setName($data->folder . '.zip')
->setRole(Attachment::ROLE_EXPORT_FILE)
->setType('application/zip')
->setSize($this->fileManager->getSize($zipFile));
$this->entityManager->saveEntity($attachment);
$resource = fopen($zipFile, 'r');
if ($resource === false) {
throw new RuntimeException("Could not open file $zipFile.");
}
$this->fileStorageManager->putStream($attachment, Utils::streamFor($resource));
return $attachment->getId();
}
private function createZip(Data $data): string
{
$zip = new ZipArchive();
$archiveFile = $data->getDir() . '.zip';
$openResult = $zip->open($archiveFile, ZipArchive::CREATE);
if ($openResult !== true) {
throw new RuntimeException("Could not open zip.");
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($data->getDir() . '/'),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($files as $file => $fileDescriptor) {
$file = str_replace("\\", "/", $file);
if (!$this->fileManager->isFile($file)) {
continue;
}
$entry = substr($file, strlen('data/tmp/' . $data->folder . '/'));
$zip->addFile(
getcwd() . '/' . $file,
$entry
);
}
$zip->close();
return $archiveFile;
}
private function createData(Params $params): Data
{
$folder = Util::camelCaseToHyphen($params->getModule()) . '-' . $params->getVersion();
return new Data(
folder: $folder,
customEntityTypeList: $this->obtainCustomEntityTypeList(),
module: $params->getModule(),
);
}
private function validate(Params $params): void
{
if (!preg_match('/^[A-Za-z][A-Za-z0-9 ]+$/', $params->getName())) {
throw new RuntimeException("Bad extension name.");
}
if (!preg_match('/^[A-Z][a-z][A-Za-z]+$/', $params->getModule())) {
throw new RuntimeException("Bad module name. Should be in CamelCase.");
}
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $params->getVersion())) {
throw new RuntimeException("Bad version number.");
}
if ($this->nameUtil->nameIsNotAllowed($params->getModule())) {
throw new RuntimeException("Not allowed module name.");
}
$forbiddenModuleNames = [
'Advanced',
'AdvancedPack',
'Sales',
'SalesPack',
'Google',
'Outlook',
'Voip',
];
if (in_array($params->getModule(), $forbiddenModuleNames)) {
throw new RuntimeException("Not allowed module name.");
}
}
/**
* @return string[]
*/
private function obtainCustomEntityTypeList(): array
{
$list = [];
/** @var array<string, array<string, mixed>> $scopes */
$scopes = $this->metadata->get('scopes', []);
foreach ($scopes as $scope => $item) {
$isCustom = $item['isCustom'] ?? null;
$isEntity = $item['entity'] ?? null;
if (!$isCustom || !$isEntity) {
continue;
}
$list[] = $scope;
}
return $list;
}
}