Реально ли при разработке модификаций для распространенного движка интернет-магазинов Opencart сосредоточиться на своих алгоритмах, а подготовку файла для подгрузки в эту CMS дать на откуп специальным скриптам? Собственно, это то, что сильно облегчило бы жизнь разработчикам под Opencart, и в данной статье я предлагаю мой вариант решения.

Немного вступления:

Формат ocmod — довольно элегантное решение для определения модификаций исходных файлов, причем независимо от их формата. Одна из частей формата – это XML-файл, в котором прописано, в каком файле и в каком месте этого файла необходимо внести определенные изменения. Вот пример ocmod-файла (взято с ocmod.net, более подробное его описание можно взять там же):

<?xml version="1.0" encoding="utf-8"?>
<modification>
    <name>Количество просмотров в товаре</name>
    <code>product-page-views</code>
    <version>1.0</version>
    <author>https://ocmod.net</author>
    <link>https://ocmod.net</link>
    <file path="catalog/controller/product/product.php">
        <operation>
            <search>
                <![CDATA[$data['images'] = array();]]>
            </search>
            <add position="after">
                <![CDATA[
                	$data['view'] = $product_info['viewed'];
                ]]>
            </add>
        </operation>
    </file>
    <file path="catalog/language/en-gb/product/product.php">
        <operation>
            <search>
                <![CDATA[$_['text_search']]]>
            </search>
            <add position="before">
                <![CDATA[
                	$_['text_view']              = 'View: ';
                ]]>
            </add>
        </operation>
    </file>
</modification>

В общих чертах задаем следующее:

<file path="в каком файле">
        <operation>
            <search><![CDATA[что ищем]]></search>
            <add position="где – перед, после или вместо">
                <![CDATA[что вставляем или чем замещаем]]>
            </add>
        </operation>
    </file>

Хотя принцип довольно прозрачен, встает вопрос: можно ли автоматизировать процесс его создания или придется писать его вручную, ведь программист должен заниматься программированием, а не онанирглупой рутинной деятельностью.

В идеале написание модификации под Opencart могло бы выглядеть так: мы скачали «непорочную» версию магазина, прямо в его исходниках внесли кое-какие изменения и запустили «волшебный» скрипт, который прямо на месте нам сгенерировал весь ocmod. На деле все немного сложнее, однако мы постараемся приблизиться к этой схеме. Основная проблема – это определение местоположения в файле для вставки (то, что между <search>…</search>). Это должен сделать программист. Как правило, его стараются сделать максимально универсальным, чтобы охватить больше потенциальных версий исходников, и при этом чтобы менялось именно там, где надо. Это явно ручная работа. Все остальное автоматизируемо.

Небольшое отступление: поиск происходит в строке целиком, и вставка возможна только перед, после или вместо нее, но никак не внутри (в классической поставке OCMOD для Opencart). Это непонятное лично для меня ограничение. Также мне не понятно, почему нельзя задать несколько тегов <search> для поиска нужного места вставки, которые бы последовательно отрабатывались – ведь поиск был бы намного более гибок. Например, если в коде PHP, то, скажем, найти имя функции, потом найти в ней нужное место или еще как-нибудь на усмотрение программиста. Но этого я не нашел, если ошибаюсь, пожалуйста, поправьте.

А теперь самое главное: автоматизировать процесс создания ocmod-файла можно, при этом надо просто придерживаться нужной схемы. Во первых, в исходном файле нам необходимо как-то обозначить место наших изменений – и просто для порядка, и чтобы наш ocmod-генератор адресно все знал. Допустим, нас зовут Петр Николаевич Иванов (совпадения случайны). Давайте все наши изменения заключать между тегами <PNI>…</PNI>, а чтобы теги не портили исходник, будем помещать эти теги в комментариях того языка, над файлом которого мы в данный момент работаем. Между тегами прямо на месте зададим строку поиска между <search></search> и добавляемый код между <add></add>. Будет понятнее на примере:

Для изменений в PHP:

…
(здесь исходный код opencart)
…
// <PNI>
// а вот здесь уже добавляемый нами один из кусков кода нашей модификации -
// мы должны задать и место поиска, и сам код (все в этом комментарии)
// <search> public function index() {</search>
// <add position=”after”>
$x = 5;
$y = 6;
//</add> </PNI>
…

Или так:

…
(здесь исходный код opencart)
…
/* <PNI>
     <search> public function index() {</search>
     <add position=”after”> */
$x = 5;
$y = 6;
/*</add> </PNI> */
…

Если у <search> или <add> присутствуют какие-то атрибуты, например, <search index=”1”>, то они «как есть» будут перенесены в наш ocmod-файл. То, что мы пишем между ними, не требует какого-либо XML-экранирования, мы просто пишем строку поиска и код.

