Pull to refresh

Разработка дополнения для MODx Revolution. Часть 3

Reading time 14 min
Views 13K


Это перевод третьей части урока. Как я писал в предыдущей статье, переводить вторую часть особого смысла нет, т.к. информации на русском по созданию страницы компонента уже достаточно. Поэтому я перескочил на третью.

В этом уроке будет рассказано как упаковать дополнение в транспортный пакет, который затем можно будет легко установить через «Управление пакетами». Упаковывать будем всё, что относится к, разработанному нами, дополнению: сниппет; файлы из core/components/ и assets/components/; действия; пункт в меню и пространство имен нашей CMP (страницы компонента); значения по умолчанию для сниппета с поддержкой интернационализации (i18n). А также добавим резольвер, который создаст пользовательские таблицы в БД.

Справка:
Для упаковки простых дополнений можно использовать PackMan. Но в данном случае мы хотим сделать это самостоятельно и полностью разобраться что из себя представляет транспортный пакет.

Настройка директории для сборки



В конце урока каталог _build будет выглядеть так:



Мы уже знакомы с файлами build.config.php и build.schema.php из первой части урока, а сейчас давайте просто посмотрим на другие части:

data — Здесь мы собираемся поместить все наши скрипты для упаковки данных пакета.
resolvers — Папка содержит резольверы для транспортного пакета.
build.transport.php — Это главный скрипт упаковщика, который нужно будет запустить для создания пакета.
setup.options.php — Настройки установщика. Позже кратко рассмотрим для чего это нужно.

Создание скрипта упаковщика



Создадим файл /www/doodles/_build/build.transport.php с таким содержинием:

<?php

$tstart = explode(' ', microtime());
$tstart = $tstart[1] + $tstart[0];
set_time_limit(0);
 
/* задаем имя пакета */
define('PKG_NAME','Doodles');
define('PKG_NAME_LOWER','doodles');
define('PKG_VERSION','1.0');
define('PKG_RELEASE','rc1');
 
/* задаем пути для упаковщика */
$root = dirname(dirname(__FILE__)).'/';
$sources = array(
    'root' => $root,
    'build' => $root . '_build/',
    'data' => $root . '_build/data/',
    'resolvers' => $root . '_build/resolvers/',
    'chunks' => $root.'core/components/'.PKG_NAME_LOWER.'/chunks/',
    'lexicon' => $root . 'core/components/'.PKG_NAME_LOWER.'/lexicon/',
    'docs' => $root.'core/components/'.PKG_NAME_LOWER.'/docs/',
    'elements' => $root.'core/components/'.PKG_NAME_LOWER.'/elements/',
    'source_assets' => $root.'assets/components/'.PKG_NAME_LOWER,
    'source_core' => $root.'core/components/'.PKG_NAME_LOWER,
);
unset($root);
 
/* override with your own defines here (see build.config.sample.php) */
require_once $sources['build'] . 'build.config.php';
require_once MODX_CORE_PATH . 'model/modx/modx.class.php';
 
$modx= new modX();
$modx->initialize('mgr');
echo '<pre>'; /* used for nice formatting of log messages */
$modx->setLogLevel(modX::LOG_LEVEL_INFO);
$modx->setLogTarget('ECHO');
 
$modx->loadClass('transport.modPackageBuilder','',false, true);
$builder = new modPackageBuilder($modx);
$builder->createPackage(PKG_NAME_LOWER,PKG_VERSION,PKG_RELEASE);
$builder->registerNamespace(PKG_NAME_LOWER,false,true,'{core_path}components/'.PKG_NAME_LOWER.'/');
 
/* zip up package */
$modx->log(modX::LOG_LEVEL_INFO,'Packing up transport package zip...');
$builder->pack();
 
$tend= explode(" ", microtime());
$tend= $tend[1] + $tend[0];
$totalTime= sprintf("%2.4f s",($tend - $tstart));
$modx->log(modX::LOG_LEVEL_INFO,"\n<br />Package Built.<br />\nExecution time: {$totalTime}\n");
exit ();


Тут довольно много всего, но заметим, что это всё, что нужно для упаковки нашего пространства имен и создания файла транспортного пакета «doodles-1.0-rc1.zip» (только основа). Разберем подробно.

$tstart = explode(' ', microtime());
$tstart = $tstart[1] + $tstart[0];
set_time_limit(0);
 
