Skip to content

Instantly share code, notes, and snippets.

@sergeytolkachyov
Created April 25, 2025 05:44
Show Gist options
  • Save sergeytolkachyov/ecfa5aef518711b6f6dff868ff2bbc3e to your computer and use it in GitHub Desktop.
Save sergeytolkachyov/ecfa5aef518711b6f6dff868ff2bbc3e to your computer and use it in GitHub Desktop.
Export articles from Joomla 3 to Joomla 5 via REST API. Joomla 3 CLI script.
<?php
/**
* @package Joomla.Cli
*
* @copyright (C) 2011 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
/**
* This is a CRON script which should be called from the command-line, not the
* web. For example something like:
*
* /usr/bin/php /path/to/joomla/cli/export_articles.php --context=articles
*
* First, you need to create all the necessary fields and categories
* in the new Joomla in order to record their IDs and aliases in the correspondence maps.
*
* You can adapt this script to transfer not only Joomla articles,
* but also other entities: categories, contacts, banners, etc.
*
* @Author Sergey Tolkachyov
* @link https://web-tolk.ru
*/
// Set flag that this is a parent file.
use Joomla\CMS\Http\Http;
use Joomla\CMS\Input\Cli;
use Joomla\Registry\Registry;
use Joomla\CMS\Http\HttpFactory;
use Joomla\CMS\Uri\Uri;
const _JEXEC = 1;
error_reporting(E_ALL | E_NOTICE);
ini_set('display_errors', 1);
// Load system defines
if (file_exists(dirname(__DIR__) . '/defines.php'))
{
require_once dirname(__DIR__) . '/defines.php';
}
if (!defined('_JDEFINES'))
{
define('JPATH_BASE', dirname(__DIR__));
require_once JPATH_BASE . '/includes/defines.php';
}
require_once JPATH_LIBRARIES . '/import.legacy.php';
require_once JPATH_LIBRARIES . '/cms.php';
// Load the configuration
require_once JPATH_CONFIGURATION . '/configuration.php';
// System configuration.
$config = new JConfig;
define('JDEBUG', $config->debug);
// Register FieldsHelper
JLoader::register('FieldsHelper', JPATH_ADMINISTRATOR . '/components/com_fields/helpers/fields.php');
/**
* This script will fetch the update information for all extensions and store
* them in the database, speeding up your administrator.
*
* @since 2.5
*/
class ExportArticles extends JApplicationCli
{
/**
* Joomla 3 category ids for handling,
* from which we process articles
*
* @var int[]
* @since 1.0.0
*/
public static $catids = [
21, // Category 1
585, // Category 2 etc.
];
/**
* A list of available commands for the script.
* Not used yet
*
* @var int[]
* @since 1.0.0
*/
public $validParams = [
'context',
];
/**
* Valid values for command `context`
*
* @var string[]
* @since 1.0.0
*/
public $validContext = [
'articles',
'categories',
];
/**
* @var array|mixed
* @since 1.0.0
*/
public $commands = [];
/**
* Joomla 5+ API URL. Where do we copy articles to.
*
* @var string
* @since 1.0.0
*/
private $apiUrl = 'https://your-joomla-5.ru';
/**
* Joomla 5+ API token. We are looking in the user's profile,
* from which we will create articles on the receiving site.
*
* @var string
* @since 1.0.0
*/
private $apiToken = 'c2hhMjU2OjI3MTo4NTY3MzhhN2M4NDZhMTIyZ********yZjBjM2Y0YmJiNzIxOWM5MWU0NTczOGQ3NTY2MmZh';
/**
* Joomla HTTP-client for HTTP requests
*
* @var Http
* @since 1.0.0
*/
private $http;
/**
* Item list limit
*
* @var int
* @since 1.0.0
*/
public static $list_limit = 100000;
/**
* A map of matching old to new data.
* The array key is an old property from Joomla 3,
* The value is a new property in Joomla 5.
*
* @var array
* @since 1.0.0
*/
private $itemPropertiesMap = [
'title' => 'title',
'alias' => 'alias',
'introtext' => 'introtext',
'fulltext' => 'fulltext',
'state' => 'state',
'catid' => 'catid',
'created' => 'created',
'modified' => 'modified',
'publish_up' => 'publish_up',
'images' => 'images',
'urls' => 'urls',
'attribs' => 'attribs',
'ordering' => 'ordering',
'metakey' => 'metakey',
'metadesc' => 'metadesc',
'hits' => 'hits',
'metadata' => 'metadata',
'featured' => 'featured',
'language' => 'language',
'note' => 'note',
];
/**
* A map of how the old categories match the new ones.
* The array key is the id of the old category in Joomla 3
* The value of the array is the id of the new category in Joomla 5
*
* @var array
* @since 1.0.0
*/
private $categoriesMapOldToNew = [
21 => 8, // Joomla 3 catid => Joomla 5 catid
585 => 9,
108 => 10,
111 => 11,
114 => 12,
];
/**
* A map of how the CUSTOM FIELDS match the old and new ONES.
* The array key is an old system name (alias) field in Joomla 3
* The array value is the system name of the new field in Joomla 5
*
* @var array
* @since 1.0.0
*/
private $customFieldsMapOldToNew = [
'photo' => 'photo',
'koordinaty' => 'coords',
'opisanie-rezhima-raboty' => 'rezhim-raboty',
'text-oplata' => 'forma-oplaty',
];
/**
* JDatabase
* @var $db
* @since 1.0.0
*/
private $db;
public function __construct(Cli $input = null, Registry $config = null, JEventDispatcher $dispatcher = null)
{
parent::__construct($input, $config, $dispatcher);
$this->commands = $this->input->getArray();
$this->http = HttpFactory::getHttp(null, ['curl', 'stream']);
$this->apiUrl = Uri::getInstance($this->apiUrl);
$this->db = JFactory::getDbo();
}
/**
* Entry point for the script
*
* @return void
*
* @since 2.5
*/
public function doExecute()
{
// are the parameters of the receiving site specified
if (!$this->checkCredentials())
{
return;
}
// are there any commands for the script
// and are they correct
if (!$this->checkCommands())
{
return;
}
$context = $this->commands['context'];
switch ($context)
{
case 'articles': // You can add your own cases here with custom methods
default :
$this->copyArticles();
break;
}
$this->out('Finished');
}
/**
* Checking if the API URL and API TOKEN are filled in
*
* @return bool
* @since 1.0.0
*/
private function checkCredentials()
{
if (empty($this->apiUrl))
{
$this->out(
'Error: $apiUrl is empty! You did not specify the api url. Fill it in with the value of the $apiUrl variable.'
);
return false;
}
if (empty($this->apiToken))
{
$this->out(
'Error: $apiToken is empty! You did not specify the api url. Fill it in with the value of the $apiToken variable.'
);
return false;
}
return true;
}
/**
* We check the commands from the command line.
*
* @return bool
* @since 1.0.0
*/
private function checkCommands()
{
if (empty($this->commands))
{
$this->out("You did not specify any commands. For example, --context=articles");
return false;
}
if (!array_key_exists('context', $this->commands))
{
$this->out(
"You did not specify the --context command. Specify it, please. For example, --context=articles"
);
return false;
}
$context = $this->commands['context'];
if (!in_array($context, $this->validContext))
{
$this->out("You have specified wrong context: " . $context);
$this->out('The list of valid contextes are: ' . implode(', ', $this->validContext));
return false;
}
return true;
}
/**
* Create articles in the new Joomla 5
* via the REST API
*
* @throws Exception
* @since 1.0.0
*/
public function copyArticles()
{
$items = $this->getArticlesList();
if(empty($items))
{
$this->out('Error: There is no articles found');
return;
}
$this->apiUrl->setPath('/api/index.php/v1/content/articles');
foreach ($items as $item)
{
$data = [];
foreach ($item as $oldKey => $oldValue)
{
if(array_key_exists($oldKey, $this->itemPropertiesMap))
{
$newKey = $this->itemPropertiesMap[$oldKey];
// Set new id for category - by Joomla 5.
if($newKey == 'catid')
{
$oldValue = !empty($this->categoriesMapOldToNew[$oldValue]) ? $this->categoriesMapOldToNew[$oldValue] : $oldValue;
$oldContentCatId = $oldValue;
}
$data[$newKey] = $oldValue;
}
}
// Get the custom fields for article
$fields = $this->getCustomFields('com_content.article', $item);
if(!empty($fields))
{
$data['com_fields'] = [];
$nerudasId = -1;
foreach ($fields as $field)
{
// Custom changes for field START
if($field['name'] == 'field-name-old' && !empty($field['value']))
{
$url = new Uri(trim($field['value']));
$anyId = $url->getVar('map')['active_id'];
$data['com_fields']['field-name-new'] = $anyId;
}
// Custom changes for field END
// If there is a name of the old field in the mapping map
if(array_key_exists($field['name'], $this->customFieldsMapOldToNew))
{
$newFieldName = $this->customFieldsMapOldToNew[$field['name']];
// FOR MEDIA FIELDS - USE OTHER SCTRUCTURE
if(in_array($newFieldName, [
'photo',
'skhema-proezda'
]))
{
$data['com_fields'][$newFieldName]['imagefile'] = trim($field['value']);
} else {
$data['com_fields'][$newFieldName] = trim($field['value']);
}
}
}
}
/**
* Additional custom changes START
*/
// Write your custom code here
/**
* Additional custom changes END
*/
// Send request to Joomla 5 REST API
if(!empty($data))
{
$result = $this->sendRequest($this->apiUrl->toString(), $data);
if($result->code !== 200)
{
$errors = [];
if(!empty($result->body))
{
$body = json_decode($result->body);
$errors[] = [
'title' => 'Creation error',
'item_title' => $data['title'],
'error_code' => $result->code,
'errors' => $body->errors,
];
}
if(!empty($errors))
{
// Log the errors to errors.txt
file_put_contents(__DIR__.'/errors.txt', print_r($errors, true).PHP_EOL, FILE_APPEND);
}
} else {
$this->out('Item '.$data['title'].' successfully transfered');
}
}
}
}
/**
* Get the articles list
*
* @throws Exception
* @since 1.0.0
*/
public function getArticlesList()
{
$items = [];
if(empty(self::$catids))
{
$this->out('There are no source category ids specified in $catids parameter');
return $items;
}
$query = $this->db->getQuery(true);
$query->select(['*'])
->from('#__content')
->where($this->db->quoteName('catid') . ' IN (' . implode(',', self::$catids) . ')')
->setLimit(self::$list_limit);
try
{
$items = $this->db->setQuery($query)->loadObjectList();
$this->out('Articles list: '.count($items).' articles found in categories id: '.implode(', ', self::$catids));
}
catch (Exception $exception)
{
$this->out('Error when trying to get a list of articles from the database.');
$this->out($exception->getCode().' - '.$exception->getMessage());
}
return $items;
}
/**
* @param string $context 'com_content.article' etc.
* @param array $item
*
* @return array
*
* @since 1.0.0
*/
public function getCustomFields($context, $item)
{
$db = $this->db;
$query = $db->getQuery(true);
$query->select(
[
'a.id',
'a.name',
'fv.value',
]
)
->from($db->quoteName('#__fields', 'a'))
->where($db->quoteName('a.context'). ' = ' . $db->quote($context))
->innerJoin('#__fields_values AS fv ON a.id = fv.field_id')
->where($db->quoteName('fv.item_id'). ' = ' . $db->quote($item->id));
$fields = $db->setQuery($query)->loadAssocList();
return $fields;
}
/**
* @param string $url
* @param array $data
*
*
* @since 1.0.0
*/
private function sendRequest($url, $data)
{
$data = (new Joomla\Registry\Registry($data))->toString();
$headers = [
'Authorization' => 'Bearer ' . $this->apiToken,
'Content-Type' => 'application/json',
];
// $data = (new Joomla\Registry\Registry($data))->toString();
$response = $this->http->post($this->apiUrl, $data, $headers);
return $response;
}
}
JApplicationCli::getInstance('ExportArticles')->execute();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment