Сборка элементов сайта в компонент (пакет), пошаговая инструкция

Привет, давно не виделись :)

Я в прошлой заметке, когда писал об уходе, обещал опубликовать свои черновики, так что выполняю обещание и сегодня рассмотрим пример с разработкой отдельных страниц сайта и сборкой их в компонент (или транспортный пакет), которые можно затем установить через обычный пакетный менеджер. Я буду все показывать на реальном случае, который я недавно закончил.

Итак, сначала описание задачи, которая стояла. Был сайт, на котором нужно было реализовать обновленный раздел с готовыми решениями. Был представлен новый дизайн с версткой, были описаны требования к некоторым аспектам работы этих новых страниц. В частности, автоматический расчет цены товаров с конвертация валют, автоматический расчет цены монтажа в зависимости от количества оборудования и прочее. Но не суть, задача была безболезненно все делать локально, пока основной сайт работает и выкладывать решение или по частям или сразу, в виде готового пакета.

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

Вводная закончилась. Продолжаем внутри заметки.



Подготовка


Итак, через панель хостинга я сделал резервную копию файлов и базы данных и скачал себе. Развернул докер-окружение, залил дамп базы и запустил сайт локально. Про докер-окружение обещаю рассказать в другой заметке. Для старта работы все готово.

Скелет дополнения


Скелет дополнения в большинстве случаев довольно банален:
— `core/components/component_name` — папка, где лежат все классы, модели, схемы и прочий PHP-код.
— `assets/components/component_name` — папка, где лежат все стили и скрипты, нужные для дополнения или сайта.
— `_build` — папка, где лежат скрипты сборки и прочие артефакты, нужны для правильной сборки всего кода в пакет. Про эту папку поговорим подробнее ниже.

Такой набор встречается у большинства дополнений. У меня есть еще две папки, которые вынесены на один уровень с остальными для удобства. Это папка `_packages`, в которую собираются готовые пакеты в момент релиза. Из нее их можно забирать и загружать на сайт. И папка `docs` — в ней лежат файл лицензий, readme и история изменений.

На уровне с папками так же есть несколько служебных файлов — `.gitignore`, `Makefile` и `readme.md`. Readme нужен для GitHub, чтобы красиво там написать что-нибудь, про `Makefile` мы поговорим дальше, `.gitgnore` приведу здесь.

