Merge pull request #3908 from octobercms/wip/halcyon-db-datasource
Database layer for the CMS objects
This commit is contained in:
commit
e7ec0be0c1
|
|
@ -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,
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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; ?>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'); ?>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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.'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue