diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 08fa49c7a..626867537 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -114,6 +114,9 @@ return [ ### Theme - `themeConfig` - Sets the configuration for theming your form and fields. See below for an example. +### Export +- `defaultExportFolder` - Sets the default folder for exported forms via console commands. + ## Control Panel You can also manage configuration settings through the Control Panel by visiting Settings → Formie. diff --git a/src/console/controllers/FormsController.php b/src/console/controllers/FormsController.php index ed84032ca..b0b1d0e33 100644 --- a/src/console/controllers/FormsController.php +++ b/src/console/controllers/FormsController.php @@ -1,12 +1,19 @@ stdout("Deleting {$count} {$elementsText} for form #{$formId} ..." . PHP_EOL, Console::FG_YELLOW); + $this->stdout("Deleting $count $elementsText for form $formId ..." . PHP_EOL, Console::FG_YELLOW); $elementsService = Craft::$app->getElements(); foreach (Db::each($query) as $element) { $elementsService->deleteElement($element); - $this->stdout("Deleted form #{$element->id} ..." . PHP_EOL, Console::FG_GREEN); + $this->stdout("Deleted form $element->id ..." . PHP_EOL, Console::FG_GREEN); + } + } + + return ExitCode::OK; + } + + /** + * List all possible Formie forms to be exported or imported. + */ + public function actionList($folderPath = null): int + { + $path = $folderPath ?? $this->getExportPath(); + try { + $files = FileHelper::findFiles($path, ['only' => ['*.json']]); + } catch (\Throwable $th) { + $this->stderr("The export directory is empty or does not exist." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if (!empty($files)) { + $listEntries[] = [ + 'title' => 'JSON to import:', + 'entriesList' => array_map(function ($file) { + return [ + 'name' => $file, + 'title' => '' + ]; + }, $files) + ]; + } + + $allForms = Formie::$plugin->getForms()->getAllForms(); + if (!empty($allForms)) { + $listEntries[] = [ + 'title' => 'Formie forms:', + 'entriesList' => array_map(function ($form) { + return [ + 'name' => "$form->id: $form->handle", + 'title' => $form->title + ]; + }, $allForms) + ]; + } + + foreach ($listEntries as $entries) { + $this->stdout($entries['title'] . PHP_EOL, Console::FG_YELLOW); + + $handleMaxLen = max(array_map('strlen', array_column($entries['entriesList'], 'name'))); + + foreach ($entries['entriesList'] as $entry) { + $this->stdout("- " . $entry['name'], Console::FG_GREEN); + $this->stdout(Console::moveCursorTo($handleMaxLen + 5)); + $this->stdout($entry['title'] . PHP_EOL); + } + } + + + return ExitCode::OK; + } + + /** + * Export Formie forms as JSON. Accepts comma-separated lists of form IDs and/or handles. + */ + public function actionExport($idsOrHandles = null): int + { + $formIds = null; + + foreach (explode(',', $idsOrHandles) as $idOrHandle) { + if (is_numeric($idOrHandle)) { + $formIds[] = $idOrHandle; + } else { + $formIds[] = Form::find()->handle($idOrHandle)->one()->id ?? null; + } + } + + if (!$formIds) { + $this->stderr('Unable to find any matching forms.' . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + $query = Form::find()->id($formIds); + $count = (int)$query->count(); + + if ($count === 0) { + $this->stdout('No forms exist for that criteria.' . PHP_EOL, Console::FG_YELLOW); + } + + $elementsText = $count === 1 ? 'form' : 'forms'; + $this->stdout("Exporting $count $elementsText ..." . PHP_EOL, Console::FG_YELLOW); + + foreach (Db::each($query) as $element) { + try { + $formExport = ImportExportHelper::generateFormExport($element); + $json = Json::encode($formExport, JSON_PRETTY_PRINT | JSON_NUMERIC_CHECK); + $exportPath = $this->generateExportPathByHandle($element->handle); + FileHelper::writeToFile($exportPath, $json); + $this->stdout("Exporting form $element->id to $exportPath." . PHP_EOL, Console::FG_GREEN); + } catch (Throwable $e) { + + $this->stderr("Unable to export form $element->id." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; } } return ExitCode::OK; } + + /** + * Import a Formie form JSON from a path. + */ + public function actionImport($fileLocation = null): int + { + if ($fileLocation === null) { + $this->stderr('You must provide a path to a JSON file.' . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if (!is_file($fileLocation)) { + $this->stderr("No file exists at the given path." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if (strtolower(pathinfo($fileLocation, PATHINFO_EXTENSION)) !== 'json') { + $this->stderr("The file is not of type JSON." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + try { + $json = Json::decode(file_get_contents($fileLocation)); + } catch (\Exception $e) { + $this->stderr("Failed to decode JSON from the file." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + // default, update existing form + $formAction = $this->create ? 'create' : 'update'; + + $form = ImportExportHelper::importFormFromJson($json, $formAction); + + // check for errors + if ($form->getConsolidatedErrors()) { + $this->stderr("Unable to import the form." . PHP_EOL, Console::FG_RED); + $errors = Json::encode($form->getConsolidatedErrors()); + $this->stderr("Errors: $errors" . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + $this->stdout("Form $form->handle has be {$formAction}d." . PHP_EOL, Console::FG_GREEN); + + return ExitCode::OK; + } + + /** + * Import all Formie JSON from a folder. + */ + public function actionImportAll($folderPath = null): int + { + $path = $folderPath ?? $this->getExportPath(); + try { + $files = FileHelper::findFiles($path, ['only' => ['*.json']]); + } catch (\Throwable $th) { + $this->stderr("The export directory is empty or does not exist." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if (empty($files)) { + $this->stderr("No JSON files found in folder $path." . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + // use jobs to prevent db overload or php timeout + foreach ($files as $file) { + + Queue::push(new ImportForm( + [ + 'fileLocation' => $file, + 'formAction' => $this->create ? 'create' : 'update' + ] + )); + + $basename = basename($file); + $this->stdout("File '$basename' has been added to the import queue." . PHP_EOL, Console::FG_GREEN); + } + + + return ExitCode::OK; + } + + // Protected Methods + // ========================================================================= + + private function generateExportPathByHandle($handle): string + { + return $this->getExportPath() . DIRECTORY_SEPARATOR . "formie-$handle.json"; + } + + private function getExportPath(): string + { + $settings = Formie::$plugin->getSettings(); + return $settings->getAbsoluteDefaultExportFolder(); + } } diff --git a/src/controllers/ImportExportController.php b/src/controllers/ImportExportController.php index abb0584ab..d2995d945 100644 --- a/src/controllers/ImportExportController.php +++ b/src/controllers/ImportExportController.php @@ -149,46 +149,12 @@ public function actionImportComplete(): ?Response } $json = Json::decode(file_get_contents($fileLocation)); + + + $form = ImportExportHelper::importFormFromJson($json, $formAction); - // Find an existing form with the same handle - $existingForm = null; - $formHandle = $json['handle'] ?? null; - - if ($formHandle) { - $existingForm = Formie::$plugin->getForms()->getFormByHandle($formHandle); - } - - // When creating a new form, change the handle - if ($formAction === 'create') { - $formHandles = (new Query()) - ->select(['handle']) - ->from(Table::FORMIE_FORMS) - ->column(); - - $json['handle'] = HandleHelper::getUniqueHandle($formHandles, $json['handle']); - } - - if ($formAction === 'update') { - // Update the form (force) - $form = ImportExportHelper::createFormFromImport($json, $existingForm); - } else { - // Create the form element, ready to go - $form = ImportExportHelper::createFormFromImport($json); - } - - // Because we also export the UID for forms, we need to check if we're importing a new form, but we've - // found a form with the same UID. If this happens, then the original form will be overwritten - if ($formAction === 'create') { - // Is there already a form that exists with this UID? Then we need to assign a new one. - // See discussion https://github.com/verbb/formie/discussions/1696 and actual issue https://github.com/verbb/formie/issues/1725 - $existingForm = Formie::$plugin->getForms()->getFormByHandle($form->handle); - - if ($existingForm) { - $form->uid = StringHelper::UUID(); - } - } - - if (!Craft::$app->getElements()->saveElement($form)) { + // check for errors + if( $form->getConsolidatedErrors() ){ $this->setFailFlash(Craft::t('formie', 'Unable to import form.')); Craft::$app->getUrlManager()->setRouteParams([ diff --git a/src/helpers/ImportExportHelper.php b/src/helpers/ImportExportHelper.php index b5414211b..18684f41d 100644 --- a/src/helpers/ImportExportHelper.php +++ b/src/helpers/ImportExportHelper.php @@ -22,6 +22,9 @@ use Craft; use craft\elements\Entry; use craft\helpers\Json; +use craft\db\Query; + +use yii\base\Exception; class ImportExportHelper { @@ -218,7 +221,7 @@ public static function createFormFromImport(array $data, ?Form $form = null): Fo foreach ($page['rows'] as $rowKey => &$row) { if (isset($row['fields'])) { foreach ($row['fields'] as $fieldKey => &$field) { - $existingField = $existingFields[$field['handle']] ?? null; + $existingField = $existingFields[$field['settings']['handle']] ?? null; if ($existingField) { $field['id'] = $existingField->id; @@ -229,7 +232,7 @@ public static function createFormFromImport(array $data, ?Form $form = null): Fo foreach ($field['rows'] as $nestedRowKey => &$nestedRow) { if (isset($nestedRow['fields'])) { foreach ($nestedRow['fields'] as $nestedFieldKey => &$nestedField) { - $existingNestedField = $existingFields[$field['handle'] . '_fields'][$nestedField['handle']] ?? null; + $existingNestedField = $existingFields[$field['settings']['handle'] . '_fields'][$nestedField['settings']['handle']] ?? null; if ($existingNestedField) { $nestedField['id'] = $existingNestedField->id; @@ -304,6 +307,53 @@ public static function createFormFromImport(array $data, ?Form $form = null): Fo return $form; } + public static function importFormFromJson($json, $formAction = "update"): Form + { + + // Find an existing form with the same handle + $existingForm = null; + $formHandle = $json['handle'] ?? null; + + if ($formHandle) { + $existingForm = Formie::$plugin->getForms()->getFormByHandle($formHandle); + } + + // When creating a new form, change the handle + if ($formAction === 'create') { + $formHandles = (new Query()) + ->select(['handle']) + ->from(Table::FORMIE_FORMS) + ->column(); + + $json['handle'] = HandleHelper::getUniqueHandle($formHandles, $json['handle']); + } + + if ($formAction === 'update') { + // Update the form (force) + $form = self::createFormFromImport($json, $existingForm); + } else { + // Create the form element, ready to go + $form = self::createFormFromImport($json); + } + + // Because we also export the UID for forms, we need to check if we're importing a new form, but we've + // found a form with the same UID. If this happens, then the original form will be overwritten + if ($formAction === 'create') { + // Is there already a form that exists with this UID? Then we need to assign a new one. + // See discussion https://github.com/verbb/formie/discussions/1696 and actual issue https://github.com/verbb/formie/issues/1725 + $existingForm = Formie::$plugin->getForms()->getFormByHandle($form->handle); + + if ($existingForm) { + $form->uid = StringHelper::UUID(); + } + } + + Craft::$app->getElements()->saveElement($form); + + return $form; + + } + // Private Methods // ========================================================================= diff --git a/src/jobs/ImportForm.php b/src/jobs/ImportForm.php new file mode 100644 index 000000000..0ddc41e41 --- /dev/null +++ b/src/jobs/ImportForm.php @@ -0,0 +1,45 @@ +setProgress($queue, 0.33); + + if (!$this->fileLocation) { + throw new Exception("No file provided."); + } + + $json = Json::decode(file_get_contents($this->fileLocation)); + $form = ImportExportHelper::importFormFromJson($json, $this->formAction); + + $this->setProgress($queue, 0.66); + + // check for errors + if ($form->getConsolidatedErrors()) { + $errors = Json::encode($form->getConsolidatedErrors()); + throw new Exception("Unable to import form {$this->fileLocation}" . PHP_EOL . "Errors: {$errors}."); + } + + $this->setProgress($queue, 1); + } + + protected function defaultDescription(): string + { + $fileName = basename($this->fileLocation); + return "Import of JSON '$fileName'."; + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 10232f0bf..e234f1995 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -9,6 +9,7 @@ use craft\base\Model; use craft\helpers\App; use craft\helpers\DateTimeHelper; +use craft\helpers\FileHelper; use yii\validators\EmailValidator; @@ -88,6 +89,8 @@ class Settings extends Model // Captcha settings are stored in Project Config, but otherwise private public array $captchas = []; + // Export + public string $defaultExportFolder = '@storage/formie-export'; // Public Methods // ========================================================================= @@ -192,4 +195,13 @@ protected function defineRules(): array return $rules; } + + public function getAbsoluteDefaultExportFolder(): ?string + { + $path = Craft::getAlias( $this->defaultExportFolder ); + $exportFolder = FileHelper::normalizePath($path); + FileHelper::createDirectory($exportFolder); + + return $exportFolder; + } }