mirror of
https://github.com/Oreolek/ifhub.club.git
synced 2024-07-05 07:54:24 +03:00
Консоль для на основе Yii
This commit is contained in:
parent
57dd36802b
commit
d1ea388879
4
engine/console/README
Normal file
4
engine/console/README
Normal file
|
@ -0,0 +1,4 @@
|
|||
Перейдите в данную папку с помощью консольной команды cd livestreet/engile/console/ и вызовете ls.
|
||||
Вы получите краткую справку и существующих командах, например:
|
||||
ls plugin new test
|
||||
Так мы создадим новый плагин с именем Test
|
67
engine/console/commands/Plugin.class.php
Normal file
67
engine/console/commands/Plugin.class.php
Normal file
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
class Plugin extends LSC {
|
||||
protected $_name;
|
||||
|
||||
/*
|
||||
* Выводим помощь о команде
|
||||
*/
|
||||
public function getHelp() {
|
||||
return <<<EOD
|
||||
USAGE
|
||||
ls plugin new <plugin-name>
|
||||
EOD;
|
||||
}
|
||||
|
||||
/*
|
||||
* Подкоманда создания нового плагина
|
||||
*/
|
||||
public function actionNew($aArgs) {
|
||||
// Передано ли имя нового плагина
|
||||
if(!isset($aArgs[0]))
|
||||
die("The plugin name is not specified.\n");
|
||||
|
||||
$this->_name = $aArgs[0];
|
||||
|
||||
$path=strtr($aArgs[0],'/\\',DIRECTORY_SEPARATOR);
|
||||
$path=Config::Get('path.root.server').'/plugins/'.$path;
|
||||
if(strpos($path,DIRECTORY_SEPARATOR)===false)
|
||||
$path='.'.DIRECTORY_SEPARATOR.$path;
|
||||
|
||||
$dir=rtrim(realpath(dirname($path)),'\\/');
|
||||
if($dir===false || !is_dir($dir))
|
||||
die("The directory '$path' is not valid. Please make sure the parent directory exists.\n");
|
||||
|
||||
$sourceDir=realpath(dirname(__FILE__).'/../protected/plugin');
|
||||
if($sourceDir===false)
|
||||
die("\nUnable to locate the source directory.\n");
|
||||
|
||||
// Создаем массив файлов для функции копирования
|
||||
$aList=$this->buildFileList($sourceDir,$path);
|
||||
|
||||
// Парсим имена плагинов и пересоздаем массив
|
||||
foreach($aList as $sName=>$aFile) {
|
||||
$sTarget=str_ireplace('Example',ucwords($this->_name),$aFile['target']);
|
||||
$sNewName=str_ireplace('Example',ucwords($this->_name),$sName);
|
||||
if($sName != $sNewName)
|
||||
unset($aList[$sName]);
|
||||
|
||||
$aFile['target'] = $sTarget;
|
||||
$aList[$sNewName]=$aFile;
|
||||
$aList[$sNewName]['callback']=array($this,'generatePlugin');
|
||||
}
|
||||
|
||||
// Копируем файлы
|
||||
$this->copyFiles($aList);
|
||||
echo "\nYour plugin has been created successfully under {$path}.\n";
|
||||
}
|
||||
|
||||
/*
|
||||
* Парсер выражений в исходниках эталонного плагина
|
||||
*/
|
||||
public function generatePlugin($source,$params) {
|
||||
$content=file_get_contents($source);
|
||||
$content=str_ireplace('Example',ucwords($this->_name),$content);
|
||||
return $content;
|
||||
}
|
||||
}
|
4
engine/console/ls
Normal file
4
engine/console/ls
Normal file
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require_once(dirname(__FILE__).'/ls.php');
|
10
engine/console/ls.php
Normal file
10
engine/console/ls.php
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
// Для эмуляции работы, т.к используется в конфиге
|
||||
$_SERVER['HTTP_HOST']='localhost';
|
||||
|
||||
require_once("./../../config/loader.php");
|
||||
require_once(dirname(__FILE__).'/lsc.php');
|
||||
|
||||
|
||||
LSC::Start();
|
199
engine/console/lsc.php
Normal file
199
engine/console/lsc.php
Normal file
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
abstract class LSC {
|
||||
|
||||
/*
|
||||
* Запускаем работу консоли
|
||||
*/
|
||||
static function Start() {
|
||||
$aArgs = $_SERVER['argv'];
|
||||
|
||||
// Если не передана команда выводим помощь
|
||||
if(count($aArgs)==1) {
|
||||
echo self::getHelp()."\n";
|
||||
return ;
|
||||
}
|
||||
|
||||
$sCommandClassName = ucwords($aArgs[1]);
|
||||
$sCommandClassPath = dirname(__FILE__).'/commands/'.$sCommandClassName.'.class.php';
|
||||
|
||||
// Существует ли такой класс, а следовательно и команда
|
||||
if(file_exists($sCommandClassPath)) {
|
||||
// Подключаем класс команды
|
||||
require_once $sCommandClassPath;
|
||||
$oCommand = new $sCommandClassName();
|
||||
$oCommand->run($aArgs);
|
||||
} else {
|
||||
die("Command not isset\n");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Отдаем управление вызванной команде
|
||||
*/
|
||||
public function run($aArgs) {
|
||||
// Если не передана подкоманда или передана подкоманда help выводим помощь
|
||||
if(!isset($aArgs[2]) or $aArgs[2]=='help') {
|
||||
echo $this->getHelp()."\n";
|
||||
return ;
|
||||
}
|
||||
|
||||
$sMethodName = 'action'.ucwords($aArgs[2]);
|
||||
|
||||
// Оставляем в массиве только параметры для подкоманды
|
||||
array_shift($aArgs);
|
||||
array_shift($aArgs);
|
||||
array_shift($aArgs);
|
||||
|
||||
$this->$sMethodName($aArgs);
|
||||
}
|
||||
|
||||
/*
|
||||
* Создает массив файлов используемый при копировании
|
||||
*/
|
||||
public function buildFileList($sSourceDir, $sTargetDir, $sBaseDir='')
|
||||
{
|
||||
$aList=array();
|
||||
$handle=opendir($sSourceDir);
|
||||
while(($sFile=readdir($handle))!==false)
|
||||
{
|
||||
if($sFile==='.' || $sFile==='..' || $sFile==='.svn' ||$sFile==='.gitignore')
|
||||
continue;
|
||||
|
||||
$sSourcePath=$sSourceDir.DIRECTORY_SEPARATOR.$sFile;
|
||||
$sTargetPath=$sTargetDir.DIRECTORY_SEPARATOR.$sFile;
|
||||
|
||||
$sName=($baseDir==='')? $sFile : $sBaseDir.'/'.$sFile;
|
||||
|
||||
// Строим массив с ключем в виде имени файла или папки, пути к исходнику и пути назначения
|
||||
$aList[$sName]=array(
|
||||
'source'=>$sSourcePath,
|
||||
'target'=>$sTargetPath
|
||||
);
|
||||
|
||||
// Если директория то рекурсивно получаем массив его содержимого и объединяем с главным
|
||||
if(is_dir($sSourcePath)) {
|
||||
$aList=array_merge($aList,$this->buildFileList($sSourcePath,$sTargetPath,$sName));
|
||||
}
|
||||
}
|
||||
closedir($handle);
|
||||
return $list;
|
||||
}
|
||||
|
||||
/*
|
||||
* Копирование файлов
|
||||
*/
|
||||
public function copyFiles($fileList)
|
||||
{
|
||||
$overwriteAll=false;
|
||||
foreach($fileList as $name=>$file)
|
||||
{
|
||||
$source=strtr($file['source'],'/\\',DIRECTORY_SEPARATOR);
|
||||
$target=strtr($file['target'],'/\\',DIRECTORY_SEPARATOR);
|
||||
|
||||
$callback=isset($file['callback']) ? $file['callback'] : null;
|
||||
$params=isset($file['params']) ? $file['params'] : null;
|
||||
|
||||
if(is_dir($source))
|
||||
{
|
||||
// Проверяем существует ли директория или досоздаем папки
|
||||
$this->ensureDirectory($target);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Если существует коллбэк то вызываем его
|
||||
if($callback!==null)
|
||||
$content=call_user_func($callback,$source,$params);
|
||||
// Либо отдаем содержимое исходника без изменений
|
||||
else
|
||||
$content=file_get_contents($source);
|
||||
|
||||
// Если файл в папке назначения уже существует
|
||||
if(is_file($target))
|
||||
{
|
||||
// Если содержимое старого и нового файла совпадают
|
||||
if($content===file_get_contents($target))
|
||||
{
|
||||
echo " unchanged $name\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Если мы выбрали перезапись в ветке false
|
||||
if($overwriteAll)
|
||||
echo " overwrite $name\n";
|
||||
else
|
||||
{
|
||||
echo " exist $name\n";
|
||||
echo " ...overwrite? [Yes|No|All|Quit] ";
|
||||
|
||||
// Спрашиваем у пользователя как поступить
|
||||
$answer=trim(fgets(STDIN));
|
||||
if(!strncasecmp($answer,'q',1))
|
||||
return;
|
||||
else if(!strncasecmp($answer,'y',1))
|
||||
echo " overwrite $name\n";
|
||||
else if(!strncasecmp($answer,'a',1))
|
||||
{
|
||||
echo " overwrite $name\n";
|
||||
$overwriteAll=true;
|
||||
}
|
||||
else
|
||||
{
|
||||
echo " skip $name\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если файла еще не существует
|
||||
else
|
||||
{
|
||||
// Досоздаем папки в случае отсутствия
|
||||
$this->ensureDirectory(dirname($target));
|
||||
echo " generate $name\n";
|
||||
}
|
||||
|
||||
// Создаем файл и записываем в него содержимое
|
||||
file_put_contents($target,$content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает родительские папки если они не существуют
|
||||
* @param string $directory
|
||||
*/
|
||||
public function ensureDirectory($directory)
|
||||
{
|
||||
if(!is_dir($directory))
|
||||
{
|
||||
$this->ensureDirectory(dirname($directory));
|
||||
echo " mkdir ".strtr($directory,'\\','/')."\n";
|
||||
mkdir($directory);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Выводит помощь и список возможных команд
|
||||
*/
|
||||
public function getHelp() {
|
||||
$aList=array();
|
||||
$handle=opendir(dirname(__FILE__).'/commands/');
|
||||
while(($file=readdir($handle))!==false)
|
||||
{
|
||||
if($file==='.' || $file==='..')
|
||||
continue;
|
||||
if(is_file(dirname(__FILE__).'/commands/'.$file))
|
||||
$aList[]=strtolower(preg_replace("/^(.*)\.(.*)\.(.*)/i","$1",$file));
|
||||
}
|
||||
closedir($handle);
|
||||
|
||||
echo "USAGE\n
|
||||
ls ";
|
||||
|
||||
foreach($aList as $iKey=>$sName) {
|
||||
if($iKey>0)
|
||||
echo " ";
|
||||
echo $sName."\n";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
82
engine/console/protected/plugin/PluginExample.class.php
Normal file
82
engine/console/protected/plugin/PluginExample.class.php
Normal file
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Запрещаем напрямую через браузер обращение к этому файлу.
|
||||
*/
|
||||
if (!class_exists('Plugin')) {
|
||||
die('Hacking attemp!');
|
||||
}
|
||||
|
||||
class PluginExample extends Plugin {
|
||||
|
||||
// Объявление делегирований (нужны для того, чтобы назначить свои экшны и шаблоны)
|
||||
public $aDelegates = array(
|
||||
/**
|
||||
* 'action' => array('ActionIndex'=>'_ActionSomepage'),
|
||||
* Замена экшна ActionIndex на ActionSomepage из папки плагина
|
||||
*
|
||||
* 'template' => array('index.tpl'=>'_my_plugin_index.tpl'),
|
||||
* Замена index.tpl из корня скина файлом /plugins/abcplugin/templates/skin/default/my_plugin_index.tpl
|
||||
*
|
||||
* 'template'=>array('actions/ActionIndex/index.tpl'=>'_actions/ActionTest/index.tpl'),
|
||||
* Замена index.tpl из скина из папки actions/ActionIndex/ файлом /plugins/abcplugin/templates/skin/default/actions/ActionTest/index.tpl
|
||||
*/
|
||||
|
||||
|
||||
);
|
||||
|
||||
// Объявление переопределений (модули, мапперы и сущности)
|
||||
protected $aInherits=array(
|
||||
/**
|
||||
* Переопределение модулей (функционал):
|
||||
* 'module' =>array('ModuleTopic'=>'_ModuleTopic'),
|
||||
*
|
||||
* К классу ModuleTopic (/classes/modules/Topic.class.php) добавляются методы из
|
||||
* PluginAbcplugin_ModuleTopic (/plugins/abcplugin/classes/modules/Topic.class.php) - новые или замена существующих
|
||||
*
|
||||
*
|
||||
*
|
||||
* Переопределение мапперов (запись/чтение объектов в/из БД):
|
||||
* 'mapper' =>array('ModuleTopic_MapperTopic' => '_ModuleTopic_MapperTopic'),
|
||||
*
|
||||
* К классу ModuleTopic_MapperTopic (/classes/modules/mapper/Topic.mapper.class.php) добавляются методы из
|
||||
* PluginAbcplugin_ModuleTopic_EntityTopic (/plugins/abcplugin/classes/modules/mapper/Topic.mapper.class.php) - новые или замена существующих
|
||||
*
|
||||
*
|
||||
*
|
||||
* Переопределение сущностей (интерфейс между объектом и записью/записями в БД):
|
||||
* 'entity' =>array('ModuleTopic_EntityTopic' => '_ModuleTopic_EntityTopic'),
|
||||
*
|
||||
* К классу ModuleTopic_EntityTopic (/classes/modules/entity/Topic.entity.class.php) добавляются методы из
|
||||
* PluginAbcplugin_ModuleTopic_EntityTopic (/plugins/abcplugin/classes/modules/entity/Topic.entity.class.php) - новые или замена существующих
|
||||
*
|
||||
*/
|
||||
);
|
||||
|
||||
// Активация плагина
|
||||
public function Activate() {
|
||||
/*
|
||||
if (!$this->isTableExists('prefix_tablename')) {
|
||||
$this->ExportSQL(dirname(__FILE__).'/install.sql'); // Если нам надо изменить БД, делаем это здесь.
|
||||
}
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
|
||||
// Деактивация плагина
|
||||
public function Deactivate(){
|
||||
/*
|
||||
$this->ExportSQL(dirname(__FILE__).'/deinstall.sql'); // Выполнить деактивационный sql, если надо.
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
// Инициализация плагина
|
||||
public function Init() {
|
||||
$this->Viewer_AppendStyle(Plugin::GetTemplatePath(__CLASS__)."/css/style.css"); // Добавление своего CSS
|
||||
$this->Viewer_AppendScript(Plugin::GetTemplatePath(__CLASS__)."/js/script.js"); // Добавление своего JS
|
||||
|
||||
//$this->Viewer_AddMenu('blog',Plugin::GetTemplatePath(__CLASS__).'/menu.blog.tpl'); // например, задаем свой вид меню
|
||||
}
|
||||
}
|
||||
?>
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
class PluginExample_ActionExample extends ActionPlugin {
|
||||
|
||||
/**
|
||||
* Инициализация экшена
|
||||
*/
|
||||
public function Init() {
|
||||
$this->SetDefaultEvent('index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрируем евенты
|
||||
*/
|
||||
protected function RegisterEvent() {
|
||||
$this->AddEvent('index','EventIndex');
|
||||
|
||||
}
|
||||
|
||||
protected function EventIndex() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершение работы экшена
|
||||
*/
|
||||
public function EventShutdown() {
|
||||
|
||||
}
|
||||
}
|
||||
?>
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
class PluginExample_BlockExample extends Block {
|
||||
public function Exec() {
|
||||
|
||||
}
|
||||
}
|
||||
?>
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
class PluginExample_HookExample extends Hook {
|
||||
|
||||
/*
|
||||
* Регистрация событий на хуки
|
||||
*/
|
||||
public function RegisterHook() {
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Хук в начало функции AddTopic() в модуле Topic (файл /classes/modules/topic/Topic.class.php , если этот модуль не переопределен в других плагинах):
|
||||
*
|
||||
* $this->AddHook('module_topic_addtopic_before','func_topic_addtopic_before');
|
||||
*
|
||||
* Будет вызвана функция func_topic_addtopic_before($aVars) , где $aVars - НЕассоциативный массив аргументов, переданных этой функции.
|
||||
* Передача результата в функцию AddTopic() делается путем изменения аргументов по ссылке - например, &$aVars[0]
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Хук в конец функции AddTopic() в модуле Topic (файл /classes/modules/topic/Topic.class.php , если этот модуль не переопределен в других плагинах):
|
||||
*
|
||||
* $this->AddHook('module_topic_addtopic_after','func_topic_addtopic_after');
|
||||
*
|
||||
* Будет вызвана функция func_topic_addtopic_after($Var) , где $Var - это то, что возвращает AddTopic() (т.е. или false или объект топика $oTopic)
|
||||
* Функция должна завершаться при помощи return $Var
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Хук в конкреное место движка
|
||||
*
|
||||
* $this->AddHook('init_action','func_init_action', __CLASS__, -5);
|
||||
*
|
||||
* Приоритет для вызова хука = -5. Этот приоритет так же можно указывать и в хуках на модели.
|
||||
* Будет вызвана функция func_init_action($Var) в том месте движка, где стоит данный хук
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Хук с делегированием
|
||||
*
|
||||
* $this->AddDelegateHook('module_topic_addtopic_before','func_topic_addtopic_new',__CLASS__);
|
||||
*
|
||||
* Полная подмена функции AddTopic() модуля Topic на свою.
|
||||
* Будет вызвана функция func_topic_addtopic_new($Var), где $aVars - НЕассоциативный массив аргументов.
|
||||
* Делегирование существует в движке только для обеспечения совместимости со старыми плагинами, рекомендуется вместо него использовать переопределение.
|
||||
*/
|
||||
}
|
||||
}
|
||||
?>
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
class PluginExample_ModuleExample extends Module {
|
||||
|
||||
|
||||
}
|
||||
?>
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
class PluginExample_ModuleExample_EntityExample extends Entity
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
?>
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
class PluginExample_ModuleExample_MapperExample extends Mapper
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
?>
|
16
engine/console/protected/plugin/config/config.php
Normal file
16
engine/console/protected/plugin/config/config.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
/**
|
||||
* Конфиг
|
||||
*/
|
||||
|
||||
$config = array();
|
||||
|
||||
// Переопределить имеющуюся переменную в конфиге:
|
||||
// Переопределение роутера на наш новый Action - добавляем свой урл http://domain.com/example
|
||||
// Config::Set('router.page.example', 'PluginExample_ActionExample');
|
||||
|
||||
// Добавить новую переменную:
|
||||
// $config['per_page'] = 15;
|
||||
// Эта переменная будет доступна в плагине как Config::Get('plugin.example.per_page')
|
||||
|
||||
return $config;
|
10
engine/console/protected/plugin/deinstall.sql
Normal file
10
engine/console/protected/plugin/deinstall.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
--
|
||||
-- SQL, которые надо выполнить движку при деактивации плагина админом. Вызывается на исполнение ВРУЧНУЮ в /plugins/PluginXxxxx.class.php в методе Deactivate()
|
||||
-- Например:
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS `prefix_tablename` (
|
||||
-- `page_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- `page_pid` int(11) unsigned DEFAULT NULL,
|
||||
-- PRIMARY KEY (`page_id`),
|
||||
-- KEY `page_pid` (`page_pid`),
|
||||
-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
|
10
engine/console/protected/plugin/install.sql
Normal file
10
engine/console/protected/plugin/install.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
--
|
||||
-- SQL, которые надо выполнить движку при активации плагина админом. Вызывается на исполнение ВРУЧНУЮ в /plugins/PluginAbcplugin.class.php в методе Activate()
|
||||
-- Например:
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS `prefix_tablename` (
|
||||
-- `page_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- `page_pid` int(11) unsigned DEFAULT NULL,
|
||||
-- PRIMARY KEY (`page_id`),
|
||||
-- KEY `page_pid` (`page_pid`),
|
||||
-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
|
22
engine/console/protected/plugin/plugin.xml
Normal file
22
engine/console/protected/plugin/plugin.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plugin>
|
||||
<name>
|
||||
<lang name="default">Example</lang>
|
||||
<lang name="russian">Example</lang>
|
||||
</name>
|
||||
<author>
|
||||
<lang name="default">author</lang>
|
||||
<lang name="russian">автор</lang>
|
||||
</author>
|
||||
<homepage>http://livestreet.ru</homepage>
|
||||
<version>1.0.0</version>
|
||||
<requires>
|
||||
<livestreet>0.5.1</livestreet>
|
||||
<plugins>
|
||||
</plugins>
|
||||
</requires>
|
||||
<description>
|
||||
<lang name="default">Description of plugin Example</lang>
|
||||
<lang name="russian">Описание плагина Example</lang>
|
||||
</description>
|
||||
</plugin>
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
/**
|
||||
* English language file for plug-in
|
||||
*/
|
||||
return array(
|
||||
'name' => 'text',
|
||||
);
|
||||
|
||||
?>
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
/**
|
||||
* Русский языковой файл плагина
|
||||
*/
|
||||
return array(
|
||||
'name' => 'text',
|
||||
);
|
||||
|
||||
?>
|
|
@ -0,0 +1,3 @@
|
|||
{include file="header.tpl"}
|
||||
|
||||
{include file="footer.tpl"}
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
Loading…
Reference in a new issue