diff --git a/docs/configuration_as_code.md b/docs/configuration_as_code.md new file mode 100644 index 000000000..f76fe2bf8 --- /dev/null +++ b/docs/configuration_as_code.md @@ -0,0 +1,47 @@ +--- +layout: default +permalink: developers/configuration_as_code/ +--- + +# Configuration as code + +Formulize now supports configuration-as-code for Forms and Elements. This allows you to version control your sites configuration. Since Formulize is a database driven application the configuration as code acts as a synchronization of the datatabase to match what is stored in the configuration file. The configuration file is considered the source of truth. This does not prevent you from making configuration changes in your formulize instance, instead it allows you to see if your formulize configuration has deviated from what's store in the configuration and synchronize/revert if necessary. + +To access the configuration screen. Go to the formulize admin page and click on the "Configuration Synchronization" link. It can also be accessed directly by adding the following to the path of your formulize url `modules/formulize/admin/ui.php?page=config-sync` + +## Configuration Files + +Configuration files are stored in the `modules/formulize/config` folder as JSON files (e.g. `forms.json`) + +## Supported operations + +When accessing the Configuration Synchronization administrative screen you can be presented with the following operations. + +### Export configuration + +This will export the current state of the database as a configuration file which you can then place inside the configuration folder `modules/formulize/config` + +### Create + +When configuration exists in the configuration file but not in the database they can be created in the database + +### Update + +When the same object exists in the database and in the configuration file but they differ in some way the element in the database can be updated to match what is in the configuration file. The differing values will be displayed on othe configuration screen. + +### Delete + +When an object exists in the database but not in the configuration file it can be deleted so that the database matches what is in the configuration file. + +## Example workflow + +Here is an example of how you could use the configuation-as-code functionality when building a formulize application. + +1. Build and configure your forms in the UI as you normally would. +1. Once you are happy with the set up go to the configuration synchronization page and export the configuration. Place this file in the config directory `modules/formulize/config` +1. Commit this file into your repository +1. As you continue to make changes to your application you can periodically do one of the following. + 1. revert the selected changes if you aren't happy with them + 1. Perform a new export and overwrite the file in the config directory + +![Configuration as code example](./images/configuration-as-code.png) diff --git a/docs/developer.html b/docs/developer.html index 5ba682812..4fbf8f5c4 100644 --- a/docs/developer.html +++ b/docs/developer.html @@ -17,4 +17,5 @@