.idea
.DS_Store
.encryption
_packages/*/
_packages/*.zip
!_packages/.gitkeep
vendor

По структуре пакета всё. Дальше будет интереснее.

Структура папки build


Сначала покажу, что там есть, а потом опишу, что там и зачем.

├── build.transport.php
├── composer.json
├── composer.lock
├── data
│   ├── menus.php
│   ├── settings.php
│   ├── snippets.php
│   ├── sources.php
│   ├── templates.php
│   └── tvs.php
├── install.script.php
├── validators
│   ├── validate.modxversion.php
│   └── validate.phpversion.php
└── vendor

Итак, `build.transport.php` — это скрипт сборки пакета, про его устройство поговорим позже, пока лишь скажу, что именно в нем собрана вся логика по упаковке сущностей в zip-пакет. Отмечу, что сборка пакета осуществляется без установленного MODX, т.е. можно просто клонировать репозиторий, с помощью composer подтянуть зависимости и собрать пакет. При желании, можно уместить это всё в однострочную команду.

Другой скрипт, название которого говорит само за себя, это `install.script.php`. Этот скрипт отвечает непосредственно за установку пакета в систему. Здесь немного поясню. Первый скрипт просто собирает пакет и кладет zip-файл куда ему сказать, но чтобы установить пакет, его нужно загрузить на работающий сайт и установить через пакетный менеджер. Но когда мы пишем код, делать это руками неудобно, нужно автоматизировать. Этот скрипт вместо наших рук берет и запускает установку пакета.

define('MODX_API_MODE', true);

require_once __DIR__ . '/../../../index.php';

$modx->initialize('mgr');

$modx->setLogLevel(xPDO::LOG_LEVEL_ERROR);
$modx->setLogTarget();

// наш скрипт сборки настроен так, что после сборки кладет zip-пакет сразу в папку core/packages, загружать руками его туда не нужно.
// поэтому здесь мы сначала запускаем процессор, которые сканирует папку в поисках новых версий пакетов
$modx->runProcessor('workspace/packages/scanlocal');

// а затем запускаем процессор, который установит наш пакет
// в качестве параметров передаем массив и указываем полную сигнатуру пакета с версией
$answer = $modx->runProcessor('workspace/packages/install',
    ['signature' => 'Solutions-0.9.1-pl']
);

$response = $answer->getResponse();
echo $response['message'], PHP_EOL;

// здесь очищаем системные настройки, если пакет такие устанавливает. Чтобы не ловить потом странные баги.
// можно еще и кеш сайта почистить, но это уже можете сами дописать в случае необходимости
$modx->getCacheManager()->refresh(['system_settings' => []]);
$modx->reloadConfig();

Далее, файл `composer.json`. В нем описаны зависимости, которые следует установить для нашего пакета. Через него можно подключать всякие библиотеки и упаковывать их в пакет, но в моем случае он используется, чтобы подтянуть код xPDO, который используется для сборки пакета. Но есть нюанс. Вместо самого xPDO, я подтягиваю весь код MODX Revolution 2.x. В самом MODX версия xPDO может поменяться между релизами, поэтому чтобы быть уверенным, что я использую тот xPDO, который уже поставляется с нужной мне версией MODX.

{
  "name": "alroniks/solutions",
  "description": "Solutions package for manage special pages.",
  "type": "library",
  "license": "proprietary",
  "authors": [
    {
      "name": "Ivan Klimchuk",
      "email": "ivan@klimchuk.com"
    }
  ],
  "minimum-stability": "dev",
  "prefer-stable": true,
  "require-dev": {
    "modx/revolution": "~2.0"
  }
}

Валидаторы, это скрипты, которые запускаются перед установкой пакета и если их проверки завершаются неудачно, установка пакета прекращается (если при сборке указать такую опцию). Я проверяю версию PHP и версию MODX. Вообще, новые версии MODX уже умеют это и сами проверять, но оставил для поддержки старых версий.

В папке `data` собраны непосредственно те элементы, которые нужно будет упаковать в пакет и затем установить на сайт. Это могут быть снипеты, чанки, и вообще любые объекты, описанные в схеме MODX. Приведу пример файла с системными настройками.

$list = [
    'solutions_root_id' => ['xtype' => 'textfield', 'value' => ''],
    'solutions_contact_id' => ['xtype' => 'textfield', 'value' => 5],
];

$settings = [];
foreach ($list as $k => $v) {
    $setting = $xpdo->newObject(modSystemSetting::class);
    $setting->fromArray(array_merge([
        'key' => $k,
        'editedon' => null,
    ], $v), '', true, true);

    $settings[] = $setting;
}

return $settings;

Мы просто создаем с помощью xPDO объекты, заполняем нужными данными и после уже добавим их в скрипт сборки. Ничего лишнего, каждый элемент добавляется руками и контролируется программистом.

Отдельно еще скажу про элементы. Я в работе использовал pdoTools и большинство элементов у меня в файлах, поэтому с созданием чанков и снипетов и вовсе не пришлось возиться, так как они просто ставятся вместе с пакетом в нужную папку и вызываются через биндинг `@FILE`. Шаблоны пришлось создать все же через xpdo и присвоить их жесткие ID, чтобы можно было к ним привязать TV.

Скрипт сборки и установки


Скрипт сборки не сильно отличается от моего подхода, описанного ранее в заметке «Сборка transport-пакета без установки MODX», но некоторые отличия все же есть, поэтому приведу весь скрипт с комментариями по коду, что там происходит.

<?php
/**
 * Copyright © Ivan Klimchuk - All Rights Reserved
 * Unauthorized copying, changing, distributing this file, via any medium, is strictly prohibited.
 * Written by Ivan Klimchuk <ivan@klimchuk.com>, 2019
 */

set_time_limit(0);
error_reporting(E_ALL | E_STRICT); ini_set('display_errors',true);

ini_set('date.timezone', 'Europe/Minsk');

// здесь мы задаем базовые параметры пакета, его название, версию и прочее. Так же зависимости от версии PHP и MODX
define('PKG_NAME', 'Solutions');
define('PKG_NAME_LOWER', strtolower(PKG_NAME));
define('PKG_VERSION', '0.9.1');
define('PKG_RELEASE', 'pl');
define('PKG_SUPPORTS_PHP', '5.6');
define('PKG_SUPPORTS_MODX', '2.6.5');

