С помощью материалов Joomla на сайте можно сделать не только контентный сайт-статейник или новостник, но каталог, простую доску объявлений и т.д. Начиная примерно года с 2016 очень много трафика из поисковиков уходит в соц.сети, стриминговые платформы, мессенджеры. Много контента стало создаваться напрямую в интерфейсе, например, Telegram. И в связи с этим встаёт вопрос об автоматическом наполнении сайта контентом из соц.сетей и мессенджеров. Для этого как раз и нужно знать как создавать материалы в Joomla 4 / Joomla 5 и старше программным способом.

Ко мне обратился один из пользователей моих расширений с просьбой помочь с импортом данных из сервиса парсинга объявлений о продаже автомобилей в Telegram. Со стороны сайта по CRON периодически выполнялся запрос к сервису, тот, видимо, проверял наличие новых объявлений и если они были - выполнял вебхук (запрос) к сайту с новыми данными. В одном запросе передавалось одно объявление. К слову сказать. у сервиса была интеграция с Joomla 4+, так как у Joomla есть REST API и всё в целом работало, материалы создавались. Однако, проблема заключалась в том, что необходимо было передавать данные в поля материалов, а их названия и id уникальные для каждого сайта и универсально сделать такую интеграцию проблематично. Поэтому было принято решение сделать отдельный плагин группы ajax для решения этой задачи.

Задача

Создание плагина создания материала Joomla по запросу из стороннего сервиса. Плагин должен преобразовать входные данные в структуру, понятную для Joomla, сохранять данные пользовательских полей. Во входящих данных также передаются фотографии в base64 кодировке, поэтому их нужно сохранить как файлы в папку на сайте и добавить в созданный материал.

Список литературы

Прежде хочу упомянуть другие источники и статьи, которые уже описывали похожие задачи:

  1. Статья Дмитрия Рекуна Как программно создать материал с настраиваемыми полями на PHP
  2. Пример CLI-расширения для Joomla из этой статьи на GitHub
  3. Плагин создания материала из формы обратной связи для плагина Radical Form. Код на GitHub.
  4. Статья Владимира Егорова Как программно добавить статью в Joomla 3 и Joomla 4
  5. Моя статья Создание плагинов с учётом новой структуры Joomla 4

Зачем нужна ещё одна статья? Затем, что здесь будут описаны некоторые нюансы, которые нужно учитывать в процессе разработки подобных плагинов.

Решение задачи. Создание ajax плагина в Joomla.

По условиям заказчика плагин нужно было написать для Joomla 4.4.6. В пятой линейке на этот момент была актуальной Joomla 5.1.2, разработка велась на ней. 

Краткий обзор задачи

Технически, для разработчика задача представляет из себя следующее: сделать на стороне Joomla точку входа вида index.php?option=com_ajax&group=ajax&plugin=autodealerpro&format=raw, получить данные в виде JSON (чаще всего), разобрать на составляющие и создаём материал в нужной нам категории. Для этого мы должны взять модель материала (паттерн MVC, где M - Model) и скормить ей подготовленные должным образом данные. То же самое проделать потом для пользовательских полей. Затем, обработать и сохранить картинки, добавить в уже созданный материал и снова его сохранить. Почему будем делать именно так - читаем далее.

Выбор группы для плагина

В Joomla развитая система плагинов. Во время работы Приложения Joomla в разные моменты его жизненного цикла вызываются разные события. Эти события "слушают" плагины, получают данные для работы и возвращают их обратно, если это необходимо или выполняют любую необходимую в данный момент времени работу. Плагины группы system вызываются всегда и раньше, чем плагины конкретных групп. Если ваш плагин работает всегда и везде и решает разные задачи в разных местах сайта - делаем системный плагин, но учитываем, что к нему будет обращение даже тогда, когда он не нужен. Плагины конкретных групп вызываются в определенный момент времени, в определенном контексте и не создают дополнительную нагрузку, так как необходимый код выполняется только в нужный момент времени. Но это накладывает определенные ограничения и порой нужно создавать 2-3 плагина, которые можно было бы объединить в одном системном.

Создание плагина

Я предполагаю, что со статьёй Создание плагинов с учётом новой структуры Joomla 4 вы уже ознакомились и имеете общее представление о том, как плагин сделать, поэтому здесь будет описана конкретика для решения данной задачи.

Наш плагин называется autodealerpro, поэтому все неймспейсы включают данное имя. В ajax плагине будет метод onAjaxAutodealerpro - точка входа для обработки запроса и вызова функций. 

<?php
namespace Joomla\Plugin\Ajax\Autodealerpro\Extension;

