* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Finder\Administrator\Indexer; use Exception; use Joomla\CMS\Factory; use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Table\Table; use Joomla\Database\DatabaseInterface; use Joomla\Database\QueryInterface; use Joomla\Utilities\ArrayHelper; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * Prototype adapter class for the Finder indexer package. * * @since 2.5 */ abstract class Adapter extends CMSPlugin { /** * The context is somewhat arbitrary but it must be unique or there will be * conflicts when managing plugin/indexer state. A good best practice is to * use the plugin name suffix as the context. For example, if the plugin is * named 'plgFinderContent', the context could be 'Content'. * * @var string * @since 2.5 */ protected $context; /** * The extension name. * * @var string * @since 2.5 */ protected $extension; /** * The sublayout to use when rendering the results. * * @var string * @since 2.5 */ protected $layout; /** * The mime type of the content the adapter indexes. * * @var string * @since 2.5 */ protected $mime; /** * The access level of an item before save. * * @var integer * @since 2.5 */ protected $old_access; /** * The access level of a category before save. * * @var integer * @since 2.5 */ protected $old_cataccess; /** * The type of content the adapter indexes. * * @var string * @since 2.5 */ protected $type_title; /** * The type id of the content. * * @var integer * @since 2.5 */ protected $type_id; /** * The database object. * * @var DatabaseInterface * @since 2.5 */ protected $db; /** * The table name. * * @var string * @since 2.5 */ protected $table; /** * The indexer object. * * @var Indexer * @since 3.0 */ protected $indexer; /** * The field the published state is stored in. * * @var string * @since 2.5 */ protected $state_field = 'state'; /** * Method to instantiate the indexer adapter. * * @param object $subject The object to observe. * @param array $config An array that holds the plugin configuration. * * @since 2.5 */ public function __construct(&$subject, $config) { // Call the parent constructor. parent::__construct($subject, $config); // Get the type id. $this->type_id = $this->getTypeId(); // Add the content type if it doesn't exist and is set. if (empty($this->type_id) && !empty($this->type_title)) { $this->type_id = Helper::addContentType($this->type_title, $this->mime); } // Check for a layout override. if ($this->params->get('layout')) { $this->layout = $this->params->get('layout'); } // Get the indexer object $this->indexer = new Indexer($this->db); } /** * Method to get the adapter state and push it into the indexer. * * @return void * * @since 2.5 * @throws Exception on error. */ public function onStartIndex() { // Get the indexer state. $iState = Indexer::getState(); // Get the number of content items. $total = (int) $this->getContentCount(); // Add the content count to the total number of items. $iState->totalItems += $total; // Populate the indexer state information for the adapter. $iState->pluginState[$this->context]['total'] = $total; $iState->pluginState[$this->context]['offset'] = 0; // Set the indexer state. Indexer::setState($iState); } /** * Method to prepare for the indexer to be run. This method will often * be used to include dependencies and things of that nature. * * @return boolean True on success. * * @since 2.5 * @throws Exception on error. */ public function onBeforeIndex() { // Get the indexer and adapter state. $iState = Indexer::getState(); $aState = $iState->pluginState[$this->context]; // Check the progress of the indexer and the adapter. if ($iState->batchOffset == $iState->batchSize || $aState['offset'] == $aState['total']) { return true; } // Run the setup method. return $this->setup(); } /** * Method to index a batch of content items. This method can be called by * the indexer many times throughout the indexing process depending on how * much content is available for indexing. It is important to track the * progress correctly so we can display it to the user. * * @return boolean True on success. * * @since 2.5 * @throws Exception on error. */ public function onBuildIndex() { // Get the indexer and adapter state. $iState = Indexer::getState(); $aState = $iState->pluginState[$this->context]; // Check the progress of the indexer and the adapter. if ($iState->batchOffset == $iState->batchSize || $aState['offset'] == $aState['total']) { return true; } // Get the batch offset and size. $offset = (int) $aState['offset']; $limit = (int) ($iState->batchSize - $iState->batchOffset); // Get the content items to index. $items = $this->getItems($offset, $limit); // Iterate through the items and index them. for ($i = 0, $n = count($items); $i < $n; $i++) { // Index the item. $this->index($items[$i]); // Adjust the offsets. $offset++; $iState->batchOffset++; $iState->totalItems--; } // Update the indexer state. $aState['offset'] = $offset; $iState->pluginState[$this->context] = $aState; Indexer::setState($iState); return true; } /** * Method to remove outdated index entries * * @return integer * * @since 4.2.0 */ public function onFinderGarbageCollection() { $db = $this->db; $type_id = $this->getTypeId(); $query = $db->getQuery(true); $subquery = $db->getQuery(true); $subquery->select('CONCAT(' . $db->quote($this->getUrl('', $this->extension, $this->layout)) . ', id)') ->from($db->quoteName($this->table)); $query->select($db->quoteName('l.link_id')) ->from($db->quoteName('#__finder_links', 'l')) ->where($db->quoteName('l.type_id') . ' = ' . $type_id) ->where($db->quoteName('l.url') . ' LIKE ' . $db->quote($this->getUrl('%', $this->extension, $this->layout))) ->where($db->quoteName('l.url') . ' NOT IN (' . $subquery . ')'); $db->setQuery($query); $items = $db->loadColumn(); foreach ($items as $item) { $this->indexer->remove($item); } return count($items); } /** * Method to change the value of a content item's property in the links * table. This is used to synchronize published and access states that * are changed when not editing an item directly. * * @param string $id The ID of the item to change. * @param string $property The property that is being changed. * @param integer $value The new value of that property. * * @return boolean True on success. * * @since 2.5 * @throws Exception on database error. */ protected function change($id, $property, $value) { // Check for a property we know how to handle. if ($property !== 'state' && $property !== 'access') { return true; } // Get the URL for the content id. $item = $this->db->quote($this->getUrl($id, $this->extension, $this->layout)); // Update the content items. $query = $this->db->getQuery(true) ->update($this->db->quoteName('#__finder_links')) ->set($this->db->quoteName($property) . ' = ' . (int) $value) ->where($this->db->quoteName('url') . ' = ' . $item); $this->db->setQuery($query); $this->db->execute(); return true; } /** * Method to index an item. * * @param Result $item The item to index as a Result object. * * @return boolean True on success. * * @since 2.5 * @throws Exception on database error. */ abstract protected function index(Result $item); /** * Method to reindex an item. * * @param integer $id The ID of the item to reindex. * * @return void * * @since 2.5 * @throws Exception on database error. */ protected function reindex($id) { // Run the setup method. $this->setup(); // Remove the old item. $this->remove($id, false); // Get the item. $item = $this->getItem($id); // Index the item. $this->index($item); Taxonomy::removeOrphanNodes(); } /** * Method to remove an item from the index. * * @param string $id The ID of the item to remove. * @param bool $removeTaxonomies Remove empty taxonomies * * @return boolean True on success. * * @since 2.5 * @throws Exception on database error. */ protected function remove($id, $removeTaxonomies = true) { // Get the item's URL $url = $this->db->quote($this->getUrl($id, $this->extension, $this->layout)); // Get the link ids for the content items. $query = $this->db->getQuery(true) ->select($this->db->quoteName('link_id')) ->from($this->db->quoteName('#__finder_links')) ->where($this->db->quoteName('url') . ' = ' . $url); $this->db->setQuery($query); $items = $this->db->loadColumn(); // Check the items. if (empty($items)) { Factory::getApplication()->triggerEvent('onFinderIndexAfterDelete', array($id)); return true; } // Remove the items. foreach ($items as $item) { $this->indexer->remove($item, $removeTaxonomies); } return true; } /** * Method to setup the adapter before indexing. * * @return boolean True on success, false on failure. * * @since 2.5 * @throws Exception on database error. */ abstract protected function setup(); /** * Method to update index data on category access level changes * * @param Table $row A Table object * * @return void * * @since 2.5 */ protected function categoryAccessChange($row) { $query = clone $this->getStateQuery(); $query->where('c.id = ' . (int) $row->id); // Get the access level. $this->db->setQuery($query); $items = $this->db->loadObjectList(); // Adjust the access level for each item within the category. foreach ($items as $item) { // Set the access level. $temp = max($item->access, $row->access); // Update the item. $this->change((int) $item->id, 'access', $temp); } } /** * Method to update index data on category access level changes * * @param array $pks A list of primary key ids of the content that has changed state. * @param integer $value The value of the state that the content has been changed to. * * @return void * * @since 2.5 */ protected function categoryStateChange($pks, $value) { /* * The item's published state is tied to the category * published state so we need to look up all published states * before we change anything. */ foreach ($pks as $pk) { $query = clone $this->getStateQuery(); $query->where('c.id = ' . (int) $pk); // Get the published states. $this->db->setQuery($query); $items = $this->db->loadObjectList(); // Adjust the state for each item within the category. foreach ($items as $item) { // Translate the state. $temp = $this->translateState($item->state, $value); // Update the item. $this->change($item->id, 'state', $temp); } } } /** * Method to check the existing access level for categories * * @param Table $row A Table object * * @return void * * @since 2.5 */ protected function checkCategoryAccess($row) { $query = $this->db->getQuery(true) ->select($this->db->quoteName('access')) ->from($this->db->quoteName('#__categories')) ->where($this->db->quoteName('id') . ' = ' . (int) $row->id); $this->db->setQuery($query); // Store the access level to determine if it changes $this->old_cataccess = $this->db->loadResult(); } /** * Method to check the existing access level for items * * @param Table $row A Table object * * @return void * * @since 2.5 */ protected function checkItemAccess($row) { $query = $this->db->getQuery(true) ->select($this->db->quoteName('access')) ->from($this->db->quoteName($this->table)) ->where($this->db->quoteName('id') . ' = ' . (int) $row->id); $this->db->setQuery($query); // Store the access level to determine if it changes $this->old_access = $this->db->loadResult(); } /** * Method to get the number of content items available to index. * * @return integer The number of content items available to index. * * @since 2.5 * @throws Exception on database error. */ protected function getContentCount() { $return = 0; // Get the list query. $query = $this->getListQuery(); // Check if the query is valid. if (empty($query)) { return $return; } // Tweak the SQL query to make the total lookup faster. if ($query instanceof QueryInterface) { $query = clone $query; $query->clear('select') ->select('COUNT(*)') ->clear('order'); } // Get the total number of content items to index. $this->db->setQuery($query); return (int) $this->db->loadResult(); } /** * Method to get a content item to index. * * @param integer $id The id of the content item. * * @return Result A Result object. * * @since 2.5 * @throws Exception on database error. */ protected function getItem($id) { // Get the list query and add the extra WHERE clause. $query = $this->getListQuery(); $query->where('a.id = ' . (int) $id); // Get the item to index. $this->db->setQuery($query); $item = $this->db->loadAssoc(); // Convert the item to a result object. $item = ArrayHelper::toObject((array) $item, Result::class); // Set the item type. $item->type_id = $this->type_id; // Set the item layout. $item->layout = $this->layout; return $item; } /** * Method to get a list of content items to index. * * @param integer $offset The list offset. * @param integer $limit The list limit. * @param QueryInterface $query A QueryInterface object. [optional] * * @return Result[] An array of Result objects. * * @since 2.5 * @throws Exception on database error. */ protected function getItems($offset, $limit, $query = null) { // Get the content items to index. $this->db->setQuery($this->getListQuery($query)->setLimit($limit, $offset)); $items = $this->db->loadAssocList(); foreach ($items as &$item) { $item = ArrayHelper::toObject($item, Result::class); // Set the item type. $item->type_id = $this->type_id; // Set the mime type. $item->mime = $this->mime; // Set the item layout. $item->layout = $this->layout; } return $items; } /** * Method to get the SQL query used to retrieve the list of content items. * * @param mixed $query A QueryInterface object. [optional] * * @return QueryInterface A database object. * * @since 2.5 */ protected function getListQuery($query = null) { // Check if we can use the supplied SQL query. return $query instanceof QueryInterface ? $query : $this->db->getQuery(true); } /** * Method to get the plugin type * * @param integer $id The plugin ID * * @return string The plugin type * * @since 2.5 */ protected function getPluginType($id) { // Prepare the query $query = $this->db->getQuery(true) ->select($this->db->quoteName('element')) ->from($this->db->quoteName('#__extensions')) ->where($this->db->quoteName('extension_id') . ' = ' . (int) $id); $this->db->setQuery($query); return $this->db->loadResult(); } /** * Method to get a SQL query to load the published and access states for * an article and category. * * @return QueryInterface A database object. * * @since 2.5 */ protected function getStateQuery() { $query = $this->db->getQuery(true); // Item ID $query->select('a.id'); // Item and category published state $query->select('a.' . $this->state_field . ' AS state, c.published AS cat_state'); // Item and category access levels $query->select('a.access, c.access AS cat_access') ->from($this->table . ' AS a') ->join('LEFT', '#__categories AS c ON c.id = a.catid'); return $query; } /** * Method to get the query clause for getting items to update by time. * * @param string $time The modified timestamp. * * @return QueryInterface A database object. * * @since 2.5 */ protected function getUpdateQueryByTime($time) { // Build an SQL query based on the modified time. $query = $this->db->getQuery(true) ->where('a.modified >= ' . $this->db->quote($time)); return $query; } /** * Method to get the query clause for getting items to update by id. * * @param array $ids The ids to load. * * @return QueryInterface A database object. * * @since 2.5 */ protected function getUpdateQueryByIds($ids) { // Build an SQL query based on the item ids. $query = $this->db->getQuery(true) ->where('a.id IN(' . implode(',', $ids) . ')'); return $query; } /** * Method to get the type id for the adapter content. * * @return integer The numeric type id for the content. * * @since 2.5 * @throws Exception on database error. */ protected function getTypeId() { // Get the type id from the database. $query = $this->db->getQuery(true) ->select($this->db->quoteName('id')) ->from($this->db->quoteName('#__finder_types')) ->where($this->db->quoteName('title') . ' = ' . $this->db->quote($this->type_title)); $this->db->setQuery($query); return (int) $this->db->loadResult(); } /** * Method to get the URL for the item. The URL is how we look up the link * in the Finder index. * * @param integer $id The id of the item. * @param string $extension The extension the category is in. * @param string $view The view for the URL. * * @return string The URL of the item. * * @since 2.5 */ protected function getUrl($id, $extension, $view) { return 'index.php?option=' . $extension . '&view=' . $view . '&id=' . $id; } /** * Method to get the page title of any menu item that is linked to the * content item, if it exists and is set. * * @param string $url The URL of the item. * * @return mixed The title on success, null if not found. * * @since 2.5 * @throws Exception on database error. */ protected function getItemMenuTitle($url) { $return = null; // Set variables $user = Factory::getUser(); $groups = implode(',', $user->getAuthorisedViewLevels()); // Build a query to get the menu params. $query = $this->db->getQuery(true) ->select($this->db->quoteName('params')) ->from($this->db->quoteName('#__menu')) ->where($this->db->quoteName('link') . ' = ' . $this->db->quote($url)) ->where($this->db->quoteName('published') . ' = 1') ->where($this->db->quoteName('access') . ' IN (' . $groups . ')'); // Get the menu params from the database. $this->db->setQuery($query); $params = $this->db->loadResult(); // Check the results. if (empty($params)) { return $return; } // Instantiate the params. $params = json_decode($params); // Get the page title if it is set. if (isset($params->page_title) && $params->page_title) { $return = $params->page_title; } return $return; } /** * Method to update index data on access level changes * * @param Table $row A Table object * * @return void * * @since 2.5 */ protected function itemAccessChange($row) { $query = clone $this->getStateQuery(); $query->where('a.id = ' . (int) $row->id); // Get the access level. $this->db->setQuery($query); $item = $this->db->loadObject(); // Set the access level. $temp = max($row->access, $item->cat_access); // Update the item. $this->change((int) $row->id, 'access', $temp); } /** * Method to update index data on published state changes * * @param array $pks A list of primary key ids of the content that has changed state. * @param integer $value The value of the state that the content has been changed to. * * @return void * * @since 2.5 */ protected function itemStateChange($pks, $value) { /* * The item's published state is tied to the category * published state so we need to look up all published states * before we change anything. */ foreach ($pks as $pk) { $query = clone $this->getStateQuery(); $query->where('a.id = ' . (int) $pk); // Get the published states. $this->db->setQuery($query); $item = $this->db->loadObject(); // Translate the state. $temp = $this->translateState($value, $item->cat_state); // Update the item. $this->change($pk, 'state', $temp); } } /** * Method to update index data when a plugin is disabled * * @param array $pks A list of primary key ids of the content that has changed state. * * @return void * * @since 2.5 */ protected function pluginDisable($pks) { // Since multiple plugins may be disabled at a time, we need to check first // that we're handling the appropriate one for the context foreach ($pks as $pk) { if ($this->getPluginType($pk) == strtolower($this->context)) { // Get all of the items to unindex them $query = clone $this->getStateQuery(); $this->db->setQuery($query); $items = $this->db->loadColumn(); // Remove each item foreach ($items as $item) { $this->remove($item); } } } } /** * Method to translate the native content states into states that the * indexer can use. * * @param integer $item The item state. * @param integer $category The category state. [optional] * * @return integer The translated indexer state. * * @since 2.5 */ protected function translateState($item, $category = null) { // If category is present, factor in its states as well if ($category !== null && $category == 0) { $item = 0; } // Translate the state switch ($item) { // Published and archived items only should return a published state case 1: case 2: return 1; // All other states should return an unpublished state default: return 0; } } }