diff --git a/src/CRM/CivixBundle/Application.php b/src/CRM/CivixBundle/Application.php index 11ec590c..262cbe22 100644 --- a/src/CRM/CivixBundle/Application.php +++ b/src/CRM/CivixBundle/Application.php @@ -10,6 +10,7 @@ use CRM\CivixBundle\Command\AddEntityCommand; use CRM\CivixBundle\Command\AddEntityBoilerplateCommand; use CRM\CivixBundle\Command\AddFormCommand; +use CRM\CivixBundle\Command\AddManagedEntityCommand; use CRM\CivixBundle\Command\AddPageCommand; use CRM\CivixBundle\Command\AddReportCommand; use CRM\CivixBundle\Command\AddSearchCommand; @@ -59,6 +60,7 @@ public function createCommands($context = 'default') { $commands[] = new AddEntityCommand(); $commands[] = new AddEntityBoilerplateCommand(); $commands[] = new AddFormCommand(); + $commands[] = new AddManagedEntityCommand(); $commands[] = new AddPageCommand(); $commands[] = new AddReportCommand(); $commands[] = new AddSearchCommand(); diff --git a/src/CRM/CivixBundle/Builder/Content.php b/src/CRM/CivixBundle/Builder/Content.php new file mode 100644 index 00000000..e91925f4 --- /dev/null +++ b/src/CRM/CivixBundle/Builder/Content.php @@ -0,0 +1,61 @@ +content = $content; + $this->path = $path; + $this->overwrite = $overwrite; + } + + public function loadInit(&$ctx) { + } + + public function init(&$ctx) { + } + + public function load(&$ctx) { + } + + /** + * Write the content + */ + public function save(&$ctx, OutputInterface $output) { + $parent = dirname($this->path); + if (!is_dir($parent)) { + mkdir($parent, Dirs::MODE, TRUE); + } + if (file_exists($this->path) && $this->overwrite === 'ignore') { + // do nothing + } + elseif (file_exists($this->path) && !$this->overwrite) { + $output->writeln("Skip " . $this->path . ": file already exists"); + } + else { + $output->writeln("Write " . $this->path); + file_put_contents($this->path, $this->getContent($ctx)); + } + } + + protected function getContent($ctx): string { + return $this->content; + } + +} diff --git a/src/CRM/CivixBundle/Builder/Dirs.php b/src/CRM/CivixBundle/Builder/Dirs.php index f6f5106b..00e2d014 100644 --- a/src/CRM/CivixBundle/Builder/Dirs.php +++ b/src/CRM/CivixBundle/Builder/Dirs.php @@ -12,10 +12,19 @@ class Dirs implements Builder { // Note: Permissions will be further restricted by umask const MODE = 0777; - public function __construct($paths) { + /** + * @var string[] + */ + private $paths; + + public function __construct($paths = []) { $this->paths = $paths; } + public function addPath(string $path) { + $this->paths[] = $path; + } + public function loadInit(&$ctx) { } diff --git a/src/CRM/CivixBundle/Builder/Info.php b/src/CRM/CivixBundle/Builder/Info.php index bda3e4fa..045002d5 100644 --- a/src/CRM/CivixBundle/Builder/Info.php +++ b/src/CRM/CivixBundle/Builder/Info.php @@ -137,6 +137,10 @@ public function getExtensionName() { return empty($this->xml->name) ? 'FIXME' : $this->xml->name; } + public function getExtensionUtilClass(): string { + return str_replace('/', '_', $this->getNamespace()) . '_ExtensionUtil'; + } + /** * Get the namespace into which civix should place files * @return string diff --git a/src/CRM/CivixBundle/Builder/PhpData.php b/src/CRM/CivixBundle/Builder/PhpData.php index 1d896ca2..e2e990c4 100644 --- a/src/CRM/CivixBundle/Builder/PhpData.php +++ b/src/CRM/CivixBundle/Builder/PhpData.php @@ -6,7 +6,7 @@ use Symfony\Component\VarExporter\VarExporter; /** - * Read/write a serialized data file based on PHP's var_export() format + * Write a data file in PHP format */ class PhpData implements Builder { @@ -21,10 +21,20 @@ class PhpData implements Builder { protected $data; /** - * @var + * @var string */ protected $header; + /** + * @var string[] + */ + private $keysToTranslate; + + /** + * @var string + */ + private $extensionUtil; + public function __construct($path, $header = NULL) { $this->path = $path; $this->header = $header; @@ -65,6 +75,26 @@ public function load(&$ctx) { $this->data = include $this->path; } + /** + * Specify fields that will be wrapped in E::ts() + * + * @param array $keysToTranslate + * @return void + */ + public function useTs(array $keysToTranslate) { + $this->keysToTranslate = $keysToTranslate; + } + + /** + * Adds `use Foo_ExtensionUtil as E;` to the top of the file + * + * @param string $extensionUtilClass + * @return void + */ + public function useExtensionUtil(string $extensionUtilClass) { + $this->extensionUtil = $extensionUtilClass; + } + /** * Write the xml document */ @@ -72,20 +102,53 @@ public function save(&$ctx, OutputInterface $output) { $output->writeln("Write " . $this->path); $content = "extensionUtil) { + $content .= "use $this->extensionUtil as E;\n"; + } if ($this->header) { $content .= $this->header; } $content .= "\nreturn "; - $content .= preg_replace_callback('/^ +/m', - // VarExporter indents with 4x spaces. Civi/Drupal code standard is 2x spaces. + $data = $this->reduceIndentation(VarExporter::export($this->data)); + $data = $this->ucConstants($data); + if ($this->keysToTranslate) { + $data = $this->translateStrings($data, $this->keysToTranslate); + } + $content .= "$data;\n"; + file_put_contents($this->path, $content); + } + + /** + * VarExporter indents with 4x spaces. Civi/Drupal code standard is 2x spaces. + */ + private function reduceIndentation(string $data): string { + return preg_replace_callback('/^ +/m', function($m) { $spaces = $m[0]; return substr($spaces, 0, ceil(strlen($spaces) / 2)); }, - VarExporter::export($this->data) + $data ); - $content .= ";\n"; - file_put_contents($this->path, $content); + } + + /** + * Uppercase constants to match Civi/Drupal code standard + */ + private function ucConstants(string $data): string { + foreach (['null', 'false', 'true'] as $const) { + $uc = strtoupper($const); + $data = str_replace(" $const,", " $uc,", $data); + } + return $data; + } + + /** + * Wrap strings in E::ts() + */ + private function translateStrings(string $data, array $keysToTranslate): string { + $keys = implode('|', array_unique($keysToTranslate)); + $data = preg_replace("/'($keys)' => ('[^']+'),/", "'\$1' => E::ts(\$2),", $data); + return $data; } } diff --git a/src/CRM/CivixBundle/Builder/Template.php b/src/CRM/CivixBundle/Builder/Template.php index a0da2020..11eba923 100644 --- a/src/CRM/CivixBundle/Builder/Template.php +++ b/src/CRM/CivixBundle/Builder/Template.php @@ -1,20 +1,16 @@ template = $template; $this->path = $path; $this->overwrite = $overwrite; $this->templateEngine = $templateEngine ?: Services::templating(); - $this->enable = FALSE; } - public function loadInit(&$ctx) { - } - - public function init(&$ctx) { - } - - public function load(&$ctx) { - } - - /** - * Write the xml document - */ - public function save(&$ctx, OutputInterface $output) { - $parent = dirname($this->path); - if (!is_dir($parent)) { - mkdir($parent, Dirs::MODE, TRUE); - } - if (file_exists($this->path) && $this->overwrite === 'ignore') { - // do nothing - } - elseif (file_exists($this->path) && !$this->overwrite) { - $output->writeln("Skip " . $this->path . ": file already exists"); - } - else { - $output->writeln("Write " . $this->path); - file_put_contents($this->path, $this->templateEngine->render($this->template, $ctx)); - } + protected function getContent($ctx): string { + return $this->templateEngine->render($this->template, $ctx); } } diff --git a/src/CRM/CivixBundle/Command/AbstractCommand.php b/src/CRM/CivixBundle/Command/AbstractCommand.php index f8237ee6..55f8ebaa 100644 --- a/src/CRM/CivixBundle/Command/AbstractCommand.php +++ b/src/CRM/CivixBundle/Command/AbstractCommand.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; abstract class AbstractCommand extends Command { @@ -16,6 +17,27 @@ protected function configure() { $this->addOption('yes', NULL, InputOption::VALUE_NONE, 'Answer yes to any questions'); } + /** + * @var \Symfony\Component\Console\Style\StyleInterface + */ + private $io; + + /** + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + */ + protected function initialize(InputInterface $input, OutputInterface $output) { + parent::initialize($input, $output); + $this->io = new SymfonyStyle($input, $output); + } + + /** + * @return \Symfony\Component\Console\Style\StyleInterface + */ + protected function getIO() { + return $this->io; + } + protected function confirm(InputInterface $input, OutputInterface $output, $message, $default = TRUE) { $message = '' . $message . ''; /* FIXME Let caller stylize */ if ($input->getOption('yes')) { diff --git a/src/CRM/CivixBundle/Command/AddManagedEntityCommand.php b/src/CRM/CivixBundle/Command/AddManagedEntityCommand.php new file mode 100644 index 00000000..78ff77cd --- /dev/null +++ b/src/CRM/CivixBundle/Command/AddManagedEntityCommand.php @@ -0,0 +1,214 @@ +setName('export') + ->setDescription('(Experimental) Exports a record in packaged format for distribution in this extension') + ->addArgument('', InputArgument::REQUIRED, 'API entity name (Ex: "SavedSearch")') + ->addArgument('', InputArgument::REQUIRED, 'Id of entity to be exported (or name if exporting an Afform)') + ->setHelp('Uses APIv4 Export to save existing records as .mgd.php files. +Specify the name of the entity and the id. +The file will be saved to the managed directory. + +This command also works to export Afforms to the ang directory. + +The command has some support for updating (re-exporting) managed records. +However, this is experimental. At time of writing, it does not interoperate +with most existing extensions+generators. +'); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $this->assertCurrentFormat(); + + $ctx = []; + $ctx['type'] = 'module'; + $ctx['basedir'] = \CRM\CivixBundle\Application::findExtDir(); + $info = $this->getModuleInfo($ctx); + + $ext = new Collection(); + $ext->builders['dirs'] = new Dirs(); + + $entityName = $input->getArgument(''); + $entityId = $input->getArgument(''); + + // Boot CiviCRM to use api4 + Services::boot(['output' => $output]); + + try { + if ($entityName === 'Afform') { + $this->exportAfform($entityId, $info, $ext, $ctx); + } + else { + $this->exportMgd($entityName, $entityId, $info, $ext, $ctx); + } + } + catch (\Exception $e) { + $output->writeln("Error: " . $e->getMessage()); + return 1; + } + + $ext->builders['info'] = $info; + + $ext->loadInit($ctx); + $ext->save($ctx, $output); + return 0; + } + + private function exportMgd($entityName, $id, Info $info, $ext, $ctx) { + $basedir = new Path($ctx['basedir']); + $ext->builders['mixins'] = new Mixins($info, $basedir->string('mixin'), ['mgd-php@1.0']); + $ext->builders['dirs']->addPath($basedir->string('managed')); + + $export = (array) \civicrm_api4($entityName, 'export', [ + 'checkPermissions' => FALSE, + 'id' => $id, + ]); + if (!$export) { + throw new \Exception("$entityName $id not found."); + } + + $localizable = $this->localizable; + // Lookup entity-specific fields that should be wrapped in E::ts() + foreach ($export as $item) { + $fields = (array) \civicrm_api4($item['entity'], 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['localizable', '=', TRUE]], + ], ['name']); + $localizable = array_merge($localizable, $fields); + } + + $managedName = $export[0]['name']; + $managedFileName = $basedir->string('managed', "$managedName.mgd.php"); + $this->assertManageableEntity($entityName, $id, $info->getKey(), $managedName, $managedFileName); + $phpData = new PhpData($managedFileName); + $phpData->useExtensionUtil($info->getExtensionUtilClass()); + $phpData->useTs($localizable); + $phpData->set($export); + $ext->builders["$managedName.mgd.php"] = $phpData; + } + + private function assertManageableEntity(string $entityName, $id, string $extKey, string $managedName, string $managedFileName): void { + $existingMgd = \civicrm_api4('Managed', 'get', [ + 'select' => ['module', 'name', 'id'], + 'where' => [ + ['entity_type', '=', $entityName], + ['entity_id', '=', $id], + ], + 'checkPermissions' => FALSE, + ])->first(); + if ($existingMgd) { + if ($existingMgd['module'] !== $extKey || $existingMgd['name'] !== $managedName) { + $this->getIO()->error([ + sprintf("Requested entity (%s) is already managed by \"%s\" (#%s). Adding new entity \"%s\" would create conflict.", + "$entityName $id", + $existingMgd['module'] . ':' . $existingMgd['name'], + $existingMgd['id'], + "$extKey:$managedName" + ), + ]); + throw new \Exception('Export would create conflict between extensions'); + } + if (!file_exists($managedFileName)) { + $this->getIO()->warning([ + sprintf('The managed entity (%s) already exists in the database, but the expected file (%s) does not exist.', + "$extKey:$managedName", + Files::relativize($managedFileName, \CRM\CivixBundle\Application::findExtDir()) + ), + 'The new file will be created, but you may have a conflict within this extension.', + ]); + } + } + } + + private function exportAfform($afformName, $info, $ext, $ctx) { + $basedir = new Path($ctx['basedir']); + $ext->builders['dirs']->addPath($basedir->string('ang')); + + $fields = \civicrm_api4('Afform', 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['type', '=', 'Field']], + ])->indexBy('name'); + // Will throw exception if not found + $afform = \civicrm_api4('Afform', 'get', [ + 'checkPermissions' => FALSE, + 'where' => [['name', '=', $afformName]], + 'select' => ['*', 'search_displays'], + 'layoutFormat' => 'html', + ])->single(); + + // An Afform consists of 2 files - a layout file and a meta file + $layoutFileName = $basedir->string('ang', "$afformName.aff.html"); + $metaFileName = $basedir->string('ang', "$afformName.aff.php"); + + // Export layout file + $ext->builders["$afformName.aff.html"] = new Content($afform['layout'], $layoutFileName, TRUE); + + // Export meta file + $meta = $afform; + unset($meta['name'], $meta['layout'], $meta['search_displays'], $meta['navigation']); + // Simplify meta file by removing values that match the defaults + foreach ($meta as $field => $value) { + if ($field !== 'type' && $value == $fields[$field]['default_value']) { + unset($meta[$field]); + } + } + $phpData = new PhpData($metaFileName); + $phpData->useExtensionUtil($info->getExtensionUtilClass()); + $phpData->useTs($this->localizable); + $phpData->set($meta); + $ext->builders["$afformName.aff.php"] = $phpData; + + // Export navigation menu item pointing to afform, if present + if (!empty($afform['server_route'])) { + $navigation = \civicrm_api4('Navigation', 'get', [ + 'checkPermissions' => FALSE, + 'select' => ['id'], + 'where' => [['url', '=', $afform['server_route']], ['is_active', '=', TRUE]], + // Just the first one; multiple domains are handled by `CRM_Core_ManagedEntities` + 'orderBy' => ['domain_id' => 'ASC'], + ])->first(); + if ($navigation) { + $this->exportMgd('Navigation', $navigation['id'], $info, $ext, $ctx); + } + } + + // Export embedded search display(s) + if (!empty($afform['search_displays'])) { + $searchNames = array_map(function ($item) { + return explode('.', $item)[0]; + }, $afform['search_displays']); + $searchIds = \civicrm_api4('SavedSearch', 'get', [ + 'checkPermissions' => FALSE, + 'where' => [['name', 'IN', $searchNames]], + ], ['id']); + foreach ($searchIds as $id) { + $this->exportMgd('SavedSearch', $id, $info, $ext, $ctx); + } + } + } + +} diff --git a/tests/e2e/AddManagedEntityTest.php b/tests/e2e/AddManagedEntityTest.php new file mode 100644 index 00000000..7d96bea8 --- /dev/null +++ b/tests/e2e/AddManagedEntityTest.php @@ -0,0 +1,53 @@ +civixGenerateModule(static::getKey()); + chdir(static::getKey()); + + $this->assertFileGlobs([ + 'info.xml' => 1, + 'civix_exportmgd.php' => 1, + 'civix_exportmgd.civix.php' => 1, + ]); + $this->civixMixin(['--disable-all' => TRUE]); + } + + public function testAddMgd(): void { + $this->assertMixinStatuses(['mgd-php@1' => 'off']); + $this->assertFileGlobs(['managed/OptionGroup_preferred_communication_method.mgd.php' => 0]); + + $tester = static::civix('export'); + $tester->execute(['' => 'OptionGroup', '' => 1]); + if ($tester->getStatusCode() !== 0) { + throw new \RuntimeException(sprintf("Failed to generate mgd (%s)", static::getKey())); + } + + $this->assertMixinStatuses(['mgd-php@1' => 'on']); + $this->assertFileGlobs(['managed/OptionGroup_preferred_communication_method.mgd.php' => 1]); + + ProcessHelper::runOk('php -l managed/OptionGroup_preferred_communication_method.mgd.php'); + $expectPhrases = [ + "use CRM_CivixExportmgd_ExtensionUtil as E;", + "'title' => E::ts('Preferred Communication Method')", + "'option_group_id.name' => 'preferred_communication_method'", + "'label' => E::ts('Phone')", + "'value' => '1'", + "'label' => E::ts('Email')", + "'value' => '2'", + ]; + $this->assertStringSequence($expectPhrases, file_get_contents('managed/OptionGroup_preferred_communication_method.mgd.php')); + } + +} diff --git a/tests/e2e/CivixProjectTestTrait.php b/tests/e2e/CivixProjectTestTrait.php index ccdd787b..af3d30fc 100644 --- a/tests/e2e/CivixProjectTestTrait.php +++ b/tests/e2e/CivixProjectTestTrait.php @@ -151,6 +151,23 @@ public function civixInfoSet(string $xpath, string $value): CommandTester { return $tester; } + /** + * Update the mixin settings by calling `civix mixin`. + * + * @param array $options + * Ex: ['--disable-all' => TRUE] + * Ex: ['--enable' => 'foo@1'] + * @return \Symfony\Component\Console\Tester\CommandTester + */ + public function civixMixin(array $options): CommandTester { + $tester = static::civix('mixin'); + $tester->execute($options); + if ($tester->getStatusCode() !== 0) { + throw new \RuntimeException(sprintf("Failed to call \"civix mixin\" with options: %s", json_encode($options))); + } + return $tester; + } + /** * Run the 'upgrade' command (non-interactively; all default choices). *