// затем подключаем класс xPDO из папки, в которую composer загрузил весь modx revolution, включая xpdo.
require_once __DIR__ . '/vendor/modx/revolution/core/xpdo/xpdo.class.php';

// Может удивить, но факт. xPDO требует указать параметры подключения к БД, но на деле, пока запросы к БД не выполняются, он этот коннект не будет подымать, поэтому мы можем использовать любые данные для подключения.
/* instantiate xpdo instance */
$xpdo = new xPDO('mysql:host=localhost;dbname=modx;charset=utf8', 'root', '',
    [xPDO::OPT_TABLE_PREFIX => 'modx_', xPDO::OPT_CACHE_PATH => __DIR__ . '/../../../core/cache/'],
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_WARNING]
);
$cacheManager= $xpdo->getCacheManager();
$xpdo->setLogLevel(xPDO::LOG_LEVEL_INFO);
$xpdo->setLogTarget();

// эта переменная хранит префикс пути, куда складывать элементы на сайте. В частности, это папка самого компонента
$target = sprintf('components/%s/', PKG_NAME_LOWER);

// здесь описаны пути к основным элементах для сборки пакета
$root = dirname(__DIR__) . '/';
$sources = [
    'build' => $root . '_build/',
    'data' => $root . '_build/data/',
    'docs' => $root . 'docs/',
    'resolvers' => $root . '_build/resolvers/',
    'validators' => $root . '_build/validators/',
    'implants' => $root . '_build/implants/',
    'elements' => $target . 'elements/',
];

$signature = implode('-', [PKG_NAME, PKG_VERSION, PKG_RELEASE]);

// данный участок отвечает за сборку релиза или обычную. 
// Если вызвать команду `php _build/build.transport.php release`, тогда пакет будет собран в папке _packages, иначе в папке core/packages установленного MODX сайта.
$release = false;
if (!empty($argv) && $argc > 1) {
    $release = $argv[1];
}

$directory = $release === 'release' ? $root . '_packages/' : __DIR__ . '/../../../core/packages/';
$filename = $directory . $signature . '.transport.zip';

/* remove the package if it's already been made */
if (file_exists($filename)) {
    unlink($filename);
}
if (file_exists($directory . $signature) && is_dir($directory . $signature)) {
    $cacheManager = $xpdo->getCacheManager();
    if ($cacheManager) {
        $cacheManager->deleteTree($directory . $signature, true, false, []);
    }
}

// таким нетривиальным способом мы загружаем необходимые для работы классы
// увы, нормальный PSR-4 есть только в отрефакторенной версии MODX 3, в 2.x все по старинке, вот так.
$xpdo->loadClass('transport.xPDOTransport', XPDO_CORE_PATH, true, true);
$xpdo->loadClass('transport.xPDOVehicle', XPDO_CORE_PATH, true, true);
$xpdo->loadClass('transport.xPDOObjectVehicle', XPDO_CORE_PATH, true, true);
$xpdo->loadClass('transport.xPDOFileVehicle', XPDO_CORE_PATH, true, true);
$xpdo->loadClass('transport.xPDOScriptVehicle', XPDO_CORE_PATH, true, true);

// создаем объект транспортного пакета
$package = new xPDOTransport($xpdo, $signature, $directory);

// загружаем классы MODX, необходимые для работы.
$xpdo->setPackage('modx', __DIR__ . '/vendor/modx/revolution/core/model/');
$xpdo->loadClass(modAccess::class);
$xpdo->loadClass(modAccessibleObject::class);
$xpdo->loadClass(modAccessibleSimpleObject::class);
$xpdo->loadClass(modPrincipal::class);
$xpdo->loadClass(modElement::class);
$xpdo->loadClass(modScript::class);

// создаем пространство имен, куда поместим наш пакет
$namespace = $xpdo->newObject(modNamespace::class);
$namespace->fromArray([
    'path' => '{core_path}components/' . PKG_NAME_LOWER . '/',
    'assets_path' => '{assets_path}components/' . PKG_NAME_LOWER . '/',
]);
$namespace->set('name', PKG_NAME_LOWER);