// use Joomla\CMS\Event\Plugin\AjaxEvent; // for Joomla 5+
use Joomla\Event\Event; // For Joomla 4+  and Joomla 5
use Joomla\CMS\Form\Form;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Filesystem\File;
use Joomla\Registry\Registry;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use Joomla\CMS\Log\Log;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

Log::addLogger(
	['text_file' => 'plg_ajax_autodiealderpro.php'],
	Log::ALL,
	['plg_ajax_autodiealderpro']
);

final class Autodealerpro extends CMSPlugin implements SubscriberInterface
{
	use DatabaseAwareTrait;
	
	/**
	 *
	 * @return array
	 *
	 * @throws \Exception
	 * @since 4.1.0
	 *
	 */
	public static function getSubscribedEvents(): array
	{
		return [
			'onAjaxAutodealerpro' => 'onAjaxAutodealerpro'
		];
	}
	/**
	 * В аргументе функции для Joomla 4+ тип переменной $event должен быть Event
	 * Для Joomla 5+ - AjaxEvent. 
	 * Соответственно в начале файле в секции с use раскомментировать 
	 * use Joomla\CMS\Event\Plugin\AjaxEvent; для Joomla 5
	 * и ЗАкомментировать use Joomla\Event\Event;
	 * 
	 * Сделать это после обновления до Joomla 5. Хотя и так будет работать.
	 */
	public function onAjaxAutodealerpro(Event $event): void
	{

		/**
		 * Проверка безопасности. В парамтерах плагина есть "код". Этот же код
		 * должен указываться в каждом входящем запросе из сервиса.
		 * Если этого кода нет или он не совпадает - ничего не делаем.
		 */
		$input = $this->getApplication()->getInput();
		/** @var string $code Токен безопасности, указанный в настройках плагина */
		$code = $this->params->get('code');
		/** @var string $external_code Токен безопасности, который получаем в запросе */
		$external_code = $input->get('code', '');

		if ($code != $external_code)
	
		{
			return;
		}

 
		/** ПОЛУЧАЕМ ДАННЫЕ ИЗ JSON */
		$json = (new Registry())->loadArray($input->json->getArray());
		
		// А это было для тестов из файла
//		$json = (new Registry())->loadFile(__DIR__ . '/test.json');

		try
		{
			$this->createArticle($json);
		}
		catch (\Exception $e)
		{
			Log::add($e->getMessage(), Log::ERROR, 'plg_' . $this->_type . '_' . $this->_name);
		}
	}
}

В Joomla 5 для разных типов событий стали создавать кастомные классы этих событий для того, чтобы можно было использовать методы работы с данными, характерными для этих только событий. Для контентных плагинов - $event->getContext() вместо получения $event->getArgument(0); или $event->updateEventResult($data) для AjaxEvent для и т.д. Поэтому, как указано в комментариях к коду, тип аргумента $event для Joomla 4 и Joomla 5 различается. В Joomla 5 стоит использовать Joomla\CMS\Event\Plugin\AjaxEvent. Однако, можно указать тип Event и тогда плагин будет поддерживать обе линейки CMS.

Структура данных

На вход мы получаем json следующей структуры. Сразу обращаем внимание, что из Telegram с точки зрения web приходит довольно "грязный" текст, который нужно очищать от всяких эмодзи, специфичных для Telegram тегов а-ля <tg-spoiler>, но это не входило в круг задач на данном этапе.

