From 52fbe3397c952156e4d3183a0035aa7fd746dba4 Mon Sep 17 00:00:00 2001 From: Marc Egger <marc.egger@uzh.ch> Date: Tue, 6 Oct 2020 13:10:21 +0200 Subject: [PATCH] Form As File: add backup whenever a sync happens --- extension/Classes/Core/Form/FormAsFile.php | 217 +++++++++++++----- extension/Classes/Core/Helper/HelperFile.php | 3 + extension/Classes/Core/Helper/Path.php | 7 +- .../Classes/Core/Report/ReportAsFile.php | 10 +- 4 files changed, 174 insertions(+), 63 deletions(-) diff --git a/extension/Classes/Core/Form/FormAsFile.php b/extension/Classes/Core/Form/FormAsFile.php index d8aba61ab..f3015a02b 100644 --- a/extension/Classes/Core/Form/FormAsFile.php +++ b/extension/Classes/Core/Form/FormAsFile.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace IMATHUZH\Qfq\Core\Form; use IMATHUZH\Qfq\Core\Database\Database; +use IMATHUZH\Qfq\Core\Exception\Thrower; use IMATHUZH\Qfq\Core\Helper\HelperFile; use IMATHUZH\Qfq\Core\Helper\OnString; use IMATHUZH\Qfq\Core\Helper\Path; @@ -16,10 +17,10 @@ class FormAsFile * Remove the form from the DB and insert it using the form file. (only if the form file was changed) * If the form file can't be read, then the form is deleted from the DB and an exception is thrown. * - * Form file location: SYSTEM_FORM_FILE_PATH - * Recognize form file change: Compare the current file stats with the ones saved in the Form table. - * Container References: The form file uses names instead if ids to reference container formElements. These references are translated when the formElements are inserted. - * Ignored keys: The keys 'id', 'formId', 'feIdContainer' are ignored when reading the form file. + * - Form file location: SYSTEM_FORM_FILE_PATH + * - Recognize form file change: Compare the current file stats with the ones saved in the Form table. + * - Container References: The form file uses names instead if ids to reference container formElements. These references are translated when the formElements are inserted. + * - Ignored keys: The keys 'id', 'formId', 'feIdContainer' are ignored when reading the form file. * * @param string $formName * @param Database $database @@ -108,67 +109,31 @@ class FormAsFile } /** - * Reads the form from the DB and saves it in the form folder as a form file. - * Warning: Overwrites form file without any checks or warning. + * Reads the form from the database and saves it in the form folder as a form file. + * If $formId is given, then $formName is ignored. * If the form file path does not exist, it is created and all forms are exported. * - * FileStats: After the export the column "fileStats" is updated with new stats from new file. - * Container FormElements: The form file uses names instead if ids to reference containers. These references are translated before saving the file. - * Ignored columns: The columns 'id', 'name' (only Form table), 'fileStats', 'formId', 'feIdContainer' are not saved in the file. - * FormElement order: The formElements are ordered by the 'ord' column before writing them to the file. + * ! Warning: Overwrites form file without any checks or warning. + * + * - FileStats: After the export the column "fileStats" is updated with new stats from new file. + * - Container FormElements: The form file uses names instead if ids to reference containers. These references are translated before saving the file. + * - Ignored columns: The columns 'id', 'name' (only Form table), 'fileStats', 'formId', 'feIdContainer' are not saved in the file. + * - FormElement order: The formElements are ordered by the 'ord' column before writing them to the file. * * @param string $formName * @param Database $database + * @param int|null $formId if given, then $formName is ignored * @throws \CodeException * @throws \DbException * @throws \UserFormException */ - public static function exportForm(string $formName, Database $database) // : void + public static function exportForm(string $formName, Database $database, ?int $formId = null) // : void { - // Get form from DB - list($sql, $parameterArray) = SqlQuery::selectFormByName($formName); - $form = $database->sql($sql, ROW_EXPECT_1, - $parameterArray, 'Form "' . $formName . '" not found or multiple forms with the same name.'); // array(column name => value) - - // Remove columns: id, name, fileStats - $formId = $form[F_ID]; - unset($form[F_ID]); - unset($form[F_NAME]); - unset($form[F_FILE_STATS]); - - // Get formElements from DB - list($sql, $parameterArray) = SqlQuery::selectFormElementById($formId); - $formElements = $database->sql($sql, ROW_REGULAR, $parameterArray); // array(array(column name => value)) - - // Translate container references (id to name) and remove all id columns - $containerNames = array_reduce($formElements, function ($result, $formElement) { - if ($formElement[FE_CLASS] === FE_CLASS_CONTAINER) { - $containerName = $formElement[FE_NAME]; - if (in_array($containerName, $result) || $containerName === '') { - throw new \UserFormException(json_encode([ - ERROR_MESSAGE_TO_USER => 'Duplicate container names.', - ERROR_MESSAGE_TO_DEVELOPER => "Container Form Elements must have a unique and nonempty name. Container name: '$containerName'."]), - ERROR_FORM_INVALID_NAME); - } - $result[$formElement[FE_ID]] = $containerName; - } - return $result; - }, []); // array(id => name) - $formElements = array_map(function ($formElement) use ($containerNames) { - $containerId = $formElement[FE_ID_CONTAINER]; - if ($containerId !== 0) { - $formElement[FE_FILE_CONTAINER_NAME] = $containerNames[$containerId]; - } - unset($formElement[FE_ID_CONTAINER]); - unset($formElement[FE_ID]); - unset($formElement[FE_FORM_ID]); - return $formElement; - }, $formElements); + list($formName, $formId, $formJson) = self::formToJson($formName, $database, $formId); - // write form as JSON to file - $form[F_FILE_FORM_ELEMENT] = $formElements; - $formJson = json_encode($form, JSON_PRETTY_PRINT); + // backup and write file $pathFileName = self::formPathFileName($formName, $database); + self::backupFormFile($pathFileName); HelperFile::file_put_contents($pathFileName, $formJson); // Update column fileStats @@ -177,6 +142,30 @@ class FormAsFile $database->sql($sql, ROW_REGULAR, $parameterArray); } + /** + * Create copy of given form file in form/backup. If given file does not exist, do nothing. + * New file name: <formName>.YYYMMDDhhmmss.file.json + * + * @param string $pathFileName + * @throws \UserFormException + */ + private static function backupFormFile(string $pathFileName) + { + if (file_exists($pathFileName)) + { + if (!is_readable($pathFileName)) { + Thrower::userFormException('Error while trying to backup form file.', "Form file is not readable: $pathFileName"); + } + + // copy file + $cwdToBackupFile = self::newBackupPathFileName(basename($pathFileName, '.json'), 'file'); + $success = copy($pathFileName, $cwdToBackupFile); + if ($success === false) { + Thrower::userFormException('Error while trying to backup form file.', "Can't copy file $pathFileName to $cwdToBackupFile"); + } + } + } + /** * Import Form from file if loaded record is Form/FormElement. * If form file was changed, import it and throw exception. @@ -219,6 +208,7 @@ class FormAsFile self::enforceFormFileWritable($formName, $database); $pathFileName = self::formPathFileName($formName, $database); if(file_exists($pathFileName)) { + self::backupFormFile($pathFileName); $success = unlink($pathFileName); if ($success === false) { throw new \UserFormException(json_encode([ @@ -310,7 +300,7 @@ class FormAsFile */ public static function isFormQuery(string $sql): bool { - // find substrings whihch start with FROM and are followed by Form or FormElement + // find substrings which start with FROM and are followed by Form or FormElement preg_match_all('/(?i)FROM(?-i)(.*?)\b(' . TABLE_NAME_FORM . '|' . TABLE_NAME_FORM_ELEMENT . ')\b/s', $sql, $matches); // Check if no other SQL keywords are in between FROM and the table name @@ -441,12 +431,32 @@ class FormAsFile */ private static function deleteFormDBWithId(int $formId, Database $database) // : void { + self::backupFormDb($formId, $database); $F_ID = F_ID; // can't use constants in strings directly $TABLE_NAME_FORM = TABLE_NAME_FORM; // can't use constants in strings directly $database->sql("DELETE FROM `$TABLE_NAME_FORM` WHERE `$F_ID`=? LIMIT 1", ROW_REGULAR, [$formId]); self::deleteFormElementsDBWithFormId($formId, $database); } + /** + * Export the given form from database into a backup file. + * Backup file name: <formName>.YYYMMDDhhmmss.db.json + * + * @param int $formId + * @param Database $database + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + */ + private static function backupFormDb(int $formId, Database $database) + { + list($formName, $formId, $formJson) = self::formToJson('', $database, $formId); + $cwdToBackupFile = self::newBackupPathFileName($formName, 'db'); + HelperFile::file_put_contents($cwdToBackupFile, $formJson); + } + + + /** * Delete form elements with given formId from DB * @@ -472,6 +482,7 @@ class FormAsFile */ private static function formFileStatsJson(string $pathFileName) { + clearstatcache (true, $pathFileName); $stats = stat($pathFileName); if ($stats === false) { return false; @@ -579,4 +590,102 @@ class FormAsFile return $result; }, []); } + + /** + * Return json string of the given form with form-elements. If $formId is given, $formName is ignored. + * + * - Container FormElements: The form file uses names instead if ids to reference containers. These references are translated before saving the file. + * - Ignored columns: The columns 'id', 'name' (only Form table), 'fileStats', 'formId', 'feIdContainer' are not added to the json output. + * - FormElement order: The formElements are ordered by the 'ord' column before writing them to the file. + * + * @param string $formName + * @param Database $database + * @param int|null $formId If given, $formName is ignored + * @return array + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + */ + private static function formToJson(string $formName, Database $database, ?int $formId = null): array + { + // Get form from DB + if ($formId !== null) { + list($sql, $parameterArray) = SqlQuery::selectFormById($formId); + $form = $database->sql($sql, ROW_EXPECT_1, + $parameterArray, "Form with id $formId not found."); // array(column name => value) + $formName = $form[F_NAME]; + } else { + list($sql, $parameterArray) = SqlQuery::selectFormByName($formName); + $form = $database->sql($sql, ROW_EXPECT_1, + $parameterArray, "Form $formName not found or multiple forms with the same name."); // array(column name => value) + } + + // Remove columns: id, name, fileStats + $formId = $form[F_ID]; + unset($form[F_ID]); + unset($form[F_NAME]); + unset($form[F_FILE_STATS]); + + // Get formElements from DB + list($sql, $parameterArray) = SqlQuery::selectFormElementById($formId); + $formElements = $database->sql($sql, ROW_REGULAR, $parameterArray); // array(array(column name => value)) + + // Translate container references (id to name) and remove all id columns + $containerNames = array_reduce($formElements, function ($result, $formElement) { + if ($formElement[FE_CLASS] === FE_CLASS_CONTAINER) { + $containerName = $formElement[FE_NAME]; + if (in_array($containerName, $result) || $containerName === '') { + throw new \UserFormException(json_encode([ + ERROR_MESSAGE_TO_USER => 'Duplicate container names.', + ERROR_MESSAGE_TO_DEVELOPER => "Container Form Elements must have a unique and nonempty name. Container name: '$containerName'."]), + ERROR_FORM_INVALID_NAME); + } + $result[$formElement[FE_ID]] = $containerName; + } + return $result; + }, []); // array(id => name) + $formElements = array_map(function ($formElement) use ($containerNames) { + $containerId = $formElement[FE_ID_CONTAINER]; + if ($containerId !== 0) { + $formElement[FE_FILE_CONTAINER_NAME] = $containerNames[$containerId]; + } + unset($formElement[FE_ID_CONTAINER]); + unset($formElement[FE_ID]); + unset($formElement[FE_FORM_ID]); + return $formElement; + }, $formElements); + + // add form elements and create json + $form[F_FILE_FORM_ELEMENT] = $formElements; + $formJson = json_encode($form, JSON_PRETTY_PRINT); + return array($formName, $formId, $formJson); + } + + /** + * Return the path to a (non-existing) form backup file with name: + * <formName>.YYYMMDDhhmmss.<tag>.json + * + * @param string $formName + * @param string $tag + * @return string + * @throws \UserFormException + */ + private static function newBackupPathFileName(string $formName, string $tag): string + { + // create backup path if not exists + $cwdToBackup = Path::cwdToProject(Path::PROJECT_TO_FORM, Path::FORM_TO_FORM_BACKUP); + if (!is_dir($cwdToBackup)) { + $success = mkdir($cwdToBackup, 0777, true); + if ($success === false) { + Thrower::userFormException('Error while trying to backup form file.', "Can't create backup path: $cwdToBackup"); + } + } + + // throw exception if backup file exists + $cwdToBackupFile = Path::join($cwdToBackup, $formName . '.' . date('YmdHis') . ".$tag.json"); + if (file_exists($cwdToBackupFile)) { + Thrower::userFormException('Error while trying to backup form file.', "Backup file already exists: $cwdToBackupFile"); + } + return $cwdToBackupFile; + } } \ No newline at end of file diff --git a/extension/Classes/Core/Helper/HelperFile.php b/extension/Classes/Core/Helper/HelperFile.php index 8622218ae..a64919e94 100644 --- a/extension/Classes/Core/Helper/HelperFile.php +++ b/extension/Classes/Core/Helper/HelperFile.php @@ -550,6 +550,7 @@ class HelperFile { /** * Wrapper for file_put_contents which throws exception on failure. + * Clear the stat cache to make sure that stat(), file_exists(),... etc. return current data. * * @param $pathFileName * @param $content @@ -564,6 +565,7 @@ class HelperFile { ERROR_MESSAGE_TO_DEVELOPER => "Can't write to file '$pathFileName'"]), ERROR_IO_WRITE_FILE); } + clearstatcache (true, $pathFileName); } /** @@ -636,6 +638,7 @@ class HelperFile { /** * Wrapper for is_readable() but throws exception if file/directory exists but is not readable. + * Returns false if file does not exist. * * @param $pathFileName * @return bool diff --git a/extension/Classes/Core/Helper/Path.php b/extension/Classes/Core/Helper/Path.php index 19fa8cab3..979923030 100644 --- a/extension/Classes/Core/Helper/Path.php +++ b/extension/Classes/Core/Helper/Path.php @@ -33,14 +33,14 @@ class Path // API const EXT_TO_API = 'Classes/Api'; - const API_TO_APP = '../../../../../'; // TODO: not relative to ext + const API_TO_APP = '../../../../../'; // TODO: make relatvie to ext instead // Icons const EXT_TO_GFX_INFO_FILE = 'Resources/Public/icons/note.gif'; const EXT_TO_PATH_ICONS = 'Resources/Public/icons'; // Annotate - const EXT_TO_HIGHLIGHT_JSON_DIR = 'Resources/Public/Json'; + const EXT_TO_HIGHLIGHT_JSON_DIR = 'Resources/Public/Json'; // TODO: refactor: remove DIR at the end // Twig const EXT_TO_TWIG_TEMPLATES = 'Resources/Public/twig_templates'; @@ -50,13 +50,14 @@ class Path private const APP_TO_PROJECT_DEFAULT = '../'; private const APP_TO_PROJECT_MIGRATE = 'fileadmin/protected/qfqProject'; const PROJECT_TO_FORM = 'form'; + const FORM_TO_FORM_BACKUP = '_backup'; const PROJECT_DIR_TO_REPORT = 'report'; // Config const APP_TO_TYPO3_CONF = 'typo3conf'; // Log files - private static $cwdToLog = null; // TODO: does it make sense to have it rel to CWD? to broad? + private static $cwdToLog = null; private static $overloadAbsoluteQfqLogFile = null; private static $overloadAbsoluteMailLogFile = null; private static $overloadAbsoluteSqlLogFile = null; diff --git a/extension/Classes/Core/Report/ReportAsFile.php b/extension/Classes/Core/Report/ReportAsFile.php index d274db6f4..130121d0f 100644 --- a/extension/Classes/Core/Report/ReportAsFile.php +++ b/extension/Classes/Core/Report/ReportAsFile.php @@ -16,9 +16,7 @@ class ReportAsFile * * @param string $bodyText * @return string|null pathFileName of report relative to CWD or null - * @throws \CodeException * @throws \UserFormException - * @throws \UserReportException */ public static function parseFileKeyword(string $bodyText) // : ?string { @@ -52,7 +50,7 @@ class ReportAsFile * Create new file named after the tt_content header or the UID if not defined. * - Invalid characters are stripped from filename. * - If file exists, throw exception. - * - The path of the file is derived the typo3 page structure (use page alias if exists, else page name). + * - The path of the file is derived from the typo3 page structure (use page alias if exists, else page name). * - The path is created if not exists. * * @param int $uid @@ -84,7 +82,7 @@ class ReportAsFile if (strlen($reportPath) > 4096) { // Throw exception in case of infinite loop. throw new \UserReportException(json_encode([ - ERROR_MESSAGE_TO_USER => "Path too long.", + ERROR_MESSAGE_TO_USER => "Exporting report to file failed.", ERROR_MESSAGE_TO_DEVELOPER => "Report path too long: '$reportPath'"]), ERROR_FORM_NOT_FOUND); } @@ -104,8 +102,8 @@ class ReportAsFile // if file exists, throw exception if (file_exists($reportPathFileNameFull)) { throw new \UserReportException(json_encode([ - ERROR_MESSAGE_TO_USER => "Writing file failed.", - ERROR_MESSAGE_TO_DEVELOPER => "Can't export report to file. File already exists: '$reportPathFileNameFull'"]), + ERROR_MESSAGE_TO_USER => "Exporting report to file failed.", + ERROR_MESSAGE_TO_DEVELOPER => "File already exists: '$reportPathFileNameFull'"]), ERROR_FORM_NOT_FOUND); } -- GitLab