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 @@
+= $this->makePartial('button_commit'); ?>
+
+= $this->makePartial('button_reset'); ?>
+
+
+
+= $this->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 @@
= e(trans('backend::lang.form.save')) ?>
-
-
-
-
-
-
+ = $this->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 @@
= e(trans('backend::lang.form.save')) ?>
-
-
-
-
-
-
+ = $this->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 @@
= e(trans('cms::lang.editor.preview')) ?>
-
-
-
-
-
-
+ = $this->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 @@
= e(trans('backend::lang.form.save')) ?>
-
-
-
-
-
-
+ = $this->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.'],
+ ];
+ }
+}