mirror of
https://github.com/Oreolek/kohana-migrations.git
synced 2024-05-18 17:08:18 +03:00
Initial import
This commit is contained in:
commit
ed98d5b7c8
39
README.md
Normal file
39
README.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Minion
|
||||
|
||||
Minion is a module for the Kohana framework which allows you to run various tasks from the cli.
|
||||
|
||||
## Getting Started
|
||||
|
||||
First off, download and enable the module in your bootstrap
|
||||
|
||||
Then you can run minion like so:
|
||||
|
||||
php index.php --uri=minion/{task}
|
||||
|
||||
To view a list of minion tasks, run
|
||||
|
||||
php index.php --uri=minion/help
|
||||
|
||||
To view help for a specific minion task run
|
||||
|
||||
php index.php --uri=minion/help/{task}
|
||||
|
||||
For security reasons Minion will only run from the cli. Attempting to access it over http will cause
|
||||
a `Request_Exception` to be thrown.
|
||||
|
||||
## Writing your own tasks
|
||||
|
||||
All minion tasks must be located in `classes/minion/task/`. They can be in any module, thus allowing you to
|
||||
ship custom minion tasks with your own module / product.
|
||||
|
||||
Each task must extend the abstract class `Minion_Task` and implement `Minion_Task::get_config_options()` and `Minion_Task::execute()`.
|
||||
See `Minion_Task` for more details.
|
||||
|
||||
## Documentation
|
||||
|
||||
Code should be commented well enough not to need documentation, and minion can extract a class' doccomment to use
|
||||
as documentation on the cli.
|
||||
|
||||
## Testing
|
||||
|
||||
This module is unittested using the [unittest module](http://github.com/kohana/unittest)
|
114
classes/controller/minion.php
Normal file
114
classes/controller/minion.php
Normal file
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Controller for interacting with minion on the cli
|
||||
*
|
||||
* @author Matt Button <matthew@sigswitch.com>
|
||||
**/
|
||||
class Controller_Minion extends Controller
|
||||
{
|
||||
/**
|
||||
* Prevent Minion from being run over http
|
||||
*/
|
||||
public function before()
|
||||
{
|
||||
if( ! Kohana::$is_cli)
|
||||
{
|
||||
throw new Request_Exception("Minion can only be ran from the cli");
|
||||
}
|
||||
|
||||
return parent::before();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints out the help for a specific task
|
||||
*
|
||||
*/
|
||||
public function action_help()
|
||||
{
|
||||
$tasks = Minion_Util::compile_task_list(Kohana::list_files('classes/minion/task'));
|
||||
$task = $this->request->param('task');
|
||||
$view = NULL;
|
||||
|
||||
if(empty($task))
|
||||
{
|
||||
$view = new View('minion/help/list');
|
||||
|
||||
$view->tasks = $tasks;
|
||||
}
|
||||
else
|
||||
{
|
||||
$class = Minion_Util::convert_task_to_class_name($task);
|
||||
|
||||
if( ! class_exists($class))
|
||||
{
|
||||
echo View::factory('minion/help/error')
|
||||
->set('error', 'Task "'.$task.'" does not exist');
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$inspector = new ReflectionClass($class);
|
||||
|
||||
list($description, $tags) = Minion_Util::parse_doccomment($inspector->getDocComment());
|
||||
|
||||
$view = View::factory('minion/help/task')
|
||||
->set('description', $description)
|
||||
->set('tags', (array) $tags)
|
||||
->set('task', $task);
|
||||
}
|
||||
|
||||
echo $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the request to execute a task.
|
||||
*
|
||||
* Responsible for parsing the tasks to execute & also any config items that
|
||||
* should be passed to the tasks
|
||||
*/
|
||||
public function action_execute()
|
||||
{
|
||||
$tasks = trim($this->request->param('task'));
|
||||
|
||||
if(empty($tasks))
|
||||
return $this->action_help();
|
||||
|
||||
$tasks = explode(',', $tasks);
|
||||
|
||||
$master = new Minion_Master;
|
||||
|
||||
$options = $master->load($tasks)->get_config_options();
|
||||
|
||||
$config = array();
|
||||
|
||||
// Allow the user to specify config for each task, namespacing each
|
||||
// config option with the name of the task that "owns" it
|
||||
foreach($options as $task_name => $task_options)
|
||||
{
|
||||
$namespace = $task_name.Minion_Util::$task_separator;
|
||||
|
||||
// Namespace each config option
|
||||
foreach($task_options as $i => $task_option)
|
||||
{
|
||||
$task_options[$i] = $namespace.$task_option;
|
||||
}
|
||||
|
||||
// Get any config options the user's passed
|
||||
$task_config = call_user_func_array(array('CLI', 'options'), $task_options);
|
||||
|
||||
if( ! empty($task_config))
|
||||
{
|
||||
$namespace_length = strlen($namespace);
|
||||
|
||||
// Strip the namespace off all the config options
|
||||
foreach($task_config as $key => $value)
|
||||
{
|
||||
$config[$task_name][substr($key, $namespace_length)] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$master->execute($config);
|
||||
}
|
||||
}
|
93
classes/minion/master.php
Normal file
93
classes/minion/master.php
Normal file
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* The Minion Master is responsible for loading and executing the various minion
|
||||
* tasks requested by the user
|
||||
*
|
||||
* @author Matt Button <matthew@sigswitch.com>
|
||||
*/
|
||||
class Minion_Master {
|
||||
|
||||
/**
|
||||
* Tasks the master will execute
|
||||
* @var array
|
||||
*/
|
||||
protected $_tasks = array();
|
||||
|
||||
/**
|
||||
* Get a list of config options that the loaded tasks accept at execution
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_config_options()
|
||||
{
|
||||
$config = array();
|
||||
|
||||
foreach($this->_tasks as $task)
|
||||
{
|
||||
$config[(string) $task] = (array) $task->get_config_options();
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a number of tasks into the task master
|
||||
*
|
||||
* Passed task can either be an instance of Minion_Task, a task name (e.g.
|
||||
* db:migrate) or an array of the above
|
||||
*
|
||||
* If an invalid task is passed then a Kohana_Exception will be thrown
|
||||
*
|
||||
* @chainable
|
||||
* @throws Kohana_Exception
|
||||
* @param array|string|Minion_Task The task(s) to load
|
||||
* @returns Minion_Master Chainable instance
|
||||
*/
|
||||
public function load($task)
|
||||
{
|
||||
if(is_array($task))
|
||||
{
|
||||
array_map(array($this, 'load'), $task);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
if(is_string($task))
|
||||
{
|
||||
$class = Minion_Util::convert_task_to_class_name($task);
|
||||
|
||||
$task = new $class;
|
||||
}
|
||||
|
||||
if( ! $task instanceof Minion_Task)
|
||||
{
|
||||
throw new Kohana_Exception(
|
||||
"Task ':task' is not a valid minion task",
|
||||
array(':task' => get_class($task))
|
||||
);
|
||||
}
|
||||
|
||||
$this->_tasks[(string) $task] = $task;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the loaded tasks one at a time
|
||||
*
|
||||
* @return Minion_Master Chainable instance
|
||||
*/
|
||||
public function execute(array $config = array())
|
||||
{
|
||||
if(empty($this->_tasks))
|
||||
return $this;
|
||||
|
||||
foreach($this->_tasks as $task)
|
||||
{
|
||||
$task->execute(Arr::get($config, (string) $task, array()));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
24
classes/minion/migration/base.php
Normal file
24
classes/minion/migration/base.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* The base migration class, must be extended by all migration files
|
||||
*
|
||||
* Each migration file must implement an up() and a down() which are used to
|
||||
* apply / remove this migration from the schema respectively
|
||||
*
|
||||
* @author Matt Button <matthew@sigswitch.com>
|
||||
*/
|
||||
abstract class Minion_Migration_Base {
|
||||
|
||||
/**
|
||||
* Runs any SQL queries necessary to bring the database up a migration version
|
||||
*
|
||||
*/
|
||||
abstract public function up();
|
||||
|
||||
/**
|
||||
* Runs any SQL queries necessary to bring the database schema down a version
|
||||
*
|
||||
*/
|
||||
abstract public function down();
|
||||
}
|
39
classes/minion/task.php
Normal file
39
classes/minion/task.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Interface that all minion tasks must implement
|
||||
*
|
||||
*/
|
||||
abstract class Minion_Task {
|
||||
|
||||
/**
|
||||
* Gets the task name for the task
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
static $task_name = NULL;
|
||||
|
||||
if($task_name === NULL)
|
||||
{
|
||||
$task_name = Minion_Util::convert_class_to_task($this);
|
||||
}
|
||||
|
||||
return $task_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a set of config options that this task can accept
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function get_config_options();
|
||||
|
||||
/**
|
||||
* Execute the task with the specified set of config
|
||||
*
|
||||
* @return boolean TRUE if task executed successfully, else FALSE
|
||||
*/
|
||||
abstract public function execute(array $config);
|
||||
}
|
28
classes/minion/task/app/cache/purge.php
vendored
Normal file
28
classes/minion/task/app/cache/purge.php
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Purges the application file cache
|
||||
*
|
||||
* @author Matt Button <matthew@sigswitch.com>
|
||||
**/
|
||||
class Minion_Task_Cache_Purge extends Minion_Task
|
||||
{
|
||||
|
||||
/**
|
||||
* Gets a set of config options this minion task accepts
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_config_options()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cache
|
||||
*/
|
||||
public function execute(array $config)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
51
classes/minion/task/db/migrate.php
Normal file
51
classes/minion/task/db/migrate.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* The Migrate task compares the current version of the database with the target
|
||||
* version and then executes the necessary commands to bring the database up to
|
||||
* date
|
||||
*
|
||||
* Available config options are:
|
||||
*
|
||||
* db:migrate:version=version
|
||||
*
|
||||
* The version to which the database should be migrated. If this is NULL then
|
||||
* it will be updated to the latest available version
|
||||
*
|
||||
* db:migrate:modules=module[,module2[,module3...]]
|
||||
*
|
||||
* A list of modules that will be used to source migration files. By default
|
||||
* migrations will be loaded from all enabled modules
|
||||
*
|
||||
* @author Matt Button <matthew@sigswitch.com>
|
||||
*/
|
||||
class Minion_Task_Db_Migrate extends Minion_Task
|
||||
{
|
||||
/**
|
||||
* Get a set of config options that migrations will accept
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_config_options()
|
||||
{
|
||||
return array(
|
||||
'version',
|
||||
'modules',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the database to the version specified
|
||||
*
|
||||
* @param array Configuration to use
|
||||
*/
|
||||
public function execute(array $config)
|
||||
{
|
||||
$k_config = Kohana::config('minion/task/migrations');
|
||||
|
||||
// Default is upgrade to latest
|
||||
$version = Arr::get($config, 'version', NULL);
|
||||
|
||||
// Do fancy migration stuff here
|
||||
}
|
||||
}
|
124
classes/minion/util.php
Normal file
124
classes/minion/util.php
Normal file
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
|
||||
/**
|
||||
* Utility class for Minion
|
||||
**/
|
||||
class Minion_Util
|
||||
{
|
||||
/**
|
||||
* The separator used to separate different levels of tasks
|
||||
* @var string
|
||||
*/
|
||||
public static $task_separator = ':';
|
||||
|
||||
/**
|
||||
* Parses a doccomment, extracting both the comment and any tags associated
|
||||
*
|
||||
* Based on the code in Kodoc::parse()
|
||||
*
|
||||
* @param string The comment to parse
|
||||
* @return array First element is the comment, second is an array of tags
|
||||
*/
|
||||
public static function parse_doccomment($comment)
|
||||
{
|
||||
// Normalize all new lines to \n
|
||||
$comment = str_replace(array("\r\n", "\n"), "\n", $comment);
|
||||
|
||||
// Remove the phpdoc open/close tags and split
|
||||
$comment = array_slice(explode("\n", $comment), 1, -1);
|
||||
|
||||
// Tag content
|
||||
$tags = array();
|
||||
|
||||
foreach ($comment as $i => $line)
|
||||
{
|
||||
// Remove all leading whitespace
|
||||
$line = preg_replace('/^\s*\* ?/m', '', $line);
|
||||
|
||||
// Search this line for a tag
|
||||
if (preg_match('/^@(\S+)(?:\s*(.+))?$/', $line, $matches))
|
||||
{
|
||||
// This is a tag line
|
||||
unset($comment[$i]);
|
||||
|
||||
$name = $matches[1];
|
||||
$text = isset($matches[2]) ? $matches[2] : '';
|
||||
|
||||
$tags[$name] = $text;
|
||||
}
|
||||
else
|
||||
{
|
||||
$comment[$i] = (string) $line;
|
||||
}
|
||||
}
|
||||
|
||||
$comment = trim(implode("\n", $comment));
|
||||
|
||||
return array($comment, $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a list of available tasks from a directory structure
|
||||
*
|
||||
* @param array Directory structure of tasks
|
||||
* @return array Compiled tasks
|
||||
*/
|
||||
public static function compile_task_list(array $files, $prefix = '')
|
||||
{
|
||||
$output = array();
|
||||
|
||||
foreach($files as $file => $path)
|
||||
{
|
||||
$file = substr($file, strrpos($file, '/') + 1);
|
||||
|
||||
if(is_array($path) AND count($path))
|
||||
{
|
||||
$task = Minion_Util::compile_task_list($path, $prefix.$file.Minion_Util::$task_separator);
|
||||
|
||||
if($task)
|
||||
{
|
||||
$output = array_merge($output, $task);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$output[] = strtolower($prefix.substr($file, 0, -strlen(EXT)));
|
||||
}
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a task (e.g. db:migrate to a class name)
|
||||
*
|
||||
* @param string Task name
|
||||
* @return string Class name
|
||||
*/
|
||||
public static function convert_task_to_class_name($task)
|
||||
{
|
||||
$task = trim($task);
|
||||
|
||||
if(empty($task))
|
||||
return '';
|
||||
|
||||
return 'Minion_Task_'.implode('_', array_map('ucfirst', explode(Minion_Util::$task_separator, $task)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the task name of a task class / task object
|
||||
*
|
||||
* @param string|Minion_Task The task class / object
|
||||
* @return string The task name
|
||||
*/
|
||||
public static function convert_class_to_task($class)
|
||||
{
|
||||
if(is_object($class))
|
||||
{
|
||||
$class = get_class($class);
|
||||
}
|
||||
|
||||
return strtolower(str_replace('_', Minion_Util::$task_separator, substr($class, 12)));
|
||||
}
|
||||
}
|
8
init.php
Normal file
8
init.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
|
||||
Route::set('minion', 'minion(/<action>)(/<task>)', array('action' => 'help'))
|
||||
->defaults(array(
|
||||
'controller' => 'minion',
|
||||
'action' => 'execute',
|
||||
));
|
26
tests/minion/master.php
Normal file
26
tests/minion/master.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Test case for the Minion Master
|
||||
*
|
||||
* @group minion
|
||||
**/
|
||||
class Minion_MasterTest extends Kohana_Unittest_TestCase
|
||||
{
|
||||
/**
|
||||
* Tests that Minion_Master::load() will accept an instance of Minion_Task
|
||||
* as a task
|
||||
*
|
||||
* @test
|
||||
* @covers Minion_Master::load
|
||||
*/
|
||||
public function test_load_accepts_objects_as_valid_tasks()
|
||||
{
|
||||
$master = new Minion_Master;
|
||||
$task = $this->getMockForAbstractClass('Minion_Task');
|
||||
|
||||
$this->assertSame($master, $master->load($task));
|
||||
|
||||
$this->assertAttributeContains($task, '_tasks', $master);
|
||||
}
|
||||
}
|
136
tests/minion/util.php
Normal file
136
tests/minion/util.php
Normal file
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Test case for Minion_Util
|
||||
*
|
||||
* @group minion
|
||||
**/
|
||||
class Minion_UtilTest extends Kohana_Unittest_TestCase
|
||||
{
|
||||
/**
|
||||
* Provides test data for test_parse_doccoment()
|
||||
*
|
||||
* @return array Test data
|
||||
*/
|
||||
public function provider_parse_doccoment()
|
||||
{
|
||||
return array(
|
||||
array(
|
||||
array(
|
||||
"This is my comment from something or\nother",
|
||||
array(
|
||||
'author' => 'Matt Button <matthew@sigswitch.com>',
|
||||
),
|
||||
),
|
||||
" /**\n * This is my comment from something or\n * other\n * \n * @author Matt Button <matthew@sigswitch.com>\n */",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Minion_Util::prase_doccoment
|
||||
*
|
||||
* @test
|
||||
* @dataProvider provider_parse_doccoment
|
||||
* @covers Minion_Util::parse_doccomment
|
||||
* @param array Expected output
|
||||
* @param string Input doccoment
|
||||
*/
|
||||
public function test_parse_doccoment($expected, $doccomment)
|
||||
{
|
||||
$this->assertSame($expected, Minion_Util::parse_doccomment($doccomment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test data for test_compile_task_list()
|
||||
*
|
||||
* @return array Test data
|
||||
*/
|
||||
public function provider_compile_task_list()
|
||||
{
|
||||
return array(
|
||||
array(
|
||||
array(
|
||||
'db:migrate',
|
||||
'db:status',
|
||||
),
|
||||
array (
|
||||
'classes/minion/task/db' => array (
|
||||
'classes/minion/task/db/migrate.php' => '/var/www/memberful/memberful-core/modules/kohana-minion/classes/minion/task/db/migrate.php',
|
||||
'classes/minion/task/db/status.php' => '/var/www/memberful/memberful-core/modules/kohana-minion/classes/minion/task/db/status.php',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that compile_task_list accurately creates a list of tasks from a directory structure
|
||||
*
|
||||
* @test
|
||||
* @covers Minion_Util::compile_task_list
|
||||
* @dataProvider provider_compile_task_list
|
||||
* @param array Expected output
|
||||
* @param array List of files
|
||||
* @param string Prefix to use
|
||||
* @param string Separator to use
|
||||
*/
|
||||
public function test_compile_task_list($expected, $files, $prefix = '', $separator = ':')
|
||||
{
|
||||
$this->assertSame($expected, Minion_Util::compile_task_list($files, $prefix, $separator));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test data for test_convert_task_to_class_name()
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function provider_convert_task_to_class_name()
|
||||
{
|
||||
return array(
|
||||
array('Minion_Task_Db_Migrate', 'db:migrate'),
|
||||
array('Minion_Task_Db_Status', 'db:status'),
|
||||
array('', ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that a task can be converted to a class name
|
||||
*
|
||||
* @test
|
||||
* @covers Minion_Util::convert_task_to_class_name
|
||||
* @dataProvider provider_convert_task_to_class_name
|
||||
* @param string Expected class name
|
||||
* @param string Input task name
|
||||
*/
|
||||
public function test_convert_task_to_class_name($expected, $task_name)
|
||||
{
|
||||
$this->assertSame($expected, Minion_Util::convert_task_to_class_name($task_name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test data for test_convert_class_to_task()
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function provider_convert_class_to_task()
|
||||
{
|
||||
return array(
|
||||
array('db:migrate', 'Minion_Task_Db_Migrate'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the task name can be found from a class name / object
|
||||
*
|
||||
* @test
|
||||
* @covers Minion_Util::convert_class_to_task
|
||||
* @dataProvider provider_convert_class_to_task
|
||||
* @param string Expected task name
|
||||
* @param mixed Input class
|
||||
*/
|
||||
public function test_convert_class_to_task($expected, $class)
|
||||
{
|
||||
$this->assertSame($expected, Minion_Util::convert_class_to_task($class));
|
||||
}
|
||||
}
|
7
views/minion/help/error.php
Normal file
7
views/minion/help/error.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php echo $error; ?>
|
||||
|
||||
Run
|
||||
|
||||
index.php --uri=minion
|
||||
|
||||
for more help
|
17
views/minion/help/list.php
Normal file
17
views/minion/help/list.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
Minion is a cli tool for performing tasks
|
||||
|
||||
Usage
|
||||
|
||||
php index.php --uri=minion/{task}
|
||||
|
||||
Where {task} is one of the following:
|
||||
|
||||
<?php foreach($tasks as $task): ?>
|
||||
* <?php echo $task; ?>
|
||||
|
||||
<?php endforeach; ?>
|
||||
|
||||
For more information on what a task does and usage details execute
|
||||
|
||||
php index.php --uri=minion/help/{task}
|
||||
|
17
views/minion/help/task.php
Normal file
17
views/minion/help/task.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
|
||||
Usage
|
||||
=======
|
||||
php index.php --uri=minion/<?php echo $task; ?> [--option1=value1] [--option2=value2]
|
||||
|
||||
Details
|
||||
=======
|
||||
<?php foreach($tags as $tag_name => $tag_content): ?>
|
||||
<?php echo ucfirst($tag_name) ?>: <?php echo $tag_content ?>
|
||||
|
||||
<?php endforeach; ?>
|
||||
|
||||
Description
|
||||
===========
|
||||
<?php echo $description; ?>
|
||||
|
||||
|
Loading…
Reference in a new issue