{
    "title": "Продам: Honda CR-V 2.0 л.,2021 г., 61 000 км.",
    "articletext": "<b>Продам: Honda CR-V 2.0 л.,  2021 г.,  61 000 км.</b>\n\n<b>Характеристики</b>\n\n • <b>Пробег:</b> 61 000 км.\n • <strong>Год выпуска автомобиля</strong>: 2021\n • <strong>Цвет автомобиля</strong>: синий\n • <strong>Тип кузова</strong>: внедорожник 5-дв.\n • <strong>Состояние</strong>: не битый\n • <strong>Тип топлива</strong>: бензин\n • <strong>КПП</strong>: вариатор\n • <strong>Привод</strong>: полный привод\n • <strong>Расположение руля</strong>: левый\n • <strong>Объем двигателя</strong>: 2.0 л.\n\n<b>Стоимость</b>: 3 850 000 RUB\n\n<strong>Дополнительно:</strong> Продам хонда срв2.0гибрид комплектация туринг.машинка в отличном состоянии.все расходники поменяны.дачики слепых зон.электро сидения.память сидений подогрев.двухзоный климат.кнопка старт стоп.дачик сближения авто само торможение.удержание в полосе.дачик дождя.+7******* телеграм\n\n👤 Продавец <a href='tg://user?id=*******'>написать</a> \n📲 <tg-spoiler>*******</tg-spoiler> <i>(ткните для просмотра номера телефона)</i>\n✅ <u>Обязательно сообщите мне, что интересуетесь по объявлению с *******</u>❗️\n\n\n#Донецк #Honda #2021_г #3850000_RUB #синий\n\n✩ <a href=\"https://t.me/*******\" target=\"_blank\" rel=\"noreferrer noopener\">Продать / Купить</a> ✩ <a href=\"https://t.me/*******\" target=\"_blank\" rel=\"noreferrer noopener\">Помощь</a> ✩ \n\n<blockquote>Как вам авто? Оставьте реакции! 👇</blockquote>\n⠀\n",
    "fields": {
        "id": "2244",
        "created": "2024-07-16 14:49:06",
        "updated": "2024-07-16 14:42:49",
        "chat_id": "***",
        "name": "",
        "price": "3850000",
        "currency": "RUB",
        "channel_id": "-*****",
        "description": "<strong>Дополнительно:</strong> Продам хонда срв2.0гибрид комплектация туринг.машинка в отличном состоянии.все расходники поменяны.дачики слепых зон.электро сидения.память сидений подогрев.двухзоный климат.кнопка старт стоп.дачик сближения авто само торможение.удержание в полосе.дачик дождя.+7******* телеграм",
        "description_": "Продам хонда срв2.0гибрид комплектация туринг.машинка в отличном состоянии.все расходники поменяны.дачики слепых зон.электро сидения.память сидений подогрев.двухзоный климат.кнопка старт стоп.дачик сближения авто само торможение.удержание в полосе.дачик дождя.+7******* телеграм",
        "phone": "79490398005",
        "email": null,
        "opened": "1",
        "active": "1",
        "finish": "1",
        "file_id": null,
        "file_type": null,
        "id_product": "0",
        "message_id": "***",
        "message_id2": "0",
        "media_group_id": "***",
        "dif": "1",
        "reason": "Продам",
        "checked": "0",
        "city_id": "15789526",
        "region_id": "15789408",
        "country_id": "3159",
        "id_marka": "14",
        "id_model": "554",
        "year": "2021",
        "probeg": "61000",
        "volume": "2.0 л.",
        "color": "синий",
        "license": "0",
        "power": null,
        "published": "1",
        "joomla_sended": "0",
        "username": "<a href='tg://user?id=***'>написать</a>",
        "first_name": "\"\\u0410\\u043b\\u0435\\u043a\\u0441\\u0430\\u043d\\u0434\\u0440\"",
        "last_name": null,
        "probeg_": "61 000 км.",
        "price_": "3 850 000",
        "power_": "0",
        "link_advert": "***",
        "marka": "Honda",
        "marka_": "Honda",
        "model": "CR-V",
        "model_": "CR_V",
        "properties": " • <strong>Год выпуска автомобиля</strong>: 2021\n • <strong>Цвет автомобиля</strong>: синий\n • <strong>Тип кузова</strong>: внедорожник 5-дв.\n • <strong>Состояние</strong>: не битый\n • <strong>Тип топлива</strong>: бензин\n • <strong>КПП</strong>: вариатор\n • <strong>Привод</strong>: полный привод\n • <strong>Расположение руля</strong>: левый\n • <strong>Объем двигателя</strong>: 2.0 л.",
        "city": "Донецк",
        "city_": "Донецк"
    },
    "images": [
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQSkZJRgABA*****CGNDUAf/Z"
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQSkZ***VhNTtO58rcYj//Z"
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQSkZ***dH//2Q=="
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQS***QAzgYMD//Z"
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQSkZJR***8AE75EuO4A/9k="
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQS***k0TkDaYB/9k="
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQ***cw/Kw//2Q=="
        },
        {
            "type": "image/jpeg",
            "base64": "/9j/4AAQSk***CJDX/2Q=="
        }
    ],
    "alias": "669792aac145e",
    "catid": "10",
    "state": 1,
    "language": "*",
    "metakey": "",
    "metadesc": ""
}

Метод программного создания материала Joomla createArticle()

 Руководствуясь статьями и репозиториями из списка литературы мы создаем метод createArticle(). Вкратце, логика работы метода:

  1. Сохраняем материал
  2. Сохраняем поля к нему
  3. Сохраняем картинки.
  4. Ещё раз сохраняем материал уже со вставленными картинками.

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

В настройки плагина мы вынесли следующие параметры.

Настройки ajax плагина в Joomla 5

Поскольку материал у нас будет сохраняться 2 раза - в момент создания и в момент добавления картинок к нему - собственно сохранение материала мы выносим в отдельный метод saveArticle(), который на вход принимает массив данных материала Joomla.