// и кладем его в пакет вот так
// все что в массиве, это конфигурация этого элемента в пакете, как он должен вести себя при установке или обновлении. Подробно разбирать не буду, это тема отдельной заметки.
$package->put($namespace, [
    xPDOTransport::UNIQUE_KEY => 'name',
    xPDOTransport::PRESERVE_KEYS => true,
    xPDOTransport::UPDATE_OBJECT => true,
    xPDOTransport::NATIVE_KEY => PKG_NAME_LOWER,
    'namespace' => PKG_NAME_LOWER
]);

// добавляем в пакет системные настройки
$settings = include __DIR__. '/data/settings.php';
foreach ($settings as $setting) {
    $package->put($setting, [
        xPDOTransport::UNIQUE_KEY => 'key',
        xPDOTransport::PRESERVE_KEYS => true,
        xPDOTransport::UPDATE_OBJECT => false,
        'class' => modSystemSetting::class,
        'namespace' => PKG_NAME_LOWER
    ]);
}

// создаем базовую категорию
$category = $xpdo->newObject(modCategory::class);
$category->fromArray(['id' => 1, 'category' => PKG_NAME, 'parent' => 0]);

// и добавляем элементы уже в эту категорию, а не сразу в пакет
// например, шаблоны
$templates = include __DIR__ . '/data/templates.php';
if (is_array($templates)) {
    $category->addMany($templates, 'Templates');
}

// интересно с подкатегориями. Код ниже из массива создает подкатегории и добавляет их в категорию. Это нужно, чтобы TV потом красиво сортировались во вкладках. ID категорий нужен здесь, чтобы в файле с TV в папке data можно было каждой TV указать, в какой категории она относится. 
$subCategories = [
    12 => 'Проекты',
];

foreach ($subCategories as $key => &$subCategory) {
    /** @var modCategory $object */
    $object = $xpdo->newObject(modCategory::class);
    $object->fromArray([
        'id' => $key,
        'category' => $subCategory,
        'parent' => 1
    ]);
    $subCategory = $object;
    unset($object);
}

// а это упаковка TVs
$tvs = include __DIR__ . '/data/tvs.php';
if (is_array($tvs)) {
    foreach ($tvs as $tv) {
        if (array_key_exists($tv->get('category'), $subCategories)) {
            /** @var modCategory $cat */
            $cat = $subCategories[$tv->get('category')];
            $cat->addMany($tv, 'TemplateVars');
        } else {
            $category->addMany($tv, 'TemplateVars');
        }
    }
}

// и наконец, в главную категорию кладем все подкатегории с элементами внутри
$category->addMany($subCategories, 'Children');

// регистрируем валидаторы
$validators = [
    ['type' => 'php', 'source' => $sources['validators'] . 'validate.phpversion.php'],
    ['type' => 'php', 'source' => $sources['validators'] . 'validate.modxversion.php']
];


// и резолверы. Это файловые резолверы, они указывают, куда каки папки будут скопированы после установки пакета.
$resolvers = [
    ['type' => 'file', 'source' => $root . 'assets/' . $target, 'target' => sprintf("return MODX_ASSETS_PATH . '%s';", dirname($target))],
    ['type' => 'file', 'source' => $root . 'core/' . $target, 'target' => sprintf("return MODX_CORE_PATH . '%s/';", dirname($target))],
    ['type' => 'file', 'source' => $root . 'core/' . $target . 'elements/', 'target' => 'return MODX_CORE_PATH;']
];

