diff --git a/config/cms.php b/config/cms.php index e0f58e236..7d176c68f 100644 --- a/config/cms.php +++ b/config/cms.php @@ -56,7 +56,7 @@ return [ | Back-end login remember |-------------------------------------------------------------------------- | - | Define live duration of backend sessions : + | Define live duration of backend sessions: | | true - session never expire (cookie expiration in 5 years) | @@ -211,6 +211,32 @@ return [ 'enableAssetDeepHashing' => null, + /* + |-------------------------------------------------------------------------- + | Database-driven Themes + |-------------------------------------------------------------------------- + | + | Stores theme templates in the database instead of the filesystem. + | + | false - All theme templates are sourced from the filesystem. + | + | true - Source theme templates from the database with fallback to the filesytem. + | + | null - Setting equal to the inverse of app.debug: debug enabled, this disabled. + | + | The database layer stores all modified CMS files in the database. Files that are + | not modified continue to be loaded from the filesystem. The `theme:sync $themeDir` + | console command is available to populate the database from the filesystem with + | the `--toFile` flag to sync in the other direction (database to filesystem) and + | the `--paths="/path/to/file.md,/path/to/file2.md" flag to sync only specific files. + | + | Files modified in the database are cached to indicate that they should be loaded + | from the database. + | + */ + + 'databaseTemplates' => false, + /* |-------------------------------------------------------------------------- | Public plugins path @@ -346,20 +372,20 @@ return [ */ 'forceBytecodeInvalidation' => true, - + /* |-------------------------------------------------------------------------- | Twig Strict Variables |-------------------------------------------------------------------------- | - | If strict_variables is disabled, Twig will silently ignore invalid + | If strict_variables is disabled, Twig will silently ignore invalid | variables (variables and or attributes/methods that do not exist) and | replace them with a null value. When enabled, Twig throws an exception | instead. If set to null, it is enabled when debug mode (app.debug) is | enabled. | */ - + 'enableTwigStrictVariables' => false, /* diff --git a/modules/cms/ServiceProvider.php b/modules/cms/ServiceProvider.php index db411be3c..40e170787 100644 --- a/modules/cms/ServiceProvider.php +++ b/modules/cms/ServiceProvider.php @@ -29,6 +29,7 @@ class ServiceProvider extends ModuleServiceProvider $this->registerComponents(); $this->registerThemeLogging(); $this->registerCombinerEvents(); + $this->registerHalcyonModels(); /* * Backend specific @@ -324,4 +325,20 @@ class ServiceProvider extends ModuleServiceProvider } }); } + + /** + * Registers the models to be made available to the theme database layer + */ + protected function registerHalcyonModels() + { + Event::listen('system.console.theme.sync.getAvailableModelClasses', function () { + return [ + Classes\Meta::class, + Classes\Page::class, + Classes\Layout::class, + Classes\Content::class, + Classes\Partial::class + ]; + }); + } } diff --git a/modules/cms/assets/js/october.cmspage.js b/modules/cms/assets/js/october.cmspage.js index 6692e2715..425a0bd48 100644 --- a/modules/cms/assets/js/october.cmspage.js +++ b/modules/cms/assets/js/october.cmspage.js @@ -173,7 +173,7 @@ var dataId = $target.closest('li').attr('data-tab-id'), title = $target.attr('title'), $sidePanel = $('#cms-side-panel') - + if (title) this.setPageTitle(title) @@ -250,6 +250,8 @@ $form.on('changed.oc.changeMonitor', function() { $panel.trigger('modified.oc.tab') + $panel.find('[data-control=commit-button]').addClass('hide'); + $panel.find('[data-control=reset-button]').addClass('hide'); self.updateModifiedCounter() }) @@ -279,6 +281,10 @@ CmsPage.prototype.onAjaxSuccess = function(ev, context, data) { var element = ev.target + // Update the visibilities of the commit & reset buttons + $('[data-control=commit-button]', element).toggleClass('hide', !data.canCommit) + $('[data-control=reset-button]', element).toggleClass('hide', !data.canReset) + if (data.templatePath !== undefined) { $('input[name=templatePath]', element).val(data.templatePath) $('input[name=templateMtime]', element).val(data.templateMtime) @@ -313,6 +319,11 @@ if (context.handler == 'onSave' && (!data['X_OCTOBER_ERROR_FIELDS'] && !data['X_OCTOBER_ERROR_MESSAGE'])) { $(element).trigger('unchange.oc.changeMonitor') } + + // Reload the form if the server has requested it + if (data.forceReload) { + this.reloadForm(element) + } } CmsPage.prototype.onAjaxError = function(ev, context, message, data, jqXHR) { @@ -359,7 +370,7 @@ }).done(function(data) { var tabs = $('#cms-master-tabs').data('oc.tab'); $.each(data.deleted, function(index, path){ - var + var tabId = templateType + '-' + data.theme + '-' + path, tab = tabs.findByIdentifier(tabId) @@ -375,7 +386,12 @@ } CmsPage.prototype.onInspectorShowing = function(ev, data) { - $(ev.currentTarget).closest('[data-control="toolbar"]').data('oc.dragScroll').goToElement(ev.currentTarget, data.callback) + var $dragScroll = $(ev.currentTarget).closest('[data-control="toolbar"]').data('oc.dragScroll') + if ($dragScroll) { + $dragScroll.goToElement(ev.currentTarget, data.callback) + } else { + data.callback(); + } ev.stopPropagation() } @@ -640,7 +656,7 @@ } CmsPage.prototype.reloadForm = function(form) { - var + var $form = $(form), data = { type: $('[name=templateType]', $form).val(), @@ -682,7 +698,7 @@ $(form).request('onGetTemplateList', { success: function(data) { $('#cms-master-tabs > .tab-content select[name="settings[layout]"]').each(function(){ - var + var $select = $(this), value = $select.val() diff --git a/modules/cms/classes/AutoDatasource.php b/modules/cms/classes/AutoDatasource.php new file mode 100644 index 000000000..dca84da58 --- /dev/null +++ b/modules/cms/classes/AutoDatasource.php @@ -0,0 +1,514 @@ + $datasource] + * @return void + */ + public function __construct(array $datasources) + { + $this->datasources = $datasources; + + $this->activeDatasourceKey = array_keys($datasources)[0]; + + $this->populateCache(); + + $this->postProcessor = new Processor; + } + + /** + * Populate the local cache of paths available in each datasource + * + * @param boolean $refresh Default false, set to true to force the cache to be rebuilt + * @return void + */ + protected function populateCache($refresh = false) + { + $pathCache = []; + foreach ($this->datasources as $datasource) { + // Remove any existing cache data + if ($refresh && $this->allowCacheRefreshes) { + Cache::forget($datasource->getPathsCacheKey()); + } + + // Load the cache + $pathCache[] = Cache::rememberForever($datasource->getPathsCacheKey(), function () use ($datasource) { + return $datasource->getAvailablePaths(); + }); + } + $this->pathCache = $pathCache; + } + + /** + * Check to see if the specified datasource has the provided Halcyon Model + * + * @param string $source The string key of the datasource to check + * @param Model $model The Halcyon Model to check for + * @return boolean + */ + public function sourceHasModel(string $source, Model $model) + { + if (!$model->exists) { + return false; + } + + $result = false; + + $sourcePaths = $this->getSourcePaths($source); + + if (!empty($sourcePaths)) { + // Generate the path + list($name, $extension) = $model->getFileNameParts(); + $path = $this->makeFilePath($model->getObjectTypeDirName(), $name, $extension); + + // Deleted paths are included as being handled by a datasource + // The functionality built on this will need to make sure they + // include deleted records when actually performing sycning actions + if (isset($sourcePaths[$path])) { + $result = true; + } + } + + return $result; + } + + /** + * Get the available paths for the specified datasource key + * + * @param string $source The string key of the datasource to check + * @return void + */ + public function getSourcePaths(string $source) + { + $result = []; + + $keys = array_keys($this->datasources); + if (in_array($source, $keys)) { + // Get the datasource's cache index key + $cacheIndex = array_search($source, $keys); + + // Return the available paths + $result = $this->pathCache[$cacheIndex]; + } + + return $result; + } + + /** + * Push the provided model to the specified datasource + * + * @param Model $model The Halcyon Model to push + * @param string $source The string key of the datasource to use + * @return void + */ + public function pushToSource(Model $model, string $source) + { + // Set the active datasource to the provided source and retrieve it + $originalActiveKey = $this->activeDatasourceKey; + $this->activeDatasourceKey = $source; + $datasource = $this->getActiveDatasource(); + + // Get the path parts + $dirName = $model->getObjectTypeDirName(); + list($fileName, $extension) = $model->getFileNameParts(); + + // Get the file content + $content = $datasource->getPostProcessor()->processUpdate($model->newQuery(), []); + + // Perform an update on the selected datasource (will insert if it doesn't exist) + $this->update($dirName, $fileName, $extension, $content); + + // Restore the original active datasource + $this->activeDatasourceKey = $originalActiveKey; + } + + /** + * Remove the provided model from the specified datasource + * + * @param Model $model The Halcyon model to remove + * @param string $source The string key of the datasource to use + * @return void + */ + public function removeFromSource(Model $model, string $source) + { + // Set the active datasource to the provided source and retrieve it + $originalActiveKey = $this->activeDatasourceKey; + $this->activeDatasourceKey = $source; + $datasource = $this->getActiveDatasource(); + + // Get the path parts + $dirName = $model->getObjectTypeDirName(); + list($fileName, $extension) = $model->getFileNameParts(); + + // Perform a forced delete on the selected datasource to ensure it's removed + $this->forceDelete($dirName, $fileName, $extension); + + // Restore the original active datasource + $this->activeDatasourceKey = $originalActiveKey; + } + + /** + * Get the appropriate datasource for the provided path + * + * @param string $path + * @return Datasource + */ + protected function getDatasourceForPath(string $path) + { + // Default to the last datasource provided + $datasourceIndex = count($this->datasources) - 1; + + $isDeleted = false; + + foreach ($this->pathCache as $i => $paths) { + if (isset($paths[$path])) { + $datasourceIndex = $i; + + // Set isDeleted to the inverse of the the path's existance flag + $isDeleted = !$paths[$path]; + + // Break on first datasource that can handle the path + break; + } + } + + if ($isDeleted) { + throw new Exception("$path is deleted"); + } + + $datasourceIndex = array_keys($this->datasources)[$datasourceIndex]; + + return $this->datasources[$datasourceIndex]; + } + + /** + * Get all valid paths for the provided directory, removing any paths marked as deleted + * + * @param string $dirName + * @param array $options Array of options, [ + * 'extensions' => ['htm', 'md', 'twig'], // Extensions to search for + * 'fileMatch' => '*gr[ae]y', // Shell matching pattern to match the filename against using the fnmatch function + * ]; + * @return array $paths ["$dirName/path/1.md", "$dirName/path/2.md"] + */ + protected function getValidPaths(string $dirName, array $options = []) + { + // Initialize result set + $paths = []; + + // Reverse the order of the sources so that earlier + // sources are prioritized over later sources + $pathsCache = array_reverse($this->pathCache); + + // Get paths available in the provided dirName, allowing proper prioritization of earlier datasources + foreach ($pathsCache as $sourcePaths) { + $paths = array_merge($paths, array_filter($sourcePaths, function ($path) use ($dirName, $options) { + $basePath = $dirName . '/'; + + $inPath = starts_with($path, $basePath); + + // Check the fileMatch if provided as an option + $fnMatch = !empty($options['fileMatch']) ? fnmatch($options['fileMatch'], str_after($path, $basePath)) : true; + + // Check the extension if provided as an option + $validExt = !empty($options['extensions']) && is_array($options['extensions']) ? in_array(pathinfo($path, PATHINFO_EXTENSION), $options['extensions']) : true; + + return $inPath && $fnMatch && $validExt; + }, ARRAY_FILTER_USE_KEY)); + } + + // Filter out 'deleted' paths: + $paths = array_filter($paths, function ($value) { return (bool) $value; }); + + // Return just an array of paths + return array_keys($paths); + } + + /** + * Helper to make file path. + * + * @param string $dirName + * @param string $fileName + * @param string $extension + * @return string + */ + protected function makeFilePath(string $dirName, string $fileName, string $extension) + { + return $dirName . '/' . $fileName . '.' . $extension; + } + + /** + * Get the datasource for use with CRUD operations + * + * @return DatasourceInterface + */ + protected function getActiveDatasource() + { + return $this->datasources[$this->activeDatasourceKey]; + } + + /** + * Returns a single template. + * + * @param string $dirName + * @param string $fileName + * @param string $extension + * @return mixed + */ + public function selectOne(string $dirName, string $fileName, string $extension) + { + try { + $result = $this->getDatasourceForPath($this->makeFilePath($dirName, $fileName, $extension))->selectOne($dirName, $fileName, $extension); + } catch (Exception $ex) { + $result = null; + } + + return $result; + } + + /** + * Returns all templates. + * + * @param string $dirName + * @param array $options Array of options, [ + * 'columns' => ['fileName', 'mtime', 'content'], // Only return specific columns + * 'extensions' => ['htm', 'md', 'twig'], // Extensions to search for + * 'fileMatch' => '*gr[ae]y', // Shell matching pattern to match the filename against using the fnmatch function + * 'orders' => false // Not implemented + * 'limit' => false // Not implemented + * 'offset' => false // Not implemented + * ]; + * @return array + */ + public function select(string $dirName, array $options = []) + { + // Handle fileName listings through just the cache + if (@$options['columns'] === ['fileName']) { + // Return just filenames of the valid paths for this directory + $results = array_values(array_map(function ($path) use ($dirName) { + return ['fileName' => str_after($path, $dirName . '/')]; + }, $this->getValidPaths($dirName, $options))); + + // Retrieve full listings from datasources directly + } else { + // Initialize result set + $sourceResults = []; + + // Reverse the order of the sources so that earlier + // sources are prioritized over later sources + $datasources = array_reverse($this->datasources); + + foreach ($datasources as $datasource) { + $sourceResults = array_merge($sourceResults, $datasource->select($dirName, $options)); + } + + // Remove duplicate results prioritizing results from earlier datasources + $sourceResults = collect($sourceResults)->keyBy('fileName'); + + // Get a list of valid filenames from the list of valid paths for this directory + $validFiles = array_map(function ($path) use ($dirName) { + return str_after($path, $dirName . '/'); + }, $this->getValidPaths($dirName, $options)); + + // Filter out deleted paths + $results = array_values($sourceResults->filter(function ($value, $key) use ($validFiles) { + return in_array($key, $validFiles); + })->all()); + } + + return $results; + } + + /** + * Creates a new template, only inserts to the active datasource + * + * @param string $dirName + * @param string $fileName + * @param string $extension + * @param string $content + * @return bool + */ + public function insert(string $dirName, string $fileName, string $extension, string $content) + { + // Insert only on the active datasource + $result = $this->getActiveDatasource()->insert($dirName, $fileName, $extension, $content); + + // Refresh the cache + $this->populateCache(true); + + return $result; + } + + /** + * Updates an existing template. + * + * @param string $dirName + * @param string $fileName + * @param string $extension + * @param string $content + * @param string $oldFileName Defaults to null + * @param string $oldExtension Defaults to null + * @return int + */ + public function update(string $dirName, string $fileName, string $extension, string $content, $oldFileName = null, $oldExtension = null) + { + $searchFileName = $oldFileName ?: $fileName; + $searchExt = $oldExtension ?: $extension; + + // Ensure that files that are being renamed have their old names marked as deleted prior to inserting the renamed file + // Also ensure that the cache only gets updated at the end of this operation instead of twice, once here and again at the end + if ($searchFileName !== $fileName || $searchExt !== $extension) { + $this->allowCacheRefreshes = false; + $this->delete($dirName, $searchFileName, $searchExt); + $this->allowCacheRefreshes = true; + } + + $datasource = $this->getActiveDatasource(); + + if (!empty($datasource->selectOne($dirName, $searchFileName, $searchExt))) { + $result = $datasource->update($dirName, $fileName, $extension, $content, $oldFileName, $oldExtension); + } else { + $result = $datasource->insert($dirName, $fileName, $extension, $content); + } + + // Refresh the cache + $this->populateCache(true); + + return $result; + } + + /** + * Run a delete statement against the datasource, only runs delete on active datasource + * + * @param string $dirName + * @param string $fileName + * @param string $extension + * @return int + */ + public function delete(string $dirName, string $fileName, string $extension) + { + try { + // Delete from only the active datasource + if ($this->forceDeleting) { + $this->getActiveDatasource()->forceDelete($dirName, $fileName, $extension); + } else { + $this->getActiveDatasource()->delete($dirName, $fileName, $extension); + } + } + catch (Exception $ex) { + // Only attempt to do an insert-delete when not force deleting the record + if (!$this->forceDeleting) { + // Check to see if this is a valid path to delete + $path = $this->makeFilePath($dirName, $fileName, $extension); + + if (in_array($path, $this->getValidPaths($dirName))) { + // Retrieve the current record + $record = $this->selectOne($dirName, $fileName, $extension); + + // Insert the current record into the active datasource so we can mark it as deleted + $this->insert($dirName, $fileName, $extension, $record['content']); + + // Perform the deletion on the newly inserted record + $this->delete($dirName, $fileName, $extension); + } else { + throw (new DeleteFileException)->setInvalidPath($path); + } + } + } + + // Refresh the cache + $this->populateCache(true); + } + + /** + * Return the last modified date of an object + * + * @param string $dirName + * @param string $fileName + * @param string $extension + * @return int + */ + public function lastModified(string $dirName, string $fileName, string $extension) + { + return $this->getDatasourceForPath($this->makeFilePath($dirName, $fileName, $extension))->lastModified($dirName, $fileName, $extension); + } + + /** + * Generate a cache key unique to this datasource. + * + * @param string $name + * @return string + */ + public function makeCacheKey($name = '') + { + $key = ''; + foreach ($this->datasources as $datasource) { + $key .= $datasource->makeCacheKey($name) . '-'; + } + $key .= $name; + + return crc32($key); + } + + /** + * Generate a paths cache key unique to this datasource + * + * @return string + */ + public function getPathsCacheKey() + { + return 'halcyon-datastore-auto'; + } + + /** + * Get all available paths within this datastore + * + * @return array $paths ['path/to/file1.md' => true (path can be handled and exists), 'path/to/file2.md' => false (path can be handled but doesn't exist)] + */ + public function getAvailablePaths() + { + $paths = []; + $datasources = array_reverse($this->datasources); + foreach ($datasources as $datasource) { + $paths = array_merge($paths, $datasource->getAvailablePaths()); + } + return $paths; + } +} diff --git a/modules/cms/classes/Meta.php b/modules/cms/classes/Meta.php new file mode 100644 index 000000000..bfebf016d --- /dev/null +++ b/modules/cms/classes/Meta.php @@ -0,0 +1,85 @@ +bindEvent('model.beforeSave', function () { + $this->content = $this->renderContent(); + }); + $this->bindEvent('model.afterFetch', function () { + $this->attributes = array_merge($this->parseContent(), $this->attributes); + }); + } + + /** + * Processes the content attribute to an array of menu data. + * @return array|null + */ + protected function parseContent() + { + if ($this->contentDataCache !== null) { + return $this->contentDataCache; + } + + $parsedData = Yaml::parse($this->content); + + if (!is_array($parsedData)) { + return null; + } + + return $this->contentDataCache = $parsedData; + } + + /** + * Renders the meta data as a content string in YAML format. + * @return string + */ + protected function renderContent() + { + return Yaml::render($this->settings); + } + + /** + * Compile the content for this CMS object, used by the theme logger. + * @return string + */ + public function toCompiled() + { + return $this->renderContent(); + } +} \ No newline at end of file diff --git a/modules/cms/classes/Theme.php b/modules/cms/classes/Theme.php index 5db2966ec..8fabc4bf5 100644 --- a/modules/cms/classes/Theme.php +++ b/modules/cms/classes/Theme.php @@ -8,13 +8,15 @@ use Lang; use Cache; use Event; use Config; -use Cms\Models\ThemeData; -use System\Models\Parameter; -use October\Rain\Halcyon\Datasource\FileDatasource; -use ApplicationException; +use Exception; use SystemException; use DirectoryIterator; -use Exception; +use ApplicationException; +use Cms\Models\ThemeData; +use System\Models\Parameter; +use October\Rain\Halcyon\Datasource\DbDatasource; +use October\Rain\Halcyon\Datasource\FileDatasource; +use October\Rain\Halcyon\Datasource\DatasourceInterface; /** * This class represents the CMS theme. @@ -519,7 +521,22 @@ class Theme } /** - * Ensures this theme is registered as a Halcyon them datasource. + * Checks to see if the database layer has been enabled + * + * @return boolean + */ + public static function databaseLayerEnabled() + { + $enableDbLayer = Config::get('cms.databaseTemplates', false); + if (is_null($enableDbLayer)) { + $enableDbLayer = !Config::get('app.debug', false); + } + + return $enableDbLayer && App::hasDatabase(); + } + + /** + * Ensures this theme is registered as a Halcyon datasource. * @return void */ public function registerHalyconDatasource() @@ -527,11 +544,30 @@ class Theme $resolver = App::make('halcyon'); if (!$resolver->hasDatasource($this->dirName)) { - $datasource = new FileDatasource($this->getPath(), App::make('files')); + if (static::databaseLayerEnabled()) { + $datasource = new AutoDatasource([ + 'database' => new DbDatasource($this->dirName, 'cms_theme_templates'), + 'filesystem' => new FileDatasource($this->getPath(), App::make('files')), + ]); + } else { + $datasource = new FileDatasource($this->getPath(), App::make('files')); + } + $resolver->addDatasource($this->dirName, $datasource); } } + /** + * Get the theme's datasource + * + * @return DatasourceInterface + */ + public function getDatasource() + { + $resolver = App::make('halcyon'); + return $resolver->datasource($this->getDirName()); + } + /** * Implements the getter functionality. * @param string $name diff --git a/modules/cms/controllers/Index.php b/modules/cms/controllers/Index.php index b72591270..c31aa95be 100644 --- a/modules/cms/controllers/Index.php +++ b/modules/cms/controllers/Index.php @@ -16,6 +16,7 @@ use Cms\Classes\Router; use Cms\Classes\Layout; use Cms\Classes\Partial; use Cms\Classes\Content; +use Cms\Classes\CmsObject; use Cms\Classes\CmsCompoundObject; use Cms\Classes\ComponentManager; use Cms\Classes\ComponentPartial; @@ -134,6 +135,8 @@ class Index extends Controller $this->vars['templatePath'] = Request::input('path'); $this->vars['lastModified'] = DateTime::makeCarbon($template->mtime); + $this->vars['canCommit'] = $this->canCommitTemplate($template); + $this->vars['canReset'] = $this->canResetTemplate($template); if ($type === 'page') { $router = new RainRouter; @@ -225,20 +228,7 @@ class Index extends Controller Flash::success(Lang::get('cms::lang.template.saved')); - $result = [ - 'templatePath' => $template->fileName, - 'templateMtime' => $template->mtime, - 'tabTitle' => $this->getTabTitle($type, $template) - ]; - - if ($type === 'page') { - $result['pageUrl'] = Url::to($template->url); - $router = new Router($this->theme); - $router->clearCache(); - CmsCompoundObject::clearCache($this->theme); - } - - return $result; + return $this->getUpdateResponse($template, $type); } /** @@ -266,6 +256,8 @@ class Index extends Controller $widget = $this->makeTemplateFormWidget($type, $template); $this->vars['templatePath'] = ''; + $this->vars['canCommit'] = $this->canCommitTemplate($template); + $this->vars['canReset'] = $this->canResetTemplate($template); return [ 'tabTitle' => $this->getTabTitle($type, $template), @@ -397,10 +389,133 @@ class Index extends Controller return $content; } + /** + * Commits the DB changes of a template to the filesystem + * + * @return array $response + */ + public function onCommit() + { + $this->validateRequestTheme(); + $type = Request::input('templateType'); + $template = $this->loadTemplate($type, trim(Request::input('templatePath'))); + + if ($this->canCommitTemplate($template)) { + // Populate the filesystem with the template and then remove it from the db + $datasource = $this->getThemeDatasource(); + $datasource->pushToSource($template, 'filesystem'); + $datasource->removeFromSource($template, 'database'); + + Flash::success(Lang::get('cms::lang.editor.commit_success', ['type' => $type])); + } + + return array_merge($this->getUpdateResponse($template, $type), ['forceReload' => true]); + } + + /** + * Resets a template to the version on the filesystem + * + * @return array $response + */ + public function onReset() + { + $this->validateRequestTheme(); + $type = Request::input('templateType'); + $template = $this->loadTemplate($type, trim(Request::input('templatePath'))); + + if ($this->canResetTemplate($template)) { + // Remove the template from the DB + $datasource = $this->getThemeDatasource(); + $datasource->removeFromSource($template, 'database'); + + Flash::success(Lang::get('cms::lang.editor.reset_success', ['type' => $type])); + } + + return array_merge($this->getUpdateResponse($template, $type), ['forceReload' => true]); + } + // - // Methods for the internal use + // Methods for internal use // + /** + * Get the response to return in an AJAX request that updates a template + * + * @param CmsObject $template The template that has been affected + * @param string $type The type of template being affected + * @return array $result; + */ + protected function getUpdateResponse(CmsObject $template, string $type) + { + $result = [ + 'templatePath' => $template->fileName, + 'templateMtime' => $template->mtime, + 'tabTitle' => $this->getTabTitle($type, $template) + ]; + + if ($type === 'page') { + $result['pageUrl'] = Url::to($template->url); + $router = new Router($this->theme); + $router->clearCache(); + CmsCompoundObject::clearCache($this->theme); + } + + $result['canCommit'] = $this->canCommitTemplate($template); + $result['canReset'] = $this->canResetTemplate($template); + + return $result; + } + + /** + * Get the active theme's datasource + * + * @return \October\Rain\Halcyon\Datasource\DatasourceInterface + */ + protected function getThemeDatasource() + { + return $this->theme->getDatasource(); + } + + /** + * Check to see if the provided template can be committed + * Only available in debug mode, the DB layer must be enabled, and the template must exist in the database + * + * @param CmsObject $template + * @return boolean + */ + protected function canCommitTemplate(CmsObject $template) + { + $result = false; + + if (Config::get('app.debug', false) && + Theme::databaseLayerEnabled() && + $this->getThemeDatasource()->sourceHasModel('database', $template) + ) { + $result = true; + } + + return $result; + } + + /** + * Check to see if the provided template can be reset + * Only available when the DB layer is enabled and the template exists in both the DB & Filesystem + * + * @param CmsObject $template + * @return boolean + */ + protected function canResetTemplate(CmsObject $template) + { + $result = false; + + if (Theme::databaseLayerEnabled()) { + $datasource = $this->getThemeDatasource(); + $result = $datasource->sourceHasModel('database', $template) && $datasource->sourceHasModel('filesystem', $template); + } + + return $result; + } + /** * Validate that the current request is within the active theme * @return void diff --git a/modules/cms/controllers/index/_button_commit.htm b/modules/cms/controllers/index/_button_commit.htm new file mode 100644 index 000000000..cf08583dd --- /dev/null +++ b/modules/cms/controllers/index/_button_commit.htm @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/modules/cms/controllers/index/_button_lastmodified.htm b/modules/cms/controllers/index/_button_lastmodified.htm new file mode 100644 index 000000000..129378fa7 --- /dev/null +++ b/modules/cms/controllers/index/_button_lastmodified.htm @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/modules/cms/controllers/index/_button_reset.htm b/modules/cms/controllers/index/_button_reset.htm new file mode 100644 index 000000000..eac4f53d4 --- /dev/null +++ b/modules/cms/controllers/index/_button_reset.htm @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/modules/cms/controllers/index/_common_toolbar_actions.htm b/modules/cms/controllers/index/_common_toolbar_actions.htm new file mode 100644 index 000000000..89cb87ee3 --- /dev/null +++ b/modules/cms/controllers/index/_common_toolbar_actions.htm @@ -0,0 +1,14 @@ +makePartial('button_commit'); ?> + +makePartial('button_reset'); ?> + + + +makePartial('button_lastmodified'); ?> \ No newline at end of file diff --git a/modules/cms/controllers/index/_content_toolbar.htm b/modules/cms/controllers/index/_content_toolbar.htm index fb01abe95..02476a792 100644 --- a/modules/cms/controllers/index/_content_toolbar.htm +++ b/modules/cms/controllers/index/_content_toolbar.htm @@ -8,20 +8,5 @@ - - - - - - + makePartial('common_toolbar_actions', ['toolbarSource' => 'content']); ?> diff --git a/modules/cms/controllers/index/_layout_toolbar.htm b/modules/cms/controllers/index/_layout_toolbar.htm index 1510ea3ca..fe336a1b1 100644 --- a/modules/cms/controllers/index/_layout_toolbar.htm +++ b/modules/cms/controllers/index/_layout_toolbar.htm @@ -8,20 +8,5 @@ - - - - - - + makePartial('common_toolbar_actions', ['toolbarSource' => 'layout']); ?> diff --git a/modules/cms/controllers/index/_page_toolbar.htm b/modules/cms/controllers/index/_page_toolbar.htm index 790da0689..e72f76d0b 100644 --- a/modules/cms/controllers/index/_page_toolbar.htm +++ b/modules/cms/controllers/index/_page_toolbar.htm @@ -19,20 +19,5 @@ - - - - - - + makePartial('common_toolbar_actions', ['toolbarSource' => 'page']); ?> diff --git a/modules/cms/controllers/index/_partial_toolbar.htm b/modules/cms/controllers/index/_partial_toolbar.htm index 6cfc6dd49..4f4df2cbd 100644 --- a/modules/cms/controllers/index/_partial_toolbar.htm +++ b/modules/cms/controllers/index/_partial_toolbar.htm @@ -8,20 +8,5 @@ - - - - - - + makePartial('common_toolbar_actions', ['toolbarSource' => 'partial']); ?> diff --git a/modules/cms/database/migrations/2018_11_01_000001_Db_Cms_Theme_Templates.php b/modules/cms/database/migrations/2018_11_01_000001_Db_Cms_Theme_Templates.php new file mode 100644 index 000000000..f81a4ba79 --- /dev/null +++ b/modules/cms/database/migrations/2018_11_01_000001_Db_Cms_Theme_Templates.php @@ -0,0 +1,26 @@ +engine = 'InnoDB'; + $table->increments('id'); + $table->string('source')->index(); + $table->string('path')->index(); + $table->longText('content'); + $table->integer('file_size')->unsigned(); + $table->dateTime('updated_at')->nullable(); + $table->dateTime('deleted_at')->nullable(); + }); + } + + public function down() + { + Schema::dropIfExists('cms_theme_templates'); + } +} diff --git a/modules/cms/lang/en/lang.php b/modules/cms/lang/en/lang.php index d96915e4c..a6096b04d 100644 --- a/modules/cms/lang/en/lang.php +++ b/modules/cms/lang/en/lang.php @@ -185,7 +185,15 @@ return [ 'open_searchbox' => 'Open Search box', 'close_searchbox' => 'Close Search box', 'open_replacebox' => 'Open Replace box', - 'close_replacebox' => 'Close Replace box' + 'close_replacebox' => 'Close Replace box', + 'commit' => 'Commit', + 'reset' => 'Reset', + 'commit_confirm' => 'Are you sure you want to commit your changes to this file to the filesystem? This will overwrite the existing file on the filesystem', + 'reset_confirm' => 'Are you sure you want to reset this file to the copy that is on the filesystem? This will completely replace it with the file that is on the filesystem', + 'committing' => 'Committing', + 'resetting' => 'Resetting', + 'commit_success' => 'The :type has been committed to the filesystem', + 'reset_success' => 'The :type has been reset to the filesystem version', ], 'asset' => [ 'menu_label' => 'Assets', diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php index c96b536ff..7933df3ef 100644 --- a/modules/system/ServiceProvider.php +++ b/modules/system/ServiceProvider.php @@ -252,6 +252,7 @@ class ServiceProvider extends ModuleServiceProvider $this->registerConsoleCommand('theme.remove', 'System\Console\ThemeRemove'); $this->registerConsoleCommand('theme.list', 'System\Console\ThemeList'); $this->registerConsoleCommand('theme.use', 'System\Console\ThemeUse'); + $this->registerConsoleCommand('theme.sync', 'System\Console\ThemeSync'); } /* diff --git a/modules/system/console/ThemeSync.php b/modules/system/console/ThemeSync.php new file mode 100644 index 000000000..35f466614 --- /dev/null +++ b/modules/system/console/ThemeSync.php @@ -0,0 +1,266 @@ +error("The application is not using a database."); + } + + // Check to see if the DB layer is enabled + if (!Theme::databaseLayerEnabled()) { + return $this->error("cms.databaseTemplates is not enabled, enable it first and try again."); + } + + // Check to see if the provided theme exists + $themeManager = ThemeManager::instance(); + $themeName = $this->argument('name') ?: Theme::getActiveThemeCode(); + $themeExists = Theme::exists($themeName); + if (!$themeExists) { + $themeName = strtolower(str_replace('.', '-', $themeName)); + $themeExists = Theme::exists($themeName); + } + if (!$themeExists) { + return $this->error(sprintf('The theme %s does not exist.', $themeName)); + } + $theme = Theme::load($themeName); + + // Get the target and source datasources + $availableSources = ['filesystem', 'database']; + $target = $this->option('target') ?: 'filesystem'; + $source = 'filesystem'; + if ($target === 'filesystem') { + $source = 'database'; + } + if (!in_array($target, $availableSources)) { + return $this->error(sprintf("Provided --target of %s is invalid. Allowed: filesystem, database", $target)); + } + $this->source = $source; + $this->target = $target; + + // Get the theme paths, taking into account if the user has specified paths + $userPaths = $this->option('paths') ?: null; + $themePaths = array_keys($theme->getDatasource()->getSourcePaths($source)); + + if (!isset($userPaths)) { + $paths = $themePaths; + } + else { + $paths = []; + $userPaths = array_map('trim', explode(',', $userPaths)); + + foreach ($userPaths as $userPath) { + foreach ($themePaths as $themePath) { + $pregMatch = '/' . str_replace('/', '\/', $userPath) . '/i'; + + if ($userPath === $themePath || preg_match($pregMatch, $themePath)) { + $paths[] = $themePath; + } + } + } + } + + // Determine valid paths based on the models made available for syncing + $validPaths = []; + + /** + * @event system.console.theme.sync.getAvailableModelClasses + * Defines the Halcyon models to be made available to the `theme:sync` tool. + * + * Example usage: + * + * Event::listen('system.console.theme.sync.getAvailableModelClasses', function () { + * return [ + * Meta::class, + * Page::class, + * Layout::class, + * Content::class, + * Partial::class, + * ]; + * }); + * + */ + $eventResults = Event::fire('system.console.theme.sync.getAvailableModelClasses'); + $validModels = []; + + foreach ($eventResults as $result) { + if (!is_array($result)) { + continue; + } + + foreach ($result as $modelClass) { + $modelObj = new $modelClass; + + if ($modelObj instanceof \October\Rain\Halcyon\Model) { + $validModels[] = $modelObj; + } + } + } + + // Check each path and map it to a corresponding model + foreach ($paths as $path) { + foreach ($validModels as $model) { + if ( + starts_with($path, $model->getObjectTypeDirName() . '/') + && in_array(pathinfo($path, PATHINFO_EXTENSION), $model->getAllowedExtensions()) + && file_exists($theme->getPath($theme->getDirName()) . '/' . $path) + ) { + $validPaths[$path] = get_class($model); + + // Skip to the next path + continue 2; + } + } + } + + if (count($validPaths) === 0) { + return $this->error(sprintf('No applicable paths found for %s.', $source)); + } + + // Confirm with the user + if (!$this->confirmToProceed(sprintf('This will OVERWRITE the %s provided paths in "themes/%s" on the %s with content from the %s', count($validPaths), $themeName, $target, $source), function () { return true; })) { + return; + } + + try { + $this->info('Syncing files, please wait...'); + $progress = $this->output->createProgressBar(count($validPaths)); + + $this->datasource = $theme->getDatasource(); + + foreach ($validPaths as $path => $model) { + $entity = $this->getModelForPath($path, $model, $theme); + if (!isset($entity)) { + continue; + } + + $this->datasource->pushToSource($entity, $target); + $progress->advance(); + } + + $progress->finish(); + $this->info(''); + $this->info(sprintf('The theme %s has been successfully synced from the %s to the %s.', $themeName, $source, $target)); + } + catch (Exception $ex) { + $this->error($ex->getMessage()); + } + } + + + /** + * Get the correct Halcyon model for the provided path from the source datasource and load the requested path data. + * + * @param string $path + * @param string $model + * @param \Cms\Classes\Theme $theme + * @return \October\Rain\Halycon\Model + */ + protected function getModelForPath($path, $modelClass, $theme) + { + $originalSource = $this->datasource->activeDatasourceKey; + $this->datasource->activeDatasourceKey = $this->source; + + $modelObj = new $modelClass; + + $entity = $modelClass::load( + $theme, + str_replace($modelObj->getObjectTypeDirName() . '/', '', $path) + ); + + if (!isset($entity)) { + return null; + } + + $this->datasource->activeDatasourceKey = $originalSource; + + return $entity; + } + + /** + * Get the console command arguments. + * @return array + */ + protected function getArguments() + { + return [ + ['name', InputArgument::OPTIONAL, 'The name of the theme (directory name). Defaults to currently active theme.'], + ]; + } + + /** + * Get the console command options. + * @return array + */ + protected function getOptions() + { + return [ + ['paths', null, InputOption::VALUE_REQUIRED, 'Comma-separated specific paths (relative to provided theme directory) to specificaly sync. Default is all paths. You may use regular expressions.'], + ['target', null, InputOption::VALUE_REQUIRED, 'The target of the sync, the other will be used as the source. Defaults to "filesystem", can be "database"'], + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run.'], + ]; + } +}