<?php
/**
 * Creates an article.
 *
 * @return  void
 *
 * @throws  \Exception
 * @throws  \RuntimeException
 * @since   1.0
 */
private function createArticle($data): int
{
	/**
	 * 1. Сохраняем материал
	 * 2. Сохраняем поля к нему
	 * 3. Сохраняем картинки.
	 * 4. Ещё раз сохраняем материал уже со вставленными картинками.
	 *
	 * Такой подход нужен для того, чтобы все проверки на уникальность алиаса,
	 * который используется для создания папки для картинок, делала Joomla.
	 * Не надо в плагин копировать лишнее.
	 */

	/**
	 * @var array $article  Массив с данными материалам. Пустой alias и id = 0 - создаст новый материал.
	 *                      При желании можно добавить ещё стандартных полей материала
	 */
	$article = [
		'id'         => 0,
		'title'      => $data->get('title'), // Заголовок материала
		'alias'      => '', // Empty alias to avoid notice warnings
		'introtext'  => $this->params->get('article_text_enable', 0) ? $data->get('articletext') : '', // Текст материала
		'catid'      => $this->params->get('category_id', 0), // id категории из настроек плагина
		'state'      => $this->params->get('default_state_published', 0), // Настройка публикации из параметров плагина
		'language'   => '*', // Язык - все
		'access'     => 1, // Группа доступа public
		'created_by' => $this->params->get('created_by') ?? null, // Автор материала
		'tags' => [2, 3], // Если нужно присвоить теги материалу - берём id тегов.
	];
	/**
	 * Здесь мы не передаем id и алиас материала в массиве,
	 * поэтому будет создан НОВЫЙ материал
	 */
	list($article_id, $article_alias) = $this->saveArticle($article);

	 /**
	 *  Часть кода пока пропустим, вернёмся к этому фрагменту чуть позже
	 */

	return $article_id;
}

Здесь мы создали массив материала с минимально необходимыми данными для него и передаём их в метод saveArticle(). Этот метод вернёт нам id созданного материала и его alias. Они нужны нам будут в дальнейшей работе.

Метод saveArticle() - сохранение материала

Вообще, вся работа с данными в Joomla как правило сводится к очень простым действиям:

  1. Загрузи нужную модель (Model из MVC) нужного компонента (компонент материалов в нашем случае)
  2. Получи данные с помощью метода getItem() или getItems(). В разных компонентах методы могут называться по разному, но суть логики остается та же.
  3. Обработай данные как тебе нужно.
  4. Сохрани данные с помощью метода модели save().

Следующий ниже код хорошо описан в статье Как программно создать материал с настраиваемыми полями на PHP Дмитрия Рекуна в разделе Создание материала. Некоторые дополнения к той статье в комментариях к коду и далее.

<?php
/**
 * Сохраняет материал
 *
 * @param   array  $article
 *
 * @return int article id
 *
 * @since 1.0.0
 */
public function saveArticle(array $article): array
{

	$contentPath = JPATH_ADMINISTRATOR . '/components/com_content';
	/**
	 * Поскольку вызываем модель из необычного места - ей нужно помочь
	 * и указать откуда подгружать формы. Из com_content она
	 * грузит их самостоятельно
	 */
	Form::addFormPath($contentPath . '/models/forms');
	Form::addFormPath($contentPath . '/model/form');
	Form::addFieldPath($contentPath . '/models/fields');
	Form::addFieldPath($contentPath . '/model/field');
	Form::addFormPath($contentPath . '/forms');

	/** Получаем модель Article, которая делает основную работу */
	$mvcFactory = $this->getApplication()->bootComponent('com_content')->getMVCFactory();
	$model      = $mvcFactory->createModel('Article', 'Administrator', ['ignore_request' => true]);

	// Load the form.
	$form = $model->getForm($article, false);

	if (!$form)
	{
		throw new \RuntimeException('Error getting form: ' . $model->getError());
	}

	// Validate the form.
	if (!$model->validate($form, $article))
	{
		throw new \RuntimeException('Error validating article: ' . $model->getError());
	}

	// Emulate save task.
	$this->getApplication()->getInput()->set('task', 'save');

	// Save an article.
	if (!$model->save($article))
	{
		throw new \RuntimeException('Error saving article: ' . $model->getError());
	}
	$item = $model->getItem();

	return [$item->id, $item->alias];
}