// добавляем категорию в пакет и прописываем параметры
$package->put($category, [
    xPDOTransport::UNIQUE_KEY => 'category',
    xPDOTransport::PRESERVE_KEYS => false, // обратите внимание, это говорит, что ключи будут созданы MODX автоматически
    xPDOTransport::UPDATE_OBJECT => true,
    xPDOTransport::ABORT_INSTALL_ON_VEHICLE_FAIL => true,
    xPDOTransport::RELATED_OBJECTS => true,
    xPDOTransport::NATIVE_KEY => true,
    xPDOTransport::RELATED_OBJECT_ATTRIBUTES => [
        'Children' => [ // это дочерние подкатегории
            xPDOTransport::UNIQUE_KEY => 'category',
            xPDOTransport::PRESERVE_KEYS => false, // и здесь тоже, поэтому те ID, что были в подкатегориях, нужны были только для сборки
            xPDOTransport::UPDATE_OBJECT => true,
            xPDOTransport::RELATED_OBJECTS => true,
            xPDOTransport::RELATED_OBJECT_ATTRIBUTES => [
                'TemplateVars' => [
                    xPDOTransport::UNIQUE_KEY => 'name',
                    xPDOTransport::PRESERVE_KEYS => true,
                    xPDOTransport::UPDATE_OBJECT => true,
                    xPDOTransport::RELATED_OBJECTS => true,
                    xPDOTransport::RELATED_OBJECT_ATTRIBUTES => [
                        'TemplateVarTemplates' => [
                            xPDOTransport::UNIQUE_KEY => ['tmplvarid', 'templateid'],
                            xPDOTransport::PRESERVE_KEYS => true,
                            xPDOTransport::UPDATE_OBJECT => true,
                        ]
                    ]
                ]
            ]
        ],
        'Templates' => [
            xPDOTransport::UNIQUE_KEY => 'id',
            xPDOTransport::PRESERVE_KEYS => true,
            xPDOTransport::UPDATE_OBJECT => true,
        ],
        'TemplateVars' => [
            xPDOTransport::UNIQUE_KEY => 'name',
            xPDOTransport::PRESERVE_KEYS => true,
            xPDOTransport::UPDATE_OBJECT => true,
            xPDOTransport::RELATED_OBJECTS => true,
            xPDOTransport::RELATED_OBJECT_ATTRIBUTES => [
                'TemplateVarTemplates' => [
                    xPDOTransport::UNIQUE_KEY => ['tmplvarid', 'templateid'],
                    xPDOTransport::PRESERVE_KEYS => true,
                    xPDOTransport::UPDATE_OBJECT => true,
                ]
            ]
        ]
    ],
    'package' => 'modx',
    'resolve' => $resolvers,
    'validate' => $validators
]);

// устанавливаем необходимые атрибуты пакета - это файл изменений, файл с документацией и лицензией.
// эти файлы в папке docs в корне пакета, потому что так удобнее, как я и говорил.
$package->setAttribute('changelog', file_get_contents($sources['docs'] . 'changelog.txt'));
$package->setAttribute('license', file_get_contents($sources['docs'] . 'license.txt'));
$package->setAttribute('readme', file_get_contents($sources['docs'] . 'readme.txt'));
$package->setAttribute('requires', [
    'php' => '>=' . PKG_SUPPORTS_PHP,
    'modx' => '>=' . PKG_SUPPORTS_MODX
]);

// и наконец, пакуем пакет
if ($package->pack()) {
    $xpdo->log(xPDO::LOG_LEVEL_INFO, 'Package built');
}

Makefile для автоматизации


Это самое вкусное, потому что теперь написание кода выглядит, как в старые добрые времена, когда его нужно было компилировать. Только теперь компилируется пакет (и автоматически устанавливается). По нажатию кнопки, конечно же и с некоторой толикой магии.

Итак, во многих системах есть такая команда `make`, которая позволяет запускать сборку разного рода компиляций, пакетов и прочего. Но она так же умеет читать и понимать файлы Makefile, и выполнять инструкции, описанные в них. Именно в таком файле я описал набор команд, которые могут пригодиться в разработке пакета.

Их четыре, `build` для сборки пакета, `release` — та же сборка пакета, но в папку `_packages`, т.е. того, который мы уже забираем и несём на продакшен. `install` — запускает установку пакета. У меня все в докере, поэтому она в себе прячем длинный путь вызова контейнера. Если без докера, скрипт можно дергать напрямую, но `make install` все равно короче. `proceed` — команда для повседневной работы. Она запускает сначала build, а потом install.

И самое удобное, что если у вас PHP Storm, вы можете установить плагин для поддержки Makefile и запускать эти команды прямо из IDE либо комбинацией клавиш Ctrl+R, либо вот так, как на скриншоте.



А для тех, у кого макбук с тачбаром, можно делать вообще вот так. Сказка просто!



Вот и всё


