Merge pull request #3908 from octobercms/wip/halcyon-db-datasource

Database layer for the CMS objects
This commit is contained in:
Samuel Georges 2019-06-01 14:28:34 +10:00 committed by GitHub
commit e7ec0be0c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1186 additions and 96 deletions

View File

@ -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

View File

@ -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
];
});
}
}

View File

@ -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) {
@ -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()
}

View File

@ -0,0 +1,514 @@
<?php namespace Cms\Classes;
use Cache;
use Exception;
use October\Rain\Halcyon\Model;
use October\Rain\Halcyon\Processors\Processor;
use October\Rain\Halcyon\Datasource\Datasource;
use October\Rain\Halcyon\Exception\DeleteFileException;
use October\Rain\Halcyon\Datasource\DatasourceInterface;
/**
* Datasource that loads from other data sources automatically
*
* @package october\cms
* @author Luke Towers
*/
class AutoDatasource extends Datasource implements DatasourceInterface
{
/**
* @var array The available datasource instances
*/
protected $datasources = [];
/**
* @var array Local cache of paths available in the datasources
*/
protected $pathCache = [];
/**
* @var boolean Flag on whether the cache should respect refresh requests
*/
protected $allowCacheRefreshes = true;
/**
* @var string The key for the datasource to perform CRUD operations on
*/
public $activeDatasourceKey = '';
/**
* Create a new datasource instance.
*
* @param array $datasources Array of datasources to utilize. Lower indexes = higher priority ['datasourceName' => $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;
}
}

View File

@ -0,0 +1,85 @@
<?php namespace Cms\Classes;
use Yaml;
/**
* The CMS meta file class, used for interacting with YAML files within the Halycon datasources
*
* @package october\cms
* @author Luke Towers
*/
class Meta extends CmsObject
{
/**
* @var string The container name associated with the model, eg: pages.
*/
protected $dirName = 'meta';
/**
* @var array Cache store used by parseContent method.
*/
protected $contentDataCache;
/**
* @var array Allowable file extensions.
*/
protected $allowedExtensions = ['yaml'];
/**
* @var string Default file extension.
*/
protected $defaultExtension = 'yaml';
/**
* {inheritDoc}
*/
public function __construct()
{
parent::__construct(...func_get_args());
// Bind data processing to model events
$this->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();
}
}

View File

@ -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)) {
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

View File

@ -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

View File

@ -0,0 +1,9 @@
<button
type="button"
class="btn btn-danger oc-icon-download <?php if (!$canCommit): ?>hide<?php endif ?>"
data-request="onCommit"
data-request-confirm="<?= e(trans('cms::lang.editor.commit_confirm')) ?>"
data-load-indicator="<?= e(trans('cms::lang.editor.committing')) ?>"
data-control="commit-button">
<?= e(trans('cms::lang.editor.commit')) ?>
</button>

View File

@ -0,0 +1,8 @@
<?php if (isset($lastModified)): ?>
<span
class="btn empty oc-icon-calendar"
title="<?= e(trans('backend::lang.media.last_modified')) ?>: <?= $lastModified ?>"
data-toggle="tooltip"
data-placement="right">
</span>
<?php endif; ?>

View File

@ -0,0 +1,9 @@
<button
type="button"
class="btn btn-danger oc-icon-bomb <?php if (!$canReset): ?>hide<?php endif ?>"
data-request="onReset"
data-request-confirm="<?= e(trans('cms::lang.editor.reset_confirm')) ?>"
data-load-indicator="<?= e(trans('cms::lang.editor.resetting')) ?>"
data-control="reset-button">
<?= e(trans('cms::lang.editor.reset')) ?>
</button>

View File

@ -0,0 +1,14 @@
<?= $this->makePartial('button_commit'); ?>
<?= $this->makePartial('button_reset'); ?>
<button
type="button"
class="btn btn-danger empty oc-icon-trash-o <?php if (!$templatePath): ?>hide<?php endif ?>"
data-request="onDelete"
data-request-confirm="<?= e(trans('cms::lang.' . $toolbarSource . '.delete_confirm_single')) ?>"
data-request-success="$.oc.cmsPage.updateTemplateList('<?= $toolbarSource ?>'); $(this).trigger('close.oc.tab', [{force: true}])"
data-control="delete-button">
</button>
<?= $this->makePartial('button_lastmodified'); ?>

View File

@ -8,20 +8,5 @@
<?= e(trans('backend::lang.form.save')) ?>
</a>
<button
type="button"
class="btn btn-danger empty oc-icon-trash-o <?php if (!$templatePath): ?>hide<?php endif ?>"
data-request="onDelete"
data-request-confirm="<?= e(trans('cms::lang.content.delete_confirm_single')) ?>"
data-request-success="$.oc.cmsPage.updateTemplateList('content'); $(this).trigger('close.oc.tab', [{force: true}])"
data-control="delete-button"></button>
<?php if (isset($lastModified)): ?>
<span
class="btn empty oc-icon-calendar"
title="<?= e(trans('backend::lang.media.last_modified')) ?>: <?= $lastModified ?>"
data-toggle="tooltip"
data-placement="right">
</span>
<?php endif; ?>
<?= $this->makePartial('common_toolbar_actions', ['toolbarSource' => 'content']); ?>
</div>

View File

@ -8,20 +8,5 @@
<?= e(trans('backend::lang.form.save')) ?>
</a>
<button
type="button"
class="btn btn-danger empty oc-icon-trash-o <?php if (!$templatePath): ?>hide<?php endif ?>"
data-request="onDelete"
data-request-confirm="<?= e(trans('cms::lang.layout.delete_confirm_single')) ?>"
data-request-success="$.oc.cmsPage.updateTemplateList('layout'); $(this).trigger('close.oc.tab', [{force: true}])"
data-control="delete-button"></button>
<?php if (isset($lastModified)): ?>
<span
class="btn empty oc-icon-calendar"
title="<?= e(trans('backend::lang.media.last_modified')) ?>: <?= $lastModified ?>"
data-toggle="tooltip"
data-placement="right">
</span>
<?php endif; ?>
<?= $this->makePartial('common_toolbar_actions', ['toolbarSource' => 'layout']); ?>
</div>

View File

@ -19,20 +19,5 @@
<?= e(trans('cms::lang.editor.preview')) ?>
</a>
<button
type="button"
class="btn btn-danger empty oc-icon-trash-o <?php if (!$templatePath): ?>hide<?php endif ?>"
data-request="onDelete"
data-request-confirm="<?= e(trans('cms::lang.page.delete_confirm_single')) ?>"
data-request-success="$.oc.cmsPage.updateTemplateList('page'); $(this).trigger('close.oc.tab', [{force: true}])"
data-control="delete-button"></button>
<?php if (isset($lastModified)): ?>
<span
class="btn empty oc-icon-calendar"
title="<?= e(trans('backend::lang.media.last_modified')) ?>: <?= $lastModified ?>"
data-toggle="tooltip"
data-placement="right">
</span>
<?php endif; ?>
<?= $this->makePartial('common_toolbar_actions', ['toolbarSource' => 'page']); ?>
</div>

View File

@ -8,20 +8,5 @@
<?= e(trans('backend::lang.form.save')) ?>
</a>
<button
type="button"
class="btn btn-danger empty oc-icon-trash-o <?php if (!$templatePath): ?>hide<?php endif ?>"
data-request="onDelete"
data-request-confirm="<?= e(trans('cms::lang.partial.delete_confirm_single')) ?>"
data-request-success="$.oc.cmsPage.updateTemplateList('partial'); $(this).trigger('close.oc.tab', [{force: true}])"
data-control="delete-button"></button>
<?php if (isset($lastModified)): ?>
<span
class="btn empty oc-icon-calendar"
title="<?= e(trans('backend::lang.media.last_modified')) ?>: <?= $lastModified ?>"
data-toggle="tooltip"
data-placement="right">
</span>
<?php endif; ?>
<?= $this->makePartial('common_toolbar_actions', ['toolbarSource' => 'partial']); ?>
</div>

View File

@ -0,0 +1,26 @@
<?php
use October\Rain\Database\Schema\Blueprint;
use October\Rain\Database\Updates\Migration;
class DbCmsThemeTemplates extends Migration
{
public function up()
{
Schema::create('cms_theme_templates', function (Blueprint $table) {
$table->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');
}
}

View File

@ -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',

View File

@ -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');
}
/*

View File

@ -0,0 +1,266 @@
<?php namespace System\Console;
use App;
use Event;
use Exception;
use Cms\Classes\Theme;
use Cms\Classes\ThemeManager;
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
/**
* Console command to sync a theme between the DB and Filesystem layers.
*
* theme:sync name --paths=file/to/sync.md,other/file/to/sync.md --target=filesystem --force
*
* - name defaults to the currently active theme
* - --paths defaults to all paths within the theme, otherwise comma-separated list of paths relative to the theme directory
* - --target defaults to "filesystem", the source will whichever of filesystem vs database is not the target
* - --force bypasses the confirmation request
*
* @package october\system
* @author Luke Towers
*/
class ThemeSync extends Command
{
use \Illuminate\Console\ConfirmableTrait;
/**
* The console command name.
* @var string
*/
protected $name = 'theme:sync';
/**
* The console command description.
* @var string
*/
protected $description = 'Sync an existing theme between the DB and Filesystem layers';
/**
* @var \October\Rain\Datasource\DatasourceInterface The theme's AutoDatasource instance
*/
protected $datasource;
/**
* @var string The datasource key that the sync is targeting
*/
protected $target;
/**
* @var string The datasource key that the sync is sourcing from
*/
protected $source;
/**
* @var array Models
*/
protected $halyconModels = [];
/**
* Execute the console command.
* @return void
*/
public function handle()
{
// Check to see if the application even uses a database
if (!App::hasDatabase()) {
return $this->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.'],
];
}
}