Стандартные модели (Model) Joomla обычно вызываются в контексте компонента. Для их работы бывают нужны формы (Joomla Form, ex JForm). И обычно определить пути для загрузки форм можно по GET-параметру option, например, option=com_contentoption=com_contact и т.д. Однако, в данном случае параметр option у нас равен com_ajax и попытка вызвать модель и провалидировать данные её методами (как описано в статье Дмитрия) не получится, так как в компоненте com_ajax нет этих форм и возникнет ошибка загрузки формы материала. Поэтому Joomla нужно подсказать, что для работы в данной необычной ситуации стоит загрузить нужные для работы формы с помощью метода Form::addFormPath(). Этот пример кода встречается в плагине New Article для Radical Form

После сохранения материала мы возвращаем id материала и его правильный alias

Теперь вернёмся в метод createArticle() и продолжим работу.

Сохранение пользовательских полей материала Joomla программным методом

В статье Дмитрия Рекуна, а так же в примерах кода предлагается следующий код для сохранения кастомных полей Joomla:

<?php
/** @var \Joomla\Component\Fields\Administrator\Model\FieldModel $model */
// Грузим модель
$model = $this->getApplication()
	->bootComponent('com_fields')
	->getMVCFactory()
	->createModel('Field', 'Administrator', ['ignore_request' => true]);

// Массив с полями вида id поля => значение поля
$fields = [
	1 => 'Text', // текстовое поле
	3 => ['Value 2', 'Value 1'], // список
	4 => [2, 1] // чекбоксы
];

// Циклом сохраняем поля
foreach ($fields as $key => $value)
{
	$model->setFieldValue($key, $articleId, $value);
}

Но нужно помнить, что у пользовательских полей в Joomla есть права доступа. А значит при сохранении поля модель (Model) компонента com_fields будет проверять права доступа для текущего пользователя. У текущего пользователя должна быть сессия (php session). И всё хорошо работает тогда, когда ajax-запрос с данными идёт из браузера. А при обращении одного сервера к другому сессия не создаётся и попытка сохранить значение поля в данном случае ни к чему не приводила - поля были пустые. В Joomla REST API работа с данными идёт из-под конкретного пользователя, так как в запросах указывается токен пользователя Joomla. Можно попытаться авторизовывать пользователя по токену в запросе и делать следующую махинацию:

<?php
use Joomla\CMS\Factory;
use Joomla\CMS\User\User;

// Получаем объект конкретного пользователя - автора материалов
$user = new User(112);
// Запускаем Приложение из-под него
Factory::getApplication()->loadIdentity($user);

Но метод setFieldValue() проверяет права доступа с помощью метода FieldsHelper::canEditFieldValue(). В нём текущий пользователь берётся из Factory::getUser(), где в свою очередь пользователь берётся из DI-контейнера, а если его (пользователя) там нет, то из сессии... В общем, в рамках создания текущего плагина не было возможности погрузиться глубоко в данную область, поэтому самым очевидным решением стало сохранение полей простым прямым запросом в базу данных.

Целиком метод выглядит следующим образом:

<?php
/**
 * Сохраняем поля материала.
 * Тут же настраиваем сопоставление id поля и его значения
 * в массиве вида [ $field_id => $field_value ]
 *
 * @param   int  $articleId  Article ID.
 *
 * @return  void
 *
 * @throws  \RuntimeException
 * @since   1.0
 */
private function saveArticleFields($articleId, $fields): void
{
	/**
	 * Карта сопоставления id полей со значениями
	 * [ $field_id => $field_value ]
	 */
	$fields = [
			1 => trim($fields->probeg),
			2 => trim($fields->year),
			3 => trim($fields->marka),
			4 => trim($fields->model),
			5 => trim($fields->city),
			6 => trim($fields->color),
			7 => trim($fields->phone),
	];


	/**
	 * При использовании модели для полей (чтоб было проще) используется метод, 
	 * куда передается id поля, id материала и значение поля. 
	 * Но внутри этого метода есть проверки на права доступа пользователя. 
	 * Имеет ли право данный пользователь изменять значения полей или что-то с ними делать. 
	 * И при выполнении запроса из браузера всё было ок. Однако, при обращении 
	 * со стороны сервера, а не браузера не создаётся php сессия. 
	 * Нет сессии - нет текущего пользователя. 
	 * Нет пользователя - нет прав доступа. И проверка не проходила, 
	 * поэтому поля не заполнялись. Попытался стартовать сессию для пользователя, 
	 * но что-то это долго оказалось. Сделал прямым запросом в базу.
	 */

	
	// Save the fields.
	foreach ($fields as $key => $value)
	{

		$new_field = new \stdClass();

		$new_field->field_id = (int) $key;
		$new_field->item_id  = $articleId;
		$new_field->value = $value;
		
		$this->getDatabase()->insertObject('#__fields_values', $new_field);
	}
}

