diff --git a/src/controllers/crud/WidgetTemplateController.php b/src/controllers/crud/WidgetTemplateController.php index 18c4a6e..4ede74d 100644 --- a/src/controllers/crud/WidgetTemplateController.php +++ b/src/controllers/crud/WidgetTemplateController.php @@ -3,11 +3,16 @@ namespace hrzg\widget\controllers\crud; use hrzg\widget\assets\WidgetAsset; -use hrzg\widget\models\crud\WidgetTemplate; -use yii\helpers\Url; +use hrzg\widget\exceptions\WidgetTemplateCreateException; +use hrzg\widget\helpers\WidgetTemplateExport; +use hrzg\widget\models\WidgetTemplateImport; +use yii\web\HttpException; +use yii\web\Response; +use yii\web\UploadedFile; /** * Class WidgetTemplateController + * * @package hrzg\widget\controllers\crud * @author Christopher Stebe */ @@ -18,4 +23,62 @@ public function beforeAction($action) WidgetAsset::register($this->view); return parent::beforeAction($action); } + + /** + * Export given widget template + * + * @param string $id + * @return void + * @throws \yii\base\ErrorException + * @throws \yii\base\ExitException + * @throws \yii\web\HttpException + */ + public function actionExport($id) + { + $model = $this->findModel($id); + + $export = new WidgetTemplateExport([ + 'widgetTemplate' => $model + ]); + + if ($export->generateTar() === false) { + throw new HttpException(500, \Yii::t('widgets', 'Error while exporting widget template')); + } + + if (\Yii::$app->getResponse()->sendFile($export->getTarFilePath()) instanceof Response) { + unlink($export->getTarFilePath()); + if (!$export->cleanupTmpDirectory()) { + \Yii::$app->getSession()->addFlash('info', \Yii::t('widgets', 'Error while creating temporary export directory')); + } + } else { + throw new HttpException(500, \Yii::t('widgets', 'Error while downloading widget template')); + } + \Yii::$app->end(); + } + + public function actionImport() + { + + $model = new WidgetTemplateImport(); + if (\Yii::$app->request->isPost) { + $model->tarFiles = UploadedFile::getInstances($model, 'tarFiles'); + if ($model->uploadAndImport()) { + if (!$model->getTmpDirectoryWasRemoved()) { + \Yii::$app->getSession()->addFlash('info', \Yii::t('widgets', 'Error while creating temporary import directory')); + } + \Yii::$app->getSession()->addFlash('success', \Yii::t('widgets', 'Import was successful')); + return $this->refresh(); + } + } + + $this->view->title = \Yii::t('widgets','Import'); + $this->view->params['breadcrumbs'][]= [ + 'label' => \Yii::t('widgets','Widget Templates'), + 'url' => ['index'] + ]; + $this->view->params['breadcrumbs'][]= $this->view->title; + return $this->render('import', [ + 'model' => $model + ]); + } } diff --git a/src/helpers/WidgetTemplateExport.php b/src/helpers/WidgetTemplateExport.php new file mode 100644 index 0000000..fb618c5 --- /dev/null +++ b/src/helpers/WidgetTemplateExport.php @@ -0,0 +1,172 @@ +_widgetTemplate; + } + + /** + * @param WidgetTemplate $widgetTemplate + */ + public function setWidgetTemplate(WidgetTemplate $widgetTemplate): void + { + $this->_widgetTemplate = $widgetTemplate; + } + + /** + * @return void + * @throws \yii\base\Exception if the export directory could not be created due to an php error + * @throws \yii\base\InvalidConfigException if either the export directory is not set or the export directory cannot + * be created due to permission errors or the widget template is configured incorrectly + */ + public function init() + { + parent::init(); + + // Check if instance of widget template is not a new record or a new instance + if ($this->widgetTemplate instanceof WidgetTemplate && $this->widgetTemplate->getIsNewRecord() === true) { + throw new InvalidConfigException('Widget template must be saved at least once'); + } + + // Set tar name to widget template name if not set + if (empty($this->tarFileName)) { + $this->tarFileName = Inflector::slug($this->getWidgetTemplate()->name) . '.tar'; + } + + $this->_exportDirectory = \Yii::getAlias($this->baseExportDirectory . DIRECTORY_SEPARATOR . uniqid('widget-template-export', false)); + } + + /** + * Absolute file path to the tar file + * + * @return string + */ + public function getTarFilePath(): string + { + return $this->_exportDirectory . DIRECTORY_SEPARATOR . $this->tarFileName; + } + + /** + * Generate a tar file with the following contents + * - template file (twig): the content from the twig_template attribute of the given widget template + * - schema file (json): the content from the twig_template attribute of the given widget template + * - meta file (json): this contains some information about the export + * + * @return bool + * @throws \yii\base\Exception + * @throws \yii\base\InvalidConfigException + */ + public function generateTar(): bool + { + + // Create export directory if not exists + if (FileHelper::createDirectory($this->_exportDirectory) === false) { + throw new InvalidConfigException("Error while creating directory at: $this->_exportDirectory"); + } + + // Remove existing tar file + if (is_file($this->getTarFilePath()) && unlink($this->getTarFilePath()) === false) { + return false; + } + + // Create the tar archive + $phar = new \PharData($this->getTarFilePath()); + // Add files by string + $phar->addFromString(self::TEMPLATE_FILE, $this->getWidgetTemplate()->twig_template); + $phar->addFromString(self::SCHEMA_FILE, $this->getWidgetTemplate()->json_schema); + $phar->addFromString(self::META_FILE, $this->metaFileContent()); + + return true; + } + + + /** + * @return bool + * @throws \yii\base\ErrorException + */ + public function cleanupTmpDirectory(): bool + { + FileHelper::removeDirectory($this->_exportDirectory); + return !is_dir($this->_exportDirectory); + } + + /** + * Generate json file content based on the value of the widget template $json_schema property + * + * @return string + */ + protected function metaFileContent(): string + { + $data = [ + 'id' => $this->getWidgetTemplate()->id, + 'name' => $this->getWidgetTemplate()->name, + 'php_class' => $this->getWidgetTemplate()->php_class, + 'created_at' => $this->getWidgetTemplate()->created_at, + 'updated_at' => $this->getWidgetTemplate()->updated_at, + 'exported_at' => date('Y-m-d H:i:s'), + 'download_url' => \Yii::$app->getRequest()->getAbsoluteUrl() + ]; + return Json::encode($data); + } + +} diff --git a/src/models/WidgetTemplateImport.php b/src/models/WidgetTemplateImport.php new file mode 100644 index 0000000..8d29017 --- /dev/null +++ b/src/models/WidgetTemplateImport.php @@ -0,0 +1,279 @@ +_importDirectory = \Yii::getAlias($this->baseImportDirectory . DIRECTORY_SEPARATOR . uniqid('widget-template-import', + false)); + } + + public function rules() + { + return [ + ['tarFiles', 'file', 'skipOnEmpty' => false, 'extensions' => 'tar', 'maxFiles' => 20] + ]; + } + + /** + * @return bool + * @throws \yii\base\Exception + * @throws \yii\base\InvalidConfigException + */ + protected function upload(): bool + { + if ($this->validate()) { + + // Create export directory if not exists + if (FileHelper::createDirectory($this->_importDirectory) === false) { + throw new InvalidConfigException("Error while creating directory at: $this->_importDirectory"); + } + foreach ($this->tarFiles as $file) { + $filename = $this->_importDirectory . DIRECTORY_SEPARATOR . uniqid($file->baseName, + false) . '.' . $file->extension; + if ($file->saveAs($filename) === false) { + $this->addError('tarFiles', \Yii::t('widgets', 'Error while uploading file')); + } else { + $this->_filenames[] = $filename; + } + } + return true; + } + + return false; + } + + /** + * @return array + */ + public function attributeLabels() + { + $attributeLabels = parent::attributeLabels(); + $attributeLabels['tarFiles'] = \Yii::t('widgets', 'Files'); + return $attributeLabels; + } + + /** + * @param string $filepath + * @param string $extractDirectory + * @return bool + */ + protected function extractTar(string $filepath, string $extractDirectory): bool + { + return (new \PharData($filepath))->extractTo($extractDirectory); + } + + /** + * @return bool + */ + protected function extractFiles(): bool + { + foreach ($this->_filenames as $filename) { + if ($this->extractTar($filename, $this->getExtractDirectory($filename)) === false) { + $this->addError('tarFiles', \Yii::t('widgets', 'Error while extracting file')); + return false; + } + } + return true; + } + + /** + * @param string $filename + * @return string + */ + protected function getExtractDirectory(string $filename): string + { + return $this->_importDirectory . DIRECTORY_SEPARATOR . Inflector::slug(basename($filename)); + } + + /** + * @return bool + */ + protected function import(): bool + { + $transaction = WidgetTemplate::getDb()->beginTransaction(); + try { + if ($transaction && $transaction->getIsActive()) { + foreach ($this->_filenames as $filename) { + if (! $this->importTemplateByDirectory($filename)) { + throw new \Exception('Error while importing files'); + } + } + } + $transaction->commit(); + return true; + } catch (\Exception $e) { + \Yii::error($e->getMessage()); + $this->addError('tarFiles', $e->getMessage()); + } + $transaction->rollBack(); + return false; + } + + /** + * @param string $filename + * @return bool + */ + protected function importTemplateByDirectory(string $filename): bool + { + $extractDirectory = $this->getExtractDirectory($filename); + $meta = $this->templateMeta($extractDirectory); + $template = $this->templateContent($extractDirectory); + $schema = $this->schemaContent($extractDirectory); + + $model = new WidgetTemplate([ + 'name' => $meta['name'], + 'php_class' => $meta['php_class'], + 'twig_template' => $template, + 'json_schema' => $schema + ]); + + if ($model->save() === false) { + $this->addError('tarFiles', print_r($model->getErrors(), true)); + return false; + } + return true; + } + + /** + * @param string $extractDirectory + * @return array + */ + protected function templateMeta(string $extractDirectory): array + { + $metaFilename = $extractDirectory . DIRECTORY_SEPARATOR . WidgetTemplateExport::META_FILE; + if (is_file($metaFilename)) { + $content = file_get_contents($metaFilename); + try { + $data = Json::decode($content); + + return [ + 'name' => $data['name'] ?? basename($extractDirectory), + 'php_class' => $data['php_class'] ?? TwigTemplate::class + ]; + } catch (InvalidArgumentException $e) { + \Yii::error($e->getMessage()); + } + } + return [ + 'name' => basename($extractDirectory), + 'php_class' => TwigTemplate::class + ]; + } + + /** + * @param string $extractDirectory + * @return string + */ + protected function templateContent(string $extractDirectory): string + { + $templateFilename = $extractDirectory . DIRECTORY_SEPARATOR . WidgetTemplateExport::TEMPLATE_FILE; + $fallbackTemplate = '{# not set #}'; + if (is_file($templateFilename)) { + return file_get_contents($templateFilename) ?: $fallbackTemplate; + } + + return $fallbackTemplate; + } + + /** + * @param string $extractDirectory + * @return string + */ + protected function schemaContent(string $extractDirectory): string + { + $schemaFilename = $extractDirectory . DIRECTORY_SEPARATOR . WidgetTemplateExport::SCHEMA_FILE; + $fallbackSchema = '{}'; + if (is_file($schemaFilename)) { + return file_get_contents($schemaFilename) ?: $fallbackSchema; + } + + return $fallbackSchema; + } + + /** + * @return bool + * @throws \yii\base\ErrorException + * @throws \yii\db\Exception + */ + public function uploadAndImport(): bool + { + if ($this->upload() && $this->extractFiles() && $this->import()) { + if ($this->cleanupTmpDirectory()) { + $this->_tmpDirectoryWasRemoved = true; + } + return true; + } + return false; + } + + /** + * @return bool + * @throws \yii\base\ErrorException + */ + protected function cleanupTmpDirectory(): bool + { + FileHelper::removeDirectory($this->_importDirectory); + return !is_dir($this->_importDirectory); + } + + /** + * @return bool + */ + public function getTmpDirectoryWasRemoved(): bool + { + return $this->_tmpDirectoryWasRemoved; + } +} diff --git a/src/models/crud/WidgetTemplate.php b/src/models/crud/WidgetTemplate.php index 1edc633..1db6d14 100644 --- a/src/models/crud/WidgetTemplate.php +++ b/src/models/crud/WidgetTemplate.php @@ -2,17 +2,18 @@ namespace hrzg\widget\models\crud; +use bedezign\yii2\audit\AuditTrailBehavior; use hrzg\widget\models\crud\base\WidgetTemplate as BaseWidgetTemplate; use Yii; -use yii\base\InvalidParamException; +use yii\base\InvalidArgumentException; use yii\behaviors\TimestampBehavior; use yii\caching\TagDependency; use yii\db\Expression; -use yii\helpers\ArrayHelper; use yii\helpers\Json; /** * Class WidgetTemplate + * * @package hrzg\widget\models\crud */ class WidgetTemplate extends BaseWidgetTemplate @@ -22,18 +23,15 @@ class WidgetTemplate extends BaseWidgetTemplate */ public function behaviors() { - return ArrayHelper::merge( - parent::behaviors(), - [ - 'timestamp' => [ - 'class' => TimestampBehavior::className(), - 'value' => new Expression('NOW()'), - ], - 'audit' => [ - 'class' => 'bedezign\yii2\audit\AuditTrailBehavior' - ] - ] - ); + $behaviors = parent::behaviors(); + $behaviors['timestamp'] = [ + 'class' => TimestampBehavior::class, + 'value' => new Expression('NOW()'), + ]; + $behaviors['audit'] = [ + 'class' => AuditTrailBehavior::class + ]; + return $behaviors; } /** @@ -41,26 +39,23 @@ public function behaviors() */ public function rules() { - return ArrayHelper::merge( - parent::rules(), - [ - [ - ['name'], - 'unique' - ], - [ - 'json_schema', - function ($attribute, $params) { - try { - Json::decode($this->$attribute); - } catch (InvalidParamException $e) { - $this->addError($attribute, 'Invalid JSON: '.$e->getMessage()); - } - }, - ], - - ] - ); + $rules = parent::rules(); + $rules[] = [ + 'name', + 'unique' + ]; + $rules[] = [ + 'json_schema', + function ($attribute) { + try { + Json::decode($this->$attribute); + } catch (InvalidArgumentException $e) { + $this->addError($attribute, \Yii::t('widgets', 'Invalid JSON: {exceptionMessage}', + ['exceptionMessage' => $e->getMessage()])); + } + }, + ]; + return $rules; } /** @@ -78,6 +73,7 @@ public function optPhpClass() /** * Format json_schema before saving to database + * * @param bool $insert * * @return bool @@ -88,12 +84,18 @@ public function beforeSave($insert) { return parent::beforeSave($insert); } + /** + * @inheritdoc + */ public function afterSave($insert, $changedAttributes) { parent::afterSave($insert, $changedAttributes); TagDependency::invalidate(\Yii::$app->cache, 'widgets'); } + /** + * @inheritdoc + */ public function afterDelete() { parent::afterDelete(); diff --git a/src/models/crud/search/WidgetContentTranslation.php b/src/models/crud/search/WidgetContentTranslation.php index 1f340a5..e533429 100644 --- a/src/models/crud/search/WidgetContentTranslation.php +++ b/src/models/crud/search/WidgetContentTranslation.php @@ -24,6 +24,7 @@ public function rules() [ [ 'widget_content_id', + 'default_properties_json', 'language', 'access_owner', 'access_domain', diff --git a/src/models/crud/search/WidgetTemplate.php b/src/models/crud/search/WidgetTemplate.php index 5a35af1..c782279 100644 --- a/src/models/crud/search/WidgetTemplate.php +++ b/src/models/crud/search/WidgetTemplate.php @@ -19,7 +19,7 @@ public function rules() { return [ [['id'], 'integer'], - [['name', 'json_schema', 'twig_template', 'created_at', 'updated_at'], 'safe'], + [['name', 'php_class', 'json_schema', 'twig_template', 'created_at', 'updated_at'], 'safe'], ]; } diff --git a/src/views/crud/widget-template/import.php b/src/views/crud/widget-template/import.php new file mode 100644 index 0000000..2207b35 --- /dev/null +++ b/src/views/crud/widget-template/import.php @@ -0,0 +1,34 @@ + +

+ + title; ?> +

+
+
+ field($model, 'tarFiles[]')->fileInput(['multiple' => true, 'accept' => 'application/x-tar','class' => 'form-control']); + ?> +
+ 'btn btn-primary']) ?> +
+ +
+
+ '.\Yii::t('widgets', 'New'), ['create'], ['class' => 'btn btn-success']) ?> + getUser()->can('widgets_crud_widget-template_import')): ?> +
+ '.\Yii::t('widgets', 'Import'), ['import'], + ['class' => 'btn btn-info']) ?> +
+
@@ -49,7 +54,12 @@ 'columns' => [ [ 'class' => 'yii\grid\ActionColumn', - 'template' => '{view} {update} {delete}', + 'template' => '{view} {update} {export} {delete}', + 'buttons' => [ + 'export' => function ($url) { + return Html::a('', $url,['data-pjax' => 0]); + } + ], 'urlCreator' => function ($action, $model, $key, $index) { // using the column name as key, not mapping to 'id' like the standard generator $params = is_array($key) ? $key : [$model->primaryKey()[0] => (string) $key]; @@ -58,6 +68,9 @@ return Url::toRoute($params); }, 'contentOptions' => ['nowrap' => 'nowrap'], + 'visibleButtons' => [ + 'export' => Yii::$app->getUser()->can('widgets_crud_widget-template_export') + ] ], 'name', 'php_class', diff --git a/src/views/crud/widget-template/view.php b/src/views/crud/widget-template/view.php index cec8548..c275acc 100644 --- a/src/views/crud/widget-template/view.php +++ b/src/views/crud/widget-template/view.php @@ -28,6 +28,15 @@ ['update', 'id' => $model->id], ['class' => 'btn btn-info'] ) ?> + getUser()->can('widgets_crud_widget-template_export')) { + echo Html::a( + ' ' . Yii::t('widgets', 'Export'), + ['export', 'id' => $model->id], + ['class' => 'btn btn-warning'] + ); + } + ?> ' . Yii::t('widgets', 'Delete'), ['delete', 'id' => $model->id], diff --git a/src/views/default/index.php b/src/views/default/index.php index 2ae8a8e..2dd80b4 100644 --- a/src/views/default/index.php +++ b/src/views/default/index.php @@ -129,6 +129,13 @@ + getUser()->can('widgets_crud_widget-template_import')) { + echo Html::a(FA::icon(FA::_UPLOAD) . ' ' . \Yii::t('widgets', 'Import'), + ['crud/widget-template/import'], + ['class' => 'btn btn-app']); + } + ?>