Еще пример, уже для изменяемого нами twig-файла:

            {# <PNI>
            <search><li><span style="text-decoration: line-through;">{{ price }}</span></li></search>
            <add position="replace">
            #}
            <li><span class="combination-base-price" style="text-decoration: line-through;">{{ price }}</span></li>
            {# </add></PNI> #}

После того, как мы оформили подобным образом все наши изменения, нам понадобится скрипт, который все это обработает, а также архиватор. Делюсь с вами своим вариантом: он состоит из файла конфигурации и собственно скрипта.

Файл конфигурации make-ocmod.opencart.local.cfg.php (кодировка UTF-8, это пример, каждый делает под себя):

<?php

define("ROOT_PATH", "../../opencart.local");

define("ENCODING", "utf-8");
define("NAME", "Мой ocmod");
define("CODE", "product-page-views");
define("VERSION", "1.0");
define("AUTHOR", "AS");
define("LINK", "");

define("TAG_OPERATION_BEGIN", "<PNI>");
define("TAG_OPERATION_END", "</PNI>");
define("TAG_SEARCH_BEGIN", "<search"); // !! без >
define("TAG_SEARCH_END", "</search>");
define("TAG_ADD_BEGIN", "<add"); // !! без >
define("TAG_ADD_END", "</add>");

// Указать тег конца кода </add> может быть возможным только в комментарии
// (чтобы код, где он написан, работал).
// Этот массив указывает последовательности, которые будут отсечены, если
// встречаются перед </add> (включая пробелы, \t, \r, \n между ними, если есть)
$commentsBegin = [ '//', '/*', '<!--', '{#' ];
// Указать тег начала кода <add> может быть возможным только в комментарии
// (чтобы код, где он написан, работал).
// Этот массив указывает последовательности, которые будут отсечены, если
// встречаются после <add> (включая пробелы, \t, \r, \n между ними, если есть)
$commentsEnd = [ '*/', '-->', '#}' ];

// Если эти подсроки встречаются в относительном пути, то эти файлы и каталоги
// не обрабатываем.
$exclude = [ '/cache/', '/cache-/' ];

// Эти файлы будут помещены в upload.
// Здесь вы укажете свои файлы, если они есть.
$upload = [
  'admin/view/stylesheet/combined-options.css',
  'admin/view/javascript/combined-options.js',
  'catalog/view/theme/default/stylesheet/combined-options.css',
  'admin/view/image/combined-options/cross.png',
  'catalog/view/javascript/combined-options/combined.js',
  'catalog/view/javascript/combined-options/aswmultiselect.js',
  'admin/view/image/combined-options/select.png'
];

// Это будет помещено в install.sql.
// (в новых версиях Opencart не учитывается)
$sql = "";

?>

Теперь главное – генератор ocmod xml-файла.
Скрипт make-ocmod.php (кодировка UTF-8):

<?php

include_once ($argv[1]);

function processFile($fileName, $relativePath) {
  global $commentsBegin, $commentsEnd, $xml, $exclude;

  if ($exclude)
    foreach ($exclude as $ex)
      if (false !== strpos($relativePath, $ex))
        return;

  $text = file_get_contents($fileName);
  $end = -1;
  while (false !== ($begin = strpos($text, TAG_OPERATION_BEGIN, $end + 1))) {
    $end = strpos($text, TAG_OPERATION_END, $begin + 1);
    if (false === $end)
      die ("No close operation tag in ".$fileName);
    $search = false;
    $searchEnd = $begin;
    while (false !== ($searchBegin = strpos($text, TAG_SEARCH_BEGIN, $searchEnd + 1)) and $searchBegin < $end) {
      $searchBeginR = strpos($text, '>', $searchBegin + 1);
      $searchAttributes = substr($text, $searchBegin + strlen(TAG_SEARCH_BEGIN), $searchBeginR - $searchBegin - strlen(TAG_SEARCH_BEGIN));
      if (false === $searchBeginR or $searchBeginR >= $end)
        die ("Invalid search tag in ".$fileName);
      $searchEnd = strpos($text, TAG_SEARCH_END, $searchBeginR + 1);
      if (false === $searchEnd or $searchEnd >= $end)
        die ("No close search tag in ".$fileName);
      // Запоминаем последний
      $search = substr($text, $searchBeginR + 1, $searchEnd - $searchBeginR - 1);
    }
    $addBegin = strpos($text, TAG_ADD_BEGIN, $begin + 1);
    if (false === $addBegin or $addBegin >= $end)
      die ("No begin add tag in ".$fileName);
    $addBeginR = strpos($text, '>', $addBegin + 1);
    $addAttributes = substr($text, $addBegin + strlen(TAG_ADD_BEGIN), $addBeginR - $addBegin - strlen(TAG_ADD_BEGIN));
    if (false === $addBeginR or $addBeginR >= $end)
      die ("Invalid add tag in ".$fileName);
    $addEnd = strpos($text, TAG_ADD_END, $addBeginR + 1);
    if (false === $addEnd or $addEnd >= $end)
      die ("No close add tag in ".$fileName);
    $codeBegin = $addBeginR + 1;
    $codeEnd = $addEnd;
    // Иногда необходимо тег начала кода закрывать комментарием,
    // а тег конца - открывать. Тогда эти теги не должны попасть в результат.
    $p = $codeBegin;
    while (@$text[$p] === " " or @$text[$p] === "\t" or @$text[$p] === "\r" or @$text[$p] === "\n")
      $p ++;
    if ($p < $addEnd) {
      foreach ($commentsEnd as $tag)
        if (substr($text, $p, strlen($tag)) === $tag)
          $codeBegin = $p + strlen($tag);
    }
    $p = $codeEnd - 1;
    while (@$text[$p] === " " or @$text[$p] === "\t" or @$text[$p] === "\r" or @$text[$p] === "\n")
      $p --;
    if ($p >= $codeBegin) {
      foreach ($commentsBegin as $tag)
        if (substr($text, $p - strlen($tag) + 1, strlen($tag)) === $tag)
          $codeEnd = $p - strlen($tag) + 1;
    }
    $code = substr($text, $codeBegin, $codeEnd - $codeBegin - 1);

    $xml .= "
    <file path=\"".str_replace('"', '\"', $relativePath)."\">
        <operation>".(false !== $search ? "
            <search{$searchAttributes}>
                <![CDATA[{$search}]]>
            </search>" : "")."
            <add{$addAttributes}>
                <![CDATA[{$code}]]>
            </add>
        </operation>
    </file>";
  }
}

function processDir($dir, $relativePath = '') {
  global $exclude;

  $cdir = scandir($dir);
  foreach ($cdir as $key => $value) {
    if (!in_array($value,array(".", ".."))) {
      $fileName = $dir . DIRECTORY_SEPARATOR . $value;
      $newRelativePath = ($relativePath ? $relativePath.'/' : '').$value;
      $excluded = false;
      if ($exclude)
        foreach ($exclude as $ex)
          $excluded = $excluded or false !== strpos($newRelativePath, $ex);
      if ($excluded)
        continue;
      if (is_dir($fileName)) {
        processDir($fileName, $newRelativePath);
      } else {
        processFile($fileName, $newRelativePath);
      }
    }
  }
}

function delTree($dir, $delRoot = false) {
  $files = array_diff(scandir($dir), array('.','..'));
  foreach ($files as $file) {
    (is_dir("$dir/$file")) ? delTree("$dir/$file", true) : unlink("$dir/$file");
  }
  return $delRoot ? rmdir($dir) : true;
}

$xml = "<?xml version=\"1.0\" encoding=\"".ENCODING."\"?>
<modification>
    <name>".NAME."</name>
    <code>".CODE."</code>
    <version>".VERSION."</version>
    <author>".AUTHOR."</author>
    <link>".LINK."</link>";

processDir(ROOT_PATH);

$xml .= "
</modification>";

file_put_contents('publish/install.xml', $xml);
file_put_contents('publish/install.sql', $sql);

delTree('publish/upload');
foreach ($upload as $file) {
  $srcfile = ROOT_PATH.(@$file[0] === '/' ? '' : '/').$file;
  $dstfile = 'publish/upload'.(@$file[0] === '/' ? '' : '/').$file;
  mkdir(dirname($dstfile), 0777, true);
  copy($srcfile, $dstfile);
}

?>

Командник make-ocmod.cmd, который все это запускает:

del /f/q/s publish.ocmod.zip
php make-ocmod.php make-ocmod.opencart.local.cfg.php
cd publish
..\7z.exe a -r -tZip ..\publish.ocmod.zip *.*

Я использую 7zip, поэтому 7z.exe должен лежать там же, где наш командник. Кто захочет использовать его же, сможет скачать его по адресу https://www.7-zip.org/.

Это командник для Windows. Кто под Linux, думаю, перепишет без проблем.

Резюме: На мой взгляд, так работать намного проще, чем каждый раз вручную править ocmod. Когда мы добавляем код, то один раз прямо на месте задаем наши теги поиска для этого куска кода, а далее сосредотачиваемся только на нашей работе. Нас уже не заботит структура xml-файла, а любое исправление нашей модификации мы вносим на месте, тут же проверяем его работу и далее одним нажатием генерируем новый ocmod-файл.