/* задаем имя пакета */
define('PKG_NAME','Doodles');
define('PKG_NAME_LOWER','doodles');
define('PKG_VERSION','1.0');
define('PKG_RELEASE','rc1');


Во-первых мы собираемся получить время начала сборки, чтобы в конце вывести сколько времени потребовалось на сборку. Это совсем не обязательно, просто полезная информация. Затем мы указываем название, версию и тип релиза. Далее:

/* задаем пути для упаковщика */
$root = dirname(dirname(__FILE__)).'/';
$sources = array(
    'root' => $root,
    'build' => $root . '_build/',
    'data' => $root . '_build/data/',
    'resolvers' => $root . '_build/resolvers/',
    'chunks' => $root.'core/components/'.PKG_NAME_LOWER.'/chunks/',
    'lexicon' => $root . 'core/components/'.PKG_NAME_LOWER.'/lexicon/',
    'docs' => $root.'core/components/'.PKG_NAME_LOWER.'/docs/',
    'elements' => $root.'core/components/'.PKG_NAME_LOWER.'/elements/',
    'source_assets' => $root.'assets/components/'.PKG_NAME_LOWER,
    'source_core' => $root.'core/components/'.PKG_NAME_LOWER,
);
unset($root);
 
/* override with your own defines here (see build.config.sample.php) */
require_once $sources['build'] . 'build.config.php';
require_once MODX_CORE_PATH . 'model/modx/modx.class.php';


Здесь мы определяем пути, где найти все части нашего пакета для упаковки.

Наконец, мы подключили файл build.config.php и класс MODx. Теперь пришло время загрузить объект MODx:

$modx = new modX();
$modx->initialize('mgr');
echo '<pre>'; /* used for nice formatting of log messages */
$modx->setLogLevel(modX::LOG_LEVEL_INFO);
$modx->setLogTarget('ECHO');
 
$modx->loadClass('transport.modPackageBuilder','',false, true);
$builder = new modPackageBuilder($modx);
$builder->createPackage(PKG_NAME_LOWER,PKG_VERSION,PKG_RELEASE);
$builder->registerNamespace(PKG_NAME_LOWER,false,true,'{core_path}components/'.PKG_NAME_LOWER.'/');


Здесь мы создаем объект modX и инициализируем контекст «mgr». Далее мы просим MODX быть более многословным в его сообщениях об ощибках во время работы нашего скрипта. Просим выводить сообщения на экран.

Затем загружаем класс «modPackageBuilder» и получаем два полезных метода createPackage и registerNamespace.

$modx->createPackage(key,version,release)


Тут задается имя нашего пакета (оно должно быть в нижнем регистре и не должно содержать точку или дефис), версию и тип релиза. Теперь modPackageBuilder автоматически упакует наше пространство имен:

$builder->registerNamespace(namespace_name,autoincludes,packageNamespace,namespacePath)


Первым параметром является имя пространства имен («doodles» в нашем случае). Вторым — массив классов, связанных с нашим пространством имен (нам это не нужно, поэтому устанавливаем false). Третим параметром мы говорим, что хотим упаковать пространство имен в пакет (устанавливаем в true). И третим параметром задаем путь до нашего пространства имен. Этот последний параметр является ключевым. Обратите внимание на плейсхолдер "{core_path}", он будет заменен на реальный путь во время установки пакета, что позволит сделать пакет более гибким. Не нужно указывать пути жестко.

И вот несколько последних строк нашего упаковщика:

/* zip up package */
$modx->log(modX::LOG_LEVEL_INFO,'Packing up transport package zip...');
$builder->pack();
 
$tend= explode(" ", microtime());
$tend= $tend[1] + $tend[0];
$totalTime= sprintf("%2.4f s",($tend - $tstart));
$modx->log(modX::LOG_LEVEL_INFO,"\n<br />Package Built.<br />\nExecution time: {$totalTime}\n");
exit ();