Developer Docs

  • Git Tips and Tricks
  • Working With Templates
  • Using SCSS and Sass
  • +
  • Configuration as code
  • diff --git a/docs/images/configuration-as-code.png b/docs/images/configuration-as-code.png new file mode 100644 index 000000000..dc179da5c Binary files /dev/null and b/docs/images/configuration-as-code.png differ diff --git a/modules/formulize/admin/config-sync.php b/modules/formulize/admin/config-sync.php new file mode 100644 index 000000000..bd86ee736 --- /dev/null +++ b/modules/formulize/admin/config-sync.php @@ -0,0 +1,75 @@ + ## +############################################################################### +## This program is free software; you can redistribute it and/or modify ## +## it under the terms of the GNU General Public License as published by ## +## the Free Software Foundation; either version 2 of the License, or ## +## (at your option) any later version. ## +## ## +## You may not change or alter any portion of this comment or credits ## +## of supporting developers from this source code or any supporting ## +## source code which is considered copyrighted (c) material of the ## +## original comment or credit authors. ## +## ## +## 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 General Public License for more details. ## +## ## +## You should have received a copy of the GNU General Public License ## +## along with this program; if not, write to the Free Software ## +## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA ## +############################################################################### +## Author of this file: Formulize Project ## +## Project: Formulize ## +############################################################################### + +/** + * Configuration as code synchronization admin screen + */ + +// Operations may require additional memory and time to perform +ini_set('memory_limit', '1024M'); +ini_set('max_execution_time', '600'); +ini_set('display_errors', 1); + +include_once '../include/formulizeConfigSync.php'; + +$breadcrumbtrail[1]['url'] = "page=home"; +$breadcrumbtrail[1]['text'] = "Home"; +$breadcrumbtrail[2]['text'] = "Configuration Synchronization"; + +$configSync = new FormulizeConfigSync('/config'); +$diff = $configSync->compareConfigurations(); + +$adminPage['template'] = "db:admin/config-sync.html"; +$adminPage['success'] = []; +$adminPage['failure'] = []; + +if (isset($_POST['action']) && $_POST['action'] == 'export') { + $export = $configSync->exportConfiguration(); + header('Content-Type: application/json'); + header('Content-Disposition: attachment; filename="forms.json"'); + echo $export; + exit(); +} + + +if (isset($_POST['action']) && $_POST['action'] == 'apply') { + $changes = $_POST['handles'] ?? []; + $result = $configSync->applyChanges($changes); + $adminPage['success'] = $result['success']; + $adminPage['failure'] = $result['failure']; + // Compare the config again if we've applied changes so the results are up to date + $diff = $configSync->compareConfigurations(); +} + +$adminPage['changes'] = $diff['changes']; +$adminPage['log'] = $diff['log']; +$adminPage['errors'] = $diff['errors']; diff --git a/modules/formulize/admin/ui.php b/modules/formulize/admin/ui.php index 936545998..a0676b80c 100644 --- a/modules/formulize/admin/ui.php +++ b/modules/formulize/admin/ui.php @@ -144,6 +144,9 @@ case "managepermissions": include "managepermissions.php"; break; + case "config-sync": + include "config-sync.php"; + break; default: case "home": include "home.php"; diff --git a/modules/formulize/include/formulizeConfigSync.php b/modules/formulize/include/formulizeConfigSync.php new file mode 100644 index 000000000..873330d18 --- /dev/null +++ b/modules/formulize/include/formulizeConfigSync.php @@ -0,0 +1,827 @@ + ## +############################################################################### +## This program is free software; you can redistribute it and/or modify ## +## it under the terms of the GNU General Public License as published by ## +## the Free Software Foundation; either version 2 of the License, or ## +## (at your option) any later version. ## +## ## +## You may not change or alter any portion of this comment or credits ## +## of supporting developers from this source code or any supporting ## +## source code which is considered copyrighted (c) material of the ## +## original comment or credit authors. ## +## ## +## 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 General Public License for more details. ## +## ## +## You should have received a copy of the GNU General Public License ## +## along with this program; if not, write to the Free Software ## +## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA ## +############################################################################### +## Author of this file: Formulize Project ## +## Project: Formulize ## +############################################################################### + +include_once XOOPS_ROOT_PATH . "/modules/formulize/include/formulizeConfigSyncElementValueProcessor.php"; + +class FormulizeConfigSync +{ + private $db; + private $configPath; + private $elementValueProcessor; + private $formHandler; + private $elementHandler; + private $changes = []; + private $diffLog = []; + private $errorLog = []; + + private $configFiles = [ + 'forms' => 'forms.json' + ]; + + /** + * Constructor + * @param string $configPath + */ + public function __construct(string $configPath) + { + $this->configPath = rtrim($configPath, '/'); + $this->formHandler = xoops_getmodulehandler('forms', 'formulize'); + $this->elementHandler = xoops_getmodulehandler('elements', 'formulize'); + $this->elementValueProcessor = new FormulizeConfigSyncElementValueProcessor(); + $this->initializeDatabase(); + } + + /** + * Initialize the database connection + * @return void + */ + private function initializeDatabase() + { + try { + // @todo we should probably be usign the $xoopsDB object + // But it's an older version of PDO which means that many + // of the newer features we're using here will need to be + // refactored. For now, we'll just use a new PDO object. + $this->db = new \PDO( + 'mysql:host=' . XOOPS_DB_HOST . ';dbname=' . XOOPS_DB_NAME, + XOOPS_DB_USER, + XOOPS_DB_PASS + ); + $this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->db->query("SET NAMES utf8mb4"); + } catch (\PDOException $e) { + throw new \Exception("Database connection failed: " . $e->getMessage()); + } + } + + /** + * Load a configuration file + * @param string $filename + * @return array Array of configuration data + */ + private function loadConfigFile(string $filename): array + { + $filepath = XOOPS_ROOT_PATH . '/modules/formulize' . $this->configPath . '/' . $filename; + if (!file_exists($filepath)) { + $this->errorLog[] = "Warning: Configuration file not found: {$filepath}"; + return []; + } + + $content = file_get_contents($filepath); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception("Invalid JSON in {$filename}: " . json_last_error_msg()); + } + + return $config; + } + + /** + * Load configuration data from a database table + * @param string $table + * @return array + */ + private function loadDatabaseConfig(string $table, string $where = ''): array + { + $table = $this->prefixTable($table); + $sql = "SELECT * FROM {$table}"; + if ($where) { + $sql .= " WHERE $where"; + } + $stmt = $this->db->prepare($sql); + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Initiate the comparision between a config file and the database + * @return array Array of changes, log, and errors + */ + public function compareConfigurations(): array + { + $this->changes = []; + $this->diffLog = []; + $this->errorLog = []; + + foreach ($this->configFiles as $type => $filename) { + $config = $this->loadConfigFile($filename); + if (!$config) { + continue; + } + + // @todo additional cases for future expansion + switch ($type) { + case 'forms': + $this->compareForms($config); + break; + } + } + + return [ + 'changes' => $this->changes, + 'log' => $this->diffLog, + 'errors' => $this->errorLog + ]; + } + + + /** + * Compare configuration against the database for all forms + * @param array $config + * @return void + */ + private function compareForms(array $config): void + { + $formExcludedFields = ['defaultform', 'defaultlist']; + // Load all forms from the database + $dbForms = $this->loadDatabaseConfig('formulize_id'); + foreach ($config['forms'] as $configForm) { + // Find the corresponding DB form and compare to the configuration + $dbForm = $this->findInArray($dbForms, 'form_handle', $configForm['form_handle']); + + $strippedFormConfig = $this->stripArrayKey($configForm, 'elements'); + + if (!$dbForm) { + $this->addChange('forms', 'create', $strippedFormConfig['form_handle'], $strippedFormConfig); + } else { + $differences = $this->compareFields($strippedFormConfig, $dbForm, $formExcludedFields); + if (!empty($differences)) { + $this->addChange('forms', 'update', $strippedFormConfig['form_handle'], $strippedFormConfig, $differences); + } + } + + if (isset($configForm['elements'])) { + if ($dbForm) { + $dbElements = $this->loadDatabaseConfig('formulize', "id_form = {$dbForm['id_form']}"); + $this->compareElements($configForm['elements'], $dbElements, $configForm['form_handle']); + } else { + $this->compareElements($configForm['elements'], [], $configForm['form_handle']); + } + } + } + + // Check for forms in DB that are not in Config + foreach ($dbForms as $dbForm) { + $found = false; + foreach ($config['forms'] as $formConfig) { + if ($formConfig['form_handle'] === $dbForm['form_handle']) { + $found = true; + break; + } + } + if (!$found) { + $this->addChange('forms', 'delete', $dbForm['form_handle'], $dbForm); + $dbElements = $this->loadDatabaseConfig('formulize', "id_form = {$dbForm['id_form']}"); + $this->compareElements([], $dbElements, $dbForm['form_handle']); + } + } + } + + /** + * Compare elements of a form + * @param array $configElements elements from the configuration + * @param array $dbElements elements from the database + * @param string $formHandle handle of the form + * @param bool $formExistsInDb + * @return void + */ + private function compareElements(array $configElements, array $dbElements, string $formHandle): void + { + foreach ($configElements as $element) { + $dbElement = $this->findInArray($dbElements, 'ele_handle', $element['ele_handle']); + $preparedElement = $this->prepareElementForDb($element); + $metadata = []; + + if (!$dbElement) { + $metadata = [ + 'form_handle' => $formHandle, + 'data_type' => $element['data_type'] + ]; + $this->addChange('elements', 'create', $preparedElement['ele_handle'], $preparedElement, [], $metadata); + continue; + } else { + $formulizeDbElement = $this->elementHandler->get($dbElement['ele_handle']); + $formulizeDbElementDataType = $formulizeDbElement->getDataTypeInformation(); + if ($formulizeDbElementDataType['dataType'] !== $element['data_type']) { + $metadata = [ + 'data_type' => $element['data_type'] + ]; + } + } + // Compare the element fields + $differences = $this->compareElementFields($preparedElement, $dbElement, $element['data_type'], $formulizeDbElementDataType['dataType']); + if (!empty($differences)) { + $this->addChange('elements', 'update', $preparedElement['ele_handle'], $preparedElement, $differences, $metadata); + } + } + + // Check for elements in DB that are not in JSON + foreach ($dbElements as $dbElement) { + $found = false; + foreach ($configElements as $element) { + if ($element['ele_handle'] === $dbElement['ele_handle']) { + $found = true; + break; + } + } + if (!$found) { + $this->addChange('elements', 'delete', $dbElement['ele_handle'], $dbElement, [], ['form_handle' => $formHandle]); + } + } + } + + private function findInArray(array $array, string $key, $value) + { + foreach ($array as $item) { + if ($item[$key] === $value) { + return $item; + } + } + return null; + } + + /** + * Generic field comparison + * + * @param array $configObject Config object + * @param array $dbObject Database object + * @param array $excludeFields + * @return array + */ + private function compareFields(array $configObject, array $dbObject, array $excludeFields = []): array + { + $differences = []; + foreach ($configObject as $field => $value) { + if (in_array($field, $excludeFields)) { + continue; + } + $normalizedJSONValue = $this->normalizeValue($value); + $normalizedDBValue = $this->normalizeValue($dbObject[$field]); + if ($normalizedJSONValue !== $normalizedDBValue) { + $differences[$field] = [ + 'config_value' => $normalizedJSONValue, + 'db_value' => $normalizedDBValue + ]; + } + } + return $differences; + } + + /** + * Compare a Config and DB element and return the differences + * + * @param array $configElement Config element configuration + * @param array $dbElement Database element configuration + * @return array + */ + private function compareElementFields(array $configElement, array $dbElement, string $configDataType = NULL, string $dbDataType = NULL): array + { + $differences = []; + + foreach ($configElement as $field => $value) { + $eleValueDiff = []; + // ele_value fields are processed differently because they are serialized + if ($field === 'ele_value') { + $convertedConfigEleValue = $value !== "" ? unserialize($value) : []; + $dbEleValue = $dbElement['ele_value'] !== "" ? unserialize($dbElement['ele_value']) : []; + foreach ($convertedConfigEleValue as $key => $val) { + if (!array_key_exists($key, $dbEleValue) || $val !== $dbEleValue[$key]) { + $eleValueDiff[$key] = [ + 'config_value' => $val, + 'db_value' => $dbEleValue[$key] ?? null + ]; + } + } + if (!empty($eleValueDiff)) { + $differences['ele_value'] = $eleValueDiff; + } + } elseif (!array_key_exists($field, $dbElement) || $this->normalizeValue($value) !== $this->normalizeValue($dbElement[$field])) { + $differences[$field] = [ + 'config_value' => $value, + 'db_value' => $dbElement[$field] ?? null + ]; + } + } + + if ($configDataType !== $dbDataType) { + $differences['data_type'] = [ + 'config_value' => $configDataType, + 'db_value' => $dbDataType + ]; + } + + return $differences; + } + + /** + * Prepare an element for database storage + * + * @param array $element + * @return array + */ + private function prepareElementForDb(array $element): array + { + $preparedElement = $element; + foreach ($preparedElement as $key => $value) { + if (is_object($value) || is_array($value)) { + if ($key == 'ele_value') { + $preparedElement[$key] = serialize($this->elementValueProcessor->processElementValueForImport($element['ele_type'], $value)); + } else { + $preparedElement[$key] = serialize($value); + } + } + } + // Remove the data_type field + unset($preparedElement['data_type']); + return $preparedElement; + } + + /** + * Add a change to the list of changes + * + * @param string $type + * @param string $operation + * @param array $data + * @param array $differences + * @return void + */ + private function addChange( + string $type, + string $operation, + string $id, + array $data, + array $differences = [], + array $metadata = [] + ): void { + $this->changes[] = [ + 'type' => $type, + 'operation' => $operation, + 'id' => $id, + 'data' => $data, + 'differences' => $differences, + 'metadata' => $metadata + ]; + + // $identifierField = $type === 'forms' ? 'form_handle' : ($type === 'elements' ? 'ele_handle' : 'rel_handle'); + $this->diffLog[] = sprintf( + "%s: %s %s '%s'", + ucfirst($operation), + $type, + $data, + $differences + ); + } + + /** + * Apply changes to the database + * + * @return array + */ + public function applyChanges(array $changeIds): array + { + $results = ['success' => [], 'failure' => []]; + $changes = []; + + if (empty($changeIds)) { + $changes = $this->changes; + } else { + foreach ($this->changes as $change) { + if (in_array($change['id'], $changeIds)) { + $changes[] = $change; + } + } + } + // Apply all form changes + foreach ($changes as $change) { + if ($change['type'] === 'forms') { + try { + $this->applyFormChange($change); + $results['success'][] = $change; + } catch (\Exception $e) { + $results['failure'][] = ['error' => $e->getMessage(), 'change' => $change]; + } + } + } + + // Apply all element changes + foreach ($changes as $change) { + if ($change['type'] === 'elements') { + try { + $this->applyElementChange($change); + $results['success'][] = $change; + } catch (\Exception $e) { + $results['failure'][] = ['error' => $e->getMessage(), 'change' => $change]; + } + } + } + + return $results; + } + + private function applyFormChange(array $change): void + { + $table = $this->getTableForType($change['type']); + $primaryKey = $this->getPrimaryKeyForType($change['type']); + + switch ($change['operation']) { + case 'create': + // Ensure the form does not exist + $existingForm = $this->formHandler->getByHandle($change['data']['form_handle']); + if ($existingForm) { + throw new \Exception("Form handle {$change['data']['form_handle']} already exists"); + } + // Insert form record + $formId = $this->insertRecord($table, $change['data']); + // Create data table + $this->formHandler->createDataTable($formId); + $formObject = $this->formHandler->get($formId); + // create the default form screen for this form + $multiPageScreenHandler = xoops_getmodulehandler('multiPageScreen', 'formulize'); + $defaultFormScreen = $multiPageScreenHandler->create(); + $multiPageScreenHandler->setDefaultFormScreenVars($defaultFormScreen, $formObject->getVar('title') . ' Form', $formId, $formObject->getVar('title')); + $defaultFormScreenId = $multiPageScreenHandler->insert($defaultFormScreen); + // create the default list screen for this form + $listScreenHandler = xoops_getmodulehandler('listOfEntriesScreen', 'formulize'); + $screen = $listScreenHandler->create(); + $listScreenHandler->setDefaultListScreenVars($screen, $defaultFormScreenId, $formObject->getVar('title') . ' List', $formId); + $defaultListScreenId = $listScreenHandler->insert($screen); + // Assign default screens to the form + $formObject->setVar('defaultform', $defaultFormScreenId); + $formObject->setVar('defaultlist', $defaultListScreenId); + $this->formHandler->insert($formObject); + break; + + case 'update': + // Ensure the fom exists + $existingForm = $this->formHandler->getByHandle($change['data']['form_handle']); + if (!$existingForm) { + throw new \Exception("Form handle {$change['data']['form_handle']} does not exist"); + } + $this->updateRecord($table, $change['data'], $primaryKey); + break; + + case 'delete': + $form = $this->formHandler->getByHandle($change['data']['form_handle']); + if ($form) { + $this->formHandler->delete($form); + } + break; + } + return; + } + + private function applyElementChange(array $change): void + { + $table = $this->getTableForType($change['type']); + $primaryKey = $this->getPrimaryKeyForType($change['type']); + + switch ($change['operation']) { + case 'create': + // Ensure the element does not exist + $existingElement = $this->elementHandler->get($change['data']['ele_handle']); + if ($existingElement) { + throw new \Exception("Element handle {$change['data']['ele_handle']} already exists"); + } + $formHandle = $change['metadata']['form_handle']; + $form = $this->formHandler->getByHandle($formHandle); + if (!$form) { + throw new \Exception("Form handle $formHandle not found"); + } else { + $formId = $form->getVar('id_form'); + $change['data']['id_form'] = $formId; + $elementId = $this->insertRecord($table, $change['data']); + $elementType = $change['metadata']['data_type']; + $this->formHandler->insertElementField($elementId, $elementType); + } + break; + + case 'update': + // Ensure the element exists + $existingElement = $this->elementHandler->get($change['data']['ele_handle']); + if (!$existingElement) { + throw new \Exception("Element handle {$change['data']['ele_handle']} does not exists"); + } + $this->updateRecord($table, $change['data'], $primaryKey); + // Apply data type changes to the element + if ($change['metadata']['data_type']) { + $this->formHandler->updateField($existingElement, $change['data']['ele_handle'], $change['metadata']['data_type']); + } + break; + + case 'delete': + $elementId = $change['data']['ele_id']; + $element = $this->elementHandler->get($elementId); + if ($element) { + $this->elementHandler->delete($element); + $this->formHandler->deleteElementField($elementId); + } + break; + } + + return; + } + + /** + * Insert a record into a database table + * + * @param string $table + * @param array $data + * @return void + */ + private function insertRecord(string $table, array $data): int + { + $fields = array_keys($data); + $placeholders = array_fill(0, count($fields), '?'); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + $this->prefixTable($table), + '`' . implode('`, `', $fields) . '`', + implode(', ', $placeholders) + ); + + $stmt = $this->db->prepare($sql); + $stmt->execute(array_values($data)); + + return (int) $this->db->lastInsertId(); + } + + /** + * Update a record in a database table + * + * @param string $table + * @param array $data + * @param string $primaryKey + * @return void + */ + private function updateRecord(string $table, array $data, string $primaryKey): void + { + $fields = array_keys($data); + $sets = array_map(function ($field) { + return "`{$field}` = ?"; + }, $fields); + + $sql = sprintf( + "UPDATE %s SET %s WHERE %s = ?", + $this->prefixTable($table), + implode(', ', $sets), + $primaryKey + ); + + $values = array_values($data); + $values[] = $data[$primaryKey]; + + $stmt = $this->db->prepare($sql); + $stmt->execute($values); + } + + /** + * Delete a record from a database table + * + * @param string $table + * @param array $data + * @param string $primaryKey + * @return void + */ + private function deleteRecord(string $table, array $data, string $primaryKey): void + { + $sql = sprintf( + "DELETE FROM %s WHERE %s = ?", + $this->prefixTable($table), + $primaryKey + ); + + $stmt = $this->db->prepare($sql); + $stmt->execute([$data[$primaryKey]]); + } + + /** + * Prefix a table name with the XOOPS database prefix + * + * @param string $table + * @return string + */ + private function prefixTable(string $table): string + { + return XOOPS_DB_PREFIX . '_' . trim($table, '_'); + } + + /** + * Export current database configuration to a JSON string + * + * @return string A JSON string of the exported configuration + */ + public function exportConfiguration(): string + { + try { + $forms = $this->exportForms(); + $config = [ + 'version' => '1.0', + 'lastUpdated' => date('Y-m-d H:i:s'), + 'forms' => $forms, + 'metadata' => [ + 'generated_by' => 'FormulizeConfigSync', + 'environment' => XOOPS_DB_NAME, // Assuming XOOPS_DB_NAME is defined + 'export_date' => date('Y-m-d H:i:s') + ] + ]; + + return json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } catch (\Exception $e) { + error_log("Error exporting configuration: " . $e->getMessage()); + return ''; + } + } + + /** + * Export forms and their elements + * + * @return array Array of form configurations + */ + private function exportForms(): array + { + $forms = []; + $formRows = $this->loadDatabaseConfig('formulize_id'); + + foreach ($formRows as $formRow) { + $form = $this->prepareFormForExport($formRow); + $form['elements'] = $this->exportElementsForForm($formRow['id_form']); + $forms[] = $form; + } + + return $forms; + } + + /** + * Prepare a form row for export + * + * @param array $formRow Raw form data from database + * @return array Prepared form data for export + */ + private function prepareFormForExport(array $formRow): array + { + $preparedForm = []; + $excludedFields = ['on_before_save', 'on_after_save', 'on_delete', 'custom_edit_check', 'defaultform', 'defaultlist']; + foreach ($formRow as $field => $value) { + if (!in_array($field, $excludedFields)) { + $preparedForm[$field] = $value; + } + } + // Remove not needed fields + unset($preparedForm['id_form']); + return $preparedForm; + } + + /** + * Export elements for a specific form + * + * @param int $formId unique form identifier from the database + * @return array Array of element configurations + */ + private function exportElementsForForm(int $formId): array + { + + $dataHandler = new formulizeDataHandler($formId); + $dataTypes = $dataHandler->gatherDataTypes(); + $elements = []; + $elementRows = $this->loadDatabaseConfig('formulize', "id_form = '$formId' ORDER BY ele_order"); + + foreach ($elementRows as $elementRow) { + $elements[] = $this->prepareElementForExport($elementRow, $dataTypes); + } + + return $elements; + } + + /** + * Prepare an element row for export + * + * @param array $elementRow Raw element data from database + * @return array Prepared element data for export + */ + private function prepareElementForExport(array $elementRow, array $dataTypes): array + { + $serializeFields = ['ele_value', 'ele_filtersettings', 'ele_disabledconditions', 'ele_exportoptions']; + $preparedElement = []; + foreach ($elementRow as $field => $value) { + if (in_array($field, $serializeFields)) { + $unserialized = $value !== "" ? @unserialize($value) : []; + if ($field == 'ele_value') { + $preparedElement[$field] = $this->elementValueProcessor->processElementValueForExport($elementRow['ele_type'], $unserialized); + } else { + $preparedElement[$field] = $unserialized; + } + } else { + $preparedElement[$field] = $value; + } + } + $preparedElement['data_type'] = $dataTypes[$elementRow['ele_handle']] ?? 'text'; + // Remove not needed fields + unset($preparedElement['id_form']); + unset($preparedElement['ele_id']); + unset($preparedElement['form_handle']); + return $preparedElement; + } + + /** + * Normalize a value for comparison + * + * @param mixed $value + * @return mixed + */ + private function normalizeValue($value) + { + if (is_string($value) && unserialize($value) !== false) { + $unserialized = unserialize($value); + ksort($unserialized); + return $unserialized; + } + if (is_array($value)) { + return array_values($value); + } + if (is_bool($value)) { + return (int) $value; + } + return (string) $value; + } + + /** + * Strip key from an array + * @param array $configArray + * @param string $key + * @return array + */ + private function stripArrayKey(array $configArray, string $key): array + { + $strippedConfigArray = $configArray; + unset($strippedConfigArray[$key]); + return $strippedConfigArray; + } + + + /** + * Get the database table name for a configuration type + * + * @param string $type + * @return string + */ + private function getTableForType(string $type): string + { + switch ($type) { + case 'forms': + return 'formulize_id'; + case 'elements': + return 'formulize'; + default: + throw new \Exception("Unknown configuration type: {$type}"); + } + } + + /** + * Get the primary key field for a configuration type + * + * @param string $type + * @return string + */ + private function getPrimaryKeyForType(string $type): string + { + switch ($type) { + case 'forms': + return 'form_handle'; + case 'elements': + return 'ele_handle'; + default: + throw new \Exception("Unknown configuration type: {$type}"); + } + } +} diff --git a/modules/formulize/include/formulizeConfigSyncElementValueProcessor.php b/modules/formulize/include/formulizeConfigSyncElementValueProcessor.php new file mode 100644 index 000000000..469754baf --- /dev/null +++ b/modules/formulize/include/formulizeConfigSyncElementValueProcessor.php @@ -0,0 +1,187 @@ + ## +############################################################################### +## This program is free software; you can redistribute it and/or modify ## +## it under the terms of the GNU General Public License as published by ## +## the Free Software Foundation; either version 2 of the License, or ## +## (at your option) any later version. ## +## ## +## You may not change or alter any portion of this comment or credits ## +## of supporting developers from this source code or any supporting ## +## source code which is considered copyrighted (c) material of the ## +## original comment or credit authors. ## +## ## +## 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 General Public License for more details. ## +## ## +## You should have received a copy of the GNU General Public License ## +## along with this program; if not, write to the Free Software ## +## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA ## +############################################################################### +## Author of this file: Formulize Project ## +## Project: Formulize ## +############################################################################### +class FormulizeConfigSyncElementValueProcessor +{ + private $elementMapping = []; + private $textElementMapping = [ + 'width' => 0, + 'maxlength' => 1, + 'default' => 2, + 'regex' => 11, + 'regexdescription' => 3, + 'regexerror' => 5, + 'regexplaceholders' => 6, + 'regexmods' => 10, + 'decimal_places' => 7, + 'thousands_sep' => 8, + 'numbertype' => 4, + ]; + private $checkboxElementMapping = [ + 'options' => 2, + ]; + private $selectElementMapping = [ + 'number_of_rows' => 0, + 'multiple_selections' => 1, + 'options' => 2, + 'groups' => 3, + 'limit_to_user_groups' => 4, + 'filter_conditions' => 5, + 'limit_to_groups_the_current_user_is_a_member_of' => 6, + 'clickable' => 7, + 'autocomplete' => 8, + 'selection_limit' => 9, + 'list_supplied_values_element_id' => 10, + 'export_supplied_values_element_id' => 11, + 'sort_values_element_id' => 12, + 'default_value_entry_id' => 13, + 'show_default_text_when_no_values' => 14 + ]; + private $textareaMapping = [ + 'default_text' => 0, + 'rows' => 1, + 'columns' => 2 + ]; + private $ynradioMapping = [ + 'yes' => '_YES', + 'no' => '_NO', + ]; + private $dateMapping = [ + 'default' => 0 + ]; + private $sliderMapping = [ + 'minValue' => 0, + 'maxValue' => 1, + 'stepSize' => 2, + 'defaultValue' => 3, + ]; + + public function __construct() + { + $this->initializeElementMapping(); + } + /** + * Initialize element mapping + */ + private function initializeElementMapping() + { + $this->elementMapping = [ + 'text' => $this->textElementMapping, + 'textarea' => $this->textareaMapping, + 'checkbox' => $this->checkboxElementMapping, + 'radio' => $this->checkboxElementMapping, + 'yn' => $this->ynradioMapping, + 'select' => $this->selectElementMapping, + 'date' => $this->dateMapping, + 'slider' => $this->sliderMapping, + ]; + } + + /** + * Process the value of an element based on its type from Config to DB format + * + * @param string $eleType + * @param array $configValue + * @param string $dbValue + * @return array + */ + public function processElementValueForImport($eleType, $configValue) + { + // If we don't have a specifc handler for this element type, return the dbArray as is + if (!array_key_exists($eleType, $this->elementMapping)) { + return $configValue; + } + + return $this->importElement($configValue, $this->elementMapping[$eleType]); + } + + /** + * Convert an element from Config to DB format + * + * @param array $configValue + * @param string $dbValue + * @return array + */ + private function importElement($configValue, $mapping) + { + $importArray = []; + + foreach ($configValue as $configKey => $configValue) { + if (array_key_exists($configKey, $mapping)) { + $importArray[$mapping[$configKey]] = $configValue; + } else { + $importArray[$configKey] = $configValue; + } + } + + return $importArray; + } + + /** + * Process the value of an element based on its type from DB to Config format + * + * @param string $eleType + * @param array $configValue + * @param array $dbArray + * @return array + */ + public function processElementValueForExport(string $eleType, array $dbArray) + { + // If we don't have a specifc handler for this element type, return the dbArray as is + if (!array_key_exists($eleType, $this->elementMapping)) { + return $dbArray; + } + return $this->exportElement($dbArray, $this->elementMapping[$eleType]); + } + + /** + * Convert an element from DB to Config format + * + * @param array $dbArray + * @return array + */ + private function exportElement(array $dbArray, $mapping) + { + $exportArray = []; + + $flippedMapping = array_flip($mapping); + + foreach ($dbArray as $dbKey => $dbValue) { + if (array_key_exists($dbKey, $flippedMapping)) { + $exportArray[$flippedMapping[$dbKey]] = $dbValue; + } else { + $exportArray[$dbKey] = $dbValue; + } + } + + return $exportArray; + } +} diff --git a/modules/formulize/templates/admin/config-sync.html b/modules/formulize/templates/admin/config-sync.html new file mode 100644 index 000000000..5586258c3 --- /dev/null +++ b/modules/formulize/templates/admin/config-sync.html @@ -0,0 +1,184 @@ +
    +

    Configuration Synchronization

    + + <{if count($adminPage.success) > 0}> + + <{/if}> + <{if count($adminPage.failure) > 0}> + + <{/if}> + <{if count($adminPage.errors) > 0}> + + <{/if}> + +

    + Here you will see differences between the configuration file and the database. Applying a change will update your database to match what is in the configuration file (This includes deleting forms or elements which are in the database, but in the configuration file). Apply changes by clicking the "Apply Selected" button. If you wish to update the configuration file to match what is in the database you can export a new configuration file by clicking the "Export Configuration" button. This will generate a new file based on the current state of the database. +

    + +
    + + +
    +
    + <{if count($adminPage.changes) == 0}> +

    No changes detected.

    + <{else}> +
    + + + + + + + + + + + + + <{foreach from=$adminPage.changes item=change}> + + + + + + + + <{/foreach}> + +
    SelectTypeOperationIdentifierDetails
    + <{if $change.type == 'forms'}> + + <{elseif $change.type == 'elements'}> + + <{/if}> + <{$change.type|capitalize}><{$change.operation|capitalize}> + <{if $change.type == 'forms'}> + <{$change.data.form_handle}> + <{elseif $change.type == 'elements'}> + <{$change.data.ele_handle}> + <{/if}> + + <{if $change.operation == 'create'}> + Found in the configuration file but not the Database + <{elseif $change.operation == 'update'}> +
      + <{foreach from=$change.differences key=field item=diff}> +
    • + <{$field}>: + <{if $field == 'ele_value' && is_array($diff)}> +
        + <{foreach from=$diff key=subField item=subDiff}> +
      • + <{$subField}>: +
          +
        • Config: <{if isset($subDiff.config_value)}><{$subDiff.config_value}><{else}>(not set)<{/if}>
        • +
        • Database: <{if isset($subDiff.db_value)}><{$subDiff.db_value}><{else}>(not set)<{/if}>
        • +
        +
      • + <{/foreach}> +
      + <{else}> +
        +
      • Config: <{if $diff.config_value !== false && !empty($diff.config_value)}><{$diff.config_value}><{else}>(not set)<{/if}>
      • +
      • Database: <{if $diff.db_value !== false && !empty($diff.db_value)}><{$diff.db_value}><{else}>(not set)<{/if}>
      • +
      + <{/if}> +
    • + <{/foreach}> +
    + <{elseif $change.operation == 'delete'}> + Found in the Database but not in the configuration file + <{/if}> +
    + +
    + <{/if}> +
    + +
    + + diff --git a/modules/formulize/templates/admin/home.html b/modules/formulize/templates/admin/home.html index d7521a3b7..b12092dda 100644 --- a/modules/formulize/templates/admin/home.html +++ b/modules/formulize/templates/admin/home.html @@ -1,66 +1,67 @@ -
    -
    -

    <{$smarty.const._AM_HOME_PREF}>

    -

    Copy Group Permissions

    -

    Synchronize With Another System

    -

    Manage API keys

    - -

    Manage Account Creation Tokens

    -

    Email Users

    -
    -

     

    <{$smarty.const._AM_HOME_NEWFORM}>         Create a new reference to a datatable

    -

    <{$smarty.const._AM_HOME_MANAGEAPP}>

    -
    -<{php}>print $GLOBALS['xoopsSecurity']->getTokenHTML()<{/php}> - - - - - - -<{include file="db:admin/ui-accordion.html" sectionTemplate="db:admin/home_sections.html" sections=$adminPage.apps}> -
    -
    - - \ No newline at end of file +
    +
    +

    <{$smarty.const._AM_HOME_PREF}>

    +

    Copy Group Permissions

    +

    Synchronize With Another System

    +

    Configuration Synchronization

    +

    Manage API keys

    + +

    Manage Account Creation Tokens

    +

    Email Users

    +
    +

     

    <{$smarty.const._AM_HOME_NEWFORM}>         Create a new reference to a datatable

    +

    <{$smarty.const._AM_HOME_MANAGEAPP}>

    +
    +<{php}>print $GLOBALS['xoopsSecurity']->getTokenHTML()<{/php}> + + + + + + +<{include file="db:admin/ui-accordion.html" sectionTemplate="db:admin/home_sections.html" sections=$adminPage.apps}> +
    +
    + + diff --git a/modules/formulize/xoops_version.php b/modules/formulize/xoops_version.php index f24c64217..6926ceb84 100644 --- a/modules/formulize/xoops_version.php +++ b/modules/formulize/xoops_version.php @@ -649,8 +649,11 @@ $modversion['templates'][] = array( 'file' => 'admin/synchronize.html', 'description' => ''); + $modversion['templates'][] = array( + 'file' => 'admin/synchronize_sections.html', + 'description' => ''); $modversion['templates'][] = array( - 'file' => 'admin/synchronize_sections.html', + 'file' => 'admin/config-sync.html', 'description' => ''); $modversion['templates'][] = array( 'file' => 'admin/sync_import.html',