В методе createArticle() мы остановились на строчке  list($article_id, $article_alias) = $this->saveArticle($article). Вернёмся туда и добавим вызов метода для сохранения данных полей Joomla.

<?php
/**
 * Здесь мы не передаем id и алиас материала в массиве,
 * поэтому будет создан НОВЫЙ материал
 */
list($article_id, $article_alias) = $this->saveArticle($article);

/**
 * Сохраняем поля
 */
try
{
	$this->saveArticleFields($article_id, $data->get('fields'));
}
catch (\Exception)
{
	throw new \RuntimeException('Error saving article fields for article id : ' . $article_id);
}

Обработка, сохранение картинок и добавление в материал Joomla

Картинки приходят массивом, где указан MIME тип файла и его содержимое в base64. Добавляем метод convertBase64ImagesToFiles(). Вернуть этот метод нам должен относительные пути к готовым файлам изображений.

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

<?php
/**
 * Декодирует картинки из base64 и сохраняет их в папку для изображений
 * конкретного материала
 *
 * @param array $images Array of arrays with images data
 * @param string $image_path Path to images folder
 *
 * @return array
 *
 * @since 1.0.0
 */
public function convertBase64ImagesToFiles($images, $image_path): array
{
	// Приходили только jpeg-и, но мало ли, вдруг придут картинки в другом формате...
	$file_extensions = [
		'image/gif' => 'gif',
		'image/jpeg' => 'jpg',
		'image/png' => 'png',
		'image/webp' => 'webp',
		'image/avif' => 'avif',
	];

	$saved_images = [];

	/** @var int $i Итератор, он же имя для файла картинки */
	$i = 1;
	foreach ($images as $image)
	{
		$image_data = base64_decode($image['base64']);

		/** @var string $file_extension Расширение файла по умолчанию */
		$file_extension = 'jpg';
		// Если вдруг пришёл не jpeg - проверяем в списке разрешенных
		// Можно ещё проверку MediaHelper'ом сделать на размер файла и т.д.

		if(in_array($image['type'], $file_extensions))
		{
			$file_extension = $file_extensions[$image['type']];
		}
		// Формируем путь к файлу
		$file_name = $image_path . '/' . $i . '.'.$file_extension;

		// Для сохранения файла нужен полный путь, поэтому добавляем JPATH_SITE
		if (File::write(JPATH_SITE . '/'. $file_name, $image_data))
		{
			$saved_images[] = $file_name;
		}
		$i++;
	}

	return $saved_images;
}

Вернёмся к методу createArticle() и вызовем метод сохранения картинок после метода для сохранения полей.

<?php
/**
* Сначала сохраняем картинки, так как их нужно вставить в изображение вступительного текста
* и остальные в тело материала.
* Изображения сохраняются в папку, название которой - алиас материала.
* $data->get('images') - массив
*/

/** @var string $image_path Путь до картинки от корня сайта со слешем в начале и без слеша в конце вида /images/folder */
$image_path = $this->params->get('image_path') . '/' . $article_alias;

$images = $this->convertBase64ImagesToFiles($data->get('images'), $image_path);