Метод pack() говорит MODX, что нужно создать файл ZIP транспотного пакета. Остальные строки просто выводят время, которое потребовалось для сборки. Вот И всё. Если вы запустите
это в браузере (у меня адрес http ://localhost/doodles/_build/build.transport.php), вы получите отладочную информацию и в папке core/packages/ это:



Это наш транспортный пакет! Однако конкретно для нашего дополнения этого не достаточно.

Добавление данных



Мы хотим добавить в пакет наш сниппет в отдельной категории «Doodles». В файле build.transport.php добавим ниже registerNamespace такой код:

<?php
$category= $modx->newObject('modCategory');
$category->set('id',1);
$category->set('category',PKG_NAME);
 
/* добавляем сниппет */
//$modx->log(modX::LOG_LEVEL_INFO,'Packaging in snippets...');
//$snippets = include $sources['data'].'transport.snippets.php';
//if (empty($snippets)) $modx->log(modX::LOG_LEVEL_ERROR,'Could not package in snippets.');
//$category->addMany($snippets);
 
/* create category vehicle */
$attr = array(
    xPDOTransport::UNIQUE_KEY => 'category',
    xPDOTransport::PRESERVE_KEYS => false,
    xPDOTransport::UPDATE_OBJECT => true,
    xPDOTransport::RELATED_OBJECTS => true,
    xPDOTransport::RELATED_OBJECT_ATTRIBUTES => array (
        'Snippets' => array(
            xPDOTransport::PRESERVE_KEYS => false,
            xPDOTransport::UPDATE_OBJECT => true,
            xPDOTransport::UNIQUE_KEY => 'name',
        ),
    ),
);
$vehicle = $builder->createVehicle($category,$attr);
$builder->putVehicle($vehicle);


Во-первых мы создаем объект modCategory (категория) с именем «Doodles». Обратите внимание, что мы не сохраняем ->save(), а только создаем объект. Далее у нас есть код для упаковки снипета, но пока проигнорируем его, мы вернемся к нему позже.

Затем мы создали большой массив атрибутов — атрибутов транспортного средства (Vehicle) категории. Что за транспортное средство? Ну, это транспортное средство, которое несет объект к транспортному пакету. Каждый объект (сниппет, пункт меню, категория и т.п.) должен иметь транспортное средство для «перевозки» в транспортный пакет. Таким образом мы создали один из них, но сначало присвоили несколько атрибутов, которые говорят MODX как это транспортное средство должно вести себя когда пользователь устанавливает пакет.

  • xPDOTransport::UNIQUE_KEY => 'category' — здесь мы говорим MODX, что уникальным ключём для этой категории является поле «category».
  • xPDOTransport::PRESERVE_KEYS => false — иногда мы хотим чтобы первичный ключ нашего объекта был «сохранен». Это полезно для не автоинкрементных ключей (PKs), таких как у меню, которое мы получим позже. Нашей категории это не нужно, поэтому устанавливаем в false.
  • xPDOTransport::UPDATE_OBJECT => true — это говорит MODX, что если категория уже существует, нужно обновить её нашей версией. Если установить в false, MODX просто пропустит категорию, если найдет её. Мы хотим чтобы категория обновилась.
  • xPDOTransport::RELATED_OBJECTS => true — это указывает связанные объекты (указываем объект сниппета). Наш случай хороший пример. Любые сниппеты, которые будут установлены, будут помещены в категорию.
  • xPDOTransport::RELATED_OBJECT_ATTRIBUTES — Это ассоциативный массив с атрибутами связанных объектов. В нашем случае это только сниппет, но это могут быть плагины, TV-параметры (дополнительные поля), чанки и т.д.


Задаем свойства объекту сниппета:

'Snippets' => array(
   xPDOTransport::PRESERVE_KEYS => false,
   xPDOTransport::UPDATE_OBJECT => true,
   xPDOTransport::UNIQUE_KEY => 'name',
),


Здесь мы говорим, что сохранять первичный ключ не требуется (аналогично категории). Затем мы хотим обновить объект, если он уже существует. И, наконец, мы говорим MODX, что поле «name» является первичным ключем.

Далее делаем так:

$vehicle = $builder->createVehicle($category,$attr);
$builder->putVehicle($vehicle);


Это упаковывает наш объект категории в небольшое транспортное средство с атрибутами, которые мы только что определили. Это и добавляет его в транспортный пакет. Готово! Наша категория упакована. Теперь добавим к ней сниппет.

Добавление сниппета



Идем дальше и создаем папку /www/doodles/_build/data/. Теперь создаем в ней файл /www/doodles/_build/data/transport.snippets.php. Поместите в него такой код:

<?php
function getSnippetContent($filename) {
    $o = file_get_contents($filename);
    $o = trim(str_replace(array('<?php','?>'),'',$o));
    return $o;
}
$snippets = array();
 
$snippets[1]= $modx->newObject('modSnippet');
$snippets[1]->fromArray(array(
    'id' => 1,
    'name' => 'Doodles',
    'description' => 'Displays a list of Doodles.',
    'snippet' => getSnippetContent($sources['elements'].'snippets/snippet.doodles.php'),
),'',true,true);
$properties = include $sources['data'].'properties/properties.doodles.php';
$snippets[1]->setProperties($properties);
unset($properties);
 
return $snippets;


Во-первых мы создали небольшой вспомогательный метод, который будет захватывать наши куски кода из файлов и убирать из него теги "<?php". Затем мы создаем объект сниппета. Помните: не нужно сохранять, только создаем. Настало время вернуться к массиву $snippets. Помните закомментированнную часть из файла build.transport.php? Вот эта часть:

/* добавляем сниппет */
$modx->log(modX::LOG_LEVEL_INFO,'Packaging in snippets...');
$snippets = include $sources['data'].'transport.snippets.php';
if (empty($snippets)) $modx->log(modX::LOG_LEVEL_ERROR,'Could not package in snippets.');
$category->addMany($snippets);


Убираем комментирование. Теперь наш сниппет загружается в транспортное средство категории. Теперь добавим свойства, которые мы упоминали ранее.

Добавление свойств сниппета



Создайте файл /www/doodles/_build/data/properties/properties.doodles.php с таким содержанием:

<?php
$properties = array(
    array(
        'name' => 'tpl',
        'desc' => 'prop_doodles.tpl_desc',
        'type' => 'textfield',
        'options' => '',
        'value' => 'rowTpl',
        'lexicon' => 'doodles:properties',
    ),
    array(
        'name' => 'sort',
        'desc' => 'prop_doodles.sort_desc',
        'type' => 'textfield',
        'options' => '',
        'value' => 'name',
        'lexicon' => 'doodles:properties',
    ),
    array(
        'name' => 'dir',
        'desc' => 'prop_doodles.dir_desc',
        'type' => 'list',
        'options' => array(
            array('text' => 'prop_doodles.ascending','value' => 'ASC'),
            array('text' => 'prop_doodles.descending','value' => 'DESC'),
        ),
        'value' => 'DESC',
        'lexicon' => 'doodles:properties',
    ),
);
return $properties;


Это PHP-представление свойств (параметров) сниппета по умолчанию. Давайте рассмотрим все его ключи:

  • name — имя сниппета. Именно это имя указывается в вызове:

    [[Doodles? &tpl=`rowTpl`]]

  • desc — описание сниппета.
  • type — это 'xtype' поля свойства. В настоящее время доступны 4 типа: «textfield» (текстовое поле), «textarea», «combo-boolean» (выпадающий список «Да/Нет») и «list» (список значений).
  • options — используется только для списка значений. Это массив массивов. Каждый из них имеет два значений: 'text' (текст) и 'value' (значение). Текст выводится пользователю в списке, а значение сохраняется в БД. Текст может быть ключем из лексикона.
  • value — значение свойства по умолчанию.
  • lexicon — При желании, свойства могут быть i18n-совместимыми. Просто укажите название лексикона и MODX сделает остальное.


Итак, у нас есть свойства. Но как вы видите мы сделали ссылку на новый раздел лексикона «doodles:properties». Давайте создадим файл лексикона /www/doodles/core/components/doodles/lexicon/en/properties.inc.php с таким содержанием:

<?php
$_lang['prop_doodles.ascending'] = 'Ascending';
$_lang['prop_doodles.descending'] = 'Descending';
$_lang['prop_doodles.dir_desc'] = 'The direction to sort by.';
$_lang['prop_doodles.sort_desc'] = 'The field to sort by.';
$_lang['prop_doodles.tpl_desc'] = 'The chunk for displaying each row.';


Как вы можете видеть тут содержание подобно разделу «default».

Если вы запустите скрипт сейчас, то наша категория и сниппет со своими свойствами будет упакован в какет. Отлично! Но мы пропустили сами файлы нашего дополнения. Давайте исправим это.

Добавление файловых резольверов (Resolvers)



Давайте добавим в пакет папки с файлами /www/doodles/core/components/doodles/ и /www/doodles/assets/components/doodles/ нашего дополнения. Мы добавим файлы в наше транспортное средство категории с помощью т.н. файловых резольверов.

Итак, в build.transport.php сразу после добавления транспортного средства категории:

$vehicle = $builder->createVehicle($category,$attr);


добавим это:

$modx->log(modX::LOG_LEVEL_INFO,'Adding file resolvers to category...');
$vehicle->resolve('file',array(
    'source' => $sources['source_assets'],
    'target' => "return MODX_ASSETS_PATH . 'components/';",
));
$vehicle->resolve('file',array(
    'source' => $sources['source_core'],
    'target' => "return MODX_CORE_PATH . 'components/';",
));


Стоит разобрать два атрибута:

source — это путь, по которому можно найти файлы. Используем наши source_assets и source_core, которые были определены нами ранее.

target — это eval-строка, которая возвращает путь где будут находиться файлы нашего дополнения.

Первый параметр в resolve() говорит MODX, что это файловый резольвер. Мы рассмотрим подробнее резольверы позже в этом уроке.

Если вы запустите упаковщик сейчас, он упакует папки doodles/core/ и doodles/assets/.

Добавление пункта меню и действия



Теперь давайте добавим пункт меню и действие для страницы компонента, которую мы сделали ранее

Добавим такой код:

$modx->log(modX::LOG_LEVEL_INFO,'Packaging in menu...');
$menu = include $sources['data'].'transport.menu.php';
if (empty($menu)) $modx->log(modX::LOG_LEVEL_ERROR,'Could not package in menu.');
$vehicle= $builder->createVehicle($menu,array (
    xPDOTransport::PRESERVE_KEYS => true,
    xPDOTransport::UPDATE_OBJECT => true,
    xPDOTransport::UNIQUE_KEY => 'text',
    xPDOTransport::RELATED_OBJECTS => true,
    xPDOTransport::RELATED_OBJECT_ATTRIBUTES => array (
        'Action' => array (
            xPDOTransport::PRESERVE_KEYS => false,
            xPDOTransport::UPDATE_OBJECT => true,
            xPDOTransport::UNIQUE_KEY => array ('namespace','controller'),
        ),
    ),
));
$modx->log(modX::LOG_LEVEL_INFO,'Adding in PHP resolvers...');
$builder->putVehicle($vehicle);
unset($vehicle,$menu);


Тут всё аналогично транстпортному средству (vehicle) категории. Создается объект меню и связанные объект действия.
  • PRESERVE_KEYS установлено в true, т.к. меню имеют уникальные ключи и мы хотим сохранить ключ нашего пункта меню.
  • UNIQUE_KEY связанного объекта действия является массивом. Это говорит MODX, что нужно искать объект modAction, который имеет пространство имен 'namespace' => 'doodles' и контроллер 'controllers/index'.


Как вы, наверное, догадались, мы должны добавить файл transport.menu.php. Создадим его /www/doodles/_build/data/transport.menu.php:

<?php
$action= $modx->newObject('modAction');
$action->fromArray(array(
    'id' => 1,
    'namespace' => 'doodles',
    'parent' => 0,
    'controller' => 'controllers/index',
    'haslayout' => true,
    'lang_topics' => 'doodles:default',
    'assets' => '',
),'',true,true);
 
$menu= $modx->newObject('modMenu');
$menu->fromArray(array(
    'text' => 'doodles',
    'parent' => 'components',
    'description' => 'doodles.desc',
    'icon' => 'images/icons/plugin.gif',
    'menuindex' => 0,
    'params' => '',
    'handler' => '',
),'',true,true);
$menu->addOne($action);
unset($menus);
 
return $menu;


Тут всё аналогично transport.snippets.php, за исключением того, что вызвали метод addOne() объекта menu. Обратите внимание, что все элементы массива fromArray() соответствуют полям в таблицах БД.

Итак, пункт меню и действие упакованы.

Добавление резольвера



Когда мы установим наше дополнение в системе, мы столкнемся с одной проблемой — таблицы БД modx_doodles не будет существовать. Давайте напишем PHP резольвер, который будет запускаться после транстпортного средства. Добавим этот резольвер к нашему транспортному средству меню. Сразу после $vehicle = $builder->createVehicle($menu) добавим такой код:

$modx->log(modX::LOG_LEVEL_INFO,'Adding in PHP resolvers...');
$vehicle->resolve('php',array(
    'source' => $sources['resolvers'] . 'resolve.tables.php',
));


Создадим файл /www/doodles/_build/resolvers/resolve.tables.php с таким содержанием:

<?php
if ($object->xpdo) {
    switch ($options[xPDOTransport::PACKAGE_ACTION]) {
        case xPDOTransport::ACTION_INSTALL:
            $modx =& $object->xpdo;
            $modelPath = $modx->getOption('doodles.core_path',null,$modx->getOption('core_path').'components/doodles/').'model/';
            $modx->addPackage('doodles',$modelPath);
 
            $manager = $modx->getManager();
 
            $manager->createObjectContainer('Doodle');
 
            break;
        case xPDOTransport::ACTION_UPGRADE:
            break;
    }
}
return true;


Отлично. Думаю тут всё понятно. Мы имеем конструкцию switch, благодаря которой можем выполнять задачи в зависимости от текущего действия. Указываем путь до нашей модели и вызываем метод addPackage(), который добавляет нашу xpdo схему (помните из первого урока?). Наконец мы запускаем $modx->getManager() и далее $manager->createObjectContainer('Doodle'). Этот метод дает MODX команду запустить SQL и создать таблицу в БД для нашего класса Doodle. Теперь можно убрать проверку на существование таблицы БД, как мы сделали в первой части (использование резольвера не обязательно, но это удобно). И в конце мы вернем true, чтобы MODX знал, что всё прошло гладко.

Теперь при установке пакета будет создаваться таблица нашего дополнения в БД.

Добавление файлов cangelog, readme, лицензии и параметров установки



Давайте создадим файл readme.txt в папке docs/ с таким содержинием:

--------------------
Extra: Doodles
--------------------
Version: 1.0
 
A simple demo extra for creating robust 3rd-Party Components in MODx Revolution.


Также создайте файлы license.txt (содержит описание лицензии) и changelog.txt (лог изменений), если их ещё нет.

Теперь давайте вернемся в скрипт build.transport.php и перед $builder->pack() добавим такие строки:

$modx->log(modX::LOG_LEVEL_INFO,'Adding package attributes and setup options...');
$builder->setPackageAttributes(array(
    'license' => file_get_contents($sources['docs'] . 'license.txt'),
    'readme' => file_get_contents($sources['docs'] . 'readme.txt'),
    'changelog' => file_get_contents($sources['docs'] . 'changelog.txt'),
    'setup-options' => array(
        'source' => $sources['build'].'setup.options.php',
    ),
));


Как видите, вызывается метод setPackageAttributes(), который устанавливает атрибуты нашему упаковщику. Также тут есть новый для нас массив 'setup-options'. У этого массива есть элемент с ключем 'source' — путь до PHP файла (подобно резольверу).

Создадим файл /www/doodles/_build/setup.options.php с таким содержинием:

<?php
$output = '';
switch ($options[xPDOTransport::PACKAGE_ACTION]) {
    case xPDOTransport::ACTION_INSTALL:
        $output = '<h2>Doodles Installer</h2>
<p>Thanks for installing Doodles! Please review the setup options below before proceeding.</p><br />';
        break;
    case xPDOTransport::ACTION_UPGRADE:
    case xPDOTransport::ACTION_UNINSTALL:
        break;
}
return $output;


Знакомо выглядит, да? Этот кусок кода позволяет нам вывести «Параметры установки», когда пользователь будет устанавливать пакет. Сейчас мы только выводим сообщение, чтобы сказать людям «Спасибо» за установку нашего дополнения.

Здесь можно добавить элементы формы, которые будут выводиться при установке пакета и далее обрабатываться установщиком. Пример можно увидеть у компонента Quip: github.com/splittingred/Quip/blob/develop/_build/resolvers/setupoptions.resolver.php.

На этом всё. Запустите упаковщик (http ://localhost/doodles/_build/build.transport.php) и в папке core/packages/ появится файл транспортного пакета «doodles-1.0-rc1.zip». Этот файл можно загрузить в репозиторий дополнений MODX и потом можно будет его установить через «Управление пакетами».



Все файлы, созданного нами, упаковщика можно найти здесь: github.com/splittingred/Doodles/tree/develop/_build.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+2
Comments 1
Comments Comments 1

Articles