Таким образом, у нас весь код для решения задачи находится под контролем версий, вносить правки или следить, что было изменено, достаточно легко, работать в команде над таким решением тоже не составляет труда, потому что git. Компиляция в пакет и установка может быть в дальнейшем автоматизирована практически через любой pipeline или CD-процесс, так как у нас на выходе готовый артефакт. У меня такой опыт уже был.

Если у вас есть вопросы, задавайте, постараюсь отвечать, насколько будет позволять время.

Ну и не забывайте про волшебную кнопку «Сказать спасибо» под постом, мне будет приятно. Донаты обещаю разумно вложить в полезное дело.

До новых встреч.
Іван Клімчук
27 августа 2019, 19:57
modx.pro
25
3 473
+38

Комментарии: 8

Павел Бигель
27 августа 2019, 20:44
+2
Ух. Боги меня услышали и я увидел how-to по этому вопросу.
Тупо батя.
Спасибо!
    Михаил
    27 августа 2019, 20:52
    0
    Емае!!! За Makefile отдельное спасибо! Как же этого не хватало
      Иван Бочкарев
      28 августа 2019, 09:22
      0
      Огромное спасибо за инструкцию!!!
        iWatchYouFromAfar
        28 августа 2019, 09:22
        +4
        Где-то пол года назад, я тоже начал собирать сайты через API, благодаря siteExtra (Ильи Уткина) и App (Васи). Особенно интересно работать с объектами кастомных пакетов. Тот же MIGX например, собрать пару таблиц JSON массивом и закинуть в TV, создать объект MIGX для работы с конфигурациями. ну и ±, во всех пакетах, одни и те же действия при работе по API, просто названия объектов другие. :)

        Вообще программно создавать сайты на MODx одно удовольствие. Во-первых — все (или почти все) создается без админки, в любимом редакторе, во-вторых — это очень хороший способ познакомиться с xPDO. Да, не везде все сильно просто, например я достаточно долго писал удобный для себя резолвер создания (шаблона политики доступа, саму политику, роль, дэшборд, саму группу, потом задание нужных настроек для группы, потом пользователя...). Но написав такой резолвер один раз, дальше его можно просто копипастить заменяя базовые значения. В-третьих — конечно возможность хранить исходник на github (а значит версионирование). Ну а самое крутое, что установку нужных пакетов и их настройки — можно задать сразу и не сидеть устанавливать все эти компоненты вручную.

        Отдельно хочу отметить свою методику создания сайтов. На dev. домене, я пишу сайт. Как только сайт написан, дальше нужно просто поставить чистый MODx и установить пакет (который и является готовым сайтом). Дальнейшая доработка осуществляется опять же на домене dev., как только всю доработку завершил, на боевом собираю уже готовую, вторую версию пакета-сайта и просто переустанавливаю ручками в админке — все готово.

        Единственный нудный момент, это когда нужно скопировать боевой сайт на dev. домен. Вся эта процедура с архивами, с путями — хочется написать скрипт который будет делать эти действия сам. Но это не относиться к теме поста, такую процедуру делают, я думаю многие.

        Иван, спасибо за пост! Тут достаточно полезной информации можно подчерпнуть.
          Антон Тарасов
          30 августа 2019, 11:41
          +1
          Иван, спасибо за инструкцию!

          И просто снова просто приятно слышать, даже настроение улучшилось :)
            Андрей
            30 июля 2020, 17:32
            0
            Здравствуйте
            Спасибо за подробное описание кода. Разбираюсь самостоятельно. Не могли бы вы показать код валидатора? Например для проверки версии MODX, который записан в файле validate.modxversion.php. Буду очень благодарен.
              Іван Клімчук
              30 июля 2020, 17:35
              +1
              <?php
                  
              if (!$object->xpdo) {
                  return false;
              }
              
              $version_data = $object->xpdo->getVersionData();
              $version = implode('.', [$version_data['version'], $version_data['major_version'], $version_data['minor_version']]);
              
              if (!version_compare($version, '2.6.5', '>=')) {
                  $object->xpdo->log(modX::LOG_LEVEL_ERROR, 'Invalid MODX version. Minimal supported version is 2.6.5.');
              
                  return false;
              }
              
              return true;
                Андрей
                30 июля 2020, 17:42
                0
                Оперативно! Спасибо большое!
              Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
              8