commit 866c63cc6311a5603dbf9625dc9b4f850106ad23 Author: Sven Wappler Date: Sat Apr 17 00:26:33 2021 +0200 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..85756e4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# EditorConfig is awesome: http://EditorConfig.org +# TYPO3 Standard: https://github.com/TYPO3/TYPO3.CMS/blob/master/.editorconfig + +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +# ReST-Files +[*.rst] +indent_size = 3 +max_line_length = 80 + +# YAML-Files +[*.{yaml,yml}] +indent_size = 2 + +# TypoScript +[*.{typoscript,tsconfig}] +indent_size = 2 + +# XLF/XML-Files +[*.{xlf,xml}] +indent_style = tab diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..03d2232 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://wappler.systems/"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cf2d99c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] Please add a speaking title" +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Used versions (please complete the following information):** + - TYPO3 Version: [e.g. 10.4.13] + - Browser: [e.g. chrome, safari] + - EXT:meilisearch Version: [e.g. 10.0.0] + - Used Meilisearch Version: [e.g. 8.8.0] + - PHP Version: [e.g. 7.4.0] + - MySQL Version: [e.g. 8.0.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..97ef321 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] Please describe your feature wish here" +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Target versions** +Please add the EXT:meilisearch target versions here. diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..26b2e1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,10 @@ +--- +name: Task +about: This template is used to decribe regular tasks +title: "[TASK]" +labels: '' +assignees: '' + +--- + +**What should be done in the scope of this task?** diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 0000000..0b527af --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,14 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 28 +# Label requiring a response +responseRequiredLabel: more-information-needed +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. Please reach out if you have or find the answers we need so + that we can investigate further. + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81bf9a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.buildpath +.project/ +.settings/ +.idea/ +atlassian-ide-plugin.xml +.DS_Store +index.php +typo3 +typo3_src +typo3conf +typo3temp +uploads +vendor +/composer.lock +.Build +Documentation/_make + +.php_cs.cache diff --git a/Classes/AbstractDataHandlerListener.php b/Classes/AbstractDataHandlerListener.php new file mode 100644 index 0000000..5305f50 --- /dev/null +++ b/Classes/AbstractDataHandlerListener.php @@ -0,0 +1,217 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\Domain\Index\Queue\RecordMonitor\Helper\ConfigurationAwareRecordService; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Database\QueryGenerator; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Changes in TYPO3 have an impact on the solr content and are caught + * by the GarbageCollector and RecordMonitor. Both act as a TCE Main Hook. + * + * This base class is used to share functionality that are needed for both + * to perform the changes in the data handler on the solr index. + * + * @author Timo Schmidt + */ +abstract class AbstractDataHandlerListener +{ + /** + * Reference to the configuration manager + * + * @var \WapplerSystems\Meilisearch\Domain\Index\Queue\RecordMonitor\Helper\ConfigurationAwareRecordService + */ + protected $configurationAwareRecordService; + + /** + * @var FrontendEnvironment + */ + protected $frontendEnvironment = null; + + /** + * AbstractDataHandlerListener constructor. + * @param ConfigurationAwareRecordService|null $recordService + */ + public function __construct(ConfigurationAwareRecordService $recordService = null, FrontendEnvironment $frontendEnvironment = null) + { + $this->configurationAwareRecordService = $recordService ?? GeneralUtility::makeInstance(ConfigurationAwareRecordService::class); + $this->frontendEnvironment = $frontendEnvironment ?? GeneralUtility::makeInstance(FrontendEnvironment::class); + } + + /** + * @return array + */ + protected function getAllRelevantFieldsForCurrentState() + { + $allCurrentStateFieldnames = []; + + foreach ($this->getUpdateSubPagesRecursiveTriggerConfiguration() as $triggerConfiguration) { + if (!isset($triggerConfiguration['currentState']) || !is_array($triggerConfiguration['currentState'])) { + // when no "currentState" configuration for the trigger exists we can skip it + continue; + } + + // we collect the currentState fields to return a unique list of all fields + $allCurrentStateFieldnames = array_merge($allCurrentStateFieldnames, array_keys($triggerConfiguration['currentState'])); + } + + return array_unique($allCurrentStateFieldnames); + } + + /** + * When the extend to subpages flag was set, we determine the affected subpages and return them. + * + * @param int $pageId + * @return array + */ + protected function getSubPageIds($pageId) + { + /** @var $queryGenerator \TYPO3\CMS\Core\Database\QueryGenerator */ + $queryGenerator = GeneralUtility::makeInstance(QueryGenerator::class); + + // here we retrieve only the subpages of this page because the permission clause is not evaluated + // on the root node. + $permissionClause = ' 1 ' . BackendUtility::BEenableFields('pages'); + $treePageIdList = $queryGenerator->getTreeList($pageId, 20, 0, $permissionClause); + $treePageIds = array_map('intval', explode(',', $treePageIdList)); + + // the first one can be ignored because this is the page itself + array_shift($treePageIds); + + return $treePageIds; + } + + /** + * Checks if a page update will trigger a recursive update of pages + * + * This can either be the case if some $changedFields are part of the RecursiveUpdateTriggerConfiguration or + * columns have explicitly been configured via plugin.tx_meilisearch.index.queue.recursiveUpdateFields + * + * @param int $pageId + * @param array $changedFields + * @return bool + */ + protected function isRecursivePageUpdateRequired($pageId, $changedFields) + { + // First check RecursiveUpdateTriggerConfiguration + $isRecursiveUpdateRequired = $this->isRecursiveUpdateRequired($pageId, $changedFields); + // If RecursiveUpdateTriggerConfiguration is false => check if changeFields are part of recursiveUpdateFields + if ($isRecursiveUpdateRequired === false) { + $solrConfiguration = $this->frontendEnvironment->getSolrConfigurationFromPageId($pageId); + $indexQueueConfigurationName = $this->configurationAwareRecordService->getIndexingConfigurationName('pages', $pageId, $solrConfiguration); + if ($indexQueueConfigurationName === null) { + return false; + } + $updateFields = $solrConfiguration->getIndexQueueConfigurationRecursiveUpdateFields($indexQueueConfigurationName); + + // Check if no additional fields have been defined and then skip recursive update + if (empty($updateFields)) { + return false; + } + // If the recursiveUpdateFields configuration is not part of the $changedFields skip recursive update + if (!array_intersect_key($changedFields, $updateFields)) { + return false; + } + } + + return true; + } + + /** + * @param int $pageId + * @param array $changedFields + * @return bool + */ + protected function isRecursiveUpdateRequired($pageId, $changedFields) + { + $fieldsForCurrentState = $this->getAllRelevantFieldsForCurrentState(); + $fieldListToRetrieve = implode(',', $fieldsForCurrentState); + $page = BackendUtility::getRecord('pages', $pageId, $fieldListToRetrieve, '', false); + foreach ($this->getUpdateSubPagesRecursiveTriggerConfiguration() as $triggerConfiguration) { + $allCurrentStateFieldsMatch = $this->getAllCurrentStateFieldsMatch($triggerConfiguration, $page); + $allChangeSetValuesMatch = $this->getAllChangeSetValuesMatch($triggerConfiguration, $changedFields); + + $aMatchingTriggerHasBeenFound = $allCurrentStateFieldsMatch && $allChangeSetValuesMatch; + if ($aMatchingTriggerHasBeenFound) { + return true; + } + } + + return false; + } + + /** + * @param array $triggerConfiguration + * @param array $pageRecord + * @return bool + */ + protected function getAllCurrentStateFieldsMatch($triggerConfiguration, $pageRecord) + { + $triggerConfigurationHasNoCurrentStateConfiguration = !array_key_exists('currentState', $triggerConfiguration); + if ($triggerConfigurationHasNoCurrentStateConfiguration) { + return true; + } + $diff = array_diff_assoc($triggerConfiguration['currentState'], $pageRecord); + return empty($diff); + } + + /** + * @param array $triggerConfiguration + * @param array $changedFields + * @return bool + */ + protected function getAllChangeSetValuesMatch($triggerConfiguration, $changedFields) + { + $triggerConfigurationHasNoChangeSetStateConfiguration = !array_key_exists('changeSet', $triggerConfiguration); + if ($triggerConfigurationHasNoChangeSetStateConfiguration) { + return true; + } + + $diff = array_diff_assoc($triggerConfiguration['changeSet'], $changedFields); + return empty($diff); + } + + /** + * The implementation of this method need to retrieve a configuration to determine which record data + * and change combination required a recursive change. + * + * The structure needs to be: + * + * [ + * [ + * 'currentState' => ['fieldName1' => 'value1'], + * 'changeSet' => ['fieldName1' => 'value1'] + * ] + * ] + * + * When the all values of the currentState AND all values of the changeSet match, a recursive update + * will be triggered. + * + * @return array + */ + abstract protected function getUpdateSubPagesRecursiveTriggerConfiguration(); +} diff --git a/Classes/Access/Rootline.php b/Classes/Access/Rootline.php new file mode 100644 index 0000000..eaec855 --- /dev/null +++ b/Classes/Access/Rootline.php @@ -0,0 +1,228 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; +use TYPO3\CMS\Frontend\Page\PageRepository; + +/** + * "Access Rootline", represents all pages and specifically those setting + * frontend user group access restrictions in a page's rootline. + * + * The access rootline only contains pages which set frontend user access + * restrictions and extend them to sub-pages. The format is as follows: + * + * pageId1:group1,group2/pageId2:group3/c:group1,group4,groupN + * + * The single elements of the access rootline are separated by a slash + * character. All but the last elements represent pages, the last element + * defines the access restrictions applied to the page's content elements + * and records shown on the page. + * Each page element is composed by the page ID of the page setting frontend + * user access restrictions, a colon, and a comma separated list of frontend + * user group IDs restricting access to the page. + * The content access element does not have a page ID, instead it replaces + * the ID by a lower case C. + * + * The groups for page elements are compared using OR, so the user needs to be + * a member of only one of the groups listed for a page. The elements are + * checked combined using AND, so the user must be member of at least one + * group in each page element. However, the groups in the content access + * element are checked using AND. So the user must be member of all the groups + * listed in the content access element to see the document. + * + * An access rootline for a generic record could instead be short like this: + * + * r:group1,group2,groupN + * + * In this case the lower case R tells us that we're dealing with a record + * like tt_news or the like. For records the groups are checked using OR + * instead of using AND as it would be the case with content elements. + * + * @author Ingo Renner + */ +class Rootline +{ + + /** + * Delimiter for page and content access right elements in the rootline. + * + * @var string + */ + const ELEMENT_DELIMITER = '/'; + + /** + * Storage for access rootline elements + * + * @var array + */ + protected $rootlineElements = []; + + /** + * Constructor, turns a string representation of an access rootline into an + * object representation. + * + * @param string $accessRootline Access Rootline String representation. + */ + public function __construct($accessRootline = null) + { + if (!is_null($accessRootline)) { + $rawRootlineElements = explode(self::ELEMENT_DELIMITER, $accessRootline); + foreach ($rawRootlineElements as $rawRootlineElement) { + try { + $this->push(GeneralUtility::makeInstance(RootlineElement::class, /** @scrutinizer ignore-type */ $rawRootlineElement)); + } catch (RootlineElementFormatException $e) { + // just ignore the faulty element for now, might log this later + } + } + } + } + + /** + * Adds an Access Rootline Element to the end of the rootline. + * + * @param RootlineElement $rootlineElement Element to add. + */ + public function push(RootlineElement $rootlineElement) + { + $lastElementIndex = max(0, (count($this->rootlineElements) - 1)); + + if (!empty($this->rootlineElements[$lastElementIndex])) { + if ($this->rootlineElements[$lastElementIndex]->getType() == RootlineElement::ELEMENT_TYPE_CONTENT) { + throw new RootlineElementFormatException( + 'Can not add an element to an Access Rootline whose\' last element is a content type element.', + 1294422132 + ); + } + + if ($this->rootlineElements[$lastElementIndex]->getType() == RootlineElement::ELEMENT_TYPE_RECORD) { + throw new RootlineElementFormatException( + 'Can not add an element to an Access Rootline whose\' last element is a record type element.', + 1308343423 + ); + } + } + + $this->rootlineElements[] = $rootlineElement; + } + + /** + * Gets the Access Rootline for a specific page Id. + * + * @param int $pageId The page Id to generate the Access Rootline for. + * @param string $mountPointParameter The mount point parameter for generating the rootline. + * @return \WapplerSystems\Meilisearch\Access\Rootline Access Rootline for the given page Id. + */ + public static function getAccessRootlineByPageId( + $pageId, + $mountPointParameter = '' + ) { + $accessRootline = GeneralUtility::makeInstance(Rootline::class); + $rootlineUtility = GeneralUtility::makeInstance(RootlineUtility::class, $pageId, $mountPointParameter); + try { + $rootline = $rootlineUtility->get(); + } catch (\RuntimeException $e) { + $rootline = []; + } + $rootline = array_reverse($rootline); + // parent pages + foreach ($rootline as $pageRecord) { + if ($pageRecord['fe_group'] + && $pageRecord['extendToSubpages'] + && $pageRecord['uid'] != $pageId + ) { + $accessRootline->push(GeneralUtility::makeInstance( + RootlineElement::class, + /** @scrutinizer ignore-type */ $pageRecord['uid'] . RootlineElement::PAGE_ID_GROUP_DELIMITER . $pageRecord['fe_group'] + )); + } + } + + /** @var $pageSelector PageRepository */ + $pageSelector = GeneralUtility::makeInstance(PageRepository::class); + + // current page + $currentPageRecord = $pageSelector->getPage($pageId, true); + if ($currentPageRecord['fe_group']) { + $accessRootline->push(GeneralUtility::makeInstance( + RootlineElement::class, + /** @scrutinizer ignore-type */ $currentPageRecord['uid'] . RootlineElement::PAGE_ID_GROUP_DELIMITER . $currentPageRecord['fe_group'] + )); + } + + return $accessRootline; + } + + /** + * Returns the string representation of the access rootline. + * + * @return string String representation of the access rootline. + */ + public function __toString() + { + $stringElements = []; + + foreach ($this->rootlineElements as $rootlineElement) { + $stringElements[] = (string)$rootlineElement; + } + + return implode(self::ELEMENT_DELIMITER, $stringElements); + } + + /** + * Gets a the groups in the Access Rootline. + * + * @return array An array of sorted, unique user group IDs required to access a page. + */ + public function getGroups() + { + $groups = []; + + foreach ($this->rootlineElements as $rootlineElement) { + $rootlineElementGroups = $rootlineElement->getGroups(); + $groups = array_merge($groups, $rootlineElementGroups); + } + + $groups = $this->cleanGroupArray($groups); + + return $groups; + } + + /** + * Cleans an array of frontend user group IDs. Removes duplicates and sorts + * the array. + * + * @param array $groups An array of frontend user group IDs + * @return array An array of cleaned frontend user group IDs, unique, sorted. + */ + public static function cleanGroupArray(array $groups) + { + $groups = array_unique($groups); // removes duplicates + sort($groups, SORT_NUMERIC); // sort + + return $groups; + } +} diff --git a/Classes/Access/RootlineElement.php b/Classes/Access/RootlineElement.php new file mode 100644 index 0000000..b2b748e --- /dev/null +++ b/Classes/Access/RootlineElement.php @@ -0,0 +1,187 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * An element in the "Access Rootline". Represents the frontend user group + * access restrictions for a page, a page's content, or a generic record. + * + * @author Ingo Renner + */ +class RootlineElement +{ + + /** + * Page access rootline element. + * + * @var int + */ + const ELEMENT_TYPE_PAGE = 1; + + /** + * Content access rootline element. + * + * @var int + */ + const ELEMENT_TYPE_CONTENT = 2; + + /** + * Record access rootline element. + * + * @var int + */ + const ELEMENT_TYPE_RECORD = 3; + + /** + * Delimiter between the page ID and the groups set for a page. + * + * @var string + */ + const PAGE_ID_GROUP_DELIMITER = ':'; + + /** + * Access type, either page (default) or content. Depending on the type, + * access is granted differently. For pages the user must meet at least one + * group requirement, for content all group requirements must be met. + * + * @var int + */ + protected $type = self::ELEMENT_TYPE_PAGE; + + /** + * Page Id for the element. NULL for the content type. + * + * @var int + */ + protected $pageId = null; + + /** + * Set of access groups assigned to the element. + * + * @var array + */ + protected $accessGroups = []; + + /** + * Constructor for RootlineElement. + * + * @param string $element String representation of an element in the access rootline, usually of the form pageId:commaSeparatedPageAccessGroups + * @throws RootlineElementFormatException on wrong access format. + */ + public function __construct($element) + { + $elementAccess = explode(self::PAGE_ID_GROUP_DELIMITER, $element); + + if (count($elementAccess) === 1 || $elementAccess[0] === 'c') { + // the content access groups part of the access rootline + $this->type = self::ELEMENT_TYPE_CONTENT; + + if (count($elementAccess) === 1) { + $elementGroups = $elementAccess[0]; + } else { + $elementGroups = $elementAccess[1]; + } + } elseif ($elementAccess[0] === 'r') { + // record element type + if (count($elementAccess) !== 2) { + throw new RootlineElementFormatException( + 'Wrong Access Rootline Element format for a record type element.', + 1308342937 + ); + } + + $this->type = self::ELEMENT_TYPE_RECORD; + $elementGroups = $elementAccess[1]; + } else { + // page element type + if (count($elementAccess) !== 2 || !is_numeric($elementAccess[0])) { + throw new RootlineElementFormatException( + 'Wrong Access Rootline Element format for a page type element.', + 1294421105 + ); + } + + $this->pageId = intval($elementAccess[0]); + $elementGroups = $elementAccess[1]; + } + + $this->accessGroups = GeneralUtility::intExplode(',', $elementGroups); + } + + /** + * Returns the String representation of an access rootline element. + * + * @return string Access Rootline Element string representation + */ + public function __toString() + { + $rootlineElement = ''; + + if ($this->type == self::ELEMENT_TYPE_CONTENT) { + $rootlineElement .= 'c'; + } elseif ($this->type == self::ELEMENT_TYPE_RECORD) { + $rootlineElement .= 'r'; + } else { + $rootlineElement .= $this->pageId; + } + + $rootlineElement .= self::PAGE_ID_GROUP_DELIMITER; + $rootlineElement .= implode(',', $this->accessGroups); + + return $rootlineElement; + } + + /** + * Gets the access rootline element's type. + * + * @return int ELEMENT_TYPE_PAGE for page, ELEMENT_TYPE_CONTENT for content access rootline elements + */ + public function getType() + { + return $this->type; + } + + /** + * Gets the page Id for page type elements. + * + * @return int Page Id. + */ + public function getPageId() + { + return $this->pageId; + } + + /** + * Gets the element's access group restrictions. + * + * @return array Array of user group Ids + */ + public function getGroups() + { + return $this->accessGroups; + } +} diff --git a/Classes/Access/RootlineElementFormatException.php b/Classes/Access/RootlineElementFormatException.php new file mode 100644 index 0000000..c805f43 --- /dev/null +++ b/Classes/Access/RootlineElementFormatException.php @@ -0,0 +1,34 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +/** + * Signals a wrong format for the access definition of a page or the content. + * + * @author Ingo Renner + */ +class RootlineElementFormatException extends \InvalidArgumentException +{ +} diff --git a/Classes/AdditionalFieldsIndexer.php b/Classes/AdditionalFieldsIndexer.php new file mode 100644 index 0000000..7654868 --- /dev/null +++ b/Classes/AdditionalFieldsIndexer.php @@ -0,0 +1,126 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ +use WapplerSystems\Meilisearch\System\Configuration\TypoScriptConfiguration; +use WapplerSystems\Meilisearch\System\ContentObject\ContentObjectService; +use WapplerSystems\Meilisearch\System\Solr\Document\Document; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Additional fields indexer. + * + * @todo Move this to an Index Queue frontend helper + * + * Adds page document fields as configured in + * plugin.tx_meilisearch.index.additionalFields. + * + * @author Ingo Renner + */ +class AdditionalFieldsIndexer implements SubstitutePageIndexer +{ + + /** + * @var TypoScriptConfiguration + */ + protected $configuration; + + /** + * @var array + */ + protected $additionalIndexingFields = []; + + /** + * @var array + */ + protected $additionalFieldNames = []; + + /** + * @var ContentObjectService + */ + protected $contentObjectService = null; + + /** + * @param TypoScriptConfiguration $configuration + * @param ContentObjectService $contentObjectService + */ + public function __construct(TypoScriptConfiguration $configuration = null, ContentObjectService $contentObjectService = null) + { + $this->configuration = $configuration === null ? Util::getSolrConfiguration() : $configuration; + $this->additionalIndexingFields = $this->configuration->getIndexAdditionalFieldsConfiguration(); + $this->additionalFieldNames = $this->configuration->getIndexMappedAdditionalFieldNames(); + $this->contentObjectService = $contentObjectService === null ? GeneralUtility::makeInstance(ContentObjectService::class) : $contentObjectService; + } + + /** + * Returns a substitute document for the currently being indexed page. + * + * Uses the original document and adds fields as defined in + * plugin.tx_meilisearch.index.additionalFields. + * + * @param Document $pageDocument The original page document. + * @return Document A Apache Solr Document object that replace the default page document + */ + public function getPageDocument(Document $pageDocument) + { + $substitutePageDocument = clone $pageDocument; + $additionalFields = $this->getAdditionalFields(); + + foreach ($additionalFields as $fieldName => $fieldValue) { + if (!isset($pageDocument->{$fieldName})) { + // making sure we only _add_ new fields + $substitutePageDocument->setField($fieldName, $fieldValue); + } + } + + return $substitutePageDocument; + } + + /** + * Gets the additional fields as an array mapping field names to values. + * + * @return array An array mapping additional field names to their values. + */ + protected function getAdditionalFields() + { + $additionalFields = []; + + foreach ($this->additionalFieldNames as $additionalFieldName) { + $additionalFields[$additionalFieldName] = $this->getFieldValue($additionalFieldName); + } + + return $additionalFields; + } + + /** + * Uses the page's cObj instance to resolve the additional field's value. + * + * @param string $fieldName The name of the field to get. + * @return string The field's value. + */ + protected function getFieldValue($fieldName) + { + return $this->contentObjectService->renderSingleContentObjectByArrayAndKey($this->additionalIndexingFields, $fieldName); + } +} diff --git a/Classes/AdditionalPageIndexer.php b/Classes/AdditionalPageIndexer.php new file mode 100644 index 0000000..bfa62a0 --- /dev/null +++ b/Classes/AdditionalPageIndexer.php @@ -0,0 +1,49 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * A copy is found in the textfile GPL.txt and important notices to the license + * from the author is found in LICENSE.txt distributed with these scripts. + * + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\System\Solr\Document\Document; + +/** + * Interface that defines the method an indexer must implement to provide + * additional documents to index for a page being indexed. + * + * @author Ingo Renner + */ +interface AdditionalPageIndexer +{ + + /** + * Provides additional documents that should be indexed together with a page. + * + * @param Document $pageDocument The original page document. + * @param array $allDocuments An array containing all the documents collected until here, including the page document + * @return array An array of additional \WapplerSystems\Meilisearch\System\Solr\Document\Document objects + */ + public function getAdditionalPageDocuments(Document $pageDocument, array $allDocuments); +} diff --git a/Classes/Api.php b/Classes/Api.php new file mode 100644 index 0000000..8b0d9b1 --- /dev/null +++ b/Classes/Api.php @@ -0,0 +1,58 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +/** + * Remote API related methods + * + * @author Ingo Renner + */ +class Api +{ + + /** + * Checks whether a string is a valid API key. + * + * @param string $apiKey API key to check for validity + * @return bool TRUE if the API key is valid, FALSE otherwise + */ + public static function isValidApiKey($apiKey) + { + return ($apiKey === self::getApiKey()); + } + + /** + * Generates the API key for the REST API + * + * @return string API key for this installation + */ + public static function getApiKey() + { + return sha1( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . + 'tx_meilisearch_api' + ); + } +} diff --git a/Classes/Backend/IndexingConfigurationSelectorField.php b/Classes/Backend/IndexingConfigurationSelectorField.php new file mode 100644 index 0000000..0bcbc6d --- /dev/null +++ b/Classes/Backend/IndexingConfigurationSelectorField.php @@ -0,0 +1,213 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\Domain\Site\Site; +use TYPO3\CMS\Backend\Form\FormResultCompiler; +use TYPO3\CMS\Backend\Form\NodeFactory; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Index Queue indexing configuration selector form field. + * + * @author Ingo Renner + */ +class IndexingConfigurationSelectorField +{ + + /** + * Site used to determine indexing configurations + * + * @var Site + */ + protected $site; + + /** + * Form element name + * + * @var string + */ + protected $formElementName = 'tx_meilisearch-index-queue-indexing-configuration-selector'; + + /** + * Selected values + * + * @var array + */ + protected $selectedValues = []; + + /** + * Constructor + * + * @param Site $site The site to use to determine indexing configurations + */ + public function __construct(Site $site = null) + { + $this->site = $site; + } + + /** + * Sets the form element name. + * + * @param string $formElementName Form element name + */ + public function setFormElementName($formElementName) + { + $this->formElementName = $formElementName; + } + + /** + * Gets the form element name. + * + * @return string form element name + */ + public function getFormElementName() + { + return $this->formElementName; + } + + /** + * Sets the selected values. + * + * @param array $selectedValues + */ + public function setSelectedValues(array $selectedValues) + { + $this->selectedValues = $selectedValues; + } + + /** + * Gets the selected values. + * + * @return array + */ + public function getSelectedValues() + { + return $this->selectedValues; + } + + /** + * Renders a field to select which indexing configurations to initialize. + * + * Uses \TYPO3\CMS\Backend\Form\FormEngine. + * + * @return string Markup for the select field + */ + public function render() + { + // transform selected values into the format used by TCEforms + $selectedValues = $this->selectedValues; + $tablesToIndex = $this->getIndexQueueConfigurationTableMap(); + + $formField = $this->renderSelectCheckbox($this->buildSelectorItems($tablesToIndex), $selectedValues); + + // need to wrap the field in a TCEforms table to make the CSS apply + $form[] = '
'; + $form[] = $formField; + $form[] = '
'; + + return implode(LF, $form); + } + + /** + * Builds a map of indexing configuration names to tables to to index. + * + * @return array Indexing configuration to database table map + */ + protected function getIndexQueueConfigurationTableMap() + { + $indexingTableMap = []; + + $solrConfiguration = $this->site->getSolrConfiguration(); + $configurationNames = $solrConfiguration->getEnabledIndexQueueConfigurationNames(); + foreach ($configurationNames as $configurationName) { + $indexingTableMap[$configurationName] = $solrConfiguration->getIndexQueueTableNameOrFallbackToConfigurationName($configurationName); + } + + return $indexingTableMap; + } + + /** + * Builds the items to render in the TCEforms select field. + * + * @param array $tablesToIndex A map of indexing configuration to database tables + * + * @return array Selectable items for the TCEforms select field + */ + protected function buildSelectorItems(array $tablesToIndex) + { + $selectorItems = []; + $iconFactory = GeneralUtility::makeInstance(IconFactory::class); + + foreach ($tablesToIndex as $configurationName => $tableName) { + $icon = $iconFactory->mapRecordTypeToIconIdentifier($tableName, []); + + $labelTableName = ''; + if ($configurationName !== $tableName) { + $labelTableName = ' (' . $tableName . ')'; + } + + $selectorItems[] = [$configurationName . $labelTableName, $configurationName, $icon]; + } + + return $selectorItems; + } + + /** + * @param array $items + * @param string $selectedValues + * + * @return string + * @throws \TYPO3\CMS\Backend\Form\Exception + */ + protected function renderSelectCheckbox($items, $selectedValues) + { + $parameterArray = [ + 'fieldChangeFunc' => [], + 'itemFormElName' => $this->formElementName, + 'itemFormElValue' => $selectedValues, + 'fieldConf' => ['config' => ['items' => $items]], + 'fieldTSConfig' => ['noMatchingValue_label' => ''] + ]; + + $nodeFactory = GeneralUtility::makeInstance(NodeFactory::class); + $options = [ + 'renderType' => 'selectCheckBox', 'table' => 'tx_meilisearch_classes_backend_indexingconfigurationselector', + 'fieldName' => 'additionalFields', 'databaseRow' => [], 'parameterArray' => $parameterArray + ]; + $options['parameterArray']['fieldConf']['config']['items'] = $items; + $options['parameterArray']['fieldTSConfig']['noMatchingValue_label'] = ''; + + $selectCheckboxResult = $nodeFactory->create($options)->render(); + $formResultCompiler = GeneralUtility::makeInstance(FormResultCompiler::class); + $formResultCompiler->mergeResult($selectCheckboxResult); + + $formHtml = isset($selectCheckboxResult['html']) ? $selectCheckboxResult['html'] : ''; + $content = $formResultCompiler->addCssFiles() . $formHtml . $formResultCompiler->printNeededJSFunctions(); + + return $content; + } +} diff --git a/Classes/Backend/SiteSelectorField.php b/Classes/Backend/SiteSelectorField.php new file mode 100644 index 0000000..003b6f0 --- /dev/null +++ b/Classes/Backend/SiteSelectorField.php @@ -0,0 +1,72 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\Domain\Site\SiteRepository; +use WapplerSystems\Meilisearch\Domain\Site\Site; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * SiteSelectorField + * + * Responsible for generating SiteSelectorField + * + * @author Thomas Hohn + */ +class SiteSelectorField +{ + /** + * Creates a dropdown selector of available TYPO3 sites with Solr configured. + * + * @param string $selectorName Name to be used in the select's name attribute + * @param Site $selectedSite Optional, currently selected site + * @return string Site selector HTML code + */ + public function getAvailableSitesSelector( + $selectorName, + Site $selectedSite = null + ) { + $siteRepository = GeneralUtility::makeInstance(SiteRepository::class); + + $sites = $siteRepository->getAvailableSites(); + $selector = ''; + + return $selector; + } +} diff --git a/Classes/ConnectionManager.php b/Classes/ConnectionManager.php new file mode 100644 index 0000000..fe95cdf --- /dev/null +++ b/Classes/ConnectionManager.php @@ -0,0 +1,267 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\Domain\Site\Site; +use WapplerSystems\Meilisearch\Domain\Site\SiteRepository; +use WapplerSystems\Meilisearch\System\Records\Pages\PagesRepository as PagesRepositoryAtExtSolr; +use WapplerSystems\Meilisearch\System\Records\SystemLanguage\SystemLanguageRepository; +use WapplerSystems\Meilisearch\System\Solr\Node; +use WapplerSystems\Meilisearch\System\Solr\SolrConnection; +use InvalidArgumentException; +use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use function json_encode; + +/** + * ConnectionManager is responsible to create SolrConnection objects. + * + * @author Ingo Renner + */ +class ConnectionManager implements SingletonInterface +{ + + /** + * @var array + */ + protected static $connections = []; + + /** + * @var SystemLanguageRepository + */ + protected $systemLanguageRepository; + + /** + * @var PagesRepositoryAtExtSolr + */ + protected $pagesRepositoryAtExtSolr; + + /** + * @var SiteRepository + */ + protected $siteRepository; + + + /** + * @param SystemLanguageRepository $systemLanguageRepository + * @param PagesRepositoryAtExtSolr|null $pagesRepositoryAtExtSolr + * @param SiteRepository $siteRepository + */ + public function __construct( + SystemLanguageRepository $systemLanguageRepository = null, + PagesRepositoryAtExtSolr $pagesRepositoryAtExtSolr = null, + SiteRepository $siteRepository = null + ) + { + $this->systemLanguageRepository = $systemLanguageRepository ?? GeneralUtility::makeInstance(SystemLanguageRepository::class); + $this->siteRepository = $siteRepository ?? GeneralUtility::makeInstance(SiteRepository::class); + $this->pagesRepositoryAtExtSolr = $pagesRepositoryAtExtSolr ?? GeneralUtility::makeInstance(PagesRepositoryAtExtSolr::class); + } + + /** + * Creates a solr connection for read and write endpoints + * + * @param array $readNodeConfiguration + * @param array $writeNodeConfiguration + * @return SolrConnection|object + */ + public function getSolrConnectionForNodes(array $readNodeConfiguration, array $writeNodeConfiguration) + { + $connectionHash = md5(json_encode($readNodeConfiguration) . json_encode($writeNodeConfiguration)); + if (!isset(self::$connections[$connectionHash])) { + $readNode = Node::fromArray($readNodeConfiguration); + $writeNode = Node::fromArray($writeNodeConfiguration); + self::$connections[$connectionHash] = GeneralUtility::makeInstance(SolrConnection::class, $readNode, $writeNode); + } + return self::$connections[$connectionHash]; + } + + /** + * Creates a solr configuration from the configuration array and returns it. + * + * @param array $config The solr configuration array + * @return SolrConnection + */ + public function getConnectionFromConfiguration(array $config) + { + if(empty($config['read']) && !empty($config['solrHost'])) { + throw new InvalidArgumentException('Invalid registry data please re-initialize your solr connections'); + } + + return $this->getSolrConnectionForNodes($config['read'], $config['write']); + } + + /** + * Gets a Solr connection for a page ID. + * + * @param int $pageId A page ID. + * @param int $language The language ID to get the connection for as the path may differ. Optional, defaults to 0. + * @param string $mount Comma list of MountPoint parameters + * @return SolrConnection A solr connection. + * @throws NoSolrConnectionFoundException + */ + public function getConnectionByPageId($pageId, $language = 0, $mount = '') + { + try { + $site = $this->siteRepository->getSiteByPageId($pageId, $mount); + $this->throwExceptionOnInvalidSite($site, 'No site for pageId ' . $pageId); + $config = $site->getSolrConnectionConfiguration($language); + $solrConnection = $this->getConnectionFromConfiguration($config); + return $solrConnection; + } catch(InvalidArgumentException $e) { + $noSolrConnectionException = $this->buildNoConnectionExceptionForPageAndLanguage($pageId, $language); + throw $noSolrConnectionException; + } + } + + /** + * Gets a Solr connection for a root page ID. + * + * @param int $pageId A root page ID. + * @param int $language The language ID to get the connection for as the path may differ. Optional, defaults to 0. + * @return SolrConnection A solr connection. + * @throws NoSolrConnectionFoundException + */ + public function getConnectionByRootPageId($pageId, $language = 0) + { + try { + $site = $this->siteRepository->getSiteByRootPageId($pageId); + $this->throwExceptionOnInvalidSite($site, 'No site for pageId ' . $pageId); + $config = $site->getSolrConnectionConfiguration($language); + $solrConnection = $this->getConnectionFromConfiguration($config); + return $solrConnection; + } catch (InvalidArgumentException $e) { + /* @var NoSolrConnectionFoundException $noSolrConnectionException */ + $noSolrConnectionException = $this->buildNoConnectionExceptionForPageAndLanguage($pageId, $language); + throw $noSolrConnectionException; + } + } + + /** + * Gets all connections found. + * + * @return SolrConnection[] An array of initialized WapplerSystems\Meilisearch\System\Solr\SolrConnection connections + * @throws NoSolrConnectionFoundException + */ + public function getAllConnections() + { + $solrConnections = []; + foreach ($this->siteRepository->getAvailableSites() as $site) { + foreach ($site->getAllSolrConnectionConfigurations() as $solrConfiguration) { + $solrConnections[] = $this->getConnectionFromConfiguration($solrConfiguration); + } + } + + return $solrConnections; + } + + /** + * Gets all connections configured for a given site. + * + * @param Site $site A TYPO3 site + * @return SolrConnection[] An array of Solr connection objects (WapplerSystems\Meilisearch\System\Solr\SolrConnection) + * @throws NoSolrConnectionFoundException + */ + public function getConnectionsBySite(Site $site) + { + $connections = []; + + foreach ($site->getAllSolrConnectionConfigurations() as $languageId => $solrConnectionConfiguration) { + $connections[$languageId] = $this->getConnectionFromConfiguration($solrConnectionConfiguration); + } + + return $connections; + } + + /** + * Creates a human readable label from the connections' configuration. + * + * @param array $connection Connection configuration + * @return string Connection label + */ + protected function buildConnectionLabel(array $connection) + { + return $connection['rootPageTitle'] + . ' (pid: ' . $connection['rootPageUid'] + . ', language: ' . $this->systemLanguageRepository->findOneLanguageTitleByLanguageId($connection['language']) + . ') - Read node: ' + . $connection['read']['host'] . ':' + . $connection['read']['port'] + . $connection['read']['path'] + .' - Write node: ' + . $connection['write']['host'] . ':' + . $connection['write']['port'] + . $connection['write']['path']; + } + + /** + * @param $pageId + * @param $language + * @return NoSolrConnectionFoundException + */ + protected function buildNoConnectionExceptionForPageAndLanguage($pageId, $language): NoSolrConnectionFoundException + { + $message = 'Could not find a Solr connection for page [' . $pageId . '] and language [' . $language . '].'; + $noSolrConnectionException = $this->buildNoConnectionException($message); + + $noSolrConnectionException->setLanguageId($language); + return $noSolrConnectionException; + } + + /** + * Throws a no connection exception when no site was passed. + * + * @param Site|null $site + * @param $message + * @throws NoSolrConnectionFoundException + */ + protected function throwExceptionOnInvalidSite(?Site $site, string $message) + { + if (!is_null($site)) { + return; + } + + throw $this->buildNoConnectionException($message); + } + + /** + * Build a NoSolrConnectionFoundException with the passed message. + * @param string $message + * @return NoSolrConnectionFoundException + */ + protected function buildNoConnectionException(string $message): NoSolrConnectionFoundException + { + /* @var NoSolrConnectionFoundException $noSolrConnectionException */ + $noSolrConnectionException = GeneralUtility::makeInstance( + NoSolrConnectionFoundException::class, + /** @scrutinizer ignore-type */ + $message, + /** @scrutinizer ignore-type */ + 1575396474 + ); + return $noSolrConnectionException; + } +} diff --git a/Classes/ContentObject/Classification.php b/Classes/ContentObject/Classification.php new file mode 100644 index 0000000..bb82c58 --- /dev/null +++ b/Classes/ContentObject/Classification.php @@ -0,0 +1,117 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\Domain\Index\Classification\Classification as ClassificationItem; +use WapplerSystems\Meilisearch\Domain\Index\Classification\ClassificationService; +use InvalidArgumentException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\ContentObject\AbstractContentObject; + +/** + * A content object (cObj) to classify content based on a configuration. + * + * Example usage: + * + * keywords = SOLR_CLASSIFICATION # supports stdWrap + * keywords { + * field = __solr_content # a comma separated field. instead of field you can also use "value" + * classes { + * 1 { + * patterns = smartphone, mobile, mobilephone # list of patterns that need to match to assign that class + * class = mobilephone # class that should be assigned when a pattern matches + * } + * } + * } + */ +class Classification extends AbstractContentObject +{ + const CONTENT_OBJECT_NAME = 'SOLR_CLASSIFICATION'; + + /** + * Executes the SOLR_CLASSIFICATION content object. + * + * Returns mapped classes when the field matches on of the configured patterns ... + * + * @inheritDoc + */ + public function render($conf = []) + { + + if (!is_array($conf['classes.'])) { + throw new InvalidArgumentException('No class configuration configured for SOLR_CLASSIFICATION object. Given configuration: ' . serialize($conf)); + } + + $configuredMappedClasses = $conf['classes.']; + unset($conf['classes.']); + + $data = ''; + if (isset($conf['value'])) { + $data = $conf['value']; + unset($conf['value']); + } + + if (!empty($conf)) { + $data = $this->cObj->stdWrap($data, $conf); + } + $classifications = $this->buildClassificationsFromConfiguration($configuredMappedClasses); + /** @var $classificationService ClassificationService */ + $classificationService = GeneralUtility::makeInstance(ClassificationService::class); + + return serialize($classificationService->getMatchingClassNames((string)$data, $classifications)); + } + + /** + * Builds an array of Classification objects from the passed classification configuration. + * + * @param array $configuredMappedClasses + * @return ClassificationItem[] + */ + protected function buildClassificationsFromConfiguration($configuredMappedClasses) : array + { + $classifications = []; + foreach ($configuredMappedClasses as $class) { + if ( (empty($class['patterns']) && empty($class['matchPatterns'])) || empty($class['class'])) { + throw new InvalidArgumentException('A class configuration in SOLR_CLASSIFCATION needs to have a pattern and a class configured. Given configuration: ' . serialize($class)); + } + + // @todo deprecate patterns configuration + $patterns = empty($class['patterns']) ? [] : GeneralUtility::trimExplode(',', $class['patterns']); + $matchPatterns = empty($class['matchPatterns']) ? [] : GeneralUtility::trimExplode(',', $class['matchPatterns']); + $matchPatterns = $matchPatterns + $patterns; + $unMatchPatters = empty($class['unmatchPatterns']) ? [] : GeneralUtility::trimExplode(',', $class['unmatchPatterns']); + + $className = $class['class']; + $classifications[] = GeneralUtility::makeInstance( + ClassificationItem::class, + /** @scrutinizer ignore-type */ $matchPatterns, + /** @scrutinizer ignore-type */ $unMatchPatters, + /** @scrutinizer ignore-type */ $className + ); + } + + return $classifications; + } +} diff --git a/Classes/ContentObject/Content.php b/Classes/ContentObject/Content.php new file mode 100644 index 0000000..e16e02b --- /dev/null +++ b/Classes/ContentObject/Content.php @@ -0,0 +1,80 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\HtmlContentExtractor; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\ContentObject\AbstractContentObject; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; + +/** + * A content object (cObj) to clean a database field in a way so that it can be + * used to fill a Solr document's content field. + * + * @author Ingo Renner + */ +class Content extends AbstractContentObject +{ + const CONTENT_OBJECT_NAME = 'SOLR_CONTENT'; + + /** + * Executes the SOLR_CONTENT content object. + * + * Cleans content coming from a database field, removing HTML tags ... + * + * @inheritDoc + */ + public function render($conf = []) + { + $contentExtractor = GeneralUtility::makeInstance( + HtmlContentExtractor::class, + /** @scrutinizer ignore-type */ $this->getRawContent($this->cObj, $conf) + ); + + return $contentExtractor->getIndexableContent(); + } + + /** + * Gets the raw content as configured - a certain value or database field. + * + * @param ContentObjectRenderer $contentObject The original content object + * @param array $configuration content object configuration + * @return string The raw content + */ + protected function getRawContent($contentObject, $configuration) + { + $content = ''; + if (isset($configuration['value'])) { + $content = $configuration['value']; + unset($configuration['value']); + } + + if (!empty($configuration)) { + $content = $contentObject->stdWrap($content, $configuration); + } + + return $content; + } +} diff --git a/Classes/ContentObject/Multivalue.php b/Classes/ContentObject/Multivalue.php new file mode 100644 index 0000000..f90a81f --- /dev/null +++ b/Classes/ContentObject/Multivalue.php @@ -0,0 +1,92 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\ContentObject\AbstractContentObject; + +/** + * A content object (cObj) to turn comma separated strings into an array to be + * used in a multi value field in a Solr document. + * + * Example usage: + * + * keywords = SOLR_MULTIVALUE # supports stdWrap + * keywords { + * field = tags # a comma separated field. instead of field you can also use "value" + * separator = , # comma is the default value + * removeEmptyValues = 1 # a flag to remove empty strings from the list, on by default. + * removeDuplicateValues = 1 # a flag to remove duplicate strings from the list, off by default. + * } + * + * @author Ingo Renner + */ +class Multivalue extends AbstractContentObject +{ + const CONTENT_OBJECT_NAME = 'SOLR_MULTIVALUE'; + + /** + * Executes the SOLR_MULTIVALUE content object. + * + * Turns a list of values into an array that can then be used to fill + * multivalued fields in a Solr document. The array is returned in + * serialized form as content objects are expected to return strings. + * + * @inheritDoc + */ + public function render($conf = []) + { + $data = ''; + if (isset($conf['value'])) { + $data = $conf['value']; + unset($conf['value']); + } + + if (!empty($conf)) { + $data = $this->cObj->stdWrap($data, $conf); + } + + if (!array_key_exists('separator', $conf)) { + $conf['separator'] = ','; + } + + $removeEmptyValues = true; + if (isset($conf['removeEmptyValues']) && $conf['removeEmptyValues'] == 0) { + $removeEmptyValues = false; + } + + $listAsArray = GeneralUtility::trimExplode( + $conf['separator'], + $data, + $removeEmptyValues + ); + + if (!empty($conf['removeDuplicateValues'])) { + $listAsArray = array_unique($listAsArray); + } + + return serialize($listAsArray); + } +} diff --git a/Classes/ContentObject/Relation.php b/Classes/ContentObject/Relation.php new file mode 100644 index 0000000..eb39209 --- /dev/null +++ b/Classes/ContentObject/Relation.php @@ -0,0 +1,385 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\System\Language\FrontendOverlayService; +use WapplerSystems\Meilisearch\System\TCA\TCAService; +use WapplerSystems\Meilisearch\Util; +use Doctrine\DBAL\Driver\Statement; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\RelationHandler; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\ContentObject\AbstractContentObject; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; + +/** + * A content object (cObj) to resolve relations between database records + * + * Configuration options: + * + * localField: the record's field to use to resolve relations + * foreignLabelField: Usually the label field to retrieve from the related records is determined automatically using TCA, using this option the desired field can be specified explicitly + * multiValue: whether to return related records suitable for a multi value field + * singleValueGlue: when not using multiValue, the related records need to be concatenated using a glue string, by default this is ", ". Using this option a custom glue can be specified. The custom value must be wrapped by pipe (|) characters. + * relationTableSortingField: field in an mm relation table to sort by, usually "sorting" + * enableRecursiveValueResolution: if the specified remote table's label field is a relation to another table, the value will be resolve by following the relation recursively. + * removeEmptyValues: Removes empty values when resolving relations, defaults to TRUE + * removeDuplicateValues: Removes duplicate values + * + * @author Ingo Renner + */ +class Relation extends AbstractContentObject +{ + const CONTENT_OBJECT_NAME = 'SOLR_RELATION'; + + /** + * Content object configuration + * + * @var array + */ + protected $configuration = []; + + /** + * @var TCAService + */ + protected $tcaService = null; + + /** + * @var FrontendOverlayService + */ + protected $frontendOverlayService = null; + + /** + * Relation constructor. + * @param TCAService|null $tcaService + * @param FrontendOverlayService|null $frontendOverlayService + */ + public function __construct(ContentObjectRenderer $cObj, TCAService $tcaService = null, FrontendOverlayService $frontendOverlayService = null) + { + $this->cObj = $cObj; + $this->configuration['enableRecursiveValueResolution'] = 1; + $this->configuration['removeEmptyValues'] = 1; + $this->tcaService = $tcaService ?? GeneralUtility::makeInstance(TCAService::class); + $this->frontendOverlayService = $frontendOverlayService ?? GeneralUtility::makeInstance(FrontendOverlayService::class); + } + + /** + * Executes the SOLR_RELATION content object. + * + * Resolves relations between records. Currently supported relations are + * TYPO3-style m:n relations. + * May resolve single value and multi value relations. + * + * @inheritDoc + */ + public function render($conf = []) + { + $this->configuration = array_merge($this->configuration, $conf); + + $relatedItems = $this->getRelatedItems($this->cObj); + + if (!empty($this->configuration['removeDuplicateValues'])) { + $relatedItems = array_unique($relatedItems); + } + + if (empty($conf['multiValue'])) { + // single value, need to concatenate related items + $singleValueGlue = !empty($conf['singleValueGlue']) ? trim($conf['singleValueGlue'], '|') : ', '; + $result = implode($singleValueGlue, $relatedItems); + } else { + // multi value, need to serialize as content objects must return strings + $result = serialize($relatedItems); + } + + return $result; + } + + /** + * Gets the related items of the current record's configured field. + * + * @param ContentObjectRenderer $parentContentObject parent content object + * @return array Array of related items, values already resolved from related records + */ + protected function getRelatedItems(ContentObjectRenderer $parentContentObject) + { + list($table, $uid) = explode(':', $parentContentObject->currentRecord); + $uid = (int) $uid; + $field = $this->configuration['localField']; + + if (!$this->tcaService->getHasConfigurationForField($table, $field)) { + return []; + } + + $overlayUid = $this->frontendOverlayService->getUidOfOverlay($table, $field, $uid); + $fieldTCA = $this->tcaService->getConfigurationForField($table, $field); + + if (isset($fieldTCA['config']['MM']) && trim($fieldTCA['config']['MM']) !== '') { + $relatedItems = $this->getRelatedItemsFromMMTable($table, $overlayUid, $fieldTCA); + } else { + $relatedItems = $this->getRelatedItemsFromForeignTable($table, $overlayUid, $fieldTCA, $parentContentObject); + } + + return $relatedItems; + } + + /** + * Gets the related items from a table using a n:m relation. + * + * @param string $localTableName Local table name + * @param int $localRecordUid Local record uid + * @param array $localFieldTca The local table's TCA + * @return array Array of related items, values already resolved from related records + */ + protected function getRelatedItemsFromMMTable($localTableName, $localRecordUid, array $localFieldTca) + { + $relatedItems = []; + $foreignTableName = $localFieldTca['config']['foreign_table']; + $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName); + $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca); + $mmTableName = $localFieldTca['config']['MM']; + + // Remove the first option of foreignLabelField for recursion + if (strpos($this->configuration['foreignLabelField'], '.') !== false) { + $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']); + unset($foreignTableLabelFieldArr[0]); + $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr); + } + + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + $relationHandler->start('', $foreignTableName, $mmTableName, $localRecordUid, $localTableName, $localFieldTca['config']); + $selectUids = $relationHandler->tableArray[$foreignTableName]; + if (!is_array($selectUids) || count($selectUids) <= 0) { + return $relatedItems; + } + + $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids); + foreach ($relatedRecords as $record) { + if (isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table']) + && $this->configuration['enableRecursiveValueResolution'] + ) { + if (strpos($this->configuration['foreignLabelField'], '.') !== false) { + $foreignTableLabelFieldArr = explode('.', $this->configuration['foreignLabelField']); + unset($foreignTableLabelFieldArr[0]); + $this->configuration['foreignLabelField'] = implode('.', $foreignTableLabelFieldArr); + } + + $this->configuration['localField'] = $foreignTableLabelField; + + $contentObject = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $contentObject->start($record, $foreignTableName); + + return $this->getRelatedItems($contentObject); + } else { + if (Util::getLanguageUid() > 0) { + $record = $this->frontendOverlayService->getOverlay($foreignTableName, $record); + } + $relatedItems[] = $record[$foreignTableLabelField]; + } + } + + return $relatedItems; + } + + /** + * Resolves the field to use as the related item's label depending on TCA + * and TypoScript configuration + * + * @param array $foreignTableTca The foreign table's TCA + * @return string The field to use for the related item's label + */ + protected function resolveForeignTableLabelField(array $foreignTableTca) + { + $foreignTableLabelField = $foreignTableTca['ctrl']['label']; + + // when foreignLabelField is not enabled we can return directly + if (empty($this->configuration['foreignLabelField'])) { + return $foreignTableLabelField; + } + + if (strpos($this->configuration['foreignLabelField'], '.') !== false) { + list($foreignTableLabelField) = explode('.', $this->configuration['foreignLabelField'], 2); + } else { + $foreignTableLabelField = $this->configuration['foreignLabelField']; + } + + return $foreignTableLabelField; + } + + /** + * Gets the related items from a table using a 1:n relation. + * + * @param string $localTableName Local table name + * @param int $localRecordUid Local record uid + * @param array $localFieldTca The local table's TCA + * @param ContentObjectRenderer $parentContentObject parent content object + * @return array Array of related items, values already resolved from related records + */ + protected function getRelatedItemsFromForeignTable( + $localTableName, + $localRecordUid, + array $localFieldTca, + ContentObjectRenderer $parentContentObject + ) { + $relatedItems = []; + $foreignTableName = $localFieldTca['config']['foreign_table']; + $foreignTableTca = $this->tcaService->getTableConfiguration($foreignTableName); + $foreignTableLabelField = $this->resolveForeignTableLabelField($foreignTableTca); + + /** @var $relationHandler RelationHandler */ + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + + $itemList = $parentContentObject->data[$this->configuration['localField']] ?? ''; + + $relationHandler->start($itemList, $foreignTableName, '', $localRecordUid, $localTableName, $localFieldTca['config']); + $selectUids = $relationHandler->tableArray[$foreignTableName]; + + if (!is_array($selectUids) || count($selectUids) <= 0) { + return $relatedItems; + } + + $relatedRecords = $this->getRelatedRecords($foreignTableName, ...$selectUids); + + foreach ($relatedRecords as $relatedRecord) { + $resolveRelatedValue = $this->resolveRelatedValue( + $relatedRecord, + $foreignTableTca, + $foreignTableLabelField, + $parentContentObject, + $foreignTableName + ); + if (!empty($resolveRelatedValue) || !$this->configuration['removeEmptyValues']) { + $relatedItems[] = $resolveRelatedValue; + } + } + + return $relatedItems; + } + + /** + * Resolves the value of the related field. If the related field's value is + * a relation itself, this method takes care of resolving it recursively. + * + * @param array $relatedRecord Related record as array + * @param array $foreignTableTca TCA of the related table + * @param string $foreignTableLabelField Field name of the foreign label field + * @param ContentObjectRenderer $parentContentObject cObject + * @param string $foreignTableName Related record table name + * + * @return string + */ + protected function resolveRelatedValue( + array $relatedRecord, + $foreignTableTca, + $foreignTableLabelField, + ContentObjectRenderer $parentContentObject, + $foreignTableName = '' + ) { + if (Util::getLanguageUid() > 0 && !empty($foreignTableName)) { + $relatedRecord = $this->frontendOverlayService->getOverlay($foreignTableName, $relatedRecord); + } + + $value = $relatedRecord[$foreignTableLabelField]; + + if ( + !empty($foreignTableName) + && isset($foreignTableTca['columns'][$foreignTableLabelField]['config']['foreign_table']) + && $this->configuration['enableRecursiveValueResolution'] + ) { + // backup + $backupRecord = $parentContentObject->data; + $backupConfiguration = $this->configuration; + + // adjust configuration for next level + $this->configuration['localField'] = $foreignTableLabelField; + $parentContentObject->data = $relatedRecord; + if (strpos($this->configuration['foreignLabelField'], '.') !== false) { + list(, $this->configuration['foreignLabelField']) = explode('.', + $this->configuration['foreignLabelField'], 2); + } else { + $this->configuration['foreignLabelField'] = ''; + } + + // recursion + $relatedItemsFromForeignTable = $this->getRelatedItemsFromForeignTable( + $foreignTableName, + $relatedRecord['uid'], + $foreignTableTca['columns'][$foreignTableLabelField], + $parentContentObject + ); + $value = array_pop($relatedItemsFromForeignTable); + + // restore + $this->configuration = $backupConfiguration; + $parentContentObject->data = $backupRecord; + } + + return $parentContentObject->stdWrap($value, $this->configuration); + } + + /** + * Return records via relation. + * + * @param string $foreignTable The table to fetch records from. + * @param int[] ...$uids The uids to fetch from table. + * @return array + */ + protected function getRelatedRecords($foreignTable, int ...$uids): array + { + /** @var QueryBuilder $queryBuilder */ + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($foreignTable); + $queryBuilder->select('*') + ->from($foreignTable) + ->where($queryBuilder->expr()->in('uid', $uids)); + if (isset($this->configuration['additionalWhereClause'])) { + $queryBuilder->andWhere($this->configuration['additionalWhereClause']); + } + $statement = $queryBuilder->execute(); + + return $this->sortByKeyInIN($statement, 'uid', ...$uids); + } + + /** + * Sorts the result set by key in array for IN values. + * Simulates MySqls ORDER BY FIELD(fieldname, COPY_OF_IN_FOR_WHERE) + * Example: SELECT * FROM a_table WHERE field_name IN (2, 3, 4) SORT BY FIELD(field_name, 2, 3, 4) + * + * + * @param Statement $statement + * @param string $columnName + * @param array $arrayWithValuesForIN + * @return array + */ + protected function sortByKeyInIN(Statement $statement, string $columnName, ...$arrayWithValuesForIN) : array + { + $records = []; + while ($record = $statement->fetch()) { + $indexNumber = array_search($record[$columnName], $arrayWithValuesForIN); + $records[$indexNumber] = $record; + } + ksort($records); + return $records; + } +} diff --git a/Classes/Controller/AbstractBaseController.php b/Classes/Controller/AbstractBaseController.php new file mode 100644 index 0000000..a0d4017 --- /dev/null +++ b/Classes/Controller/AbstractBaseController.php @@ -0,0 +1,276 @@ + + * @author Timo Hund + */ +abstract class AbstractBaseController extends ActionController +{ + /** + * @var ContentObjectRenderer + */ + private $contentObjectRenderer; + + /** + * @var TypoScriptFrontendController + */ + protected $typoScriptFrontendController; + + /** + * @var \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface + */ + protected $configurationManager; + + /** + * @var SolrConfigurationManager + */ + private $solrConfigurationManager; + + /** + * The configuration is private if you need it please get it from the controllerContext. + * + * @var TypoScriptConfiguration + */ + protected $typoScriptConfiguration; + + /** + * @var \WapplerSystems\Meilisearch\Mvc\Controller\SolrControllerContext + */ + protected $controllerContext; + + /** + * @var \WapplerSystems\Meilisearch\Domain\Search\ResultSet\SearchResultSetService + */ + protected $searchService; + + /** + * @var \WapplerSystems\Meilisearch\Domain\Search\SearchRequestBuilder + */ + protected $searchRequestBuilder; + + /** + * @var bool + */ + protected $resetConfigurationBeforeInitialize = true; + + /** + * @param \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface $configurationManager + * @return void + */ + public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager) + { + $this->configurationManager = $configurationManager; + // @extensionScannerIgnoreLine + $this->contentObjectRenderer = $this->configurationManager->getContentObject(); + } + + /** + * @param \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $contentObjectRenderer + */ + public function setContentObjectRenderer($contentObjectRenderer) + { + $this->contentObjectRenderer = $contentObjectRenderer; + } + + /** + * @return \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer + */ + public function getContentObjectRenderer() + { + return $this->contentObjectRenderer; + } + + /** + * @param SolrConfigurationManager $configurationManager + */ + public function injectSolrConfigurationManager(SolrConfigurationManager $configurationManager) + { + $this->solrConfigurationManager = $configurationManager; + } + + /** + * @param boolean $resetConfigurationBeforeInitialize + */ + public function setResetConfigurationBeforeInitialize($resetConfigurationBeforeInitialize) + { + $this->resetConfigurationBeforeInitialize = $resetConfigurationBeforeInitialize; + } + + /** + * Initialize the controller context + * + * @return \TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext ControllerContext to be passed to the view + * @api + */ + protected function buildControllerContext() + { + /** @var $controllerContext \WapplerSystems\Meilisearch\Mvc\Controller\SolrControllerContext */ + $controllerContext = $this->objectManager->get(SolrControllerContext::class); + $controllerContext->setRequest($this->request); + $controllerContext->setResponse($this->response); + if ($this->arguments !== null) { + $controllerContext->setArguments($this->arguments); + } + $controllerContext->setUriBuilder($this->uriBuilder); + + $controllerContext->setTypoScriptConfiguration($this->typoScriptConfiguration); + + return $controllerContext; + } + + /** + * Initialize action + */ + protected function initializeAction() + { + // Reset configuration (to reset flexform overrides) if resetting is enabled + if ($this->resetConfigurationBeforeInitialize) { + $this->solrConfigurationManager->reset(); + } + /** @var TypoScriptService $typoScriptService */ + $typoScriptService = $this->objectManager->get(TypoScriptService::class); + + // Merge settings done by typoscript with solrConfiguration plugin.tx_meilisearch (obsolete when part of ext:solr) + $frameWorkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + $pluginSettings = []; + foreach (['search', 'settings', 'suggest', 'statistics', 'logging', 'general', 'solr', 'view'] as $key) { + if (isset($frameWorkConfiguration[$key])) { + $pluginSettings[$key] = $frameWorkConfiguration[$key]; + } + } + + $this->typoScriptConfiguration = $this->solrConfigurationManager->getTypoScriptConfiguration(); + if ($pluginSettings !== []) { + $this->typoScriptConfiguration->mergeSolrConfiguration( + $typoScriptService->convertPlainArrayToTypoScriptArray($pluginSettings), + true, + false + ); + } + + $this->objectManager->get(ConfigurationService::class) + ->overrideConfigurationWithFlexFormSettings( + $this->contentObjectRenderer->data['pi_flexform'], + $this->typoScriptConfiguration + ); + + parent::initializeAction(); + $this->typoScriptFrontendController = $GLOBALS['TSFE']; + $this->initializeSettings(); + + if ($this->actionMethodName !== 'solrNotAvailableAction') { + $this->initializeSearch(); + } + } + + /** + * Inject settings of plugin.tx_meilisearch + * + * @return void + */ + protected function initializeSettings() + { + /** @var $typoScriptService TypoScriptService */ + $typoScriptService = $this->objectManager->get(TypoScriptService::class); + + // Make sure plugin.tx_meilisearch.settings are available in the view as {settings} + $this->settings = $typoScriptService->convertTypoScriptArrayToPlainArray( + $this->typoScriptConfiguration->getObjectByPathOrDefault('plugin.tx_meilisearch.settings.', []) + ); + } + + /** + * Initialize the Solr connection and + * test the connection through a ping + */ + protected function initializeSearch() + { + /** @var \WapplerSystems\Meilisearch\ConnectionManager $solrConnection */ + try { + $solrConnection = $this->objectManager->get(ConnectionManager::class)->getConnectionByPageId($this->typoScriptFrontendController->id, Util::getLanguageUid(), $this->typoScriptFrontendController->MP); + $search = $this->objectManager->get(Search::class, $solrConnection); + + $this->searchService = $this->objectManager->get( + SearchResultSetService::class, + /** @scrutinizer ignore-type */ $this->typoScriptConfiguration, + /** @scrutinizer ignore-type */ $search + ); + } catch (NoSolrConnectionFoundException $e) { + $this->handleSolrUnavailable(); + } + } + + /** + * @return SearchRequestBuilder + */ + protected function getSearchRequestBuilder() + { + if ($this->searchRequestBuilder === null) { + $this->searchRequestBuilder = GeneralUtility::makeInstance(SearchRequestBuilder::class, /** @scrutinizer ignore-type */ $this->typoScriptConfiguration); + } + + return $this->searchRequestBuilder; + } + + /** + * Called when the solr server is unavailable. + * + * @return void + */ + protected function handleSolrUnavailable() + { + if ($this->typoScriptConfiguration->getLoggingExceptions()) { + /** @var SolrLogManager $logger */ + $logger = GeneralUtility::makeInstance(SolrLogManager::class, /** @scrutinizer ignore-type */ __CLASS__); + $logger->log(SolrLogManager::ERROR, 'Solr server is not available'); + } + } + + /** + * Emits signal for various actions + * + * @param string $className Name of the class containing the signal + * @param string $signalName Name of the signal slot + * @param array $signalArguments arguments for the signal slot + * + * @return array + */ + protected function emitActionSignal($className, $signalName, array $signalArguments) + { + return $this->signalSlotDispatcher->dispatch($className, $signalName, $signalArguments)[0]; + } +} diff --git a/Classes/Controller/Backend/PageModuleSummary.php b/Classes/Controller/Backend/PageModuleSummary.php new file mode 100644 index 0000000..725e82f --- /dev/null +++ b/Classes/Controller/Backend/PageModuleSummary.php @@ -0,0 +1,214 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Reflection\ObjectAccess; +use TYPO3\CMS\Fluid\View\StandaloneView; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Backend\View\PageLayoutView; + +/** + * Summary to display flexform settings in the page layout backend module. + * + * @author Ingo Renner + * @author Timo Hund + */ +class PageModuleSummary +{ + /** + * PageLayoutView + * + * @var PageLayoutView + */ + protected $pageLayoutView; + + /** + * @var array + */ + protected $pluginContentElement = []; + + /** + * @var array + */ + protected $flexformData = []; + + /** + * @var array + */ + protected $settings = []; + + /** + * Returns information about a plugin's flexform configuration + * + * @param array $parameters Parameters to the hook + * @return string Plugin configuration information + */ + public function getSummary(array $parameters) + { + $this->initialize($parameters['row'], $parameters['pObj']); + + $this->addTargetPage(); + $this->addSettingFromFlexForm('Filter', 'search.query.filter'); + $this->addSettingFromFlexForm('Sorting', 'search.query.sortBy'); + $this->addSettingFromFlexForm('Results per Page', 'search.results.resultsPerPage'); + $this->addSettingFromFlexForm('Boost Function', 'search.query.boostFunction'); + $this->addSettingFromFlexForm('Boost Query', 'search.query.boostQuery'); + $this->addSettingFromFlexForm('Tie Breaker', 'search.query.tieParameter'); + $this->addSettingFromFlexForm('Template', 'view.templateFiles.results'); + return $this->render(); + } + + /** + * @param array $contentElement + * @param PageLayoutView $pObj + */ + protected function initialize(array $contentElement, PageLayoutView $pObj) + { + $this->pageLayoutView = $pObj; + + /** @var $service \TYPO3\CMS\Core\Service\FlexFormService::class */ + $service = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Service\FlexFormService::class); + $this->flexformData = $service->convertFlexFormContentToArray($contentElement['pi_flexform']); + $this->pluginContentElement = $contentElement; + } + + /** + * Adds the target page to the settings. + */ + protected function addTargetPage() + { + $targetPageId = $this->getFieldFromFlexform('search.targetPage'); + if (!empty($targetPageId)) { + $page = BackendUtility::getRecord('pages', $targetPageId, 'title'); + $this->settings['Target Page'] = '[' . (int)$targetPageId . '] ' . $page['title']; + } + } + + /** + * @param string $settingName + * @param string $flexFormField + */ + protected function addSettingFromFlexForm($settingName, $flexFormField) + { + $value = $this->getFieldFromFlexform($flexFormField); + + if (is_array($value)) { + $value = $this->addSettingFromFlexFormArray($settingName, $value); + } + $this->addSettingIfNotEmpty($settingName, (string)$value); + } + + /** + * @param string $settingName + * @param array $values + * @return bool + */ + protected function addSettingFromFlexFormArray($settingName, $values) + { + foreach ($values as $item) { + if (!isset($item['field'])) { + continue; + } + $field = $item['field']; + + $label = $settingName . ' '; + $label .= isset($field['field']) ? $field['field'] : ''; + $fieldValue = isset($field['value']) ? $field['value'] : ''; + $this->addSettingIfNotEmpty($label, (string)$fieldValue); + } + } + + /** + * @param string $settingName + * @param string $value + */ + protected function addSettingIfNotEmpty($settingName, $value) + { + if (!empty($value)) { + $this->settings[$settingName] = $value; + } + } + + /** + * Gets a field's value from flexform configuration, will check if + * flexform configuration is available. + * + * @param string $path name of the field + * @return string if nothing found, value if found + */ + protected function getFieldFromFlexform($path) + { + return ObjectAccess::getPropertyPath($this->flexformData, $path); + } + + /** + * @return string + */ + protected function render() + { + /** @var $standaloneView StandaloneView */ + $standaloneView = GeneralUtility::makeInstance(StandaloneView::class); + $standaloneView->setTemplatePathAndFilename( + GeneralUtility::getFileAbsFileName('EXT:meilisearch/Resources/Private/Templates/Backend/PageModule/Summary.html') + ); + + $standaloneView->assignMultiple([ + 'pluginLabel' => $this->getPluginLabel(), + 'hidden' => $this->pluginContentElement['hidden'], + 'settings' => $this->settings, + ]); + return $standaloneView->render(); + } + + /** + * Returns the plugin label + * + * @return string + */ + protected function getPluginLabel() + { + $label = BackendUtility::getLabelFromItemListMerged($this->pluginContentElement['pid'], 'tt_content', 'list_type', $this->pluginContentElement['list_type']); + if (!empty($label)) { + $label = $this->getLanguageService()->sL($label); + } else { + $label = sprintf($this->getLanguageService()->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue'), $this->pluginContentElement['list_type']); + } + + return $this->pageLayoutView->linkEditContent(htmlspecialchars($label), $this->pluginContentElement); + } + + /** + * Returns the language service + * + * @return LanguageService + */ + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Controller/Backend/Search/AbstractModuleController.php b/Classes/Controller/Backend/Search/AbstractModuleController.php new file mode 100644 index 0000000..f48f424 --- /dev/null +++ b/Classes/Controller/Backend/Search/AbstractModuleController.php @@ -0,0 +1,328 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\ConnectionManager; +use WapplerSystems\Meilisearch\Domain\Site\SiteRepository; +use WapplerSystems\Meilisearch\Domain\Site\Site; +use WapplerSystems\Meilisearch\System\Solr\SolrConnection as SolrCoreConnection; +use WapplerSystems\Meilisearch\System\Mvc\Backend\Component\Exception\InvalidViewObjectNameException; +use WapplerSystems\Meilisearch\System\Mvc\Backend\Service\ModuleDataStorageService; +use TYPO3\CMS\Backend\Template\Components\Menu\Menu; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Backend\View\BackendTemplateView; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Messaging\AbstractMessage; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; +use TYPO3\CMS\Extbase\Mvc\View\NotFoundView; +use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; +use TYPO3\CMS\Extbase\Utility\LocalizationUtility; + +/** + * Abstract Module + * + * @property BackendTemplateView $view + */ +abstract class AbstractModuleController extends ActionController +{ + /** + * Backend Template Container + * + * @var string + */ + protected $defaultViewObjectName = BackendTemplateView::class; + + /** + * In the pagetree selected page UID + * + * @var int + */ + protected $selectedPageUID; + + /** + * Holds the requested page UID because the selected page uid, + * might be overwritten by the automatic site selection. + * + * @var int + */ + protected $requestedPageUID; + + /** + * @var Site + */ + protected $selectedSite; + + /** + * @var SiteRepository + */ + protected $siteRepository; + + /** + * @var SolrCoreConnection + */ + protected $selectedSolrCoreConnection; + + /** + * @var Menu + */ + protected $coreSelectorMenu = null; + + /** + * @var ConnectionManager + */ + protected $solrConnectionManager = null; + + /** + * @var ModuleDataStorageService + */ + protected $moduleDataStorageService = null; + + /** + * @param Site $selectedSite + */ + public function setSelectedSite(Site $selectedSite) + { + $this->selectedSite = $selectedSite; + } + + /** + * @param SiteRepository $siteRepository + */ + public function injectSiteRepository(SiteRepository $siteRepository) + { + $this->siteRepository = $siteRepository; + } + + /** + * Initializes the controller and sets needed vars. + */ + protected function initializeAction() + { + parent::initializeAction(); + $this->solrConnectionManager = GeneralUtility::makeInstance(ConnectionManager::class); + $this->moduleDataStorageService = GeneralUtility::makeInstance(ModuleDataStorageService::class); + + $this->selectedPageUID = (int)GeneralUtility::_GP('id'); + if ($this->request->hasArgument('id')) { + $this->selectedPageUID = (int)$this->request->getArgument('id'); + } + + $this->requestedPageUID = $this->selectedPageUID; + + if ($this->autoSelectFirstSiteAndRootPageWhenOnlyOneSiteIsAvailable()) { + return; + } + + if ($this->selectedPageUID < 1) { + return; + } + + try { + $this->selectedSite = $this->siteRepository->getSiteByPageId($this->selectedPageUID); + } catch (\InvalidArgumentException $exception) { + return; + } + } + + /** + * @return bool + * @throws \Exception + */ + protected function autoSelectFirstSiteAndRootPageWhenOnlyOneSiteIsAvailable(): bool + { + if (count($this->siteRepository->getAvailableSites()) == 1) { + $this->selectedSite = $this->siteRepository->getFirstAvailableSite(); + + // we only overwrite the selected pageUid when no id was passed + if ($this->selectedPageUID === 0) { + $this->selectedPageUID = $this->selectedSite->getRootPageId(); + } + return true; + } + + return false; + } + + /** + * Set up the doc header properly here + * + * @param ViewInterface $view + * @return void + */ + protected function initializeView(ViewInterface $view) + { + parent::initializeView($view); + $sites = $this->siteRepository->getAvailableSites(); + + $selectOtherPage = count($sites) > 0 || $this->selectedPageUID < 1; + $this->view->assign('showSelectOtherPage', $selectOtherPage); + $this->view->assign('pageUID', $this->selectedPageUID); + if ($view instanceof NotFoundView || $this->selectedPageUID < 1) { + return; + } + $this->view->getModuleTemplate()->addJavaScriptCode('mainJsFunctions', ' + top.fsMod.recentIds["searchbackend"] = ' . (int)$this->selectedPageUID . ';' + ); + if (null === $this->selectedSite) { + return; + } + + /* @var BackendUserAuthentication $beUser */ + $beUser = $GLOBALS['BE_USER']; + $permissionClause = $beUser->getPagePermsClause(1); + $pageRecord = BackendUtility::readPageAccess($this->selectedSite->getRootPageId(), $permissionClause); + + if (false === $pageRecord) { + throw new \InvalidArgumentException(vsprintf('There is something wrong with permissions for page "%s" for backend user "%s".', [$this->selectedSite->getRootPageId(), $beUser->user['username']]), 1496146317); + } + $this->view->getModuleTemplate()->getDocHeaderComponent()->setMetaInformation($pageRecord); + } + + /** + * Generates selector menu in backends doc header using selected page from page tree. + * + * @param string|null $uriToRedirectTo + */ + public function generateCoreSelectorMenuUsingPageTree(string $uriToRedirectTo = null) + { + if ($this->selectedPageUID < 1 || null === $this->selectedSite) { + return; + } + + if ($this->view instanceof NotFoundView) { + $this->initializeSelectedSolrCoreConnection(); + return; + } + + $this->generateCoreSelectorMenu($this->selectedSite, $uriToRedirectTo); + } + + /** + * Generates Core selector Menu for given Site. + * + * @param Site $site + * @param string|null $uriToRedirectTo + * @throws InvalidViewObjectNameException + */ + protected function generateCoreSelectorMenu(Site $site, string $uriToRedirectTo = null) + { + if (!$this->view instanceof BackendTemplateView) { + throw new InvalidViewObjectNameException(vsprintf( + 'The controller "%s" must use BackendTemplateView to be able to generate menu for backends docheader. \ + Please set `protected $defaultViewObjectName = BackendTemplateView::class;` field in your controller.', + [static::class]), 1493804179); + } + $this->view->getModuleTemplate()->setFlashMessageQueue($this->controllerContext->getFlashMessageQueue()); + + $this->coreSelectorMenu = $this->view->getModuleTemplate()->getDocHeaderComponent()->getMenuRegistry()->makeMenu(); + $this->coreSelectorMenu->setIdentifier('component_core_selector_menu'); + + if (!isset($uriToRedirectTo)) { + $uriToRedirectTo = $this->uriBuilder->reset()->uriFor(); + } + + $this->initializeSelectedSolrCoreConnection(); + $cores = $this->solrConnectionManager->getConnectionsBySite($site); + foreach ($cores as $core) { + $coreAdmin = $core->getAdminService(); + $menuItem = $this->coreSelectorMenu->makeMenuItem(); + $menuItem->setTitle($coreAdmin->getCorePath()); + $uri = $this->uriBuilder->reset()->uriFor('switchCore', + [ + 'corePath' => $coreAdmin->getCorePath(), + 'uriToRedirectTo' => $uriToRedirectTo + ] + ); + $menuItem->setHref($uri); + + if ($coreAdmin->getCorePath() == $this->selectedSolrCoreConnection->getAdminService()->getCorePath()) { + $menuItem->setActive(true); + } + $this->coreSelectorMenu->addMenuItem($menuItem); + } + + $this->view->getModuleTemplate()->getDocHeaderComponent()->getMenuRegistry()->addMenu($this->coreSelectorMenu); + } + + /** + * Switches used core. + * + * Note: Does not check availability of core in site. All this stuff is done in the generation step. + * + * @param string $corePath + * @param string $uriToRedirectTo + */ + public function switchCoreAction(string $corePath, string $uriToRedirectTo) + { + $moduleData = $this->moduleDataStorageService->loadModuleData(); + $moduleData->setCore($corePath); + + $this->moduleDataStorageService->persistModuleData($moduleData); + $message = LocalizationUtility::translate('coreselector_switched_successfully', 'solr', [$corePath]); + $this->addFlashMessage($message); + $this->redirectToUri($uriToRedirectTo); + } + + /** + * Initializes the solr core connection considerately to the components state. + * Uses and persists default core connection if persisted core in Site does not exist. + * + */ + private function initializeSelectedSolrCoreConnection() + { + $moduleData = $this->moduleDataStorageService->loadModuleData(); + + $solrCoreConnections = $this->solrConnectionManager->getConnectionsBySite($this->selectedSite); + $currentSolrCorePath = $moduleData->getCore(); + if (empty($currentSolrCorePath)) { + $this->initializeFirstAvailableSolrCoreConnection($solrCoreConnections, $moduleData); + return; + } + foreach ($solrCoreConnections as $solrCoreConnection) { + if ($solrCoreConnection->getAdminService()->getCorePath() == $currentSolrCorePath) { + $this->selectedSolrCoreConnection = $solrCoreConnection; + } + } + if (!$this->selectedSolrCoreConnection instanceof SolrCoreConnection && count($solrCoreConnections) > 0) { + $this->initializeFirstAvailableSolrCoreConnection($solrCoreConnections, $moduleData); + $message = LocalizationUtility::translate('coreselector_switched_to_default_core', 'solr', [$currentSolrCorePath, $this->selectedSite->getLabel(), $this->selectedSolrCoreConnection->getAdminService()->getCorePath()]); + $this->addFlashMessage($message, '', AbstractMessage::NOTICE); + } + } + + /** + * @param SolrCoreConnection[] $solrCoreConnections + */ + private function initializeFirstAvailableSolrCoreConnection(array $solrCoreConnections, $moduleData) + { + if (empty($solrCoreConnections)) { + return; + } + $this->selectedSolrCoreConnection = $solrCoreConnections[0]; + $moduleData->setCore($this->selectedSolrCoreConnection->getAdminService()->getCorePath()); + $this->moduleDataStorageService->persistModuleData($moduleData); + } +} diff --git a/Classes/Controller/Backend/Search/CoreOptimizationModuleController.php b/Classes/Controller/Backend/Search/CoreOptimizationModuleController.php new file mode 100644 index 0000000..e70b028 --- /dev/null +++ b/Classes/Controller/Backend/Search/CoreOptimizationModuleController.php @@ -0,0 +1,386 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\Utility\ManagedResourcesUtility; +use TYPO3\CMS\Backend\Template\ModuleTemplate; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; + +/** + * Manage Synonyms and Stop words in Backend Module + * @property \TYPO3\CMS\Extbase\Mvc\Web\Response $response + */ +class CoreOptimizationModuleController extends AbstractModuleController +{ + /** + * Set up the doc header properly here + * + * @param ViewInterface $view + * @return void + */ + protected function initializeView(ViewInterface $view) + { + parent::initializeView($view); + + $this->generateCoreSelectorMenuUsingPageTree(); + /* @var ModuleTemplate $module */ // holds the state of chosen tab + $module = $this->objectManager->get(ModuleTemplate::class); + $coreOptimizationTabs = $module->getDynamicTabMenu([], 'coreOptimization'); + $this->view->assign('tabs', $coreOptimizationTabs); + } + + /** + * Gets synonyms and stopwords for the currently selected core + * + * @return void + */ + public function indexAction() + { + if ($this->selectedSolrCoreConnection === null) { + $this->view->assign('can_not_proceed', true); + return; + } + + $synonyms = []; + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $rawSynonyms = $coreAdmin->getSynonyms(); + foreach ($rawSynonyms as $baseWord => $synonymList) { + $synonyms[$baseWord] = implode(', ', $synonymList); + } + + $stopWords = $coreAdmin->getStopWords(); + $this->view->assignMultiple([ + 'synonyms' => $synonyms, + 'stopWords' => implode(PHP_EOL, $stopWords), + 'stopWordsCount' => count($stopWords) + ]); + } + + /** + * Add synonyms to selected core + * + * @param string $baseWord + * @param string $synonyms + * @param bool $overrideExisting + * @return void + */ + public function addSynonymsAction(string $baseWord, string $synonyms, $overrideExisting) + { + if (empty($baseWord) || empty($synonyms)) { + $this->addFlashMessage( + 'Please provide a base word and synonyms.', + 'Missing parameter', + FlashMessage::ERROR + ); + } else { + $baseWord = mb_strtolower($baseWord); + $synonyms = mb_strtolower($synonyms); + + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + if ($overrideExisting && $coreAdmin->getSynonyms($baseWord)) { + $coreAdmin->deleteSynonym($baseWord); + } + $coreAdmin->addSynonym($baseWord, GeneralUtility::trimExplode(',', $synonyms, true)); + $coreAdmin->reloadCore(); + + $this->addFlashMessage( + '"' . $synonyms . '" added as synonyms for base word "' . $baseWord . '"' + ); + } + + $this->redirect('index'); + } + + /** + * @param string $fileFormat + * @return void + */ + public function exportStopWordsAction($fileFormat = 'txt') + { + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $this->exportFile( + implode(PHP_EOL, $coreAdmin->getStopWords()), + 'stopwords', + $fileFormat + ); + } + + /** + * Exports synonyms to a download file. + * + * @param string $fileFormat + * @return string + */ + public function exportSynonymsAction($fileFormat = 'txt') + { + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $synonyms = $coreAdmin->getSynonyms(); + return $this->exportFile(ManagedResourcesUtility::exportSynonymsToTxt($synonyms), 'synonyms', $fileFormat); + } + + /** + * @param array $synonymFileUpload + * @param bool $overrideExisting + * @param bool $deleteSynonymsBefore + * @return void + */ + public function importSynonymListAction(array $synonymFileUpload, $overrideExisting, $deleteSynonymsBefore) + { + if ($deleteSynonymsBefore) { + $this->deleteAllSynonyms(); + } + + $fileLines = ManagedResourcesUtility::importSynonymsFromPlainTextContents($synonymFileUpload); + $synonymCount = 0; + + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + foreach ($fileLines as $baseWord => $synonyms) { + if (!isset($baseWord) || empty($synonyms)) { + continue; + } + $this->deleteExistingSynonym($overrideExisting, $deleteSynonymsBefore, $baseWord); + $coreAdmin->addSynonym($baseWord, $synonyms); + $synonymCount++; + } + + $coreAdmin->reloadCore(); + $this->addFlashMessage( + $synonymCount . ' synonyms imported.' + ); + $this->redirect('index'); + + } + + /** + * @param array $stopwordsFileUpload + * @param bool $replaceStopwords + * @return void + */ + public function importStopWordListAction(array $stopwordsFileUpload, $replaceStopwords) + { + $this->saveStopWordsAction( + ManagedResourcesUtility::importStopwordsFromPlainTextContents($stopwordsFileUpload), + $replaceStopwords + ); + } + + /** + * Delete complete synonym list + * + * @return void + */ + public function deleteAllSynonymsAction() + { + $allSynonymsCouldBeDeleted = $this->deleteAllSynonyms(); + + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $reloadResponse = $coreAdmin->reloadCore(); + + if ($allSynonymsCouldBeDeleted + && $reloadResponse->getHttpStatus() == 200 + ) { + $this->addFlashMessage( + 'All synonym removed.' + ); + } else { + $this->addFlashMessage( + 'Failed to remove all synonyms.', + 'An error occurred', + FlashMessage::ERROR + ); + } + $this->redirect('index'); + } + + /** + * Deletes a synonym mapping by its base word. + * + * @param string $baseWord Synonym mapping base word + */ + public function deleteSynonymsAction($baseWord) + { + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $deleteResponse = $coreAdmin->deleteSynonym($baseWord); + $reloadResponse = $coreAdmin->reloadCore(); + + if ($deleteResponse->getHttpStatus() == 200 + && $reloadResponse->getHttpStatus() == 200 + ) { + $this->addFlashMessage( + 'Synonym removed.' + ); + } else { + $this->addFlashMessage( + 'Failed to remove synonym.', + 'An error occurred', + FlashMessage::ERROR + ); + } + + $this->redirect('index'); + } + + /** + * Saves the edited stop word list to Solr + * + * @param string $stopWords + * @param bool $replaceStopwords + * @return void + */ + public function saveStopWordsAction(string $stopWords, $replaceStopwords = true) + { + // lowercase stopword before saving because terms get lowercased before stopword filtering + $newStopWords = mb_strtolower($stopWords); + $newStopWords = GeneralUtility::trimExplode("\n", $newStopWords, true); + + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $oldStopWords = $coreAdmin->getStopWords(); + + if ($replaceStopwords) { + $removedStopWords = array_diff($oldStopWords, $newStopWords); + $wordsRemoved = $this->removeStopsWordsFromIndex($removedStopWords); + } else { + $wordsRemoved = true; + } + + $wordsAdded = true; + $addedStopWords = array_diff($newStopWords, $oldStopWords); + if (!empty($addedStopWords)) { + $wordsAddedResponse = $coreAdmin->addStopWords($addedStopWords); + $wordsAdded = ($wordsAddedResponse->getHttpStatus() == 200); + } + + $reloadResponse = $coreAdmin->reloadCore(); + if ($wordsRemoved && $wordsAdded && $reloadResponse->getHttpStatus() == 200) { + $this->addFlashMessage( + 'Stop Words Updated.' + ); + } + + $this->redirect('index'); + } + + /** + * @param string $content + * @param string $type + * @param string $fileExtension + * @return string + */ + protected function exportFile($content, $type = 'synonyms', $fileExtension = 'txt') : string + { + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + + $this->response->setHeader('Content-type', 'text/plain', true); + $this->response->setHeader('Cache-control', 'public', true); + $this->response->setHeader('Content-Description', 'File transfer', true); + $this->response->setHeader( + 'Content-disposition', + 'attachment; filename =' . $type . '_' . + $coreAdmin->getPrimaryEndpoint()->getCore() . '.' . $fileExtension, + true + ); + + $this->response->setContent($content); + $this->sendFileResponse(); + } + + /** + * This method send the headers and content and does an exit, since without the exit TYPO3 produces and error. + * @return void + */ + protected function sendFileResponse() + { + $this->response->sendHeaders(); + $this->response->send(); + $this->response->shutdown(); + + exit(); + } + + /** + * Delete complete synonym list form solr + * + * @return bool + */ + protected function deleteAllSynonyms() : bool + { + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + $synonyms = $coreAdmin->getSynonyms(); + $allSynonymsCouldBeDeleted = true; + + foreach ($synonyms as $baseWord => $synonym) { + $deleteResponse = $coreAdmin->deleteSynonym($baseWord); + $allSynonymsCouldBeDeleted = $allSynonymsCouldBeDeleted && $deleteResponse->getHttpStatus() == 200; + } + + return $allSynonymsCouldBeDeleted; + } + + /** + * @param $stopwordsToRemove + * @return bool + */ + protected function removeStopsWordsFromIndex($stopwordsToRemove) : bool + { + $wordsRemoved = true; + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + + foreach ($stopwordsToRemove as $word) { + $response = $coreAdmin->deleteStopWord($word); + if ($response->getHttpStatus() != 200) { + $wordsRemoved = false; + $this->addFlashMessage( + 'Failed to remove stop word "' . $word . '".', + 'An error occurred', + FlashMessage::ERROR + ); + break; + } + } + + return $wordsRemoved; + } + + /** + * Delete synonym entry if selceted before + * @param bool $overrideExisting + * @param bool $deleteSynonymsBefore + * @param string $baseWord + */ + protected function deleteExistingSynonym($overrideExisting, $deleteSynonymsBefore, $baseWord) + { + $coreAdmin = $this->selectedSolrCoreConnection->getAdminService(); + + if (!$deleteSynonymsBefore && + $overrideExisting && + $coreAdmin->getSynonyms($baseWord) + ) { + $coreAdmin->deleteSynonym($baseWord); + } + + } +} diff --git a/Classes/Controller/Backend/Search/IndexAdministrationModuleController.php b/Classes/Controller/Backend/Search/IndexAdministrationModuleController.php new file mode 100644 index 0000000..cf802bb --- /dev/null +++ b/Classes/Controller/Backend/Search/IndexAdministrationModuleController.php @@ -0,0 +1,195 @@ + + * All rights reserved + * + * This script is part of the TYPO3 project. The TYPO3 project is + * free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * The GNU General Public License can be found at + * http://www.gnu.org/copyleft/gpl.html. + * + * This script is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * This copyright notice MUST APPEAR in all copies of the script! + ***************************************************************/ + +use WapplerSystems\Meilisearch\ConnectionManager; +use WapplerSystems\Meilisearch\IndexQueue\Queue; +use WapplerSystems\Meilisearch\System\Solr\SolrConnection; +use WapplerSystems\Meilisearch\Util; +use TYPO3\CMS\Backend\Routing\UriBuilder as BackendUriBuilder; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Mvc\Web\ReferringRequest; +use TYPO3\CMS\Extbase\Utility\LocalizationUtility; + +/** + * Index Administration Module + * + * @author Ingo Renner + */ +class IndexAdministrationModuleController extends AbstractModuleController +{ + + /** + * @var Queue + */ + protected $indexQueue; + + /** + * @var ConnectionManager + */ + protected $solrConnectionManager = null; + + /** + * @param ConnectionManager $solrConnectionManager + */ + public function setSolrConnectionManager(ConnectionManager $solrConnectionManager) + { + $this->solrConnectionManager = $solrConnectionManager; + } + + /** + * Initializes the controller before invoking an action method. + */ + protected function initializeAction() + { + parent::initializeAction(); + $this->indexQueue = GeneralUtility::makeInstance(Queue::class); + $this->solrConnectionManager = GeneralUtility::makeInstance(ConnectionManager::class); + } + + /** + * Index action, shows an overview of available index maintenance operations. + * + * @return void + */ + public function indexAction() + { + if ($this->selectedSite === null || empty($this->solrConnectionManager->getConnectionsBySite($this->selectedSite))) { + $this->view->assign('can_not_proceed', true); + } + } + + /** + * Empties the site's indexes. + * + * @return void + */ + public function emptyIndexAction() + { + $siteHash = $this->selectedSite->getSiteHash(); + + try { + $affectedCores = []; + $solrServers = $this->solrConnectionManager->getConnectionsBySite($this->selectedSite); + foreach ($solrServers as $solrServer) { + $writeService = $solrServer->getWriteService(); + /* @var $solrServer SolrConnection */ + $writeService->deleteByQuery('siteHash:' . $siteHash); + $writeService->commit(false, false, false); + $affectedCores[] = $writeService->getPrimaryEndpoint()->getCore(); + } + $message = LocalizationUtility::translate('solr.backend.index_administration.index_emptied_all', 'Solr', [$this->selectedSite->getLabel(), implode(', ', $affectedCores)]); + $this->addFlashMessage($message); + } catch (\Exception $e) { + $this->addFlashMessage(LocalizationUtility::translate('solr.backend.index_administration.error.on_empty_index', 'Solr', [$e->__toString()]), '', FlashMessage::ERROR); + } + + $this->redirect('index'); + } + + /** + * Empties the Index Queue + * + * @return void + */ + public function clearIndexQueueAction() + { + $this->indexQueue->deleteItemsBySite($this->selectedSite); + $this->addFlashMessage( + LocalizationUtility::translate('solr.backend.index_administration.success.queue_emptied', 'Solr', + [$this->selectedSite->getLabel()]) + ); + $this->redirectToReferrerModule(); + } + + /** + * Reloads the site's Solr cores. + * + * @return void + */ + public function reloadIndexConfigurationAction() + { + $coresReloaded = true; + $reloadedCores = []; + $solrServers = $this->solrConnectionManager->getConnectionsBySite($this->selectedSite); + + foreach ($solrServers as $solrServer) { + /* @var $solrServer SolrConnection */ + $coreAdmin = $solrServer->getAdminService(); + $coreReloaded = $coreAdmin->reloadCore()->getHttpStatus() === 200; + + $coreName = $coreAdmin->getPrimaryEndpoint()->getCore(); + if (!$coreReloaded) { + $coresReloaded = false; + + $this->addFlashMessage( + 'Failed to reload index configuration for core "' . $coreName . '"', + '', + FlashMessage::ERROR + ); + break; + } + + $reloadedCores[] = $coreName; + } + + if ($coresReloaded) { + $this->addFlashMessage( + 'Core configuration reloaded (' . implode(', ', $reloadedCores) . ').', + '', + FlashMessage::OK + ); + } + + $this->redirect('index'); + } + + /** + * Redirects to the referrer module index Action. + * + * Fluids