// Если массив с путями к картинкам не пустой - сохраняем ещё раз материал
if(count($images))
{

$article_images = new Registry();

// Включён ли параметр Сохранять 1-е изображение как изображение вступительного текста?
// в настройках плагина
if($this->params->get('article_image_intro_enable', 0) == 1)
{
	$article_images->set('image_intro', $images[0]); //  изображение вступительного текста
	$article_images->set('float_intro',''); //  выравнивание изображения вступительного текста: right - вправо, left - влево, none - без выравнивания
	$article_images->set('image_intro_alt',''); //  альтернативный текст изображения вступительного текста
	$article_images->set('image_intro_caption',''); //  текст, который будет отображаться под изображением вступительного текста
}

// Если включен параметр для изображения полного текста.
$full_text_image = $this->params->get('article_image_full_enable', 0);
if($full_text_image != 0)
{
	$article_image = '';

	if($full_text_image == 'first'){
		$article_image = $images[0];
	} elseif($full_text_image == 'second' && array_key_exists(1, $images)) {
		// Если картинок пришло больше, чем одна и включен параметр "вторая картинка"
		$article_image = $images[1];
	}

	if(!empty($article_image))
	{
		$article_images->set('image_fulltext', $article_image); //  изображение полного текста
		$article_images->set('float_fulltext', ''); //  выравнивание изображения полного текста: right - вправо, left - влево, none - без выравнивания
		$article_images->set('image_fulltext_alt', ''); //  альтернативный текст изображения полного текста
		$article_images->set('image_fulltext_caption', ''); //  текст, который будет отображаться под изображением полного текста
	}
}
$article['images'] = $article_images->toString();

/**
 * КАРТИНКИ В ТЕЛЕ СТАТЬИ!
 * Тут можно настроить куда именно вставлять остальные картинки.
 * Вариантов много, принцип один: берем $article->introtext и вставляем их в начале или в конце.
 * Либо более сложные варианты:
 * - строковая замена с помощью str_replace какой-нибудь комбинации в духе шорт-кода {TG_IMAGES}
 * - или же можно предварительно обернуть пути к картинкам шорткодами для плагинов а-ля WT Content image gallery
 * https://web-tolk.ru/dev/joomla-plugins/wt-content-image-gallery
 */

$rendered_images = '';
foreach ($images as $image)
{
	$alt_text = ' ';
	// Атрибуты для картинки
	// Подробнее читаем https://t.me/webtolkru/252
	$img_attribs = [
		'class' => 'css-class-for-images asdfasdf asdfasdf asdfasdf',
		'loading' => 'lazy',
		'title' => 'атрибут title для картинок'
	];
	$rendered_images .= HTMLHelper::image($image, $alt_text, $img_attribs);
}
/*
 * Пример обёртывания картинок в шорт-коды для контент плагинов.
 * Такой шорт-код подойдет для Simple Image Gallery или WT Content Image Gallery.
 */
// $rendered_images = '
'; /** * Если используется WT Content Image Gallery, то можно упростить и не рендерить изображения * с помощью HTMLHelper, а просто передать в плагин пути к картинкам. * А цикл foreach ($images as $image) убрать - закомментировать или удалить. * Пример кода */ // $rendered_images = '
';
}

Оставлю ссылку на плагин WT Content image gallery здесь.

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

<?php
// Вставляем картинки в конец текста
$article['introtext'] .= $rendered_images; 
// id НЕ РАВНО НУЛЮ - изменяем существующий материал.
$article['id'] = $article_id;
// с алиасом надёжнее
$article['alias'] = $article_alias;
// Сохраняем...
$this->saveArticle($article);
// Вернем id материала, вдруг пригодится...
return $article_id;

Целиком метод createArticle() выглядит так:

<?php
/**
 * Creates an article.
 *
 * @return  void
 *
 * @throws  \Exception
 * @throws  \RuntimeException
 * @since   1.0
 */
