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).
*