private function createArticle($data): int
{
	/**
	 * 1. Сохраняем материал
	 * 2. Сохраняем поля к нему
	 * 3. Сохраняем картинки.
	 * 4. Ещё раз сохраняем материал уже со вставленными картинками.
	 *
	 * Такой подход нужен для того, чтобы все проверки на уникальность алиаса,
	 * который используется для создания папки для картинок, делала Joomla.
	 * Не надо в плагин копировать лишнее.
	 */

	/**
	 * @var array $article  Массив с данными материалам. Пустой alias и id = 0 - создаст новый материал.
	 *                      При желании можно добавить ещё стандартных полей материала
	 */
	$article = [
		'id'         => 0,
		'title'      => $data->get('title'), // Title
		'alias'      => '', // Empty alias to avoid notice warnings
		'introtext'  => $this->params->get('article_text_enable', 0) ? $data->get('articletext') : '', // Text
		'catid'      => $this->params->get('category_id', 0), // Category
		'state'      => $this->params->get('default_state_published', 0), // Publishing state
		'language'   => '*', // Language
		'access'     => 1, // Access level
		'created_by' => $this->params->get('created_by') ?? null, // Access level
		'tags' => [2, 3], // Если нужно присвоить теги материалу - берём id тегов.
	];
	/**
	 * Здесь мы не передаем id и алиас материала в массиве,
	 * поэтому будет создан НОВЫЙ материал
	 */
	list($article_id, $article_alias) = $this->saveArticle($article);

	/**
	 * Сохраняем поля
	 */
	try
	{
		$this->saveArticleFields($article_id, $data->get('fields'));

	}
	catch (\Exception)
	{

		throw new \RuntimeException('Error saving article fields for article id : ' . $article_id);

	}


	/**
	 * Сначала сохраняем картинки, так как их нужно вставить в изображение вступительного текста
	 * и остальные в тело материала.
	 * Изображения сохраняются в папку, название которой - алиас материала.
	 * $data->images - массив
	 */

	/** @var string $image_path Путь до картинки от корня сайта со слешем в начале и без слеша в конце вида /images/folder */
	$image_path = $this->params->get('image_path') . '/' . $article_alias;

	$images = $this->convertBase64ImagesToFiles($data->get('images'), $image_path);

	// Если массив с путями к картинкам не пустой - сохраняем ещё раз материал
	if(count($images))
	{

		$article_images = new Registry();
		// Включён ли параметр Сохранять 1-е изображение как изображение вступительного текста?
		// в настройках плагина
		if($this->params->get('article_image_intro_enable', 0) == 1)
		{
			$article_images->set('image_intro', $images[0]); //  изображение вступительного текста
			$article_images->set('float_intro',''); //  выравнивание изображения вступительного текста: right - вправо, left - влево, none - без выравнивания
			$article_images->set('image_intro_alt',''); //  альтернативный текст изображения вступительного текста
			$article_images->set('image_intro_caption',''); //  текст, который будет отображаться под изображением вступительного текста
		}

		// Если включен параметр для изображения полного текста.
		$full_text_image = $this->params->get('article_image_full_enable', 0);
		if($full_text_image != 0)
		{
			$article_image = '';

			if($full_text_image == 'first'){
				$article_image = $images[0];
			} elseif($full_text_image == 'second' && array_key_exists(1, $images)) {
				// Если картинок пришло больше, чем одна и включен параметр "вторая картинка"
				$article_image = $images[1];
			}

			if(!empty($article_image))
			{
				$article_images->set('image_fulltext', $article_image); //  изображение полного текста
				$article_images->set('float_fulltext', ''); //  выравнивание изображения полного текста: right - вправо, left - влево, none - без выравнивания
				$article_images->set('image_fulltext_alt', ''); //  альтернативный текст изображения полного текста
				$article_images->set('image_fulltext_caption', ''); //  текст, который будет отображаться под изображением полного текста
			}
		}
		$article['images'] = $article_images->toString();

		/**
		 * КАРТИНКИ В ТЕЛЕ СТАТЬИ!
		 * Тут можно настроить куда именно вставлять остальные картинки.
		 * Вариантов много, принцип один: берем $article->introtext и вставляем их в начале или в конце.
		 * Либо более сложные варианты:
		 * - строковая замена с помощью str_replace какой-нибудь комбинации в духе шорт-кода {TG_IMAGES}
		 * - или же можно предварительно обернуть пути к картинкам шорткодами для плагинов а-ля WT Content image gallery
		 * https://web-tolk.ru/dev/joomla-plugins/wt-content-image-gallery
		 */

		$rendered_images = '';
		foreach ($images as $image)
		{
			$alt_text = ' ';
			// Атрибуты для картинки
			// Подробнее читаем https://t.me/webtolkru/252
			$img_attribs = [
				'class' => 'css-class-for-images asdfasdf asdfasdf asdfasdf',
				'loading' => 'lazy',
				'title' => 'атрибут title для картинок'
			];
			$rendered_images .= HTMLHelper::image($image, $alt_text, $img_attribs);
		}
		/*
		 * Пример обёртывания картинок в шорт-коды для контент плагинов.
		 * Такой шорт-код подойдет для Simple Image Gallery или WT Content Image Gallery.
		 */
		// $rendered_images = '
'; /** * Если используется WT Content Image Gallery, то можно упростить и не рендерить изображения * с помощью HTMLHelper, а просто передать в плагин пути к картинкам. * А цикл foreach ($images as $image) убрать - закомментировать или удалить. * Пример кода */ // $rendered_images = '
'; $article['introtext'] .= $rendered_images; // Вставляем картинки в конец текста $article['id'] = $article_id; $article['alias'] = $article_alias; $this->saveArticle($article); } return $article_id; }

Вместо заключения

Данный плагин стоит рассматривать как заготовку, которую стоит изменять для своих нужд в конкретных условиях. Например, "карту" сопоставления id полей и входящих значений. Возможно, нужна будет более сложная логика формирования пути к картинкам или же потребуется очистка текста материалов от мусорных тегов и эмодзи - это всё остаётся за рамками статьи. Но в целом, данная заготовка должна помочь учесть нюансы работы Joomla и облегчить работу. 

Толкачев Сергей Юрьевич
Толкачев Сергей Юрьевич

Joomla-разработчик. Контрибьютер ядра Joomla. Один из ведущих Telegram-канала русскоязычного Joomla-сообщества JoomlaFeed, один из модераторов чата русскоязычного Joomla-сообщества. Мои расширения в официальном маркетплейсе расширений Joomla - Joomla Extensions Directory. Имею публикации в официальном журнале международного Joomla-сообщества - Joomla Community Magazine.

Муж. Отец 3 детей.

Россия, Саратов.

Расширения Joomla WebTolk

89 Всего расширений
11 Категорий
395 Выпущено версий
380537 Всего скачиваний
Корзина
Корзина пуста