diff --git a/composer.json b/composer.json index ea2be30ac8..80a3513eb7 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "require-dev": { "phpunit/phpunit-selenium": "~4.1", "phpunit/dbunit": "~3.0", - "phpunit/phpunit": "6.4.4" + "phpunit/phpunit": "6.4.4", + "myclabs/deep-copy": "1.8.0" }, "autoload": { "files": ["sources/Autoloader.class.php"], diff --git a/sources/admin/ManageSearch.controller.php b/sources/admin/ManageSearch.controller.php index 3f931dd172..7aa4e88adb 100644 --- a/sources/admin/ManageSearch.controller.php +++ b/sources/admin/ManageSearch.controller.php @@ -176,7 +176,7 @@ public function action_searchSettings_display() */ private function _settings() { - global $txt; + global $txt, $modSettings; // What are we editing anyway? $config_vars = array( @@ -194,13 +194,8 @@ private function _settings() ); // Perhaps the search method wants to add some settings? - $search = new \ElkArte\Search\Search(); - $searchAPI = $search->findSearchAPI(); - - if (is_callable(array($searchAPI, 'searchSettings'))) - { - call_user_func_array($searchAPI->searchSettings); - } + $searchAPI = new \ElkArte\Search\SearchApiWrapper(!empty($modSettings['search_index']) ? $modSettings['search_index'] : ''); + $searchAPI->searchSettings($config_vars); // Add new settings with a nice hook, makes them available for admin settings search as well call_integration_hook('integrate_modify_search_settings', array(&$config_vars)); diff --git a/sources/controllers/Display.controller.php b/sources/controllers/Display.controller.php index 50b07d9da2..556b6967ca 100644 --- a/sources/controllers/Display.controller.php +++ b/sources/controllers/Display.controller.php @@ -521,14 +521,10 @@ public function action_display() 'board_name' => htmlspecialchars(strtr(strip_tags($board_info['name']), array('&' => '&')), ENT_COMPAT, 'UTF-8'), 'child_level' => $board_info['child_level'], ); - - // Set the callback. (do you REALIZE how much memory all the messages would take?!?) - // This will be called from the template. - $context['get_message'] = array($this, 'prepareDisplayContext_callback'); - $this->_icon_sources = new MessageTopicIcons(!empty($modSettings['messageIconChecks_enable']), $settings['theme_dir']); list ($sig_limits) = explode(':', $modSettings['signature_settings']); $signature_settings = explode(',', $sig_limits); + $this->_icon_sources = new MessageTopicIcons(!empty($modSettings['messageIconChecks_enable']), $settings['theme_dir']); if ($user_info['is_guest']) { $this->_show_signatures = !empty($signature_settings[8]) ? (int) $signature_settings[8] : 0; @@ -538,6 +534,19 @@ public function action_display() $this->_show_signatures = !empty($signature_settings[9]) ? (int) $signature_settings[9] : 0; } + Elk_Autoloader::instance()->register(SUBSDIR . '/MessagesCallback', '\\ElkArte\\sources\\subs\\MessagesCallback'); + + // Set the callback. (do you REALIZE how much memory all the messages would take?!?) + // This will be called from the template. + $bodyParser = new \ElkArte\sources\subs\MessagesCallback\BodyParser\Normal(array(), false); + $opt = new \ElkArte\ValuesContainer([ + 'icon_sources' => $this->_icon_sources, + 'show_signatures' => $this->_show_signatures, + ]); + $renderer = new \ElkArte\sources\subs\MessagesCallback\DisplayRenderer($messages_request, $bodyParser, $opt); + + $context['get_message'] = array($renderer, 'getContext'); + // Now set all the wonderful, wonderful permissions... like moderation ones... $common_permissions = array( 'can_approve' => 'approve_posts', @@ -758,161 +767,4 @@ public function action_quickmod2() redirectexit(!empty($topicGone) ? 'board=' . $board : 'topic=' . $topic . '.' . (int) $this->_req->query->start); } - - /** - * Callback for the message display. - * It actually gets and prepares the message context. - * This method will start over from the beginning if reset is set to true, which is - * useful for showing an index before or after the posts. - * - * @param bool $reset default false. - * - * @return array - */ - public function prepareDisplayContext_callback($reset = false) - { - global $settings, $txt, $modSettings, $scripturl, $user_info; - global $memberContext, $context, $messages_request, $topic; - static $counter = null; - static $signature_shown = null; - - // If the query returned false, bail. - if ($messages_request === false) - return false; - - // Remember which message this is. (ie. reply #83) - if ($counter === null || $reset) - $counter = $context['start']; - - // Start from the beginning... - if ($reset) - return currentContext($messages_request, $reset); - - // Attempt to get the next message. - $message = currentContext($messages_request); - if (!$message) - return false; - - // If you're a lazy bum, you probably didn't give a subject... - $message['subject'] = $message['subject'] != '' ? $message['subject'] : $txt['no_subject']; - - // Are you allowed to remove at least a single reply? - $context['can_remove_post'] |= allowedTo('delete_own') && (empty($modSettings['edit_disable_time']) || $message['poster_time'] + $modSettings['edit_disable_time'] * 60 >= time()) && $message['id_member'] == $user_info['id']; - - // Have you liked this post, can you? - $message['you_liked'] = !empty($context['likes'][$message['id_msg']]['member']) - && isset($context['likes'][$message['id_msg']]['member'][$user_info['id']]); - $message['use_likes'] = allowedTo('like_posts') && empty($context['is_locked']) - && ($message['id_member'] != $user_info['id'] || !empty($modSettings['likeAllowSelf'])) - && (empty($modSettings['likeMinPosts']) ? true : $modSettings['likeMinPosts'] <= $user_info['posts']); - $message['like_count'] = !empty($context['likes'][$message['id_msg']]['count']) ? $context['likes'][$message['id_msg']]['count'] : 0; - - // If it couldn't load, or the user was a guest.... someday may be done with a guest table. - if (!loadMemberContext($message['id_member'], true)) - { - // Notice this information isn't used anywhere else.... - $memberContext[$message['id_member']]['name'] = $message['poster_name']; - $memberContext[$message['id_member']]['id'] = 0; - $memberContext[$message['id_member']]['group'] = $txt['guest_title']; - $memberContext[$message['id_member']]['link'] = $message['poster_name']; - $memberContext[$message['id_member']]['email'] = $message['poster_email']; - $memberContext[$message['id_member']]['show_email'] = showEmailAddress(true, 0); - $memberContext[$message['id_member']]['is_guest'] = true; - } - else - { - $memberContext[$message['id_member']]['can_view_profile'] = allowedTo('profile_view_any') || ($message['id_member'] == $user_info['id'] && allowedTo('profile_view_own')); - $memberContext[$message['id_member']]['is_topic_starter'] = $message['id_member'] == $context['topic_starter_id']; - $memberContext[$message['id_member']]['can_see_warning'] = !isset($context['disabled_fields']['warning_status']) && $memberContext[$message['id_member']]['warning_status'] && ($context['user']['can_mod'] || (!$user_info['is_guest'] && !empty($modSettings['warning_show']) && ($modSettings['warning_show'] > 1 || $message['id_member'] == $user_info['id']))); - - if ($this->_show_signatures === 1) - { - if (empty($signature_shown[$message['id_member']])) - { - $signature_shown[$message['id_member']] = true; - } - else - { - $memberContext[$message['id_member']]['signature'] = ''; - } - } - elseif ($this->_show_signatures === 2) - { - $memberContext[$message['id_member']]['signature'] = ''; - } - } - - $memberContext[$message['id_member']]['ip'] = $message['poster_ip']; - $memberContext[$message['id_member']]['show_profile_buttons'] = $settings['show_profile_buttons'] && (!empty($memberContext[$message['id_member']]['can_view_profile']) || (!empty($memberContext[$message['id_member']]['website']['url']) && !isset($context['disabled_fields']['website'])) || (in_array($memberContext[$message['id_member']]['show_email'], array('yes', 'yes_permission_override', 'no_through_forum'))) || $context['can_send_pm']); - - // Do the censor thang. - $message['body'] = censor($message['body']); - $message['subject'] = censor($message['subject']); - - // Run BBC interpreter on the message. - $context['id_msg'] = $message['id_msg']; - $bbc_wrapper = \BBC\ParserWrapper::instance(); - $message['body'] = $bbc_wrapper->parseMessage($message['body'], $message['smileys_enabled']); - - call_integration_hook('integrate_before_prepare_display_context', array(&$message)); - - // Compose the memory eat- I mean message array. - require_once(SUBSDIR . '/Attachments.subs.php'); - $output = array( - 'attachment' => loadAttachmentContext($message['id_msg']), - 'alternate' => $counter % 2, - 'id' => $message['id_msg'], - 'href' => $scripturl . '?topic=' . $topic . '.msg' . $message['id_msg'] . '#msg' . $message['id_msg'], - 'link' => '' . $message['subject'] . '', - 'member' => &$memberContext[$message['id_member']], - 'icon' => $message['icon'], - 'icon_url' => $this->_icon_sources->{$message['icon']}, - 'subject' => $message['subject'], - 'time' => standardTime($message['poster_time']), - 'html_time' => htmlTime($message['poster_time']), - 'timestamp' => forum_time(true, $message['poster_time']), - 'counter' => $counter, - 'modified' => array( - 'time' => standardTime($message['modified_time']), - 'html_time' => htmlTime($message['modified_time']), - 'timestamp' => forum_time(true, $message['modified_time']), - 'name' => $message['modified_name'] - ), - 'body' => $message['body'], - 'new' => empty($message['is_read']), - 'approved' => $message['approved'], - 'first_new' => isset($context['start_from']) && $context['start_from'] == $counter, - 'is_ignored' => !empty($modSettings['enable_buddylist']) && in_array($message['id_member'], $context['user']['ignoreusers']), - 'is_message_author' => $message['id_member'] == $user_info['id'], - 'can_approve' => !$message['approved'] && $context['can_approve'], - 'can_unapprove' => !empty($modSettings['postmod_active']) && $context['can_approve'] && $message['approved'], - 'can_modify' => (!$context['is_locked'] || allowedTo('moderate_board')) && (allowedTo('modify_any') || (allowedTo('modify_replies') && $context['user']['started']) || (allowedTo('modify_own') && $message['id_member'] == $user_info['id'] && (empty($modSettings['edit_disable_time']) || !$message['approved'] || $message['poster_time'] + $modSettings['edit_disable_time'] * 60 > time()))), - 'can_remove' => allowedTo('delete_any') || (allowedTo('delete_replies') && $context['user']['started']) || (allowedTo('delete_own') && $message['id_member'] == $user_info['id'] && (empty($modSettings['edit_disable_time']) || $message['poster_time'] + $modSettings['edit_disable_time'] * 60 > time())), - 'can_see_ip' => allowedTo('moderate_forum') || ($message['id_member'] == $user_info['id'] && !empty($user_info['id'])), - 'can_like' => $message['use_likes'] && !$message['you_liked'], - 'can_unlike' => $message['use_likes'] && $message['you_liked'], - 'like_counter' => $message['like_count'], - 'likes_enabled' => !empty($modSettings['likes_enabled']) && ($message['use_likes'] || ($message['like_count'] != 0)), - 'classes' => array(), - ); - - if (!empty($output['modified']['name'])) - $output['modified']['last_edit_text'] = sprintf($txt['last_edit_by'], $output['modified']['time'], $output['modified']['name'], standardTime($output['modified']['timestamp'])); - - if (!empty($output['member']['karma']['allow'])) - { - $output['member']['karma'] += array( - 'applaud_url' => $scripturl . '?action=karma;sa=applaud;uid=' . $output['member']['id'] . ';topic=' . $context['current_topic'] . '.' . $context['start'] . ';m=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], - 'smite_url' => $scripturl . '?action=karma;sa=smite;uid=' . $output['member']['id'] . ';topic=' . $context['current_topic'] . '.' . $context['start'] . ';m=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'] - ); - } - - call_integration_hook('integrate_prepare_display_context', array(&$output, &$message)); - - $output['classes'] = implode(' ', $output['classes']); - - $counter++; - - return $output; - } } diff --git a/sources/controllers/PersonalMessage.controller.php b/sources/controllers/PersonalMessage.controller.php index e0607aea87..71659f0a7a 100644 --- a/sources/controllers/PersonalMessage.controller.php +++ b/sources/controllers/PersonalMessage.controller.php @@ -424,7 +424,6 @@ public function action_folder() // Set up some basic template stuff. $context['from_or_to'] = $context['folder'] !== 'sent' ? 'from' : 'to'; - $context['get_pmessage'] = 'preparePMContext_callback'; $context['signature_enabled'] = substr($modSettings['signature_settings'], 0, 1) == 1; $context['disabled_fields'] = isset($modSettings['disabled_profile_fields']) ? array_flip(explode(',', $modSettings['disabled_profile_fields'])) : array(); @@ -635,12 +634,12 @@ public function action_folder() foreach (array_reverse($pms) as $pm) $orderBy[] = 'pm.id_pm = ' . $pm; - // Separate query for these bits, preparePMContext_callback will use it as required + // Separate query for these bits, the callback will use it as required $subjects_request = loadPMSubjectRequest($pms, $orderBy); } // Execute the load message query if a message has been chosen and let - // preparePMContext_callback fetch the results. Otherwise just show the pm selection list + // the callback fetch the results. Otherwise just show the pm selection list if (empty($pmsg) && empty($pmID) && $context['display_mode'] != 0) { $messages_request = false; @@ -655,6 +654,16 @@ public function action_folder() $messages_request = false; } + Elk_Autoloader::instance()->register(SUBSDIR . '/MessagesCallback', '\\ElkArte\\sources\\subs\\MessagesCallback'); + + $bodyParser = new \ElkArte\sources\subs\MessagesCallback\BodyParser\Normal(array(), false); + $renderer = new \ElkArte\sources\subs\MessagesCallback\PmRenderer($messages_request, $bodyParser); + $srenderer = new \ElkArte\sources\subs\MessagesCallback\PmRenderer($subjects_request, $bodyParser); + + $context['get_pmessage'] = array($renderer, 'getContext'); + $context['get_psubject'] = array($srenderer, 'getContext'); + + $context['topic_starter_id'] = 0; // Prepare some items for the template $context['can_send_pm'] = allowedTo('pm_send'); $context['can_send_email'] = allowedTo('send_email_to_members'); @@ -2950,193 +2959,3 @@ private function _highlighted_callback($matches) return isset($matches[2]) && $matches[2] == $matches[1] ? stripslashes($matches[1]) : '' . $matches[1] . ''; } } - -/** - * Get a personal message for the template. (used to save memory.) - * - * - This is a callback function that will fetch the actual results, as needed, of a previously run - * subject (loadPMSubjectRequest) or message (loadPMMessageRequest) query. - * - * @param string $type - * @param boolean $reset - * - * @return array - */ -function preparePMContext_callback($type = 'subject', $reset = false) -{ - global $txt, $scripturl, $modSettings, $settings, $context, $memberContext, $recipients, $user_info; - global $subjects_request, $messages_request; - static $counter = null, $temp_pm_selected = null; - - // We need this - $db = database(); - - // Count the current message number.... - if ($counter === null || $reset) - { - $counter = $context['start']; - } - - if ($temp_pm_selected === null) - { - $temp_pm_selected = isset($_SESSION['pm_selected']) ? $_SESSION['pm_selected'] : array(); - $_SESSION['pm_selected'] = array(); - } - - // If we're in non-boring view do something exciting! - if ($context['display_mode'] != 0 && $subjects_request && $type === 'subject') - { - $subject = $db->fetch_assoc($subjects_request); - if (!$subject) - { - $db->free_result($subjects_request); - - return false; - } - - // Make sure we have a subject - $subject['subject'] = $subject['subject'] === '' ? $txt['no_subject'] : $subject['subject']; - $subject['subject'] = censor($subject['subject']); - - $output = array( - 'id' => $subject['id_pm'], - 'member' => array( - 'id' => $subject['id_member_from'], - 'name' => $subject['from_name'], - 'link' => $subject['not_guest'] ? '' . $subject['from_name'] . '' : $subject['from_name'], - ), - 'recipients' => &$recipients[$subject['id_pm']], - 'subject' => $subject['subject'], - 'time' => standardTime($subject['msgtime']), - 'html_time' => htmlTime($subject['msgtime']), - 'timestamp' => forum_time(true, $subject['msgtime']), - 'number_recipients' => count($recipients[$subject['id_pm']]['to']), - 'labels' => &$context['message_labels'][$subject['id_pm']], - 'fully_labeled' => count($context['message_labels'][$subject['id_pm']]) == count($context['labels']), - 'is_replied_to' => &$context['message_replied'][$subject['id_pm']], - 'is_unread' => &$context['message_unread'][$subject['id_pm']], - 'is_selected' => !empty($temp_pm_selected) && in_array($subject['id_pm'], $temp_pm_selected), - ); - - // In conversation view we need to indicate on the subject listing if any message inside of - // that conversation is unread, not just if the latest is unread. - if ($context['display_mode'] == 2 && isset($context['conversation_unread'][$output['id']])) - { - $output['is_unread'] = true; - } - - return $output; - } - - // Bail if it's false, ie. no messages. - if ($messages_request === false) - { - return false; - } - - // Reset the data? - if ($reset === true) - { - return $db->data_seek($messages_request, 0); - } - - // Get the next one... bail if anything goes wrong. - $message = $db->fetch_assoc($messages_request); - if (!$message) - { - if ($type != 'subject') - { - $db->free_result($messages_request); - } - - return false; - } - - // Use '(no subject)' if none was specified. - $message['subject'] = $message['subject'] === '' ? $txt['no_subject'] : $message['subject']; - - // Load the message's information - if it's not there, load the guest information. - if (!loadMemberContext($message['id_member_from'], true)) - { - $memberContext[$message['id_member_from']]['name'] = $message['from_name']; - $memberContext[$message['id_member_from']]['id'] = 0; - - // Sometimes the forum sends messages itself (Warnings are an example) - in this case don't label it from a guest. - $memberContext[$message['id_member_from']]['group'] = $message['from_name'] == $context['forum_name'] ? '' : $txt['guest_title']; - $memberContext[$message['id_member_from']]['link'] = $message['from_name']; - $memberContext[$message['id_member_from']]['email'] = ''; - $memberContext[$message['id_member_from']]['show_email'] = showEmailAddress(true, 0); - $memberContext[$message['id_member_from']]['is_guest'] = true; - } - else - { - $memberContext[$message['id_member_from']]['can_view_profile'] = allowedTo('profile_view_any') || ($message['id_member_from'] == $user_info['id'] && allowedTo('profile_view_own')); - $memberContext[$message['id_member_from']]['can_see_warning'] = !isset($context['disabled_fields']['warning_status']) && $memberContext[$message['id_member_from']]['warning_status'] && ($context['user']['can_mod'] || (!empty($modSettings['warning_show']) && ($modSettings['warning_show'] > 1 || $message['id_member_from'] == $user_info['id']))); - } - - $memberContext[$message['id_member_from']]['show_profile_buttons'] = $settings['show_profile_buttons'] && (!empty($memberContext[$message['id_member_from']]['can_view_profile']) || (!empty($memberContext[$message['id_member_from']]['website']['url']) && !isset($context['disabled_fields']['website'])) || (in_array($memberContext[$message['id_member_from']]['show_email'], array('yes', 'yes_permission_override', 'no_through_forum'))) || $context['can_send_pm']); - - // Censor all the important text... - $message['body'] = censor($message['body']); - $message['subject'] = censor($message['subject']); - - // Run BBC interpreter on the message. - $bbc_parser = \BBC\ParserWrapper::instance(); - $message['body'] = $bbc_parser->parsePM($message['body']); - - // Return the array. - $output = array( - 'alternate' => $counter % 2, - 'id' => $message['id_pm'], - 'member' => &$memberContext[$message['id_member_from']], - 'subject' => $message['subject'], - 'time' => standardTime($message['msgtime']), - 'html_time' => htmlTime($message['msgtime']), - 'timestamp' => forum_time(true, $message['msgtime']), - 'counter' => $counter, - 'body' => $message['body'], - 'recipients' => &$recipients[$message['id_pm']], - 'number_recipients' => count($recipients[$message['id_pm']]['to']), - 'labels' => &$context['message_labels'][$message['id_pm']], - 'fully_labeled' => count($context['message_labels'][$message['id_pm']]) == count($context['labels']), - 'is_replied_to' => &$context['message_replied'][$message['id_pm']], - 'is_unread' => &$context['message_unread'][$message['id_pm']], - 'is_selected' => !empty($temp_pm_selected) && in_array($message['id_pm'], $temp_pm_selected), - 'is_message_author' => $message['id_member_from'] == $user_info['id'], - 'can_report' => !empty($modSettings['enableReportPM']), - 'can_see_ip' => allowedTo('moderate_forum') || ($message['id_member_from'] == $user_info['id'] && !empty($user_info['id'])), - ); - - $context['additional_pm_drop_buttons'] = array(); - - // Can they report this message - if (!empty($output['can_report']) && $context['folder'] !== 'sent' && $output['member']['id'] != $user_info['id']) - { - $context['additional_pm_drop_buttons']['warn_button'] = array( - 'href' => $scripturl . '?action=pm;sa=report;l=' . $context['current_label_id'] . ';pmsg=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], - 'text' => $txt['pm_report_to_admin'] - ); - } - - // Or mark it as unread - if (empty($output['is_unread']) && $context['folder'] !== 'sent' && $output['member']['id'] != $user_info['id']) - { - $context['additional_pm_drop_buttons']['restore_button'] = array( - 'href' => $scripturl . '?action=pm;sa=markunread;l=' . $context['current_label_id'] . ';pmsg=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], - 'text' => $txt['pm_mark_unread'] - ); - } - - // Or give / take karma for a PM - if (!empty($output['member']['karma']['allow'])) - { - $output['member']['karma'] += array( - 'applaud_url' => $scripturl . '?action=karma;sa=applaud;uid=' . $output['member']['id'] . ';f=' . $context['folder'] . ';start=' . $context['start'] . ($context['current_label_id'] != -1 ? ';l=' . $context['current_label_id'] : '') . ';pm=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], - 'smite_url' => $scripturl . '?action=karma;sa=smite;uid=' . $output['member']['id'] . ';f=' . $context['folder'] . ';start=' . $context['start'] . ($context['current_label_id'] != -1 ? ';l=' . $context['current_label_id'] : '') . ';pm=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], - ); - } - - $counter++; - - return $output; -} diff --git a/sources/controllers/Search.controller.php b/sources/controllers/Search.controller.php index ebcf5419a8..de5ae8bb01 100644 --- a/sources/controllers/Search.controller.php +++ b/sources/controllers/Search.controller.php @@ -23,24 +23,6 @@ */ class Search_Controller extends Action_Controller { - /** - * Weighing factor each area, ie frequency, age, sticky, etc - * @var array - */ - protected $_weight = array(); - - /** - * Holds the total of all weight factors, should be 100 - * @var int - */ - protected $_weight_total = 0; - - /** - * Holds array of search result relevancy weigh factors - * @var array - */ - protected $_weight_factors = array(); - /** * Holds the search object * @var \ElkArte\Search\Search @@ -53,6 +35,12 @@ class Search_Controller extends Action_Controller */ protected $_icon_sources = null; + /** + * + * @var mixed[] + */ + protected $_participants = []; + /** * Called before any other action method in this class. * @@ -85,6 +73,7 @@ public function pre_dispatch() { throw new Elk_Exception('loadavg_search_disabled', false); } + Elk_Autoloader::instance()->register(SUBSDIR . '/Search', '\\ElkArte\\Search'); } /** @@ -160,11 +149,9 @@ public function action_search() // If you got back from search;sa=results by using the linktree, you get your original search parameters back. if ($this->_search === null && isset($_REQUEST['params'])) { - Elk_Autoloader::instance()->register(SUBSDIR . '/Search', '\\ElkArte\\Search'); - $this->_search = new \ElkArte\Search\Search(); - $this->_search->searchParamsFromString($_REQUEST['params']); + $search_params = new \ElkArte\Search\SearchParams($_REQUEST['params'] ?? ''); - $context['search_params'] = $this->_search->getParams(); + $context['search_params'] = $search_params->get(); } if (isset($_REQUEST['search'])) @@ -258,18 +245,16 @@ public function action_results() { global $scripturl, $modSettings, $txt, $settings; global $user_info, $context, $options, $messages_request, $boards_can; - global $participants; // No, no, no... this is a bit hard on the server, so don't you go prefetching it! stop_prefetching(); - // Set up the weights to help tune result relevancy - $this->_setup_weight_factors(); - // These vars don't require an interface, they're just here for tweaking. $recentPercentage = 0.30; + // Message length used to tweak messages relevance of the results $humungousTopicPosts = 200; $maxMembersToSearch = 500; + // Maximum number of results $maxMessageResults = empty($modSettings['search_max_results']) ? 0 : $modSettings['search_max_results'] * 5; // Start with no errors. @@ -292,15 +277,11 @@ public function action_results() isAllowedTo('search_posts'); $this->_search = new \ElkArte\Search\Search(); - $this->_search->setWeights($this->_weight_factors, $this->_weight, $this->_weight_total); - - // Load up the search API we are going to use. - $searchAPI = $this->_search->findSearchAPI(); + $this->_search->setWeights(new \ElkArte\Search\WeightFactors($modSettings, $user_info['is_admin'])); + $search_params = new \ElkArte\Search\SearchParams($_REQUEST['params'] ?? ''); + $search_params->merge($_REQUEST, $recentPercentage, $maxMembersToSearch); + $this->_search->setParams($search_params, !empty($modSettings['search_simple_fulltext'])); - if (isset($_REQUEST['params'])) - $this->_search->searchParamsFromString($_REQUEST['params']); - - $this->_search->mergeSearchParams($_REQUEST, $recentPercentage, $maxMembersToSearch); $context['compact'] = $this->_search->isCompact(); // Nothing?? @@ -313,7 +294,7 @@ public function action_results() // Build the search array // $modSettings ['search_simple_fulltext'] is an hidden setting that will // do fulltext searching in the most basic way. - $searchArray = $this->_search->searchArray(!empty($modSettings['search_simple_fulltext'])); + $searchArray = $this->_search->getSearchArray(); // This is used to remember words that will be ignored (because too short usually) $context['search_ignored'] = $this->_search->getIgnored(); @@ -331,19 +312,17 @@ public function action_results() unset($context['search_errors']['invalid_search_string']); } - $searchWords = $this->_search->searchWords(); - // *** Spell checking? if (!empty($modSettings['enableSpellChecking']) && function_exists('pspell_new')) { $context['did_you_mean'] = ''; $context['did_you_mean_params'] = ''; // @todo maybe move the html to a $settings - $this->_search->loadSuggestions($context['did_you_mean'], $context['did_you_mean_params'], '{word}'); + $this->loadSuggestions($context['did_you_mean'], $context['did_you_mean_params'], '{word}'); } // Let the user adjust the search query, should they wish? - $context['search_params'] = $this->_search->getParams(); + $context['search_params'] = (array) $this->_search->getSearchParams(true); if (isset($context['search_params']['search'])) $context['search_params']['search'] = Util::htmlspecialchars($context['search_params']['search']); if (isset($context['search_params']['userspec'])) @@ -355,29 +334,7 @@ public function action_results() $context['search_params'] = $this->_fill_default_search_params($context['search_params']); - // Do we have captcha enabled? - if ($user_info['is_guest'] && !empty($modSettings['search_enable_captcha']) && empty($_SESSION['ss_vv_passed']) && (empty($_SESSION['last_ss']) || $_SESSION['last_ss'] != $this->_search->param('search'))) - { - // If we come from another search box tone down the error... - if (!isset($_REQUEST['search_vv'])) - $context['search_errors']['need_verification_code'] = true; - else - { - $verificationOptions = array( - 'id' => 'search', - ); - $context['require_verification'] = VerificationControls_Integrate::create($verificationOptions, true); - - if (is_array($context['require_verification'])) - { - foreach ($context['require_verification'] as $error) - $context['search_errors'][$error] = true; - } - // Don't keep asking for it - they've proven themselves worthy. - else - $_SESSION['ss_vv_passed'] = true; - } - } + $this->_controlVerifications(); $context['params'] = $this->_search->compileURLparams(); @@ -401,7 +358,7 @@ public function action_results() // One or more search errors? Go back to the first search screen. if (!empty($context['search_errors'])) - $this->action_search(); + return $this->action_search(); // Spam me not, Spam-a-lot? if (empty($_SESSION['last_ss']) || $_SESSION['last_ss'] != $this->_search->param('search')) @@ -410,46 +367,22 @@ public function action_results() // Store the last search string to allow pages of results to be browsed. $_SESSION['last_ss'] = $this->_search->param('search'); - // *** Reserve an ID for caching the search results. - $query_params = $this->_search->getParams(); - - // Can this search rely on the API given the parameters? - if (is_callable(array($searchAPI, 'searchQuery'))) + try { - $participants = array(); - $searchArray = array(); - $num_results = $searchAPI->searchQuery($query_params, $searchWords, $this->_search->getExcludedIndexWords(), $participants, $searchArray); + $search_config = new \ElkArte\ValuesContainer(array( + 'humungousTopicPosts' => $humungousTopicPosts, + 'maxMessageResults' => $maxMessageResults, + 'search_index' => !empty($modSettings['search_index']) ? $modSettings['search_index'] : '', + 'banned_words' => empty($modSettings['search_banned_words']) ? array() : explode(',', $modSettings['search_banned_words']), + )); + $context['topics'] = $this->_search->searchQuery( + new \ElkArte\Search\SearchApiWrapper($search_config, $this->_search->getSearchParams()) + ); } - // Update the cache if the current search term is not yet cached. - else + catch (\Exception $e) { - $update_cache = empty($_SESSION['search_cache']) || ($_SESSION['search_cache']['params'] != $context['params']); - if ($update_cache) - { - $this->_increase_pointer(); - - // Clear the previous cache of the final results cache. - $this->_search->clearCacheResults($_SESSION['search_cache']['id_search']); - - if ($this->_search->param('subject_only')) - $_SESSION['search_cache']['num_results'] = $this->_search->getSubjectResults($_SESSION['search_cache']['id_search'], $humungousTopicPosts); - else - { - $num_res = $this->_search->getResults($_SESSION['search_cache']['id_search'], $humungousTopicPosts, $maxMessageResults); - if (empty($num_res)) - { - $context['search_errors']['query_not_specific_enough'] = true; - $this->action_search(); - } - - $_SESSION['search_cache']['num_results'] = $num_res; - } - } - - // *** Retrieve the results to be shown on the page - $participants = $this->_search->addRelevance($context['topics'], $_SESSION['search_cache']['id_search'], (int) $_REQUEST['start'], $modSettings['search_results_per_page']); - - $num_results = $_SESSION['search_cache']['num_results']; + $context['search_errors'][$e->getMessage()] = true; + return $this->action_search(); } if (!empty($context['topics'])) @@ -485,414 +418,198 @@ public function action_results() if ($this->_search->noMessages($messages_request)) $context['topics'] = array(); - // If we want to know who participated in what then load this now. - if (!empty($modSettings['enableParticipation']) && !$user_info['is_guest']) - { - require_once(SUBSDIR . '/MessageIndex.subs.php'); - $topics_participated_in = topicsParticipation($user_info['id'], array_keys($participants)); - - foreach ($topics_participated_in as $topic) - $participants[$topic['id_topic']] = true; - } + $this->_prepareParticipants(!empty($modSettings['enableParticipation']), $user_info['is_guest'] ? $user_info['id'] : 0); } // Now that we know how many results to expect we can start calculating the page numbers. - $context['page_index'] = constructPageIndex($scripturl . '?action=search;sa=results;params=' . $context['params'], $_REQUEST['start'], $num_results, $modSettings['search_results_per_page'], false); + $context['page_index'] = constructPageIndex($scripturl . '?action=search;sa=results;params=' . $context['params'], $_REQUEST['start'], $this->_search->getNumResults(), $modSettings['search_results_per_page'], false); // Consider the search complete! Cache::instance()->remove('search_start:' . ($user_info['is_guest'] ? $user_info['ip'] : $user_info['id'])); - $context['key_words'] = &$searchArray; $context['sub_template'] = 'results'; $context['page_title'] = $txt['search_results']; - $context['get_topics'] = array($this, 'prepareSearchContext_callback'); + + Elk_Autoloader::instance()->register(SUBSDIR . '/MessagesCallback', '\\ElkArte\\sources\\subs\\MessagesCallback'); + $this->_icon_sources = new MessageTopicIcons(!empty($modSettings['messageIconChecks_enable']), $settings['theme_dir']); + // Set the callback. (do you REALIZE how much memory all the messages would take?!?) + // This will be called from the template. + $bodyParser = new \ElkArte\sources\subs\MessagesCallback\BodyParser\Normal($this->_search->getSearchArray(), empty($modSettings['search_method'])); + $opt = new \ElkArte\ValuesContainer([ + 'icon_sources' => $this->_icon_sources, + 'show_signatures' => false, + ]); + $renderer = new \ElkArte\sources\subs\MessagesCallback\SearchRenderer($messages_request, $bodyParser, $opt); + $renderer->setParticipants($this->_participants); + + $context['topic_starter_id'] = 0; + $context['get_topics'] = array($renderer, 'getContext'); + $context['jump_to'] = array( 'label' => addslashes(un_htmlspecialchars($txt['jump_to'])), 'board_name' => addslashes(un_htmlspecialchars($txt['select_destination'])), ); } - /** - * Callback to return messages - saves memory. - * - * @todo Fix this, update it, whatever... from Display.controller.php mainly. - * Note that the call to loadAttachmentContext() doesn't work: - * this function doesn't fulfill the pre-condition to fill $attachments global... - * So all it does is to fallback and return. - * - * What it does: - * - * - Callback function for the results sub template. - * - Loads the necessary contextual data to show a search result. - * - * @param boolean $reset = false - * @return array of messages that match the search - */ - public function prepareSearchContext_callback($reset = false) + protected function _controlVerifications() { - global $txt, $modSettings, $scripturl, $user_info; - global $memberContext, $context, $options, $messages_request; - global $boards_can, $participants; + global $user_info, $modSettings, $context; - // Remember which message this is. (ie. reply #83) - static $counter = null; - if ($counter === null || $reset) - $counter = $_REQUEST['start'] + 1; + // Do we have captcha enabled? + if ($user_info['is_guest'] && !empty($modSettings['search_enable_captcha']) && empty($_SESSION['ss_vv_passed']) && (empty($_SESSION['last_ss']) || $_SESSION['last_ss'] != $this->_search->param('search'))) + { + // If we come from another search box tone down the error... + if (!isset($_REQUEST['search_vv'])) + { + $context['search_errors']['need_verification_code'] = true; + } + else + { + $verificationOptions = array( + 'id' => 'search', + ); + $context['require_verification'] = VerificationControls_Integrate::create($verificationOptions, true); - // Start from the beginning... - if ($reset) - return currentContext($messages_request, $reset); + if (is_array($context['require_verification'])) + { + foreach ($context['require_verification'] as $error) + $context['search_errors'][$error] = true; + } + // Don't keep asking for it - they've proven themselves worthy. + else + $_SESSION['ss_vv_passed'] = true; + } + } + } - // Attempt to get the next in line - $message = currentContext($messages_request); - if (!$message) - return false; + /** + * Setup spellchecking suggestions and load them into the two variable + * passed by ref + * + * @param string $suggestion_display - the string to display in the template + * @param string $suggestion_param - a param string to be used in a url + * @param string $display_highlight - a template to enclose in each suggested word + */ + protected function loadSuggestions(&$suggestion_display = '', &$suggestion_param = '', $display_highlight = '') + { + global $txt; - // Can't have an empty subject can we? - $message['subject'] = $message['subject'] != '' ? $message['subject'] : $txt['no_subject']; + // Windows fix. + ob_start(); + $old = error_reporting(0); - $message['first_subject'] = $message['first_subject'] != '' ? $message['first_subject'] : $txt['no_subject']; - $message['last_subject'] = $message['last_subject'] != '' ? $message['last_subject'] : $txt['no_subject']; + pspell_new('en'); + $pspell_link = pspell_new($txt['lang_dictionary'], $txt['lang_spelling'], '', 'utf-8', PSPELL_FAST | PSPELL_RUN_TOGETHER); - // If it couldn't load, or the user was a guest.... someday may be done with a guest table. - if (!loadMemberContext($message['id_member'])) + if (!$pspell_link) { - // Notice this information isn't used anywhere else.... *cough guest table cough*. - $memberContext[$message['id_member']]['name'] = $message['poster_name']; - $memberContext[$message['id_member']]['id'] = 0; - $memberContext[$message['id_member']]['group'] = $txt['guest_title']; - $memberContext[$message['id_member']]['link'] = $message['poster_name']; - $memberContext[$message['id_member']]['email'] = $message['poster_email']; + $pspell_link = pspell_new('en', '', '', '', PSPELL_FAST | PSPELL_RUN_TOGETHER); } - $memberContext[$message['id_member']]['ip'] = $message['poster_ip']; - // Do the censor thang... - $message['body'] = censor($message['body']); - $message['subject'] = censor($message['subject']); - $message['first_subject'] = censor($message['first_subject']); - $message['last_subject'] = censor($message['last_subject']); + error_reporting($old); + @ob_end_clean(); - // Shorten this message if necessary. - if ($context['compact']) + if (empty($pspell_link)) { - // Set the number of characters before and after the searched keyword. - $charLimit = 50; + return; + } - $message['body'] = strtr($message['body'], array("\n" => ' ', '
' => "\n")); - $bbc_parser = \BBC\ParserWrapper::instance(); - $message['body'] = $bbc_parser->parseMessage($message['body'], $message['smileys_enabled']); - $message['body'] = strip_tags(strtr($message['body'], array('' => '
', '' => '
')), '
'); + $did_you_mean = array('search' => array(), 'display' => array()); + $found_misspelling = false; + foreach ($this->_search->getSearchArray() as $word) + { + // Don't check phrases. + if (preg_match('~^\w+$~', $word) === 0) + { + $did_you_mean['search'][] = '"' . $word . '"'; + $did_you_mean['display'][] = '"' . \Util::htmlspecialchars($word) . '"'; + continue; + } + // For some strange reason spell check can crash PHP on decimals. + elseif (preg_match('~\d~', $word) === 1) + { + $did_you_mean['search'][] = $word; + $did_you_mean['display'][] = \Util::htmlspecialchars($word); + continue; + } + elseif (pspell_check($pspell_link, $word)) + { + $did_you_mean['search'][] = $word; + $did_you_mean['display'][] = \Util::htmlspecialchars($word); + continue; + } - if (Util::strlen($message['body']) > $charLimit) + $suggestions = pspell_suggest($pspell_link, $word); + foreach ($suggestions as $i => $s) { - if (empty($context['key_words'])) - $message['body'] = Util::substr($message['body'], 0, $charLimit) . '...'; - else + // Search is case insensitive. + if (\Util::strtolower($s) == \Util::strtolower($word)) { - $matchString = ''; - $force_partial_word = false; - foreach ($context['key_words'] as $keyword) - { - $keyword = un_htmlspecialchars($keyword); - $keyword = preg_replace_callback('~(&#(\d{1,7}|x[0-9a-fA-F]{1,6});)~', 'entity_fix__callback', strtr($keyword, array('\\\'' => '\'', '&' => '&'))); - if (preg_match('~[\'\.,/@%&;:(){}\[\]_\-+\\\\]$~', $keyword) != 0 || preg_match('~^[\'\.,/@%&;:(){}\[\]_\-+\\\\]~', $keyword) != 0) - $force_partial_word = true; - $matchString .= strtr(preg_quote($keyword, '/'), array('\*' => '.+?')) . '|'; - } - $matchString = un_htmlspecialchars(substr($matchString, 0, -1)); - - $message['body'] = un_htmlspecialchars(strtr($message['body'], array(' ' => ' ', '
' => "\n", '[' => '[', ']' => ']', ':' => ':', '@' => '@'))); - - if (empty($modSettings['search_method']) || $force_partial_word) - preg_match_all('/([^\s\W]{' . $charLimit . '}[\s\W]|[\s\W].{0,' . $charLimit . '}?|^)(' . $matchString . ')(.{0,' . $charLimit . '}[\s\W]|[^\s\W]{0,' . $charLimit . '})/isu', $message['body'], $matches); - else - preg_match_all('/([^\s\W]{' . $charLimit . '}[\s\W]|[\s\W].{0,' . $charLimit . '}?[\s\W]|^)(' . $matchString . ')([\s\W].{0,' . $charLimit . '}[\s\W]|[\s\W][^\s\W]{0,' . $charLimit . '})/isu', $message['body'], $matches); - - $message['body'] = ''; - foreach ($matches[0] as $match) - { - $match = strtr(htmlspecialchars($match, ENT_QUOTES, 'UTF-8'), array("\n" => ' ')); - $message['body'] .= '…… ' . $match . ' ……'; - } + unset($suggestions[$i]); + } + // Plus, don't suggest something the user thinks is rude! + elseif ($suggestions[$i] != censor($s)) + { + unset($suggestions[$i]); } - - // Re-fix the international characters. - $message['body'] = preg_replace_callback('~(&#(\d{1,7}|x[0-9a-fA-F]{1,6});)~', 'entity_fix__callback', $message['body']); } - } - else - { - // Run BBC interpreter on the message. - $bbc_parser = \BBC\ParserWrapper::instance(); - $message['body'] = $bbc_parser->parseMessage($message['body'], $message['smileys_enabled']); - } - // Make sure we don't end up with a practically empty message body. - $message['body'] = preg_replace('~^(?: )+$~', '', $message['body']); - - // Do we have quote tag enabled? - $quote_enabled = empty($modSettings['disabledBBC']) || !in_array('quote', explode(',', $modSettings['disabledBBC'])); - - $output = array_merge($context['topics'][$message['id_msg']], array( - 'id' => $message['id_topic'], - 'is_sticky' => !empty($message['is_sticky']), - 'is_locked' => !empty($message['locked']), - 'is_poll' => !empty($modSettings['pollMode']) && $message['id_poll'] > 0, - 'is_hot' => !empty($modSettings['useLikesNotViews']) ? $message['num_likes'] >= $modSettings['hotTopicPosts'] : $message['num_replies'] >= $modSettings['hotTopicPosts'], - 'is_very_hot' => !empty($modSettings['useLikesNotViews']) ? $message['num_likes'] >= $modSettings['hotTopicVeryPosts'] : $message['num_replies'] >= $modSettings['hotTopicVeryPosts'], - 'posted_in' => !empty($participants[$message['id_topic']]), - 'views' => $message['num_views'], - 'replies' => $message['num_replies'], - 'tests' => array( - 'can_reply' => in_array($message['id_board'], $boards_can['post_reply_any']) || in_array(0, $boards_can['post_reply_any']), - 'can_quote' => (in_array($message['id_board'], $boards_can['post_reply_any']) || in_array(0, $boards_can['post_reply_any'])) && $quote_enabled, - 'can_mark_notify' => in_array($message['id_board'], $boards_can['mark_any_notify']) || in_array(0, $boards_can['mark_any_notify']) && !$context['user']['is_guest'], - ), - 'first_post' => array( - 'id' => $message['first_msg'], - 'time' => standardTime($message['first_poster_time']), - 'html_time' => htmlTime($message['first_poster_time']), - 'timestamp' => forum_time(true, $message['first_poster_time']), - 'subject' => $message['first_subject'], - 'href' => $scripturl . '?topic=' . $message['id_topic'] . '.0', - 'link' => '' . $message['first_subject'] . '', - 'icon' => $message['first_icon'], - 'icon_url' => $this->_icon_sources->{$message['first_icon']}, - 'member' => array( - 'id' => $message['first_member_id'], - 'name' => $message['first_member_name'], - 'href' => !empty($message['first_member_id']) ? $scripturl . '?action=profile;u=' . $message['first_member_id'] : '', - 'link' => !empty($message['first_member_id']) ? '' . $message['first_member_name'] . '' : $message['first_member_name'] - ) - ), - 'last_post' => array( - 'id' => $message['last_msg'], - 'time' => standardTime($message['last_poster_time']), - 'html_time' => htmlTime($message['last_poster_time']), - 'timestamp' => forum_time(true, $message['last_poster_time']), - 'subject' => $message['last_subject'], - 'href' => $scripturl . '?topic=' . $message['id_topic'] . ($message['num_replies'] == 0 ? '.0' : '.msg' . $message['last_msg']) . '#msg' . $message['last_msg'], - 'link' => '' . $message['last_subject'] . '', - 'icon' => $message['last_icon'], - 'icon_url' => $this->_icon_sources->{$message['last_icon']}, - 'member' => array( - 'id' => $message['last_member_id'], - 'name' => $message['last_member_name'], - 'href' => !empty($message['last_member_id']) ? $scripturl . '?action=profile;u=' . $message['last_member_id'] : '', - 'link' => !empty($message['last_member_id']) ? '' . $message['last_member_name'] . '' : $message['last_member_name'] - ) - ), - 'board' => array( - 'id' => $message['id_board'], - 'name' => $message['board_name'], - 'href' => $scripturl . '?board=' . $message['id_board'] . '.0', - 'link' => '' . $message['board_name'] . '' - ), - 'category' => array( - 'id' => $message['id_cat'], - 'name' => $message['cat_name'], - 'href' => $scripturl . $modSettings['default_forum_action'] . '#c' . $message['id_cat'], - 'link' => '' . $message['cat_name'] . '' - ) - )); - - determineTopicClass($output); - - if ($output['posted_in']) - $output['class'] = 'my_' . $output['class']; - - $body_highlighted = $message['body']; - $subject_highlighted = $message['subject']; - - if (!empty($options['display_quick_mod'])) - { - $started = $output['first_post']['member']['id'] == $user_info['id']; - - $output['quick_mod'] = array( - 'lock' => in_array(0, $boards_can['lock_any']) || in_array($output['board']['id'], $boards_can['lock_any']) || ($started && (in_array(0, $boards_can['lock_own']) || in_array($output['board']['id'], $boards_can['lock_own']))), - 'sticky' => (in_array(0, $boards_can['make_sticky']) || in_array($output['board']['id'], $boards_can['make_sticky'])), - 'move' => in_array(0, $boards_can['move_any']) || in_array($output['board']['id'], $boards_can['move_any']) || ($started && (in_array(0, $boards_can['move_own']) || in_array($output['board']['id'], $boards_can['move_own']))), - 'remove' => in_array(0, $boards_can['remove_any']) || in_array($output['board']['id'], $boards_can['remove_any']) || ($started && (in_array(0, $boards_can['remove_own']) || in_array($output['board']['id'], $boards_can['remove_own']))), - ); - - $context['can_lock'] |= $output['quick_mod']['lock']; - $context['can_sticky'] |= $output['quick_mod']['sticky']; - $context['can_move'] |= $output['quick_mod']['move']; - $context['can_remove'] |= $output['quick_mod']['remove']; - $context['can_merge'] |= in_array($output['board']['id'], $boards_can['merge_any']); - $context['can_markread'] = $context['user']['is_logged']; - - $context['qmod_actions'] = array('remove', 'lock', 'sticky', 'move', 'markread'); - call_integration_hook('integrate_quick_mod_actions_search'); + // Anything found? If so, correct it! + if (!empty($suggestions)) + { + $suggestions = array_values($suggestions); + $did_you_mean['search'][] = $suggestions[0]; + $did_you_mean['display'][] = str_replace('{word}', \Util::htmlspecialchars($suggestions[0]), $display_highlight); + $found_misspelling = true; + } + else + { + $did_you_mean['search'][] = $word; + $did_you_mean['display'][] = \Util::htmlspecialchars($word); + } } - foreach ($context['key_words'] as $query) + if ($found_misspelling) { - // Fix the international characters in the keyword too. - $query = un_htmlspecialchars($query); - $query = trim($query, '\*+'); - $query = strtr(Util::htmlspecialchars($query), array('\\\'' => '\'')); - - $body_highlighted = preg_replace_callback('/((<[^>]*)|' . preg_quote(strtr($query, array('\'' => ''')), '/') . ')/iu', array($this, '_highlighted_callback'), $body_highlighted); - $subject_highlighted = preg_replace('/(' . preg_quote($query, '/') . ')/iu', '$1', $subject_highlighted); - } + // Don't spell check excluded words, but add them still... + $temp_excluded = array('search' => array(), 'display' => array()); + foreach ($this->_search->getExcludedWords() as $word) + { + if (preg_match('~^\w+$~', $word) == 0) + { + $temp_excluded['search'][] = '-"' . $word . '"'; + $temp_excluded['display'][] = '-"' . \Util::htmlspecialchars($word) . '"'; + } + else + { + $temp_excluded['search'][] = '-' . $word; + $temp_excluded['display'][] = '-' . \Util::htmlspecialchars($word); + } + } - require_once(SUBSDIR . '/Attachments.subs.php'); - $output['matches'][] = array( - 'id' => $message['id_msg'], - 'attachment' => loadAttachmentContext($message['id_msg']), - 'alternate' => $counter % 2, - 'member' => &$memberContext[$message['id_member']], - 'icon' => $message['icon'], - 'icon_url' => $this->_icon_sources->{$message['icon']}, - 'subject' => $message['subject'], - 'subject_highlighted' => $subject_highlighted, - 'time' => standardTime($message['poster_time']), - 'html_time' => htmlTime($message['poster_time']), - 'timestamp' => forum_time(true, $message['poster_time']), - 'counter' => $counter, - 'modified' => array( - 'time' => standardTime($message['modified_time']), - 'html_time' => htmlTime($message['modified_time']), - 'timestamp' => forum_time(true, $message['modified_time']), - 'name' => $message['modified_name'] - ), - 'body' => $message['body'], - 'body_highlighted' => $body_highlighted, - 'start' => 'msg' . $message['id_msg'] - ); - $counter++; + $did_you_mean['search'] = array_merge($did_you_mean['search'], $temp_excluded['search']); + $did_you_mean['display'] = array_merge($did_you_mean['display'], $temp_excluded['display']); - if (!$context['compact']) - { - $output['buttons'] = array( - // Can we request notification of topics? - 'notify' => array( - 'href' => $scripturl . '?action=notify;topic=' . $output['id'] . '.msg' . $message['id_msg'], - 'text' => $txt['notify'], - 'test' => 'can_mark_notify', - ), - // If they *can* reply? - 'reply' => array( - 'href' => $scripturl . '?action=post;topic=' . $output['id'] . '.msg' . $message['id_msg'], - 'text' => $txt['reply'], - 'test' => 'can_reply', - ), - // If they *can* quote? - 'quote' => array( - 'href' => $scripturl . '?action=post;topic=' . $output['id'] . '.msg' . $message['id_msg'] . ';quote=' . $message['id_msg'], - 'text' => $txt['quote'], - 'test' => 'can_quote', - ), - ); + // Provide the potential correct spelling term in the param + $suggestion_param = $this->_search->compileURLparams($did_you_mean['search']); + $suggestion_display = implode(' ', $did_you_mean['display']); } - - call_integration_hook('integrate_search_message_context', array($counter, &$output)); - - return $output; } - /** - * Used to highlight body text with strings that match the search term - * - * Callback function used in $body_highlighted - * - * @param string[] $matches - * - * @return string - */ - private function _highlighted_callback($matches) - { - return isset($matches[2]) && $matches[2] == $matches[1] ? stripslashes($matches[1]) : '' . $matches[1] . ''; - } - - /** - * Prepares the weighting factors - */ - private function _setup_weight_factors() + protected function _prepareParticipants($participationEnabled, $user_id) { - global $user_info, $modSettings; - - $default_factors = $this->_weight_factors = array( - 'frequency' => array( - 'search' => 'COUNT(*) / (MAX(t.num_replies) + 1)', - 'results' => '(t.num_replies + 1)', - ), - 'age' => array( - 'search' => 'CASE WHEN MAX(m.id_msg) < {int:min_msg} THEN 0 ELSE (MAX(m.id_msg) - {int:min_msg}) / {int:recent_message} END', - 'results' => 'CASE WHEN t.id_first_msg < {int:min_msg} THEN 0 ELSE (t.id_first_msg - {int:min_msg}) / {int:recent_message} END', - ), - 'length' => array( - 'search' => 'CASE WHEN MAX(t.num_replies) < {int:huge_topic_posts} THEN MAX(t.num_replies) / {int:huge_topic_posts} ELSE 1 END', - 'results' => 'CASE WHEN t.num_replies < {int:huge_topic_posts} THEN t.num_replies / {int:huge_topic_posts} ELSE 1 END', - ), - 'subject' => array( - 'search' => 0, - 'results' => 0, - ), - 'first_message' => array( - 'search' => 'CASE WHEN MIN(m.id_msg) = MAX(t.id_first_msg) THEN 1 ELSE 0 END', - ), - 'sticky' => array( - 'search' => 'MAX(t.is_sticky)', - 'results' => 't.is_sticky', - ), - 'likes' => array( - 'search' => 'MAX(t.num_likes)', - 'results' => 't.num_likes', - ), - ); - - // These are fallback weights in case of errors somewhere. - // Not intended to be passed to the hook - $default_weights = array( - 'search_weight_frequency' => 30, - 'search_weight_age' => 25, - 'search_weight_length' => 20, - 'search_weight_subject' => 15, - 'search_weight_first_message' => 10, - ); - - call_integration_hook('integrate_search_weights', array(&$this->_weight_factors)); - - // Set the weight factors for each area (frequency, age, etc) as defined in the ACP - $this->_calculate_weights($this->_weight_factors, $modSettings); - - // Zero weight. Weightless :P. - if (empty($this->_weight_total)) + // If we want to know who participated in what then load this now. + if ($participationEnabled === true && $user_id !== 0) { - // Admins can be bothered with a failure - if ($user_info['is_admin']) - throw new Elk_Exception('search_invalid_weights'); - - // Even if users will get an answer, the admin should know something is broken - Errors::instance()->log_lang_error('search_invalid_weights'); - - // Instead is better to give normal users and guests some kind of result - // using our defaults. - // Using a different variable here because it may be the hook is screwing - // things up - $this->_calculate_weights($default_factors, $default_weights); - } - } + $this->_participants = $this->_search->getParticipants(); - /** - * Fill the $_weight variable and calculate the total weight - * - * @param mixed[] $factors - * @param int[] $weights - */ - private function _calculate_weights($factors, $weights) - { - $this->_weight = array(); - $this->_weight_total = 0; + require_once(SUBSDIR . '/MessageIndex.subs.php'); + $topics_participated_in = topicsParticipation($user_id, array_keys($this->_participants)); - foreach ($factors as $weight_factor => $value) - { - $this->_weight[$weight_factor] = empty($weights['search_weight_' . $weight_factor]) ? 0 : (int) $weights['search_weight_' . $weight_factor]; - $this->_weight_total += $this->_weight[$weight_factor]; + foreach ($topics_participated_in as $topic) + $this->_participants[$topic['id_topic']] = true; } } @@ -905,53 +622,25 @@ private function _calculate_weights($factors, $weights) */ private function _fill_default_search_params($array) { - if (empty($array['search'])) - $array['search'] = ''; + $default = array( + 'search' => '', + 'userspec' => '*', + 'searchtype' => 0, + 'show_complete' => 0, + 'subject_only' => 0, + 'minage' => 0, + 'maxage' => 9999, + 'sort' => 'relevance', + ); + + $array = array_merge($default, $array); if (empty($array['userspec'])) + { $array['userspec'] = '*'; - if (empty($array['searchtype'])) - $array['searchtype'] = 0; - - if (!isset($array['show_complete'])) - $array['show_complete'] = 0; - else - $array['show_complete'] = (int) $array['show_complete']; - - if (!isset($array['subject_only'])) - $array['subject_only'] = 0; - else - $array['subject_only'] = (int) $array['subject_only']; - - if (empty($array['minage'])) - $array['minage'] = 0; - if (empty($array['maxage'])) - $array['maxage'] = 9999; - if (empty($array['sort'])) - $array['sort'] = 'relevance'; + } + $array['show_complete'] = (int) $array['show_complete']; + $array['subject_only'] = (int) $array['subject_only']; return $array; } - - /** - * Increase the search pointer by 1. - * - * - The maximum value is 255, so when it becomes bigger, the pointer is reset to 0. - */ - private function _increase_pointer() - { - global $modSettings, $context; - - // Increase the pointer... - $modSettings['search_pointer'] = empty($modSettings['search_pointer']) ? 0 : (int) $modSettings['search_pointer']; - - // ...and store it right off. - updateSettings(array('search_pointer' => $modSettings['search_pointer'] >= 255 ? 0 : $modSettings['search_pointer'] + 1)); - - // As long as you don't change the parameters, the cache result is yours. - $_SESSION['search_cache'] = array( - 'id_search' => $modSettings['search_pointer'], - 'num_results' => -1, - 'params' => $context['params'], - ); - } } diff --git a/sources/subs/Boards.subs.php b/sources/subs/Boards.subs.php index cf628b8ddf..f0ca92b522 100644 --- a/sources/subs/Boards.subs.php +++ b/sources/subs/Boards.subs.php @@ -759,10 +759,13 @@ function reorderBoards() } } - $db->query( - '', - ' - UPDATE {db_prefix}boards + if (empty($update_query)) + { + return; + } + + $db->query('', + 'UPDATE {db_prefix}boards SET board_order = CASE id_board ' . $update_query . ' END', diff --git a/sources/subs/MessagesCallback/BodyParser/BodyParserInterface.php b/sources/subs/MessagesCallback/BodyParser/BodyParserInterface.php new file mode 100644 index 0000000000..62cbe2a103 --- /dev/null +++ b/sources/subs/MessagesCallback/BodyParser/BodyParserInterface.php @@ -0,0 +1,51 @@ +_searchArray = $highlight; + $this->_use_partial_words = $use_partial_words; + $this->_highlight = !empty($highlight); + $this->_bbc_parser = \BBC\ParserWrapper::instance(); + } + + /** + * {@inheritdoc } + */ + public function getSearchArray() + { + return $this->_searchArray; + } + + /** + * {@inheritdoc } + */ + public function prepare($body, $smileys_enabled) + { + // Set the number of characters before and after the searched keyword. + $charLimit = 50; + + $body = censor($body); + $body = strtr($body, array("\n" => ' ', '
' => "\n")); + $body = $this->_bbc_parser->parseMessage($body, $smileys_enabled); + $body = strip_tags(strtr($body, array('' => '
', '' => '
')), '
'); + + if (Util::strlen($body) > $charLimit) + { + if ($this->_highlight === false) + { + $body = Util::substr($body, 0, $charLimit) . '...'; + } + else + { + $matchString = ''; + $force_partial_word = false; + foreach ($this->_searchArray as $keyword) + { + $keyword = un_htmlspecialchars($keyword); + $keyword = preg_replace_callback('~(&#(\d{1,7}|x[0-9a-fA-F]{1,6});)~', 'entity_fix__callback', strtr($keyword, array('\\\'' => '\'', '&' => '&'))); + if (preg_match('~[\'\.,/@%&;:(){}\[\]_\-+\\\\]$~', $keyword) != 0 || preg_match('~^[\'\.,/@%&;:(){}\[\]_\-+\\\\]~', $keyword) != 0) + $force_partial_word = true; + $matchString .= strtr(preg_quote($keyword, '/'), array('\*' => '.+?')) . '|'; + } + $matchString = un_htmlspecialchars(substr($matchString, 0, -1)); + + $body = un_htmlspecialchars(strtr($body, array(' ' => ' ', '
' => "\n", '[' => '[', ']' => ']', ':' => ':', '@' => '@'))); + + if ($this->_use_partial_words || $force_partial_word) + preg_match_all('/([^\s\W]{' . $charLimit . '}[\s\W]|[\s\W].{0,' . $charLimit . '}?|^)(' . $matchString . ')(.{0,' . $charLimit . '}[\s\W]|[^\s\W]{0,' . $charLimit . '})/isu', $body, $matches); + else + preg_match_all('/([^\s\W]{' . $charLimit . '}[\s\W]|[\s\W].{0,' . $charLimit . '}?[\s\W]|^)(' . $matchString . ')([\s\W].{0,' . $charLimit . '}[\s\W]|[\s\W][^\s\W]{0,' . $charLimit . '})/isu', $body, $matches); + + $body = ''; + foreach ($matches[0] as $match) + { + $match = strtr(htmlspecialchars($match, ENT_QUOTES, 'UTF-8'), array("\n" => ' ')); + $body .= '…… ' . $match . ' ……'; + } + } + + // Re-fix the international characters. + $body = preg_replace_callback('~(&#(\d{1,7}|x[0-9a-fA-F]{1,6});)~', 'entity_fix__callback', $body); + } + return $body; + } +} \ No newline at end of file diff --git a/sources/subs/MessagesCallback/BodyParser/Normal.php b/sources/subs/MessagesCallback/BodyParser/Normal.php new file mode 100644 index 0000000000..348fd3d64d --- /dev/null +++ b/sources/subs/MessagesCallback/BodyParser/Normal.php @@ -0,0 +1,80 @@ +_searchArray = $highlight; + $this->_use_partial_words = $use_partial_words; + $this->_highlight = !empty($highlight); + $this->_bbc_parser = \BBC\ParserWrapper::instance(); + } + + /** + * {@inheritdoc } + */ + public function getSearchArray() + { + return $this->_searchArray; + } + + /** + * {@inheritdoc } + */ + public function prepare($body, $smileys_enabled) + { + $body = censor($body); + + // Run BBC interpreter on the message. + return $this->_bbc_parser->parseMessage($body, $smileys_enabled); + } +} \ No newline at end of file diff --git a/sources/subs/MessagesCallback/DisplayRenderer.php b/sources/subs/MessagesCallback/DisplayRenderer.php new file mode 100644 index 0000000000..c88e70beb6 --- /dev/null +++ b/sources/subs/MessagesCallback/DisplayRenderer.php @@ -0,0 +1,119 @@ +_this_message['poster_time'] + $modSettings['edit_disable_time'] * 60 >= time()) && $this->_this_message['id_member'] == $user_info['id']; + + // Have you liked this post, can you? + $this->_this_message['you_liked'] = !empty($context['likes'][$this->_this_message['id_msg']]['member']) + && isset($context['likes'][$this->_this_message['id_msg']]['member'][$user_info['id']]); + $this->_this_message['use_likes'] = allowedTo('like_posts') && empty($context['is_locked']) + && ($this->_this_message['id_member'] != $user_info['id'] || !empty($modSettings['likeAllowSelf'])) + && (empty($modSettings['likeMinPosts']) ? true : $modSettings['likeMinPosts'] <= $user_info['posts']); + $this->_this_message['like_count'] = !empty($context['likes'][$this->_this_message['id_msg']]['count']) ? $context['likes'][$this->_this_message['id_msg']]['count'] : 0; + } + + /** + * {@inheritdoc } + */ + protected function _adjustAllMembers() + { + global $memberContext, $settings, $context; + + $id_member = $this->_this_message[$this->_idx_mapper->id_member]; + + $memberContext[$id_member]['ip'] = $this->_this_message['poster_ip'] ?? ''; + $memberContext[$id_member]['show_profile_buttons'] = $settings['show_profile_buttons'] && (!empty($memberContext[$id_member]['can_view_profile']) || (!empty($memberContext[$id_member]['website']['url']) && !isset($context['disabled_fields']['website'])) || (in_array($memberContext[$id_member]['show_email'], array('yes', 'yes_permission_override', 'no_through_forum'))) || $context['can_send_pm']); + + $context['id_msg'] = $this->_this_message['id_msg'] ?? ''; + } + + /** + * {@inheritdoc } + */ + protected function _buildOutputArray() + { + global $scripturl, $topic, $context, $modSettings, $user_info, $txt; + + require_once(SUBSDIR . '/Attachments.subs.php'); + + $output = parent::_buildOutputArray(); + $output += array( + 'attachment' => loadAttachmentContext($this->_this_message['id_msg']), + 'href' => $scripturl . '?topic=' . $topic . '.msg' . $this->_this_message['id_msg'] . '#msg' . $this->_this_message['id_msg'], + 'link' => '' . $this->_this_message['subject'] . '', + 'icon' => $this->_this_message['icon'], + 'icon_url' => $this->_options->icon_sources->{$this->_this_message['icon']}, + 'modified' => array( + 'time' => standardTime($this->_this_message['modified_time']), + 'html_time' => htmlTime($this->_this_message['modified_time']), + 'timestamp' => forum_time(true, $this->_this_message['modified_time']), + 'name' => $this->_this_message['modified_name'] + ), + 'new' => empty($this->_this_message['is_read']), + 'approved' => $this->_this_message['approved'], + 'first_new' => isset($context['start_from']) && $context['start_from'] == $this->_counter, + 'is_ignored' => !empty($modSettings['enable_buddylist']) && in_array($this->_this_message['id_member'], $context['user']['ignoreusers']), + 'is_message_author' => $this->_this_message['id_member'] == $user_info['id'], + 'can_approve' => !$this->_this_message['approved'] && $context['can_approve'], + 'can_unapprove' => !empty($modSettings['postmod_active']) && $context['can_approve'] && $this->_this_message['approved'], + 'can_modify' => (!$context['is_locked'] || allowedTo('moderate_board')) && (allowedTo('modify_any') || (allowedTo('modify_replies') && $context['user']['started']) || (allowedTo('modify_own') && $this->_this_message['id_member'] == $user_info['id'] && (empty($modSettings['edit_disable_time']) || !$this->_this_message['approved'] || $this->_this_message['poster_time'] + $modSettings['edit_disable_time'] * 60 > time()))), + 'can_remove' => allowedTo('delete_any') || (allowedTo('delete_replies') && $context['user']['started']) || (allowedTo('delete_own') && $this->_this_message['id_member'] == $user_info['id'] && (empty($modSettings['edit_disable_time']) || $this->_this_message['poster_time'] + $modSettings['edit_disable_time'] * 60 > time())), + 'can_like' => $this->_this_message['use_likes'] && !$this->_this_message['you_liked'], + 'can_unlike' => $this->_this_message['use_likes'] && $this->_this_message['you_liked'], + 'like_counter' => $this->_this_message['like_count'], + 'likes_enabled' => !empty($modSettings['likes_enabled']) && ($this->_this_message['use_likes'] || ($this->_this_message['like_count'] != 0)), + 'classes' => array(), + ); + + if (!empty($output['modified']['name'])) + $output['modified']['last_edit_text'] = sprintf($txt['last_edit_by'], $output['modified']['time'], $output['modified']['name'], standardTime($output['modified']['timestamp'])); + + if (!empty($output['member']['karma']['allow'])) + { + $output['member']['karma'] += array( + 'applaud_url' => $scripturl . '?action=karma;sa=applaud;uid=' . $output['member']['id'] . ';topic=' . $context['current_topic'] . '.' . $context['start'] . ';m=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], + 'smite_url' => $scripturl . '?action=karma;sa=smite;uid=' . $output['member']['id'] . ';topic=' . $context['current_topic'] . '.' . $context['start'] . ';m=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'] + ); + } + + return $output; + } +} \ No newline at end of file diff --git a/sources/subs/MessagesCallback/PmRenderer.php b/sources/subs/MessagesCallback/PmRenderer.php new file mode 100644 index 0000000000..127d13b7a0 --- /dev/null +++ b/sources/subs/MessagesCallback/PmRenderer.php @@ -0,0 +1,146 @@ +_idx_mapper = new ValuesContainer([ + 'id_msg' => 'id_pm', + 'id_member' => 'id_member_from', + 'name' => 'from_name', + 'time' => 'msgtime', + ]); + + $this->_temp_pm_selected = isset($_SESSION['pm_selected']) ? $_SESSION['pm_selected'] : array(); + $_SESSION['pm_selected'] = array(); + } + + /** + * {@inheritdoc } + */ + protected function _setupPermissions() + { + } + + /** + * {@inheritdoc } + */ + protected function _adjustGuestContext() + { + global $memberContext, $context; + + parent::_adjustGuestContext(); + + // Sometimes the forum sends messages itself (Warnings are an example) + // in this case don't label it from a guest. + if ($this->_this_message['from_name'] === $context['forum_name']) + { + $memberContext[$this->_this_message['id_member_from']]['group'] = ''; + } + $memberContext[$this->_this_message['id_member_from']]['email'] = ''; + } + + /** + * {@inheritdoc } + */ + protected function _adjustAllMembers() + { + global $memberContext, $context, $settings; + + $memberContext[$this->_this_message['id_member_from']]['show_profile_buttons'] = $settings['show_profile_buttons'] && (!empty($memberContext[$this->_this_message['id_member_from']]['can_view_profile']) || (!empty($memberContext[$this->_this_message['id_member_from']]['website']['url']) && !isset($context['disabled_fields']['website'])) || (in_array($memberContext[$this->_this_message['id_member_from']]['show_email'], array('yes', 'yes_permission_override', 'no_through_forum'))) || $context['can_send_pm']); + } + + /** + * {@inheritdoc } + */ + protected function _buildOutputArray() + { + global $recipients, $context, $user_info, $modSettings, $scripturl, $txt; + + $id_pm = $this->_this_message['id_pm']; + + $output = parent::_buildOutputArray(); + $output += array( + 'recipients' => &$recipients[$id_pm], + 'number_recipients' => count($recipients[$id_pm]['to']), + 'labels' => &$context['message_labels'][$id_pm], + 'fully_labeled' => count($context['message_labels'][$id_pm]) == count($context['labels']), + 'is_replied_to' => &$context['message_replied'][$id_pm], + 'is_unread' => &$context['message_unread'][$id_pm], + 'is_selected' => !empty($this->_temp_pm_selected) && in_array($id_pm, $this->_temp_pm_selected), + 'is_message_author' => $this->_this_message['id_member_from'] == $user_info['id'], + 'can_report' => !empty($modSettings['enableReportPM']), + ); + + $context['additional_pm_drop_buttons'] = array(); + + // Can they report this message + if (!empty($output['can_report']) && $context['folder'] !== 'sent' && $output['member']['id'] != $user_info['id']) + { + $context['additional_pm_drop_buttons']['warn_button'] = array( + 'href' => $scripturl . '?action=pm;sa=report;l=' . $context['current_label_id'] . ';pmsg=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], + 'text' => $txt['pm_report_to_admin'] + ); + } + + // Or mark it as unread + if (empty($output['is_unread']) && $context['folder'] !== 'sent' && $output['member']['id'] != $user_info['id']) + { + $context['additional_pm_drop_buttons']['restore_button'] = array( + 'href' => $scripturl . '?action=pm;sa=markunread;l=' . $context['current_label_id'] . ';pmsg=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], + 'text' => $txt['pm_mark_unread'] + ); + } + + // Or give / take karma for a PM + if (!empty($output['member']['karma']['allow'])) + { + $output['member']['karma'] += array( + 'applaud_url' => $scripturl . '?action=karma;sa=applaud;uid=' . $output['member']['id'] . ';f=' . $context['folder'] . ';start=' . $context['start'] . ($context['current_label_id'] != -1 ? ';l=' . $context['current_label_id'] : '') . ';pm=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], + 'smite_url' => $scripturl . '?action=karma;sa=smite;uid=' . $output['member']['id'] . ';f=' . $context['folder'] . ';start=' . $context['start'] . ($context['current_label_id'] != -1 ? ';l=' . $context['current_label_id'] : '') . ';pm=' . $output['id'] . ';' . $context['session_var'] . '=' . $context['session_id'], + ); + } + + return $output; + } +} \ No newline at end of file diff --git a/sources/subs/MessagesCallback/Renderer.php b/sources/subs/MessagesCallback/Renderer.php new file mode 100644 index 0000000000..b2ebb34c7e --- /dev/null +++ b/sources/subs/MessagesCallback/Renderer.php @@ -0,0 +1,332 @@ +_dbRequest = $request; + $this->_bodyParser = $bodyParser; + $this->_db = database(); + $this->_idx_mapper = new ValuesContainer([ + 'id_msg' => 'id_msg', + 'id_member' => 'id_member', + 'name' => 'poster_name', + 'time' => 'poster_time', + ]); + + // opt: + // icon_sources + // show_signatures + if ($opt === null) + { + $this->_options = new ValuesContainer(); + } + else + { + $this->_options = $opt; + } + } + + /** + * The main part: reads the DB resource and returns the complex array + * for a certain message. + * + * @param bool $reset + * + * @return bool|mixed[] + */ + public function getContext($reset = false) + { + global $settings, $txt, $modSettings, $scripturl, $user_info; + global $memberContext, $context, $topic; + static $counter = null; + + // If the query returned false, bail. + if ($this->_dbRequest === false) + { + return false; + } + + // Remember which message this is. (ie. reply #83) + if ($this->_counter === null || $reset === true) + { + $this->_counter = $context['start']; + } + + // Start from the beginning... + if ($reset === true) + { + $this->_currentContext($reset); + } + // Attempt to get the next message. + else + { + $this->_currentContext(); + } + + if (empty($this->_this_message)) + { + return false; + } + + // If you're a lazy bum, you probably didn't give a subject... + $this->_this_message['subject'] = $this->_this_message['subject'] != '' ? $this->_this_message['subject'] : $txt['no_subject']; + + $this->_setupPermissions(); + + $id_member = $this->_this_message[$this->_idx_mapper->id_member]; + + // If it couldn't load, or the user was a guest.... someday may be done with a guest table. + if (!loadMemberContext($id_member, true)) + { + $this->_adjustGuestContext(); + } + else + { + $this->_adjustMemberContext(); + } + $this->_adjustAllMembers(); + + // Do the censor thang. + $this->_this_message['subject'] = censor($this->_this_message['subject']); + + // Run BBC interpreter on the message. + $this->_this_message['body'] = $this->_bodyParser->prepare($this->_this_message['body'], $this->_this_message['smileys_enabled']); + + call_integration_hook(static::BEFORE_PREPARE_HOOK, array(&$this->_this_message)); + + // Compose the memory eat- I mean message array. + $output = $this->_buildOutputArray(); + + call_integration_hook(static::CONTEXT_HOOK, array(&$output, &$this->_this_message, $this->_counter)); + + $output['classes'] = implode(' ', $output['classes']); + + $this->_counter++; + + return $output; + } + + /** + * This function receives a request handle and attempts to retrieve the next result. + * + * What it does: + * + * - It is used by the controller callbacks from the template, such as + * posts in topic display page, posts search results page, or personal messages. + * + * @param bool $reset + * + * @return boolean + */ + protected function _currentContext($reset = false) + { + // Start from the beginning... + if ($reset) + { + $this->_db->data_seek($this->_dbRequest, 0); + } + + // If the query has already returned false, get out of here + if (empty($this->_dbRequest)) + { + return false; + } + + // Attempt to get the next message. + $this->_this_message = $this->_db->fetch_assoc($this->_dbRequest); + if (!$this->_this_message) + { + $this->_db->free_result($this->_dbRequest); + + return false; + } + + return true; + } + + /** + * Utility function, it shall be implemented by the extending class. + * Run just before loadMemberContext is executed. + */ + abstract protected function _setupPermissions(); + + /** + * Utility function, it can be overridden to alter something just after the + * members' data have been loaded from the database. + * Run only if loadMemberContext succeeded. + */ + protected function _adjustGuestContext() + { + global $memberContext, $txt; + + $member_id = $this->_this_message[$this->_idx_mapper->id_member]; + + // Notice this information isn't used anywhere else.... + $memberContext[$member_id]['name'] = $this->_this_message[$this->_idx_mapper->name]; + $memberContext[$member_id]['id'] = 0; + $memberContext[$member_id]['group'] = $txt['guest_title']; + $memberContext[$member_id]['link'] = $this->_this_message[$this->_idx_mapper->name]; + $memberContext[$member_id]['email'] = $this->_this_message['poster_email'] ?? ''; + $memberContext[$member_id]['show_email'] = showEmailAddress(true, 0); + $memberContext[$member_id]['is_guest'] = true; + } + + /** + * Utility function, it can be overridden to alter something just after the + * members data have been set. + * Run only if loadMemberContext failed. + */ + protected function _adjustMemberContext() + { + global $memberContext, $txt, $user_info, $context, $modSettings; + + $member_id = $this->_this_message[$this->_idx_mapper->id_member]; + + $memberContext[$member_id]['can_view_profile'] = allowedTo('profile_view_any') || ($member_id == $user_info['id'] && allowedTo('profile_view_own')); + $memberContext[$member_id]['is_topic_starter'] = $member_id == $context['topic_starter_id']; + $memberContext[$member_id]['can_see_warning'] = !isset($context['disabled_fields']['warning_status']) && $memberContext[$member_id]['warning_status'] && (!empty($context['user']['can_mod']) || (!$user_info['is_guest'] && !empty($modSettings['warning_show']) && ($modSettings['warning_show'] > 1 || $member_id == $user_info['id']))); + + if ($this->_options->show_signatures === 1) + { + if (empty($this->_signature_shown[$member_id])) + { + $this->_signature_shown[$member_id] = true; + } + else + { + $memberContext[$member_id]['signature'] = ''; + } + } + elseif ($this->_options->show_signatures === 2) + { + $memberContext[$member_id]['signature'] = ''; + } + } + + /** + * Utility function, it can be overridden to alter something just after either + * the members or the guests data have been loaded from the database. + * Run both if loadMemberContext succeeded or failed. + */ + protected function _adjustAllMembers() + { + } + + /** + * The most important bit that differentiate the various implementations. + * It is supposed to prepare the $output array with all the information + * needed by the template to properly render the message. + * + * The method of the class extending this abstract may run + * parent::_buildOutputArray() + * as first statement in order to have a starting point and + * some commonly used content for the array. + * + * @return mixed[] + */ + protected function _buildOutputArray() + { + global $user_info, $memberContext; + + return array( + 'alternate' => $this->_counter % 2, + 'id' => $this->_this_message[$this->_idx_mapper->id_msg], + 'member' => &$memberContext[$this->_this_message[$this->_idx_mapper->id_member]], + 'subject' => $this->_this_message['subject'], + 'html_time' => htmlTime($this->_this_message[$this->_idx_mapper->time]), + 'time' => standardTime($this->_this_message[$this->_idx_mapper->time]), + 'timestamp' => forum_time(true, $this->_this_message[$this->_idx_mapper->time]), + 'counter' => $this->_counter, + 'body' => $this->_this_message['body'], + 'can_see_ip' => allowedTo('moderate_forum') || ($this->_this_message[$this->_idx_mapper->id_member] == $user_info['id'] && !empty($user_info['id'])), + 'classes' => array() + ); + + } +} \ No newline at end of file diff --git a/sources/subs/MessagesCallback/SearchRenderer.php b/sources/subs/MessagesCallback/SearchRenderer.php new file mode 100644 index 0000000000..59de594e2c --- /dev/null +++ b/sources/subs/MessagesCallback/SearchRenderer.php @@ -0,0 +1,234 @@ +_participants = $participants; + } + + /** + * {@inheritdoc } + */ + protected function _setupPermissions() + { + global $txt; + + $this->_this_message['first_subject'] = $this->_this_message['first_subject'] != '' ? $this->_this_message['first_subject'] : $txt['no_subject']; + $this->_this_message['last_subject'] = $this->_this_message['last_subject'] != '' ? $this->_this_message['last_subject'] : $txt['no_subject']; + } + + /** + * {@inheritdoc } + */ + protected function _adjustMemberContext() + { + } + + /** + * {@inheritdoc } + */ + protected function _adjustAllMembers() + { + global $memberContext; + + $memberContext[$this->_this_message['id_member']]['ip'] = $this->_this_message['poster_ip']; + + $this->_this_message['first_subject'] = censor($this->_this_message['first_subject']); + $this->_this_message['last_subject'] = censor($this->_this_message['last_subject']); + } + + /** + * {@inheritdoc } + */ + protected function _buildOutputArray() + { + global $modSettings, $context, $scripturl, $options, $user_info, $memberContext, $txt; + global $boards_can; + + // Make sure we don't end up with a practically empty message body. + $this->_this_message['body'] = preg_replace('~^(?: )+$~', '', $this->_this_message['body']); + + // Do we have quote tag enabled? + $quote_enabled = empty($modSettings['disabledBBC']) || !in_array('quote', explode(',', $modSettings['disabledBBC'])); + + $output_pre = \Topic_Util::prepareContext(array($this->_this_message))[$this->_this_message['id_topic']]; + + $output = array_merge($context['topics'][$this->_this_message['id_msg']], $output_pre); + + $output['posted_in'] = !empty($this->_participants[$this->_this_message['id_topic']]); + $output['tests'] = array( + 'can_reply' => in_array($this->_this_message['id_board'], $boards_can['post_reply_any']) || in_array(0, $boards_can['post_reply_any']), + 'can_quote' => (in_array($this->_this_message['id_board'], $boards_can['post_reply_any']) || in_array(0, $boards_can['post_reply_any'])) && $quote_enabled, + 'can_mark_notify' => in_array($this->_this_message['id_board'], $boards_can['mark_any_notify']) || in_array(0, $boards_can['mark_any_notify']) && !$context['user']['is_guest'], + ); + $output['board'] = array( + 'id' => $this->_this_message['id_board'], + 'name' => $this->_this_message['bname'], + 'href' => $scripturl . '?board=' . $this->_this_message['id_board'] . '.0', + 'link' => '' . $this->_this_message['bname'] . '' + ); + $output['category'] = array( + 'id' => $this->_this_message['id_cat'], + 'name' => $this->_this_message['cat_name'], + 'href' => $scripturl . $modSettings['default_forum_action'] . '#c' . $this->_this_message['id_cat'], + 'link' => '' . $this->_this_message['cat_name'] . '' + ); + + determineTopicClass($output); + + if ($output['posted_in']) + { + $output['class'] = 'my_' . $output['class']; + } + + $body_highlighted = $this->_this_message['body']; + $subject_highlighted = $this->_this_message['subject']; + + if (!empty($options['display_quick_mod'])) + { + $started = $output['first_post']['member']['id'] == $user_info['id']; + + $output['quick_mod'] = array( + 'lock' => in_array(0, $boards_can['lock_any']) || in_array($output['board']['id'], $boards_can['lock_any']) || ($started && (in_array(0, $boards_can['lock_own']) || in_array($output['board']['id'], $boards_can['lock_own']))), + 'sticky' => (in_array(0, $boards_can['make_sticky']) || in_array($output['board']['id'], $boards_can['make_sticky'])), + 'move' => in_array(0, $boards_can['move_any']) || in_array($output['board']['id'], $boards_can['move_any']) || ($started && (in_array(0, $boards_can['move_own']) || in_array($output['board']['id'], $boards_can['move_own']))), + 'remove' => in_array(0, $boards_can['remove_any']) || in_array($output['board']['id'], $boards_can['remove_any']) || ($started && (in_array(0, $boards_can['remove_own']) || in_array($output['board']['id'], $boards_can['remove_own']))), + ); + + $context['can_lock'] |= $output['quick_mod']['lock']; + $context['can_sticky'] |= $output['quick_mod']['sticky']; + $context['can_move'] |= $output['quick_mod']['move']; + $context['can_remove'] |= $output['quick_mod']['remove']; + $context['can_merge'] |= in_array($output['board']['id'], $boards_can['merge_any']); + $context['can_markread'] = $context['user']['is_logged']; + + $context['qmod_actions'] = array('remove', 'lock', 'sticky', 'move', 'markread'); + call_integration_hook('integrate_quick_mod_actions_search'); + } + + foreach ($this->_bodyParser->getSearchArray() as $query) + { + + // Fix the international characters in the keyword too. + $query = un_htmlspecialchars($query); + $query = trim($query, '\*+'); + $query = strtr(\Util::htmlspecialchars($query), array('\\\'' => '\'')); + + $body_highlighted = preg_replace_callback('/((<[^>]*)|' . preg_quote(strtr($query, array('\'' => ''')), '/') . ')/iu', array($this, '_highlighted_callback'), $body_highlighted); + $subject_highlighted = preg_replace('/(' . preg_quote($query, '/') . ')/iu', '$1', $subject_highlighted); + } + + $output['matches'][] = array( + 'id' => $this->_this_message['id_msg'], + 'attachment' => loadAttachmentContext($this->_this_message['id_msg']), + 'alternate' => $this->_counter % 2, + 'member' => &$memberContext[$this->_this_message['id_member']], + 'icon' => $this->_this_message['icon'], + 'icon_url' => $this->_options->icon_sources->{$this->_this_message['icon']}, + 'subject' => $this->_this_message['subject'], + 'subject_highlighted' => $subject_highlighted, + 'time' => standardTime($this->_this_message['poster_time']), + 'html_time' => htmlTime($this->_this_message['poster_time']), + 'timestamp' => forum_time(true, $this->_this_message['poster_time']), + 'counter' => $this->_counter, + 'modified' => array( + 'time' => standardTime($this->_this_message['modified_time']), + 'html_time' => htmlTime($this->_this_message['modified_time']), + 'timestamp' => forum_time(true, $this->_this_message['modified_time']), + 'name' => $this->_this_message['modified_name'] + ), + 'body' => $this->_this_message['body'], + 'body_highlighted' => $body_highlighted, + 'start' => 'msg' . $this->_this_message['id_msg'] + ); + + if (!$context['compact']) + { + $output['buttons'] = array( + // Can we request notification of topics? + 'notify' => array( + 'href' => $scripturl . '?action=notify;topic=' . $output['id'] . '.msg' . $this->_this_message['id_msg'], + 'text' => $txt['notify'], + 'test' => 'can_mark_notify', + ), + // If they *can* reply? + 'reply' => array( + 'href' => $scripturl . '?action=post;topic=' . $output['id'] . '.msg' . $this->_this_message['id_msg'], + 'text' => $txt['reply'], + 'test' => 'can_reply', + ), + // If they *can* quote? + 'quote' => array( + 'href' => $scripturl . '?action=post;topic=' . $output['id'] . '.msg' . $this->_this_message['id_msg'] . ';quote=' . $this->_this_message['id_msg'], + 'text' => $txt['quote'], + 'test' => 'can_quote', + ), + ); + } + + return $output; + } + + /** + * Used to highlight body text with strings that match the search term + * + * Callback function used in $body_highlighted + * + * @param string[] $matches + * + * @return string + */ + private function _highlighted_callback($matches) + { + return isset($matches[2]) && $matches[2] == $matches[1] ? stripslashes($matches[1]) : '' . $matches[1] . ''; + } +} \ No newline at end of file diff --git a/sources/subs/PersonalMessage.subs.php b/sources/subs/PersonalMessage.subs.php index 7ede0fb9b3..f2793144c1 100644 --- a/sources/subs/PersonalMessage.subs.php +++ b/sources/subs/PersonalMessage.subs.php @@ -83,7 +83,8 @@ function loadPMLabels($labels) 'not_deleted' => 0, ) ); - $labels = array(); + +// $labels = array(); while ($row = $db->fetch_assoc($result)) { $this_labels = explode(',', $row['labels']); @@ -1973,7 +1974,8 @@ function loadPMSubjectRequest($pms, $orderBy) $subjects_request = $db->query('', ' SELECT pm.id_pm, pm.subject, pm.id_member_from, pm.msgtime, COALESCE(mem.real_name, pm.from_name) AS from_name, - COALESCE(mem.id_member, 0) AS not_guest + COALESCE(mem.id_member, 0) AS not_guest, + {string:empty} as body, {int:smileys_enabled} as smileys_enabled FROM {db_prefix}personal_messages AS pm LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = pm.id_member_from) WHERE pm.id_pm IN ({array_int:pm_list}) @@ -1981,6 +1983,9 @@ function loadPMSubjectRequest($pms, $orderBy) LIMIT ' . count($pms), array( 'pm_list' => $pms, + 'empty' => '', + 'smileys_enabled' => 1, + 'from_time' => 0, ) ); @@ -2006,7 +2011,8 @@ function loadPMMessageRequest($display_pms, $sort_by_query, $sort_by, $descendin $messages_request = $db->query('', ' SELECT - pm.id_pm, pm.subject, pm.id_member_from, pm.body, pm.msgtime, pm.from_name + pm.id_pm, pm.subject, pm.id_member_from, pm.body, pm.msgtime, pm.from_name, + {int:smileys_enabled} as smileys_enabled FROM {db_prefix}personal_messages AS pm' . ($folder == 'sent' ? ' LEFT JOIN {db_prefix}pm_recipients AS pmr ON (pmr.id_pm = pm.id_pm)' : '') . ($sort_by == 'name' ? ' LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = {raw:id_member})' : '') . ' @@ -2017,6 +2023,7 @@ function loadPMMessageRequest($display_pms, $sort_by_query, $sort_by, $descendin array( 'display_pms' => $display_pms, 'id_member' => $folder == 'sent' ? 'pmr.id_member' : 'pm.id_member_from', + 'smileys_enabled' => 1, ) ); diff --git a/sources/subs/Post.subs.php b/sources/subs/Post.subs.php index 29802a6576..07f348ddba 100644 --- a/sources/subs/Post.subs.php +++ b/sources/subs/Post.subs.php @@ -380,10 +380,8 @@ function createPost(&$msgOptions, &$topicOptions, &$posterOptions) } // If there's a custom search index, it may need updating... - $search = new \ElkArte\Search\Search; - $searchAPI = $search->findSearchAPI(); - if (is_callable(array($searchAPI, 'postCreated'))) - $searchAPI->postCreated($msgOptions, $topicOptions, $posterOptions); + $searchAPI = new \ElkArte\Search\SearchApiWrapper(!empty($modSettings['search_index']) ? $modSettings['search_index'] : ''); + $searchAPI->postCreated($msgOptions, $topicOptions, $posterOptions); // Increase the post counter for the user that created the post. if (!empty($posterOptions['update_post_count']) && !empty($posterOptions['id']) && $msgOptions['approved']) @@ -536,10 +534,8 @@ function modifyPost(&$msgOptions, &$topicOptions, &$posterOptions) } // If there's a custom search index, it needs to be modified... - $search = new \ElkArte\Search\Search; - $searchAPI = $search->findSearchAPI(); - if (is_callable(array($searchAPI, 'postModified'))) - $searchAPI->postModified($msgOptions, $topicOptions, $posterOptions); + $searchAPI = new \ElkArte\Search\SearchApiWrapper(!empty($modSettings['search_index']) ? $modSettings['search_index'] : ''); + $searchAPI->postModified($msgOptions, $topicOptions, $posterOptions); if (isset($msgOptions['subject'])) { diff --git a/sources/subs/Search/API/Custom.php b/sources/subs/Search/API/Custom.php index 3cbcd8ed1b..5d94c97a73 100644 --- a/sources/subs/Search/API/Custom.php +++ b/sources/subs/Search/API/Custom.php @@ -23,7 +23,7 @@ * * @package Search */ -class Custom extends SearchAPI +class Custom extends Standard { /** *This is the last version of ElkArte that this was tested on, to protect against API changes. @@ -52,7 +52,7 @@ class Custom extends SearchAPI /** * Custom::__construct() */ - public function __construct() + public function __construct($config, $searchParams) { global $modSettings; @@ -66,6 +66,7 @@ public function __construct() if (empty($modSettings['search_custom_index_config'])) return; + parent::__construct($config, $searchParams); $this->indexSettings = \Util::unserialize($modSettings['search_custom_index_config']); $this->bannedWords = empty($modSettings['search_stopwords']) ? array() : explode(',', $modSettings['search_stopwords']); @@ -101,14 +102,9 @@ public function searchSort($a, $b) } /** - * Do we have to do some work with the words we are searching for to prepare them? - * - * @param string $word A word to index - * @param mixed[] $wordsSearch The Search words - * @param string[] $wordsExclude Words to exclude - * @param boolean $isExcluded + * {@inheritdoc } */ - public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded) + public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords) { global $modSettings; @@ -134,6 +130,11 @@ public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded } } + public function useWordIndex() + { + return true; + } + /** * Search for indexed words. * diff --git a/sources/subs/Search/API/Fulltext.php b/sources/subs/Search/API/Fulltext.php index 1b2a42e541..910e20938f 100644 --- a/sources/subs/Search/API/Fulltext.php +++ b/sources/subs/Search/API/Fulltext.php @@ -22,7 +22,7 @@ * * @package Search */ -class Fulltext extends SearchAPI +class Fulltext extends Standard { /** * This is the last version of ElkArte that this was tested on, to protect against API changes. @@ -63,7 +63,7 @@ class Fulltext extends SearchAPI /** * Fulltext::__construct() */ - public function __construct() + public function __construct($config, $searchParams) { global $modSettings; @@ -75,6 +75,7 @@ public function __construct() return; } + parent::__construct($config, $searchParams); $this->bannedWords = empty($modSettings['search_banned_words']) ? array() : explode(',', $modSettings['search_banned_words']); $this->min_word_length = $this->_getMinWordLength(); } @@ -133,16 +134,9 @@ public function searchSort($a, $b) } /** - * Fulltext::prepareIndexes() - * - * Do we have to do some work with the words we are searching for to prepare them? - * - * @param string $word A word to index - * @param mixed[] $wordsSearch The Search words - * @param string[] $wordsExclude Words to exclude - * @param boolean $isExcluded If the $wordsSearch are those to exclude + * {@inheritdoc } */ - public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded) + public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords) { global $modSettings; @@ -178,6 +172,11 @@ public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded } } + public function useWordIndex() + { + return true; + } + /** * Fulltext::indexedWordQuery() * diff --git a/sources/subs/Search/API/SearchAPI.php b/sources/subs/Search/API/SearchAPI.php index 3cb577e2b5..1d2c6fa646 100644 --- a/sources/subs/Search/API/SearchAPI.php +++ b/sources/subs/Search/API/SearchAPI.php @@ -44,6 +44,12 @@ abstract class SearchAPI */ protected $_excludedWords = array(); + /** + * + * @var int + */ + protected $_num_results = 0; + /** * What words are banned? * @var array @@ -57,26 +63,108 @@ abstract class SearchAPI protected $min_word_length = 3; /** - * What databases support the custom index? + * All the search configurations + * @var \ElkArte\ValuesContainer + */ + protected $config = null; + + /** + * + * @var null|\ElkArte\Search\SearchParams + */ + protected $_searchParams = null; + + /** + * The weights to associate to various areas for relevancy + * @var array + */ + protected $_weight_factors = array(); + + /** + * Weighing factor each area, ie frequency, age, sticky, etc + * @var array + */ + protected $_weight = array(); + + /** + * The sum of the _weight_factors, normally but not always 100 + * @var int + */ + protected $_weight_total = 0; + + /** + * If we are creating a tmp db table + * @var bool + */ + protected $_createTemporary = true; + + /** + * Builds the array of words for use in the db query + * @var array + */ + protected $_searchWords = array(); + + /** + * Phrases not to be found in the search results (-"some phrase") + * @var array + */ + protected $_excludedPhrases = array(); + + /** + * Database instance + * @var \Database|null + */ + protected $_db = null; + + /** + * Search db instance + * @var \DbSearch|null + */ + protected $_db_search = null; + + /** + * Words excluded from indexes + * @var array + */ + protected $_excludedIndexWords = array(); + + /** + * Words not be be found in the subject (-word) + * @var array + */ + protected $_excludedSubjectWords = array(); + + /** + * Holds the words and phrases to be searched on + * @var \ElkArte\Search\SearchArray + */ + protected $_searchArray = null; + + /** + * What databases do we support? (In general.) * @var array */ protected $supported_databases = array('MySQL', 'PostgreSQL'); /** - * Fulltext::__construct() + * __construct() */ - public function __construct() + public function __construct($config, $searchParams) { - global $modSettings; + $this->config = $config; + $this->_searchParams = $searchParams; - $this->bannedWords = empty($modSettings['search_banned_words']) ? array() : explode(',', $modSettings['search_banned_words']); + $this->bannedWords = $config->banned_words; $this->min_word_length = $this->_getMinWordLength(); + + $this->_db = database(); + $this->_db_search = db_search(); } /** - * Fulltext::_getMinWordLength() + * What is a sensible minimum word length? * - * What is the minimum word length full text supports? + * @return int */ protected function _getMinWordLength() { @@ -85,6 +173,8 @@ protected function _getMinWordLength() /** * If the settings don't exist we can't continue. + * + * @return bool */ public function isValid() { @@ -102,6 +192,105 @@ public function setExcludedWords($words) $this->_excludedWords = $words; } + /** + * Adds the excluded phrases list + * + * @param string[] $phrases An array of phrases to exclude + */ + public function setExcludedPhrases($phrases) + { + $this->_excludedPhrases = $phrases; + } + + /** + * Sets the SearchArray... heck if I know what it is. + * + * @param \ElkArte\Search\SearchArray $searchArray + */ + public function setSearchArray(\ElkArte\Search\SearchArray $searchArray) + { + $this->_searchArray = $searchArray; + } + + /** + * If we use a temporary table or not + * + * @param bool $use + */ + public function useTemporary($use = false) + { + $this->_createTemporary = $use; + } + + /** + * Adds the weight factors + * + * @param \ElkArte\Search\WeightFactors $weights + */ + public function setWeightFactors(\ElkArte\Search\WeightFactors $weights) + { + $this->_weight_factors = $weights->getFactors(); + + $this->_weight = $weights->getWeight(); + + $this->_weight_total = $weights->getTotal(); + } + + /** + * Number of results? + * + * @return int + */ + public function getNumResults() + { + return $this->_num_results; + } + + /** + * Callback function for usort used to sort the fulltext results. + * + * - In the standard search ordering is not needed, so only 0 is returned. + * + * @param string $a Word A + * @param string $b Word B + * + * @return int An integer indicating how the words should be sorted (-1, 0 1) + */ + public function searchSort($a, $b) + { + return 0; + } + + /** + * Prepares the indexes + * + * @param string $word + * @param string $wordsSearch + * @param string $wordsExclude + * @param string $isExcluded + * @param string $excludedSubjectWords + */ + public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords) + { + } + + /** + * If the current API can use a word index. + * + * @return bool + */ + abstract public function useWordIndex(); + + /** + * Search for indexed words. + * + * @param mixed[] $words An array of words + * @param mixed[] $search_data An array of search data + * + * @return resource + */ + abstract public function indexedWordQuery($words, $search_data); + /** * Escape words passed by the client * diff --git a/sources/subs/Search/API/Sphinx.php b/sources/subs/Search/API/Sphinx.php index 41e530bac2..5aca15f0fc 100644 --- a/sources/subs/Search/API/Sphinx.php +++ b/sources/subs/Search/API/Sphinx.php @@ -70,7 +70,7 @@ class Sphinx extends SearchAPI /** * Check we support this db, set banned words */ - public function __construct() + public function __construct($config, $searchParams) { // Is this database supported? if (!in_array(DB_TYPE, $this->supported_databases)) @@ -80,7 +80,7 @@ public function __construct() return; } - parent::__construct(); + parent::__construct($config, $searchParams); } /** @@ -112,14 +112,16 @@ public function searchSort($a, $b) } /** - * Do we have to do some work with the words we are searching for to prepare them? - * - * @param string Word(s) to index - * @param mixed[] $wordsSearch The Search words - * @param string[] $wordsExclude Words to exclude - * @param boolean $isExcluded + * {@inheritdoc } + */ + public function indexedWordQuery($words, $search_data) + { + } + + /** + * {@inheritdoc } */ - public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded) + public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords) { $subwords = text2words($word, null, false); @@ -132,21 +134,13 @@ public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded } /** - * This has it's own custom search. - * - * @param mixed[] $search_params - * @param mixed[] $search_words - * @param string[] $excluded_words - * @param int[] $participants - * @param string[] $search_results - * - * @return int|mixed + * {@inheritdoc } */ - public function searchQuery($search_params, $search_words, $excluded_words, &$participants, &$search_results) + public function searchQuery($search_words, $excluded_words, &$participants, &$search_results) { global $user_info, $context, $modSettings; - if (!$search_params['subject_only']) + if (!$this->_searchParams->subject_only) { return 0; } @@ -164,17 +158,17 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa $mySphinx->SetLimits(0, (int) $modSettings['sphinx_max_results'], (int) $modSettings['sphinx_max_results']); // Put together a sort string; besides the main column sort (relevance, id_topic, or num_replies), - $search_params['sort_dir'] = strtoupper($search_params['sort_dir']); - $sphinx_sort = $search_params['sort'] === 'id_msg' ? 'id_topic' : $search_params['sort']; + $this->_searchParams->sort_dir = strtoupper($this->_searchParams->sort_dir); + $sphinx_sort = $this->_searchParams->sort === 'id_msg' ? 'id_topic' : $this->_searchParams->sort; // Add secondary sorting based on relevance value (if not the main sort method) and age - $sphinx_sort .= ' ' . $search_params['sort_dir'] . ($search_params['sort'] === 'relevance' ? '' : ', relevance DESC') . ', poster_time DESC'; + $sphinx_sort .= ' ' . $this->_searchParams->sort_dir . ($this->_searchParams->sort === 'relevance' ? '' : ', relevance DESC') . ', poster_time DESC'; // Include the engines weight values in the group sort - $sphinx_sort = str_replace('relevance ', '@weight ' . $search_params['sort_dir'] . ', relevance ', $sphinx_sort); + $sphinx_sort = str_replace('relevance ', '@weight ' . $this->_searchParams->sort_dir . ', relevance ', $sphinx_sort); // Grouping by topic id makes it return only one result per topic, so don't set that for in-topic searches - if (empty($search_params['topic'])) + if (empty($this->_searchParams->topic)) { $mySphinx->SetGroupBy('id_topic', SPH_GROUPBY_ATTR, $sphinx_sort); } @@ -186,24 +180,24 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa $mySphinx->SetFieldWeights(array('subject' => !empty($modSettings['search_weight_subject']) ? $modSettings['search_weight_subject'] * 10 : 100, 'body' => 100)); // Set the limits based on the search parameters. - if (!empty($search_params['min_msg_id']) || !empty($search_params['max_msg_id'])) + if (!empty($this->_searchParams->min_msg_id) || !empty($this->_searchParams->max_msg_id)) { - $mySphinx->SetIDRange($search_params['min_msg_id'], empty($search_params['max_msg_id']) ? (int) $modSettings['maxMsgID'] : $search_params['max_msg_id']); + $mySphinx->SetIDRange($this->_searchParams->min_msg_id, empty($this->_searchParams->max_msg_id) ? (int) $modSettings['maxMsgID'] : $this->_searchParams->max_msg_id); } - if (!empty($search_params['topic'])) + if (!empty($this->_searchParams->topic)) { - $mySphinx->SetFilter('id_topic', array((int) $search_params['topic'])); + $mySphinx->SetFilter('id_topic', array((int) $this->_searchParams->topic)); } - if (!empty($search_params['brd'])) + if (!empty($this->_searchParams->brd)) { - $mySphinx->SetFilter('id_board', $search_params['brd']); + $mySphinx->SetFilter('id_board', $this->_searchParams->brd); } - if (!empty($search_params['memberlist'])) + if (!empty($this->_searchParams->_memberlist)) { - $mySphinx->SetFilter('id_member', $search_params['memberlist']); + $mySphinx->SetFilter('id_member', $this->_searchParams->_memberlist); } // Construct the (binary mode & |) query while accounting for excluded words @@ -231,7 +225,7 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa $query = count($orResults) === 1 ? $orResults[0] : '(' . implode(') | (', $orResults) . ')'; // Subject only searches need to be specified. - if ($search_params['subject_only']) + if ($this->_searchParams->subject_only) { $query = '@(subject) ' . $query; } @@ -240,7 +234,7 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa $mode = SPH_MATCH_ALL; // Over two words and searching for any (since we build a binary string, this will never get set) - if (substr_count($query, ' ') > 1 && (!empty($search_params['searchtype']) && $search_params['searchtype'] == 2)) + if (substr_count($query, ' ') > 1 && (!empty($this->_searchParams->searchtype) && $this->_searchParams->searchtype == 2)) { $mode = SPH_MATCH_ANY; } @@ -282,7 +276,7 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa $cached_results['matches'][$msgID] = array( 'id' => $match['attrs']['id_topic'], 'relevance' => round($match['attrs']['@count'] + $match['attrs']['relevance'] / 5000, 1) . '%', - 'num_matches' => empty($search_params['topic']) ? $match['attrs']['@count'] : 0, + 'num_matches' => empty($this->_searchParams->topic) ? $match['attrs']['@count'] : 0, 'matches' => array(), ); } @@ -293,9 +287,10 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa } $participants = array(); + $topics = array(); foreach (array_slice(array_keys($cached_results['matches']), (int) $_REQUEST['start'], $modSettings['search_results_per_page']) as $msgID) { - $context['topics'][$msgID] = $cached_results['matches'][$msgID]; + $topics[$msgID] = $cached_results['matches'][$msgID]; $participants[$cached_results['matches'][$msgID]['id']] = false; } @@ -305,8 +300,14 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa { $search_results = array_merge($search_results, $search_words[$orIndex]['subject_words']); } + $this->_num_results = $cached_results['num_results']; - return $cached_results['num_results']; + return $topics; + } + + public function useWordIndex() + { + return false; } /** diff --git a/sources/subs/Search/API/Sphinxql.php b/sources/subs/Search/API/Sphinxql.php index ca97df7073..f23cce8c76 100644 --- a/sources/subs/Search/API/Sphinxql.php +++ b/sources/subs/Search/API/Sphinxql.php @@ -70,7 +70,7 @@ class Sphinxql extends SearchAPI /** * Nothing to do ... */ - public function __construct() + public function __construct($config, $searchParams) { // Is this database supported? if (!in_array(DB_TYPE, $this->supported_databases)) @@ -80,7 +80,7 @@ public function __construct() return; } - parent::__construct(); + parent::__construct($config, $searchParams); } /** @@ -112,14 +112,16 @@ public function searchSort($a, $b) } /** - * Do we have to do some work with the words we are searching for to prepare them? - * - * @param string $word word(s) to index - * @param mixed[] $wordsSearch The Search words - * @param string[] $wordsExclude Words to exclude - * @param boolean $isExcluded + * {@inheritdoc } + */ + public function indexedWordQuery($words, $search_data) + { + } + + /** + * {@inheritdoc } */ - public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded) + public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords) { $subwords = text2words($word, null, false); @@ -132,17 +134,9 @@ public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded } /** - * This has it's own custom search. - * - * @param mixed[] $search_params - * @param mixed[] $search_words Words to search - * @param string[] $excluded_words Words to exclude, not used in this API - * @param int[] $participants - * @param string[] $search_results - * - * @return int + * {@inheritdoc } */ - public function searchQuery($search_params, $search_words, $excluded_words, &$participants, &$search_results) + public function searchQuery($search_words, $excluded_words, &$participants, &$search_results) { global $user_info, $context, $modSettings; @@ -161,10 +155,10 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa // Compile different options for our query $index = (!empty($modSettings['sphinx_index_prefix']) ? $modSettings['sphinx_index_prefix'] : 'elkarte') . '_index'; - $query = 'SELECT *' . (empty($search_params['topic']) ? ', COUNT(*) num' : '') . ', WEIGHT() weights, (weights + (relevance/10)) rank FROM ' . $index; + $query = 'SELECT *' . (empty($this->_searchParams->topic) ? ', COUNT(*) num' : '') . ', WEIGHT() weights, (weights + (relevance/10)) rank FROM ' . $index; // Construct the (binary mode & |) query. - $where_match = $this->_constructQuery($search_params['search']); + $where_match = $this->_constructQuery($this->_searchParams->search); // Nothing to search, return zero results if (trim($where_match) === '') @@ -172,7 +166,7 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa return 0; } - if ($search_params['subject_only']) + if ($this->_searchParams->subject_only) { $where_match = '@subject ' . $where_match; } @@ -181,21 +175,21 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa // Set the limits based on the search parameters. $extra_where = array(); - if (!empty($search_params['min_msg_id']) || !empty($search_params['max_msg_id'])) + if (!empty($this->_searchParams->_minMsgID) || !empty($this->_searchParams->_maxMsgID)) { - $extra_where[] = 'id >= ' . $search_params['min_msg_id'] . ' AND id <= ' . (empty($search_params['max_msg_id']) ? (int) $modSettings['maxMsgID'] : $search_params['max_msg_id']); + $extra_where[] = 'id >= ' . $this->_searchParams->_minMsgID . ' AND id <= ' . (empty($this->_searchParams->_maxMsgID) ? (int) $modSettings['maxMsgID'] : $this->_searchParams->_maxMsgID); } - if (!empty($search_params['topic'])) + if (!empty($this->_searchParams->topic)) { - $extra_where[] = 'id_topic = ' . (int) $search_params['topic']; + $extra_where[] = 'id_topic = ' . (int) $this->_searchParams->topic; } - if (!empty($search_params['brd'])) + if (!empty($this->_searchParams->brd)) { - $extra_where[] = 'id_board IN (' . implode(',', $search_params['brd']) . ')'; + $extra_where[] = 'id_board IN (' . implode(',', $this->_searchParams->brd) . ')'; } - if (!empty($search_params['memberlist'])) + if (!empty($this->_searchParams->_memberlist)) { - $extra_where[] = 'id_member IN (' . implode(',', $search_params['memberlist']) . ')'; + $extra_where[] = 'id_member IN (' . implode(',', $this->_searchParams->_memberlist) . ')'; } if (!empty($extra_where)) { @@ -203,17 +197,17 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa } // Put together a sort string; besides the main column sort (relevance, id_topic, or num_replies) - $search_params['sort_dir'] = strtoupper($search_params['sort_dir']); - $sphinx_sort = $search_params['sort'] === 'id_msg' ? 'id_topic' : $search_params['sort']; + $this->_searchParams->sort_dir = strtoupper($this->_searchParams->sort_dir); + $sphinx_sort = $this->_searchParams->sort === 'id_msg' ? 'id_topic' : $this->_searchParams->sort; // Add secondary sorting based on relevance value (if not the main sort method) and age - $sphinx_sort .= ' ' . $search_params['sort_dir'] . ($search_params['sort'] === 'relevance' ? '' : ', relevance DESC') . ', poster_time DESC'; + $sphinx_sort .= ' ' . $this->_searchParams->sort_dir . ($this->_searchParams->sort === 'relevance' ? '' : ', relevance DESC') . ', poster_time DESC'; // Replace relevance with the returned rank value, rank uses sphinx weight + our own computed field weight relevance $sphinx_sort = str_replace('relevance ', 'rank ', $sphinx_sort); // Grouping by topic id makes it return only one result per topic, so don't set that for in-topic searches - if (empty($search_params['topic'])) + if (empty($this->_searchParams->topic)) { // In the topic, base weights is the best ORDER BY param as relevance/rank is topic level $query .= ' GROUP BY id_topic WITHIN GROUP ORDER BY ' . str_replace('rank ', 'weights ', $sphinx_sort); @@ -253,7 +247,7 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa { while ($match = mysqli_fetch_assoc($request)) { - if (empty($search_params['topic'])) + if (empty($this->_searchParams->topic)) { $num = isset($match['num']) ? $match['num'] : (isset($match['@count']) ? $match['@count'] : 0); } @@ -282,9 +276,10 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa } $participants = array(); + $topics = array(); foreach (array_slice(array_keys($cached_results['matches']), (int) $_REQUEST['start'], $modSettings['search_results_per_page']) as $msgID) { - $context['topics'][$msgID] = $cached_results['matches'][$msgID]; + $topics[$msgID] = $cached_results['matches'][$msgID]; $participants[$cached_results['matches'][$msgID]['id']] = false; } @@ -294,8 +289,14 @@ public function searchQuery($search_params, $search_words, $excluded_words, &$pa { $search_results = array_merge($search_results, $search_words[$orIndex]['subject_words']); } + $this->_num_results = $cached_results['num_results']; - return $cached_results['num_results']; + return $topics; + } + + public function useWordIndex() + { + return false; } /** diff --git a/sources/subs/Search/API/Standard.php b/sources/subs/Search/API/Standard.php index 12d3032e09..ff84f99cf6 100644 --- a/sources/subs/Search/API/Standard.php +++ b/sources/subs/Search/API/Standard.php @@ -41,4 +41,901 @@ class Standard extends SearchAPI * @var boolean */ public $is_supported = true; + + /** + * + * @var object + */ + protected $_search_cache = null; + + /** + * + * @var int + */ + protected $_num_results = 0; + + /** + * Wrapper for searchQuery of the SearchAPI + * @param string[] $search_words + * @param string[] $excluded_words + * @param bool[] $participants + * @param string[] $search_results + * + * @return mixed[] + */ + public function searchQuery($search_words, $excluded_words, &$participants, &$search_results) + { + global $context, $modSettings; + + $this->_search_cache = new \ElkArte\Search\Cache\Session(); + $this->_searchWords = $search_words; + $search_id = 0; + + if ($this->_search_cache->existsWithParams($context['params']) === false) + { + $search_id = $this->_search_cache->increaseId($modSettings['search_pointer'] ?? 0); + // Store the new id right off. + updateSettings([ + 'search_pointer' => $search_id + ]); + + // Clear the previous cache of the final results cache. + $this->clearCacheResults($search_id); + + if ($this->_searchParams['subject_only']) + { + $num_res = $this->getSubjectResults( + $search_id, + $search_words, $excluded_words + ); + } + else + { + $num_res = $this->getResults( + $search_id + ); + if (empty($num_res)) + { + throw new \Exception('query_not_specific_enough'); + } + } + + $this->_search_cache->setNumResults($num_res); + } + + $topics = array(); + // *** Retrieve the results to be shown on the page + $participants = $this->addRelevance( + $topics, + $search_id, + (int) $_REQUEST['start'], + $modSettings['search_results_per_page'] + ); + $this->_num_results = $this->_search_cache->getNumResults(); + + return $topics; + } + + /** + * Grabs results when the search is performed only within the subject + * + * @param int $id_search - the id of the search + * + * @return int - number of results otherwise + */ + protected function getSubjectResults($id_search, $search_words, $excluded_words) + { + global $modSettings; + + $numSubjectResults = 0; + // We do this to try and avoid duplicate keys on databases not supporting INSERT IGNORE. + foreach ($search_words as $words) + { + $subject_query_params = array(); + $subject_query = array( + 'from' => '{db_prefix}topics AS t', + 'inner_join' => array(), + 'left_join' => array(), + 'where' => array(), + ); + + if ($modSettings['postmod_active']) + { + $subject_query['where'][] = 't.approved = {int:is_approved}'; + } + + $numTables = 0; + $prev_join = 0; + $numSubjectResults = 0; + foreach ($words['subject_words'] as $subjectWord) + { + $numTables++; + if (in_array($subjectWord, $excluded_words)) + { + $subject_query['left_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.word ' . (empty($modSettings['search_match_words']) ? 'LIKE {string:subject_words_' . $numTables . '_wild}' : '= {string:subject_words_' . $numTables . '}') . ' AND subj' . $numTables . '.id_topic = t.id_topic)'; + $subject_query['where'][] = '(subj' . $numTables . '.word IS NULL)'; + } + else + { + $subject_query['inner_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.id_topic = ' . ($prev_join === 0 ? 't' : 'subj' . $prev_join) . '.id_topic)'; + $subject_query['where'][] = 'subj' . $numTables . '.word ' . (empty($modSettings['search_match_words']) ? 'LIKE {string:subject_words_' . $numTables . '_wild}' : '= {string:subject_words_' . $numTables . '}'); + $prev_join = $numTables; + } + + $subject_query_params['subject_words_' . $numTables] = $subjectWord; + $subject_query_params['subject_words_' . $numTables . '_wild'] = '%' . $subjectWord . '%'; + } + + if (!empty($this->_searchParams->_userQuery)) + { + $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_topic = t.id_topic)'; + $subject_query['where'][] = $this->_searchParams->_userQuery; + } + + if (!empty($this->_searchParams['topic'])) + { + $subject_query['where'][] = 't.id_topic = ' . $this->_searchParams['topic']; + } + + if (!empty($this->_searchParams->_minMsgID)) + { + $subject_query['where'][] = 't.id_first_msg >= ' . $this->_searchParams->_minMsgID; + } + + if (!empty($this->_searchParams->_maxMsgID)) + { + $subject_query['where'][] = 't.id_last_msg <= ' . $this->_searchParams->_maxMsgID; + } + + if (!empty($this->_searchParams->_boardQuery)) + { + $subject_query['where'][] = 't.id_board ' . $this->_searchParams->_boardQuery; + } + + if (!empty($this->_excludedPhrases)) + { + $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; + + $count = 0; + foreach ($this->_excludedPhrases as $phrase) + { + $subject_query['where'][] = 'm.subject NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? 'LIKE' : 'RLIKE') . ' {string:excluded_phrases_' . $count . '}'; + $subject_query_params['excluded_phrases_' . ($count++)] = $this->prepareWord($phrase, $this->noRegexp()); + } + } + + // Build the search query + $subject_query['select'] = array( + 'id_search' => '{int:id_search}', + 'id_topic' => 't.id_topic', + 'relevance' => $this->_build_relevance(), + 'id_msg' => empty($this->_searchParams->_userQuery) ? 't.id_first_msg' : 'm.id_msg', + 'num_matches' => 1, + ); + + $subject_query['parameters'] = array_merge($subject_query_params, array( + 'id_search' => $id_search, + 'min_msg' => $this->_searchParams->_minMsg, + 'recent_message' => $this->_searchParams->_recentMsg, + 'huge_topic_posts' => $this->config->humungousTopicPosts, + 'is_approved' => 1, + 'limit' => empty($modSettings['search_max_results']) ? 0 : $modSettings['search_max_results'] - $numSubjectResults, + )); + + call_integration_hook('integrate_subject_only_search_query', array(&$subject_query, &$subject_query_params)); + + $numSubjectResults += $this->_build_search_results_log($subject_query, 'insert_log_search_results_subject'); + + if (!empty($modSettings['search_max_results']) && $numSubjectResults >= $modSettings['search_max_results']) + { + break; + } + } + + return $numSubjectResults; + } + + /** + * If the query uses regexp or not + * + * @return bool + */ + protected function noRegexp() + { + return $this->_searchArray->getNoRegexp(); + } + + /** + * Grabs results when the search is performed in subjects and bodies + * + * @param int $id_search - the id of the search + * + * @return bool|int - boolean (false) in case of errors, number of results otherwise + */ + public function getResults($id_search) + { + global $modSettings; + + $num_results = 0; + + $main_query = array( + 'select' => array( + 'id_search' => $id_search, + 'relevance' => '0', + ), + 'weights' => array(), + 'from' => '{db_prefix}topics AS t', + 'inner_join' => array( + '{db_prefix}messages AS m ON (m.id_topic = t.id_topic)' + ), + 'left_join' => array(), + 'where' => array(), + 'group_by' => array(), + 'parameters' => array( + 'min_msg' => $this->_searchParams->_minMsg, + 'recent_message' => $this->_searchParams->_recentMsg, + 'huge_topic_posts' => $this->config->humungousTopicPosts, + 'is_approved' => 1, + 'limit' => $modSettings['search_max_results'], + ), + ); + + if (empty($this->_searchParams['topic']) && empty($this->_searchParams['show_complete'])) + { + $main_query['select']['id_topic'] = 't.id_topic'; + $main_query['select']['id_msg'] = 'MAX(m.id_msg) AS id_msg'; + $main_query['select']['num_matches'] = 'COUNT(*) AS num_matches'; + $main_query['weights'] = $this->_weight_factors; + $main_query['group_by'][] = 't.id_topic'; + } + else + { + // This is outrageous! + $main_query['select']['id_topic'] = 'm.id_msg AS id_topic'; + $main_query['select']['id_msg'] = 'm.id_msg'; + $main_query['select']['num_matches'] = '1 AS num_matches'; + + $main_query['weights'] = array( + 'age' => array( + 'search' => '((m.id_msg - t.id_first_msg) / CASE WHEN t.id_last_msg = t.id_first_msg THEN 1 ELSE t.id_last_msg - t.id_first_msg END)', + ), + 'first_message' => array( + 'search' => 'CASE WHEN m.id_msg = t.id_first_msg THEN 1 ELSE 0 END', + ), + ); + + if (!empty($this->_searchParams['topic'])) + { + $main_query['where'][] = 't.id_topic = {int:topic}'; + $main_query['parameters']['topic'] = $this->_searchParams->topic; + } + + if (!empty($this->_searchParams['show_complete'])) + { + $main_query['group_by'][] = 'm.id_msg, t.id_first_msg, t.id_last_msg'; + } + } + + // *** Get the subject results. + $numSubjectResults = $this->_log_search_subjects($id_search); + + if ($numSubjectResults !== 0) + { + $main_query['weights']['subject']['search'] = 'CASE WHEN MAX(lst.id_topic) IS NULL THEN 0 ELSE 1 END'; + $main_query['left_join'][] = '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics AS lst ON (' . ($this->_createTemporary ? '' : 'lst.id_search = {int:id_search} AND ') . 'lst.id_topic = t.id_topic)'; + if (!$this->_createTemporary) + { + $main_query['parameters']['id_search'] = $id_search; + } + } + + // We building an index? + if ($this->useWordIndex()) + { + $indexedResults = $this->_prepare_word_index($id_search); + + if (empty($indexedResults) && empty($numSubjectResults) && !empty($modSettings['search_force_index'])) + { + return false; + } + elseif (!empty($indexedResults)) + { + $main_query['inner_join'][] = '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_messages AS lsm ON (lsm.id_msg = m.id_msg)'; + + if (!$this->_createTemporary) + { + $main_query['where'][] = 'lsm.id_search = {int:id_search}'; + $main_query['parameters']['id_search'] = $id_search; + } + } + } + // Not using an index? All conditions have to be carried over. + else + { + $orWhere = array(); + $count = 0; + $excludedWords = $this->_searchArray->getExcludedWords(); + foreach ($this->_searchWords as $words) + { + $where = array(); + foreach ($words['all_words'] as $regularWord) + { + $where[] = 'm.body' . (in_array($regularWord, $excludedWords) ? ' NOT' : '') . (empty($modSettings['search_match_words']) || $this->noRegexp() ? ' LIKE ' : ' RLIKE ') . '{string:all_word_body_' . $count . '}'; + if (in_array($regularWord, $excludedWords)) + { + $where[] = 'm.subject NOT' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? ' LIKE ' : ' RLIKE ') . '{string:all_word_body_' . $count . '}'; + } + $main_query['parameters']['all_word_body_' . ($count++)] = $this->prepareWord($regularWord, $this->noRegexp()); + } + + if (!empty($where)) + { + $orWhere[] = count($where) > 1 ? '(' . implode(' AND ', $where) . ')' : $where[0]; + } + } + + if (!empty($orWhere)) + { + $main_query['where'][] = count($orWhere) > 1 ? '(' . implode(' OR ', $orWhere) . ')' : $orWhere[0]; + } + + if (!empty($this->_searchParams->_userQuery)) + { + $main_query['where'][] = '{raw:user_query}'; + $main_query['parameters']['user_query'] = $this->_searchParams->_userQuery; + } + + if (!empty($this->_searchParams['topic'])) + { + $main_query['where'][] = 'm.id_topic = {int:topic}'; + $main_query['parameters']['topic'] = $this->_searchParams->topic; + } + + if (!empty($this->_searchParams->_minMsgID)) + { + $main_query['where'][] = 'm.id_msg >= {int:min_msg_id}'; + $main_query['parameters']['min_msg_id'] = $this->_searchParams->_minMsgID; + } + + if (!empty($this->_searchParams->_maxMsgID)) + { + $main_query['where'][] = 'm.id_msg <= {int:max_msg_id}'; + $main_query['parameters']['max_msg_id'] = $this->_searchParams->_maxMsgID; + } + + if (!empty($this->_searchParams->_boardQuery)) + { + $main_query['where'][] = 'm.id_board {raw:board_query}'; + $main_query['parameters']['board_query'] = $this->_searchParams->_boardQuery; + } + } + call_integration_hook('integrate_main_search_query', array(&$main_query)); + + // Did we either get some indexed results, or otherwise did not do an indexed query? + if (!empty($indexedResults) || $this->useWordIndex() === false) + { + $main_query['select']['relevance'] = $this->_build_relevance($main_query['weights']); + $num_results += $this->_build_search_results_log($main_query, 'insert_log_search_results_no_index'); + } + + // Insert subject-only matches. + if ($num_results < $modSettings['search_max_results'] && $numSubjectResults !== 0) + { + $subject_query = array( + 'select' => array( + 'id_search' => '{int:id_search}', + 'id_topic' => 't.id_topic', + 'relevance' => $this->_build_relevance(), + 'id_msg' => 't.id_first_msg', + 'num_matches' => 1, + ), + 'from' => '{db_prefix}topics AS t', + 'inner_join' => array( + '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics AS lst ON (lst.id_topic = t.id_topic)' + ), + 'where' => array( + $this->_createTemporary ? '1=1' : 'lst.id_search = {int:id_search}', + ), + 'parameters' => array( + 'id_search' => $id_search, + 'min_msg' => $this->_searchParams->_minMsg, + 'recent_message' => $this->_searchParams->_recentMsg, + 'huge_topic_posts' => $this->config->humungousTopicPosts, + 'limit' => empty($modSettings['search_max_results']) ? 0 : $modSettings['search_max_results'] - $num_results, + ), + ); + + $num_results += $this->_build_search_results_log($subject_query, 'insert_log_search_results_sub_only', true); + } + elseif ($num_results == -1) + { + $num_results = 0; + } + + return $num_results; + } + + /** + * Build the search relevance query + * + * @param null|int[] $factors - is factors are specified that array will + * be used to build the relevance value, otherwise the function will use + * $this->_weight_factors + * + * @return string + */ + private function _build_relevance($factors = null) + { + $relevance = '1000 * ('; + + if ($factors !== null && is_array($factors)) + { + $weight_total = 0; + foreach ($factors as $type => $value) + { + $relevance .= $this->_weight[$type]; + if (!empty($value['search'])) + { + $relevance .= ' * ' . $value['search']; + } + + $relevance .= ' + '; + $weight_total += $this->_weight[$type]; + } + } + else + { + $weight_total = $this->_weight_total; + foreach ($this->_weight_factors as $type => $value) + { + if (isset($value['results'])) + { + $relevance .= $this->_weight[$type]; + if (!empty($value['results'])) + { + $relevance .= ' * ' . $value['results']; + } + + $relevance .= ' + '; + } + } + } + + $relevance = substr($relevance, 0, -3) . ') / ' . $weight_total . ' AS relevance'; + + return $relevance; + } + + /** + * Populates log_search_messages + * + * @param int $id_search - the id of the search to delete from logs + * + * @return int - the number of indexed results + */ + private function _prepare_word_index($id_search) + { + $indexedResults = 0; + $inserts = array(); + + // Clear, all clear! + if (!$this->_createTemporary) + { + $this->_db_search->search_query('delete_log_search_messages', ' + DELETE FROM {db_prefix}log_search_messages + WHERE id_search = {int:id_search}', + array( + 'id_search' => $id_search, + ) + ); + } + $excludedWords = $this->_searchArray->getExcludedWords(); + + foreach ($this->_searchWords as $words) + { + // Search for this word, assuming we have some words! + if (!empty($words['indexed_words'])) + { + // Variables required for the search. + $search_data = array( + 'insert_into' => ($this->_createTemporary ? 'tmp_' : '') . 'log_search_messages', + 'no_regexp' => $this->noRegexp(), + 'max_results' => $this->config->maxMessageResults, + 'indexed_results' => $indexedResults, + 'params' => array( + 'id_search' => !$this->_createTemporary ? $id_search : 0, + 'excluded_words' => $excludedWords, + 'user_query' => !empty($this->_searchParams->_userQuery) ? $this->_searchParams->_userQuery : '', + 'board_query' => !empty($this->_searchParams->_boardQuery) ? $this->_searchParams->_boardQuery : '', + 'topic' => (int) $this->_searchParams->topic, + 'min_msg_id' => (int) $this->_searchParams->_minMsgID, + 'max_msg_id' => (int) $this->_searchParams->_maxMsgID, + 'excluded_phrases' => $this->_excludedPhrases, + 'excluded_index_words' => $this->_excludedIndexWords, + 'excluded_subject_words' => $this->_excludedSubjectWords, + ), + ); + + $ignoreRequest = $this->indexedWordQuery($words, $search_data); + + if (!$this->_db->support_ignore()) + { + while ($row = $this->_db->fetch_row($ignoreRequest)) + { + // No duplicates! + if (isset($inserts[$row[0]])) + { + continue; + } + + $inserts[$row[0]] = $row; + } + $this->_db->free_result($ignoreRequest); + $indexedResults = count($inserts); + } + else + { + $indexedResults += $this->_db->affected_rows(); + } + + if (!empty($this->config->maxMessageResults) && $indexedResults >= $this->config->maxMessageResults) + { + break; + } + } + } + + // More non-MySQL stuff needed? + if (!empty($inserts)) + { + $this->_db->insert('', + '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_messages', + $this->_createTemporary ? array('id_msg' => 'int') : array('id_msg' => 'int', 'id_search' => 'int'), + $inserts, + $this->_createTemporary ? array('id_msg') : array('id_msg', 'id_search') + ); + } + + return $indexedResults; + } + + /** + * Inserts the data into log_search_results + * + * @param mixed[] $main_query - An array holding all the query parts. + * Structure: + * 'select' => string[] - the select columns + * 'from' => string - the table for the FROM clause + * 'inner_join' => string[] - any INNER JOIN + * 'left_join' => string[] - any LEFT JOIN + * 'where' => string[] - the conditions + * 'group_by' => string[] - the fields to group by + * 'parameters' => mixed[] - any parameter required by the query + * @param string $query_identifier - a string to identify the query + * @param bool $use_old_ids - if true the topic ids retrieved by a previous + * call to this function will be used to identify duplicates + * + * @return int - the number of rows affected by the query + */ + private function _build_search_results_log($main_query, $query_identifier, $use_old_ids = false) + { + static $usedIDs; + + $ignoreRequest = $this->_db_search->search_query($query_identifier, ($this->_db->support_ignore() ? (' + INSERT IGNORE INTO {db_prefix}log_search_results + (' . implode(', ', array_keys($main_query['select'])) . ')') : '') . ' + SELECT + ' . implode(', + ', $main_query['select']) . ' + FROM ' . $main_query['from'] . (!empty($main_query['inner_join']) ? ' + INNER JOIN ' . implode(' + INNER JOIN ', array_unique($main_query['inner_join'])) : '') . (!empty($main_query['left_join']) ? ' + LEFT JOIN ' . implode(' + LEFT JOIN ', array_unique($main_query['left_join'])) : '') . (!empty($main_query['where']) ? ' + WHERE ' : '') . implode(' + AND ', array_unique($main_query['where'])) . (!empty($main_query['group_by']) ? ' + GROUP BY ' . implode(', ', array_unique($main_query['group_by'])) : '') . (!empty($main_query['parameters']['limit']) ? ' + LIMIT {int:limit}' : ''), + $main_query['parameters'] + ); + + // If the database doesn't support IGNORE to make this fast we need to do some tracking. + if (!$this->_db->support_ignore()) + { + $inserts = array(); + + while ($row = $this->_db->fetch_assoc($ignoreRequest)) + { + // No duplicates! + if ($use_old_ids) + { + if (isset($usedIDs[$row['id_topic']])) + { + continue; + } + } + else + { + if (isset($inserts[$row['id_topic']])) + { + continue; + } + } + + $usedIDs[$row['id_topic']] = true; + foreach ($row as $key => $value) + $inserts[$row['id_topic']][] = (int) $row[$key]; + } + $this->_db->free_result($ignoreRequest); + + // Now put them in! + if (!empty($inserts)) + { + $query_columns = array(); + foreach ($main_query['select'] as $k => $v) + $query_columns[$k] = 'int'; + + $this->_db->insert('', + '{db_prefix}log_search_results', + $query_columns, + $inserts, + array('id_search', 'id_topic') + ); + } + $num_results = count($inserts); + } + else + { + $num_results = $this->_db->affected_rows(); + } + + return $num_results; + } + + /** + * Determines and add the relevance to the results + * + * @param mixed[] $topics - The search results (passed by reference) + * @param int $id_search - the id of the search + * @param int $start - Results are shown starting from here + * @param int $limit - No more results than this + * + * @return bool[] + */ + public function addRelevance(&$topics, $id_search, $start, $limit) + { + // *** Retrieve the results to be shown on the page + $participants = array(); + $request = $this->_db_search->search_query('', ' + SELECT ' . (empty($this->_searchParams['topic']) ? 'lsr.id_topic' : $this->_searchParams->topic . ' AS id_topic') . ', lsr.id_msg, lsr.relevance, lsr.num_matches + FROM {db_prefix}log_search_results AS lsr' . ($this->_searchParams->sort === 'num_replies' ? ' + INNER JOIN {db_prefix}topics AS t ON (t.id_topic = lsr.id_topic)' : '') . ' + WHERE lsr.id_search = {int:id_search} + ORDER BY {raw:sort} {raw:sort_dir} + LIMIT {int:start}, {int:limit}', + array( + 'id_search' => $id_search, + 'sort' => $this->_searchParams->sort, + 'sort_dir' => $this->_searchParams->sort_dir, + 'start' => $start, + 'limit' => $limit, + ) + ); + while ($row = $this->_db->fetch_assoc($request)) + { + $topics[$row['id_msg']] = array( + 'relevance' => round($row['relevance'] / 10, 1) . '%', + 'num_matches' => $row['num_matches'], + 'matches' => array(), + ); + // By default they didn't participate in the topic! + $participants[$row['id_topic']] = false; + } + $this->_db->free_result($request); + + return $participants; + } + + /** + * Delete logs of previous searches + * + * @param int $id_search - the id of the search to delete from logs + */ + public function clearCacheResults($id_search) + { + $this->_db_search->search_query('delete_log_search_results', ' + DELETE FROM {db_prefix}log_search_results + WHERE id_search = {int:search_id}', + array( + 'search_id' => $id_search, + ) + ); + } + + /** + * If searching in topics only (?), inserts results in log_search_topics + * + * @param int $id_search - the id of the search to delete from logs + * + * @return int - the number of search results + */ + private function _log_search_subjects($id_search) + { + global $modSettings; + + if (!empty($this->_searchParams['topic'])) + { + return 0; + } + + $inserts = array(); + $numSubjectResults = 0; + + // Clean up some previous cache. + if (!$this->_createTemporary) + { + $this->_db_search->search_query('delete_log_search_topics', ' + DELETE FROM {db_prefix}log_search_topics + WHERE id_search = {int:search_id}', + array( + 'search_id' => $id_search, + ) + ); + } + + foreach ($this->_searchWords as $words) + { + $subject_query = array( + 'from' => '{db_prefix}topics AS t', + 'inner_join' => array(), + 'left_join' => array(), + 'where' => array(), + 'params' => array(), + ); + + $numTables = 0; + $prev_join = 0; + $count = 0; + foreach ($words['subject_words'] as $subjectWord) + { + $numTables++; + if (in_array($subjectWord, $this->_excludedSubjectWords)) + { + $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; + $subject_query['left_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.word ' . (empty($modSettings['search_match_words']) ? 'LIKE {string:subject_not_' . $count . '}' : '= {string:subject_not_' . $count . '}') . ' AND subj' . $numTables . '.id_topic = t.id_topic)'; + $subject_query['params']['subject_not_' . $count] = empty($modSettings['search_match_words']) ? '%' . $subjectWord . '%' : $subjectWord; + + $subject_query['where'][] = '(subj' . $numTables . '.word IS NULL)'; + $subject_query['where'][] = 'm.body NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? ' LIKE ' : ' RLIKE ') . '{string:body_not_' . $count . '}'; + $subject_query['params']['body_not_' . ($count++)] = $this->prepareWord($subjectWord, $this->noRegexp()); + } + else + { + $subject_query['inner_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.id_topic = ' . ($prev_join === 0 ? 't' : 'subj' . $prev_join) . '.id_topic)'; + $subject_query['where'][] = 'subj' . $numTables . '.word LIKE {string:subject_like_' . $count . '}'; + $subject_query['params']['subject_like_' . ($count++)] = empty($modSettings['search_match_words']) ? '%' . $subjectWord . '%' : $subjectWord; + $prev_join = $numTables; + } + } + + if (!empty($this->_searchParams->_userQuery)) + { + $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; + $subject_query['where'][] = '{raw:user_query}'; + $subject_query['params']['user_query'] = $this->_searchParams->_userQuery; + } + + if (!empty($this->_searchParams['topic'])) + { + $subject_query['where'][] = 't.id_topic = {int:topic}'; + $subject_query['params']['topic'] = $this->_searchParams->topic; + } + + if (!empty($this->_searchParams->_minMsgID)) + { + $subject_query['where'][] = 't.id_first_msg >= {int:min_msg_id}'; + $subject_query['params']['min_msg_id'] = $this->_searchParams->_minMsgID; + } + + if (!empty($this->_searchParams->_maxMsgID)) + { + $subject_query['where'][] = 't.id_last_msg <= {int:max_msg_id}'; + $subject_query['params']['max_msg_id'] = $this->_searchParams->_maxMsgID; + } + + if (!empty($this->_searchParams->_boardQuery)) + { + $subject_query['where'][] = 't.id_board {raw:board_query}'; + $subject_query['params']['board_query'] = $this->_searchParams->_boardQuery; + } + + if (!empty($this->_excludedPhrases)) + { + $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; + $count = 0; + foreach ($this->_excludedPhrases as $phrase) + { + $subject_query['where'][] = 'm.subject NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? 'LIKE' : 'RLIKE') . ' {string:exclude_phrase_' . $count . '}'; + $subject_query['where'][] = 'm.body NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? 'LIKE' : 'RLIKE') . ' {string:exclude_phrase_' . $count . '}'; + $subject_query['params']['exclude_phrase_' . ($count++)] = $this->prepareWord($phrase, $this->noRegexp()); + } + } + + call_integration_hook('integrate_subject_search_query', array(&$subject_query)); + + // Nothing to search for? + if (empty($subject_query['where'])) + { + continue; + } + + $ignoreRequest = $this->_db_search->search_query('insert_log_search_topics', ($this->_db->support_ignore() ? (' + INSERT IGNORE INTO {db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics + (' . ($this->_createTemporary ? '' : 'id_search, ') . 'id_topic)') : '') . ' + SELECT ' . ($this->_createTemporary ? '' : $id_search . ', ') . 't.id_topic + FROM ' . $subject_query['from'] . (empty($subject_query['inner_join']) ? '' : ' + INNER JOIN ' . implode(' + INNER JOIN ', array_unique($subject_query['inner_join']))) . (empty($subject_query['left_join']) ? '' : ' + LEFT JOIN ' . implode(' + LEFT JOIN ', array_unique($subject_query['left_join']))) . ' + WHERE ' . implode(' + AND ', array_unique($subject_query['where'])) . (empty($modSettings['search_max_results']) ? '' : ' + LIMIT ' . ($modSettings['search_max_results'] - $numSubjectResults)), + $subject_query['params'] + ); + + // Don't do INSERT IGNORE? Manually fix this up! + if (!$this->_db->support_ignore()) + { + while ($row = $this->_db->fetch_row($ignoreRequest)) + { + $ind = $this->_createTemporary ? 0 : 1; + + // No duplicates! + if (isset($inserts[$row[$ind]])) + { + continue; + } + + $inserts[$row[$ind]] = $row; + } + $this->_db->free_result($ignoreRequest); + $numSubjectResults = count($inserts); + } + else + { + $numSubjectResults += $this->_db->affected_rows(); + } + + if (!empty($modSettings['search_max_results']) && $numSubjectResults >= $modSettings['search_max_results']) + { + break; + } + } + + // Got some non-MySQL data to plonk in? + if (!empty($inserts)) + { + $this->_db->insert('', + ('{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics'), + $this->_createTemporary ? array('id_topic' => 'int') : array('id_search' => 'int', 'id_topic' => 'int'), + $inserts, + $this->_createTemporary ? array('id_topic') : array('id_search', 'id_topic') + ); + } + + return $numSubjectResults; + } + + public function useWordIndex() + { + return false; + } + + /** + * {@inheritdoc } + */ + public function indexedWordQuery($words, $search_data) + { + } } diff --git a/sources/subs/Search/Cache/Session.php b/sources/subs/Search/Cache/Session.php new file mode 100644 index 0000000000..c64f35ff69 --- /dev/null +++ b/sources/subs/Search/Cache/Session.php @@ -0,0 +1,80 @@ +_session_index = 'search_' . $index; + } + } + + public function __destruct() + { + new SessionIndex($this->_session_index, array( + 'id_search' => $this->_id_search, + 'num_results' => $this->_num_results, + 'params' => $this->_params, + )); + } + + public function getId() + { + return $this->_id_search; + } + + public function increaseId($pointer = 0) + { + $this->_id_search = (int) $pointer; + $this->_id_search += 1; + + if ($this->_id_search > 255) + { + $this->_id_search = 0; + } + + return $this->getId(); + } + + public function existsWithParams($params) + { + return $this->_params == $params; + } + + public function setNumResults($num_results = 0) + { + $this->_num_results = (int) $num_results; + } + + /** + * Returns the number of results obtained from the query. + * + * @return int + */ + public function getNumResults() + { + return $this->_num_results; + } +} \ No newline at end of file diff --git a/sources/subs/Search/Search.php b/sources/subs/Search/Search.php index 522e68e51d..72be2c1b1c 100644 --- a/sources/subs/Search/Search.php +++ b/sources/subs/Search/Search.php @@ -17,36 +17,31 @@ namespace ElkArte\Search; +use \ElkArte\Search\SearchParams; + /** * Actually do the searches */ class Search { - /** - * This is the minimum version of ElkArte that an API could have been written - * for to work. - * (strtr to stop accidentally updating version on release) - */ - private $_search_version = ''; - /** * This is the forum version but is repeated due to some people * rewriting FORUM_VERSION. */ - private $_forum_version = ''; + const FORUM_VERSION = 'ElkArte 2.0 dev'; /** - * $_search_params will carry all settings that differ from the default search parameters. - * - * That way, the URLs involved in a search page will be kept as short as possible. + * This is the minimum version of ElkArte that an API could have been written + * for to work. + * (strtr to stop accidentally updating version on release) */ - private $_search_params = array(); + private $_search_version = ''; /** * Holds the words and phrases to be searched on - * @var array + * @var \ElkArte\Search\SearchArray */ - private $_searchArray = array(); + private $_searchArray = null; /** * Holds instance of the search api in use such as ElkArte\Search\API\Standard_Search @@ -66,48 +61,12 @@ class Search */ private $_db_search = null; - /** - * Holds words that will not be search on to inform the user they were skipped - * @var array - */ - private $_ignored = array(); - /** * Searching for posts from a specific user(s) * @var array */ private $_memberlist = array(); - /** - * Message "age" via ID, given bounds, needed to calculate relevance - * @var int - */ - private $_recentMsg = 0; - - /** - * The minimum message id we will search, needed to calculate relevance - * @var int - */ - private $_minMsgID = 0; - - /** - * Needed to calculate relevance - * @var int - */ - private $_minMsg = 0; - - /** - * The maximum message ID we will search, needed to calculate relevance - * @var int - */ - private $_maxMsgID = 0; - - /** - * If we are performing a boolean or simple search - * @var bool - */ - private $_no_regexp = false; - /** * Builds the array of words for use in the db query * @var array @@ -120,12 +79,6 @@ class Search */ private $_excludedIndexWords = array(); - /** - * Words not be be found in the search results (-word) - * @var array - */ - private $_excludedWords = array(); - /** * Words not be be found in the subject (-word) * @var array @@ -138,53 +91,29 @@ class Search */ private $_excludedPhrases = array(); - /** - * If search words were found on the blacklist - * @var bool - */ - private $_foundBlackListedWords = false; - - /** - * Words we do not search due to length or common terms - * @var array - */ - private $_blacklisted_words = array(); - - /** - * The db query for brd's - * @var string - */ - private $_boardQuery = ''; - - /** - * the db query for members - * @var string - */ - private $_userQuery = ''; - /** * The weights to associate to various areas for relevancy - * @var array + * @var \ElkArte\Search\WeightFactors */ - private $_weight_factors = array(); + private $_weightFactors = array(); /** - * Weighing factor each area, ie frequency, age, sticky, etc - * @var array + * If we are creating a tmp db table + * @var bool */ - private $_weight = array(); + private $_createTemporary = true; /** - * The sum of the _weight_factors, normally but not always 100 - * @var int + * + * @var mixed[] */ - private $_weight_total = 0; + protected $_participants = []; /** - * If we are creating a tmp db table - * @var bool + * + * @var null|\ElkArte\Search\SearchParams */ - private $_createTemporary = true; + protected $_searchParams = null; /** * Constructor @@ -195,7 +124,6 @@ class Search public function __construct() { $this->_search_version = strtr('ElkArte 1+1', array('+' => '.', '=' => ' ')); - $this->_forum_version = 'ElkArte 2.0 dev'; $this->_db = database(); $this->_db_search = db_search(); @@ -242,47 +170,6 @@ public function __construct() ); } - /** - * Creates a search API and returns the object. - * - * @param string $searchClass - * - * @return API\Standard|null|object - */ - public function findSearchAPI($searchClass = '') - { - global $modSettings, $txt; - - require_once(SUBSDIR . '/Package.subs.php'); - \Elk_Autoloader::instance()->register(SUBSDIR . '/Search', '\\ElkArte\\Search'); - - // Load up the search API we are going to use. - if (empty($searchClass)) - { - $searchClass = !empty($modSettings['search_index']) ? $modSettings['search_index'] : 'standard'; - } - - // Try to initialize the API - $fqcn = '\\ElkArte\\Search\\API\\' . ucfirst($searchClass); - if (class_exists($fqcn) && is_a($fqcn, 'ElkArte\\Search\\API\\SearchAPI', true)) - { - // Create an instance of the search API and check it is valid for this version of the software. - $this->_searchAPI = new $fqcn; - } - - // An invalid Search API? Log the error and set it to use the standard API - if (!$this->_searchAPI || (!$this->_searchAPI->isValid()) || !matchPackageVersion($this->_forum_version, $this->_searchAPI->min_elk_version . '-' . $this->_searchAPI->version_compatible)) - { - // Log the error. - theme()->getTemplates()->loadLanguageFile('Errors'); - \Errors::instance()->log_error(sprintf($txt['search_api_not_compatible'], $fqcn), 'critical'); - - $this->_searchAPI = new \ElkArte\Search\API\Standard; - } - - return $this->_searchAPI; - } - /** * Returns a search parameter. * @@ -292,9 +179,9 @@ public function findSearchAPI($searchClass = '') */ public function param($name) { - if (isset($this->_search_params[$name])) + if (isset($this->_searchParams[$name])) { - return $this->_search_params[$name]; + return $this->_searchParams[$name]; } else { @@ -309,10 +196,10 @@ public function param($name) */ public function getParams() { - return array_merge($this->_search_params, array( - 'min_msg_id' => (int) $this->_minMsgID, - 'max_msg_id' => (int) $this->_maxMsgID, - 'memberlist' => $this->_memberlist, + return array_merge($this->_searchParams, array( + 'min_msg_id' => (int) $this->_searchParams->_minMsgID, + 'max_msg_id' => (int) $this->_searchParams->_maxMsgID, + 'memberlist' => $this->_searchParams->_memberlist, )); } @@ -321,41 +208,30 @@ public function getParams() */ public function getIgnored() { - return $this->_ignored; - } - - /** - * Returns words excluded from indexes - */ - public function getExcludedIndexWords() - { - return $this->_excludedIndexWords; + return $this->_searchArray->getIgnored(); } /** * Set the weight factors * - * @param mixed[] $weight_factors - * @param mixed[] $weight - weight for each factor - * @param int $weight_total - som of all the weights + * @param \ElkArte\Search\WeightFactors $weight */ - public function setWeights($weight_factors, $weight, $weight_total) + public function setWeights(\ElkArte\Search\WeightFactors $weight) { - $this->_weight_factors = $weight_factors; - - $this->_weight = $weight; - - $this->_weight_total = $weight_total; + $this->_weightFactors = $weight; } - /** - * If the query uses regexp or not - * - * @return bool - */ - public function noRegexp() + public function setParams(SearchParams $paramObject, $search_simple_fulltext = false) { - return $this->_no_regexp; + $this->_searchParams = $paramObject; + + // Unfortunately, searching for words like this is going to be slow, so we're blacklisting them. + // @todo Setting to add more here? + // @todo Maybe only blacklist if they are the only word, or "any" is used? + $blacklisted_words = array('img', 'url', 'quote', 'www', 'http', 'the', 'is', 'it', 'are', 'if'); + call_integration_hook('integrate_search_blacklisted_words', array(&$blacklisted_words)); + + $this->_searchArray = new \ElkArte\Search\SearchArray($this->_searchParams->search, $blacklisted_words, $search_simple_fulltext); } /** @@ -365,132 +241,46 @@ public function noRegexp() */ public function foundBlackListedWords() { - return $this->_foundBlackListedWords; + return $this->_searchArray->foundBlackListedWords(); } - /** - * Builds the search array - * - * @param bool - Force splitting of strings enclosed in double quotes - * - * @return 0|array - */ - public function searchArray($search_simple_fulltext = false) + public function getSearchArray() { - // Change non-word characters into spaces. - $stripped_query = preg_replace('~(?:[\x0B\0\x{A0}\t\r\s\n(){}\\[\\]<>!@$%^*.,:+=`\~\?/\\\\]+|&(?:amp|lt|gt|quot);)+~u', ' ', $this->param('search')); - - // Make the query lower case. It's gonna be case insensitive anyway. - $stripped_query = un_htmlspecialchars(\Util::strtolower($stripped_query)); - - // This option will do fulltext searching in the most basic way. - if ($search_simple_fulltext) - { - $stripped_query = strtr($stripped_query, array('"' => '')); - } - - $this->_no_regexp = preg_match('~&#(?:\d{1,7}|x[0-9a-fA-F]{1,6});~', $stripped_query) === 1; - - // Extract phrase parts first (e.g. some words "this is a phrase" some more words.) - preg_match_all('/(?:^|\s)([-]?)"([^"]+)"(?:$|\s)/', $stripped_query, $matches, PREG_PATTERN_ORDER); - $phraseArray = $matches[2]; - - // Remove the phrase parts and extract the words. - $wordArray = preg_replace('~(?:^|\s)(?:[-]?)"(?:[^"]+)"(?:$|\s)~u', ' ', $this->param('search')); - $wordArray = explode(' ', \Util::htmlspecialchars(un_htmlspecialchars($wordArray), ENT_QUOTES)); - - // A minus sign in front of a word excludes the word.... so... - // .. first, we check for things like -"some words", but not "-some words". - $phraseArray = $this->_checkExcludePhrase($matches[1], $phraseArray); - - // Now we look for -test, etc.... normaller. - $wordArray = $this->_checkExcludeWord($wordArray); - - // The remaining words and phrases are all included. - $this->_searchArray = array_merge($phraseArray, $wordArray); - - // Trim everything and make sure there are no words that are the same. - foreach ($this->_searchArray as $index => $value) - { - // Skip anything practically empty. - if (($this->_searchArray[$index] = trim($value, '-_\' ')) === '') - { - unset($this->_searchArray[$index]); - } - // Skip blacklisted words. Make sure to note we skipped them in case we end up with nothing. - elseif (in_array($this->_searchArray[$index], $this->_blacklisted_words)) - { - $this->_foundBlackListedWords = true; - unset($this->_searchArray[$index]); - } - // Don't allow very, very short words. - elseif (\Util::strlen($value) < 2) - { - $this->_ignored[] = $value; - unset($this->_searchArray[$index]); - } - } - - $this->_searchArray = array_slice(array_unique($this->_searchArray), 0, 10); - - return $this->_searchArray; + return $this->_searchArray->getSearchArray(); } - /** - * Looks for phrases that should be excluded from results - * - * - Check for things like -"some words", but not "-some words" - * - Prevents redundancy with blacklisted words - * - * @param string[] $matches - * @param string[] $phraseArray - * - * @return string[] - */ - private function _checkExcludePhrase($matches, $phraseArray) + public function getExcludedWords() { - foreach ($matches as $index => $word) - { - if ($word === '-') - { - if (($word = trim($phraseArray[$index], '-_\' ')) !== '' && !in_array($word, $this->_blacklisted_words)) - { - $this->_excludedWords[] = $word; - } - - unset($phraseArray[$index]); - } - } + return $this->_searchArray->getExcludedWords(); + } - return $phraseArray; + public function getExcludedSubjectWords() + { + return $this->_excludedSubjectWords; } /** - * Looks for words that should be excluded in the results (-word) - * - * - Look for -test, etc - * - Prevents excluding blacklisted words since it is redundant + * Returns the search parameters. * - * @param string[] $wordArray + * @param bool $array If true returns an array, otherwise an object * - * @return string[] + * @return mixed */ - private function _checkExcludeWord($wordArray) + public function getSearchParams($array = false) { - foreach ($wordArray as $index => $word) + if ($array === true) { - if (strpos(trim($word), '-') === 0) - { - if (($word = trim($word, '-_\' ')) !== '' && !in_array($word, $this->_blacklisted_words)) - { - $this->_excludedWords[] = $word; - } - - unset($wordArray[$index]); - } + return $this->_searchParams->get(); + } + else + { + return $this->_searchParams; } + } - return $wordArray; + public function getExcludedPhrases() + { + return $this->_excludedPhrases; } /** @@ -507,23 +297,25 @@ public function searchWords() $orParts = array(); $this->_searchWords = array(); + $searchArray = $this->_searchArray->getSearchArray(); + $excludedWords = $this->_searchArray->getExcludedWords(); // All words/sentences must match. - if (!empty($this->_searchArray) && empty($this->_search_params['searchtype'])) + if (!empty($searchArray) && empty($this->_searchParams['searchtype'])) { - $orParts[0] = $this->_searchArray; + $orParts[0] = $searchArray; } // Any word/sentence must match. else { - foreach ($this->_searchArray as $index => $value) + foreach ($searchArray as $index => $value) $orParts[$index] = array($value); } // Make sure the excluded words are in all or-branches. foreach ($orParts as $orIndex => $andParts) { - foreach ($this->_excludedWords as $word) + foreach ($excludedWords as $word) { $orParts[$orIndex][] = $word; } @@ -540,16 +332,13 @@ public function searchWords() 'complex_words' => array(), ); + $this->_searchAPI->setExcludedWords($excludedWords); // Sort the indexed words (large words -> small words -> excluded words). - if (is_callable(array($this->_searchAPI, 'searchSort'))) - { - $this->_searchAPI->setExcludedWords($this->_excludedWords); - usort($orParts[$orIndex], array($this->_searchAPI, 'searchSort')); - } + usort($orParts[$orIndex], array($this->_searchAPI, 'searchSort')); foreach ($orParts[$orIndex] as $word) { - $is_excluded = in_array($word, $this->_excludedWords); + $is_excluded = in_array($word, $excludedWords); $this->_searchWords[$orIndex]['all_words'][] = $word; $subjectWords = text2words($word); @@ -568,10 +357,7 @@ public function searchWords() } // Have we got indexes to prepare? - if (is_callable(array($this->_searchAPI, 'prepareIndexes'))) - { - $this->_searchAPI->prepareIndexes($word, $this->_searchWords[$orIndex], $this->_excludedIndexWords, $is_excluded); - } + $this->_searchAPI->prepareIndexes($word, $this->_searchWords[$orIndex], $this->_excludedIndexWords, $is_excluded, $this->_excludedSubjectWords); } // Search_force_index requires all AND parts to have at least one fulltext word. @@ -580,7 +366,7 @@ public function searchWords() $context['search_errors']['query_not_specific_enough'] = true; break; } - elseif ($this->param('subject_only') && empty($this->_searchWords[$orIndex]['subject_words']) && empty($this->_excludedSubjectWords)) + elseif ($this->_searchParams->subject_only && empty($this->_searchWords[$orIndex]['subject_words']) && empty($this->_excludedSubjectWords)) { $context['search_errors']['query_not_specific_enough'] = true; break; @@ -598,7 +384,15 @@ public function searchWords() } /** - * Encodes search params ($this->_search_params) in an URL-compatible way + * Tell me, do I want to see the full message or just a piece? + */ + public function isCompact() + { + return empty($this->_searchParams['show_complete']); + } + + /** + * Wrapper around SearchParams::compileURL * * @param array $search build param index with specific search term (did you mean?) * @@ -606,1398 +400,134 @@ public function searchWords() */ public function compileURLparams($search = array()) { - $temp_params = $this->_search_params; - $encoded = array(); - - if (!empty($search)) - { - $temp_params['search'] = implode(' ', $search); - } - - // *** Encode all search params - // All search params have been checked, let's compile them to a single string... made less simple by PHP 4.3.9 and below. - if (isset($temp_params['brd'])) - { - $temp_params['brd'] = implode(',', $temp_params['brd']); - } - - foreach ($temp_params as $k => $v) - $encoded[] = $k . '|\'|' . $v; - - if (!empty($encoded)) - { - // Due to old IE's 2083 character limit, we have to compress long search strings - $params = @gzcompress(implode('|"|', $encoded)); - - // Gzcompress failed, use try non-gz - if (empty($params)) - { - $params = implode('|"|', $encoded); - } + return $this->_searchParams->compileURL($search); + } - // Base64 encode, then replace +/= with uri safe ones that can be reverted - $encoded = str_replace(array('+', '/', '='), array('-', '_', '.'), base64_encode($params)); - } - else - { - $encoded = ''; - } + /** + * Finds the posters of the messages + * + * @param int[] $msg_list - All the messages we want to find the posters + * @param int $limit - There are only so much topics + * + * @return int[] - array of members id + */ + public function loadPosters($msg_list, $limit) + { + // Load the posters... + $request = $this->_db->query('', ' + SELECT + id_member + FROM {db_prefix}messages + WHERE id_member != {int:no_member} + AND id_msg IN ({array_int:message_list}) + LIMIT {int:limit}', + array( + 'message_list' => $msg_list, + 'limit' => $limit, + 'no_member' => 0, + ) + ); + $posters = array(); + while ($row = $this->_db->fetch_assoc($request)) + $posters[] = $row['id_member']; + $this->_db->free_result($request); - return $encoded; + return $posters; } /** - * Extract search params from a string + * Finds the posters of the messages * - * @param string $string - the string containing encoded search params + * @param int[] $msg_list - All the messages we want to find the posters + * @param int $limit - There are only so much topics + * + * @return resource */ - public function searchParamsFromString($string) + public function loadMessagesRequest($msg_list, $limit) { - // Due to IE's 2083 character limit, we have to compress long search strings - $temp_params = base64_decode(str_replace(array('-', '_', '.'), array('+', '/', '='), $string)); - - // Test for gzuncompress failing - $temp_params2 = @gzuncompress($temp_params); - $temp_params = explode('|"|', (!empty($temp_params2) ? $temp_params2 : $temp_params)); + global $modSettings; - foreach ($temp_params as $i => $data) - { - list($k, $v) = array_pad(explode('|\'|', $data), 2, ''); - $this->_search_params[$k] = $v; - } + $request = $this->_db->query('', ' + SELECT + m.id_msg, m.subject, m.poster_name, m.poster_email, m.poster_time, + m.id_member, m.icon, m.poster_ip, m.body, m.smileys_enabled, + m.modified_time, m.modified_name, first_m.id_msg AS id_first_msg, + first_m.subject AS first_subject, first_m.icon AS first_icon, + first_m.poster_time AS first_poster_time, + first_mem.id_member AS first_id_member, + COALESCE(first_mem.real_name, first_m.poster_name) AS first_display_name, + COALESCE(first_mem.member_name, first_m.poster_name) AS first_member_name, + last_m.id_msg AS id_last_msg, last_m.poster_time AS last_poster_time, + last_mem.id_member AS last_id_member, + COALESCE(last_mem.real_name, last_m.poster_name) AS last_display_name, + COALESCE(last_mem.member_name, last_m.poster_name) AS last_member_name, + last_m.icon AS last_icon, last_m.subject AS last_subject, + t.id_topic, t.is_sticky, t.locked, t.id_poll, t.num_replies, + t.num_views, t.num_likes, + b.id_board, b.name AS bname, c.id_cat, c.name AS cat_name + FROM {db_prefix}messages AS m + INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic) + INNER JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board) + INNER JOIN {db_prefix}categories AS c ON (c.id_cat = b.id_cat) + INNER JOIN {db_prefix}messages AS first_m ON (first_m.id_msg = t.id_first_msg) + INNER JOIN {db_prefix}messages AS last_m ON (last_m.id_msg = t.id_last_msg) + LEFT JOIN {db_prefix}members AS first_mem ON (first_mem.id_member = first_m.id_member) + LEFT JOIN {db_prefix}members AS last_mem ON (last_mem.id_member = first_m.id_member) + WHERE m.id_msg IN ({array_int:message_list})' . ($modSettings['postmod_active'] ? ' + AND m.approved = {int:is_approved}' : '') . ' + ORDER BY FIND_IN_SET(m.id_msg, {string:message_list_in_set}) + LIMIT {int:limit}', + array( + 'message_list' => $msg_list, + 'is_approved' => 1, + 'message_list_in_set' => implode(',', $msg_list), + 'limit' => $limit, + ) + ); - if (isset($this->_search_params['brd'])) - { - $this->_search_params['brd'] = empty($this->_search_params['brd']) ? array() : explode(',', $this->_search_params['brd']); - } + return $request; } /** - * Merge search params extracted with Search::searchParamsFromString - * with those present in the $param array (usually $_REQUEST['params']) + * Did the user find any message at all? * - * @param mixed[] $params - An array of search parameters - * @param int $recentPercentage - A coefficient to calculate the lowest - * message id to start search from - * @param int $maxMembersToSearch - The maximum number of members to consider - * when multiple are found + * @param resource $messages_request holds a query result * - * @throws \Elk_Exception topic_gone + * @return boolean */ - public function mergeSearchParams($params, $recentPercentage, $maxMembersToSearch) + public function noMessages($messages_request) { - global $user_info, $modSettings, $context; + return $this->_db->num_rows($messages_request) == 0; + } - // Store whether simple search was used (needed if the user wants to do another query). - if (!isset($this->_search_params['advanced'])) - { - $this->_search_params['advanced'] = empty($params['advanced']) ? 0 : 1; - } + public function searchQuery(\ElkArte\Search\SearchApiWrapper $searchAPI) + { + $this->_searchAPI = $searchAPI; + $searchAPI->setExcludedPhrases($this->_excludedPhrases); + $searchAPI->setWeightFactors($this->_weightFactors); + $searchAPI->useTemporary($this->_createTemporary); + $searchAPI->setSearchArray($this->_searchArray); + + return $searchAPI->searchQuery( + $this->searchWords(), + $this->_excludedIndexWords, + $this->_participants, + $this->_searchAPI + ); + } - // 1 => 'allwords' (default, don't set as param) / 2 => 'anywords'. - if (!empty($this->_search_params['searchtype']) || (!empty($params['searchtype']) && $params['searchtype'] == 2)) - { - $this->_search_params['searchtype'] = 2; - } + /** + * Returns the number of results obtained from the query. + * + * @return int + */ + public function getNumResults() + { + return $this->_searchAPI->getNumResults(); + } - // Minimum age of messages. Default to zero (don't set param in that case). - if (!empty($this->_search_params['minage']) || (!empty($params['minage']) && $params['minage'] > 0)) - { - $this->_search_params['minage'] = !empty($this->_search_params['minage']) ? (int) $this->_search_params['minage'] : (int) $params['minage']; - } - - // Maximum age of messages. Default to infinite (9999 days: param not set). - if (!empty($this->_search_params['maxage']) || (!empty($params['maxage']) && $params['maxage'] < 9999)) - { - $this->_search_params['maxage'] = !empty($this->_search_params['maxage']) ? (int) $this->_search_params['maxage'] : (int) $params['maxage']; - } - - // Searching a specific topic? - if (!empty($params['topic']) || (!empty($params['search_selection']) && $params['search_selection'] === 'topic')) - { - $this->_search_params['topic'] = empty($params['search_selection']) ? (int) $params['topic'] : (isset($params['sd_topic']) ? (int) $params['sd_topic'] : ''); - $this->_search_params['show_complete'] = true; - } - elseif (!empty($this->_search_params['topic'])) - { - $this->_search_params['topic'] = (int) $this->_search_params['topic']; - } - - if (!empty($this->_search_params['minage']) || !empty($this->_search_params['maxage'])) - { - $request = $this->_db->query('', ' - SELECT ' . (empty($this->_search_params['maxage']) ? '0, ' : 'COALESCE(MIN(id_msg), -1), ') . (empty($this->_search_params['minage']) ? '0' : 'COALESCE(MAX(id_msg), -1)') . ' - FROM {db_prefix}messages - WHERE 1=1' . ($modSettings['postmod_active'] ? ' - AND approved = {int:is_approved_true}' : '') . (empty($this->_search_params['minage']) ? '' : ' - AND poster_time <= {int:timestamp_minimum_age}') . (empty($this->_search_params['maxage']) ? '' : ' - AND poster_time >= {int:timestamp_maximum_age}'), - array( - 'timestamp_minimum_age' => empty($this->_search_params['minage']) ? 0 : time() - 86400 * $this->_search_params['minage'], - 'timestamp_maximum_age' => empty($this->_search_params['maxage']) ? 0 : time() - 86400 * $this->_search_params['maxage'], - 'is_approved_true' => 1, - ) - ); - list ($this->_minMsgID, $this->_maxMsgID) = $this->_db->fetch_row($request); - if ($this->_minMsgID < 0 || $this->_maxMsgID < 0) - { - $context['search_errors']['no_messages_in_time_frame'] = true; - } - $this->_db->free_result($request); - } - - // Default the user name to a wildcard matching every user (*). - if (!empty($this->_search_params['userspec']) || (!empty($params['userspec']) && $params['userspec'] != '*')) - { - $this->_search_params['userspec'] = isset($this->_search_params['userspec']) ? $this->_search_params['userspec'] : $params['userspec']; - } - - // If there's no specific user, then don't mention it in the main query. - if (empty($this->_search_params['userspec'])) - { - $this->_userQuery = ''; - } - else - { - $userString = strtr(\Util::htmlspecialchars($this->_search_params['userspec'], ENT_QUOTES), array('"' => '"')); - $userString = strtr($userString, array('%' => '\%', '_' => '\_', '*' => '%', '?' => '_')); - - preg_match_all('~"([^"]+)"~', $userString, $matches); - $possible_users = array_merge($matches[1], explode(',', preg_replace('~"[^"]+"~', '', $userString))); - - for ($k = 0, $n = count($possible_users); $k < $n; $k++) - { - $possible_users[$k] = trim($possible_users[$k]); - - if (strlen($possible_users[$k]) == 0) - { - unset($possible_users[$k]); - } - } - - // Create a list of database-escaped search names. - $realNameMatches = array(); - foreach ($possible_users as $possible_user) - $realNameMatches[] = $this->_db->quote( - '{string:possible_user}', - array( - 'possible_user' => $possible_user - ) - ); - - // Retrieve a list of possible members. - $request = $this->_db->query('', ' - SELECT - id_member - FROM {db_prefix}members - WHERE {raw:match_possible_users}', - array( - 'match_possible_users' => 'real_name LIKE ' . implode(' OR real_name LIKE ', $realNameMatches), - ) - ); - - // Simply do nothing if there're too many members matching the criteria. - if ($this->_db->num_rows($request) > $maxMembersToSearch) - { - $this->_userQuery = ''; - } - elseif ($this->_db->num_rows($request) == 0) - { - $this->_userQuery = $this->_db->quote( - 'm.id_member = {int:id_member_guest} AND ({raw:match_possible_guest_names})', - array( - 'id_member_guest' => 0, - 'match_possible_guest_names' => 'm.poster_name LIKE ' . implode(' OR m.poster_name LIKE ', $realNameMatches), - ) - ); - } - else - { - while ($row = $this->_db->fetch_assoc($request)) - { - $this->_memberlist[] = $row['id_member']; - } - - $this->_userQuery = $this->_db->quote( - '(m.id_member IN ({array_int:matched_members}) OR (m.id_member = {int:id_member_guest} AND ({raw:match_possible_guest_names})))', - array( - 'matched_members' => $this->_memberlist, - 'id_member_guest' => 0, - 'match_possible_guest_names' => 'm.poster_name LIKE ' . implode(' OR m.poster_name LIKE ', $realNameMatches), - ) - ); - } - $this->_db->free_result($request); - } - - // Ensure that boards are an array of integers (or nothing). - if (!empty($this->_search_params['brd']) && is_array($this->_search_params['brd'])) - { - $query_boards = array_map('intval', $this->_search_params['brd']); - } - elseif (!empty($params['brd']) && is_array($params['brd'])) - { - $query_boards = array_map('intval', $params['brd']); - } - elseif (!empty($params['brd'])) - { - $query_boards = array_map('intval', explode(',', $params['brd'])); - } - elseif (!empty($params['search_selection']) && $params['search_selection'] === 'board' && !empty($params['sd_brd']) && is_array($params['sd_brd'])) - { - $query_boards = array_map('intval', $params['sd_brd']); - } - elseif (!empty($params['search_selection']) && $params['search_selection'] === 'board' && isset($params['sd_brd']) && (int) $params['sd_brd'] !== 0) - { - $query_boards = array((int) $params['sd_brd']); - } - else - { - $query_boards = array(); - } - - // Special case for boards: searching just one topic? - if (!empty($this->_search_params['topic'])) - { - $request = $this->_db->query('', ' - SELECT - b.id_board - FROM {db_prefix}topics AS t - INNER JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board) - WHERE t.id_topic = {int:search_topic_id} - AND {query_see_board}' . ($modSettings['postmod_active'] ? ' - AND t.approved = {int:is_approved_true}' : '') . ' - LIMIT 1', - array( - 'search_topic_id' => $this->_search_params['topic'], - 'is_approved_true' => 1, - ) - ); - - if ($this->_db->num_rows($request) == 0) - { - throw new \Elk_Exception('topic_gone', false); - } - - $this->_search_params['brd'] = array(); - list ($this->_search_params['brd'][0]) = $this->_db->fetch_row($request); - $this->_db->free_result($request); - } - // Select all boards you've selected AND are allowed to see. - elseif ($user_info['is_admin'] && (!empty($this->_search_params['advanced']) || !empty($query_boards))) - { - $this->_search_params['brd'] = $query_boards; - } - else - { - require_once(SUBSDIR . '/Boards.subs.php'); - $this->_search_params['brd'] = array_keys(fetchBoardsInfo(array('boards' => $query_boards), array('include_recycle' => false, 'include_redirects' => false, 'wanna_see_board' => empty($this->_search_params['advanced'])))); - - // This error should pro'bly only happen for hackers. - if (empty($this->_search_params['brd'])) - { - $context['search_errors']['no_boards_selected'] = true; - } - } - - if (count($this->_search_params['brd']) != 0) - { - foreach ($this->_search_params['brd'] as $k => $v) - $this->_search_params['brd'][$k] = (int) $v; - - // If we've selected all boards, this parameter can be left empty. - require_once(SUBSDIR . '/Boards.subs.php'); - $num_boards = countBoards(); - - if (count($this->_search_params['brd']) == $num_boards) - { - $this->_boardQuery = ''; - } - elseif (count($this->_search_params['brd']) == $num_boards - 1 && !empty($modSettings['recycle_board']) && !in_array($modSettings['recycle_board'], $this->_search_params['brd'])) - { - $this->_boardQuery = '!= ' . $modSettings['recycle_board']; - } - else - { - $this->_boardQuery = 'IN (' . implode(', ', $this->_search_params['brd']) . ')'; - } - } - else - { - $this->_boardQuery = ''; - } - - $this->_search_params['show_complete'] = !empty($this->_search_params['show_complete']) || !empty($params['show_complete']); - $this->_search_params['subject_only'] = !empty($this->_search_params['subject_only']) || !empty($params['subject_only']); - - // Get the sorting parameters right. Default to sort by relevance descending. - $sort_columns = array( - 'relevance', - 'num_replies', - 'id_msg', - ); - - // Allow integration to add additional sort columns - call_integration_hook('integrate_search_sort_columns', array(&$sort_columns)); - - if (empty($this->_search_params['sort']) && !empty($params['sort'])) - { - list ($this->_search_params['sort'], $this->_search_params['sort_dir']) = array_pad(explode('|', $params['sort']), 2, ''); - } - - $this->_search_params['sort'] = !empty($this->_search_params['sort']) && in_array($this->_search_params['sort'], $sort_columns) ? $this->_search_params['sort'] : 'relevance'; - - if (!empty($this->_search_params['topic']) && $this->_search_params['sort'] === 'num_replies') - { - $this->_search_params['sort'] = 'id_msg'; - } - - // Sorting direction: descending unless stated otherwise. - $this->_search_params['sort_dir'] = !empty($this->_search_params['sort_dir']) && $this->_search_params['sort_dir'] === 'asc' ? 'asc' : 'desc'; - - // Determine some values needed to calculate the relevance. - $this->_minMsg = (int) ((1 - $recentPercentage) * $modSettings['maxMsgID']); - $this->_recentMsg = $modSettings['maxMsgID'] - $this->_minMsg; - - // *** Parse the search query - call_integration_hook('integrate_search_params', array(&$this->_search_params)); - - // Unfortunately, searching for words like this is going to be slow, so we're blacklisting them. - // @todo Setting to add more here? - // @todo Maybe only blacklist if they are the only word, or "any" is used? - $this->_blacklisted_words = array('img', 'url', 'quote', 'www', 'http', 'the', 'is', 'it', 'are', 'if'); - call_integration_hook('integrate_search_blacklisted_words', array(&$this->_blacklisted_words)); - - // What are we searching for? - if (empty($this->_search_params['search'])) - { - if (isset($_GET['search'])) - { - $this->_search_params['search'] = un_htmlspecialchars($_GET['search']); - } - elseif (isset($_POST['search'])) - { - $this->_search_params['search'] = $_POST['search']; - } - else - { - $this->_search_params['search'] = ''; - } - } - } - - /** - * Tell me, do I want to see the full message or just a piece? - */ - public function isCompact() - { - return empty($this->_search_params['show_complete']); - } - - /** - * Setup spellchecking suggestions and load them into the two variable - * passed by ref - * - * @param string $suggestion_display - the string to display in the template - * @param string $suggestion_param - a param string to be used in a url - * @param string $display_highlight - a template to enclose in each suggested word - */ - public function loadSuggestions(&$suggestion_display = '', &$suggestion_param = '', $display_highlight = '') - { - global $txt; - - // Windows fix. - ob_start(); - $old = error_reporting(0); - - pspell_new('en'); - $pspell_link = pspell_new($txt['lang_dictionary'], $txt['lang_spelling'], '', 'utf-8', PSPELL_FAST | PSPELL_RUN_TOGETHER); - - if (!$pspell_link) - { - $pspell_link = pspell_new('en', '', '', '', PSPELL_FAST | PSPELL_RUN_TOGETHER); - } - - error_reporting($old); - @ob_end_clean(); - - $did_you_mean = array('search' => array(), 'display' => array()); - $found_misspelling = false; - foreach ($this->_searchArray as $word) - { - if (empty($pspell_link)) - { - continue; - } - - // Don't check phrases. - if (preg_match('~^\w+$~', $word) === 0) - { - $did_you_mean['search'][] = '"' . $word . '"'; - $did_you_mean['display'][] = '"' . \Util::htmlspecialchars($word) . '"'; - continue; - } - // For some strange reason spell check can crash PHP on decimals. - elseif (preg_match('~\d~', $word) === 1) - { - $did_you_mean['search'][] = $word; - $did_you_mean['display'][] = \Util::htmlspecialchars($word); - continue; - } - elseif (pspell_check($pspell_link, $word)) - { - $did_you_mean['search'][] = $word; - $did_you_mean['display'][] = \Util::htmlspecialchars($word); - continue; - } - - $suggestions = pspell_suggest($pspell_link, $word); - foreach ($suggestions as $i => $s) - { - // Search is case insensitive. - if (\Util::strtolower($s) == \Util::strtolower($word)) - { - unset($suggestions[$i]); - } - // Plus, don't suggest something the user thinks is rude! - elseif ($suggestions[$i] != censor($s)) - { - unset($suggestions[$i]); - } - } - - // Anything found? If so, correct it! - if (!empty($suggestions)) - { - $suggestions = array_values($suggestions); - $did_you_mean['search'][] = $suggestions[0]; - $did_you_mean['display'][] = str_replace('{word}', \Util::htmlspecialchars($suggestions[0]), $display_highlight); - $found_misspelling = true; - } - else - { - $did_you_mean['search'][] = $word; - $did_you_mean['display'][] = \Util::htmlspecialchars($word); - } - } - - if ($found_misspelling) - { - // Don't spell check excluded words, but add them still... - $temp_excluded = array('search' => array(), 'display' => array()); - foreach ($this->_excludedWords as $word) - { - if (preg_match('~^\w+$~', $word) == 0) - { - $temp_excluded['search'][] = '-"' . $word . '"'; - $temp_excluded['display'][] = '-"' . \Util::htmlspecialchars($word) . '"'; - } - else - { - $temp_excluded['search'][] = '-' . $word; - $temp_excluded['display'][] = '-' . \Util::htmlspecialchars($word); - } - } - - $did_you_mean['search'] = array_merge($did_you_mean['search'], $temp_excluded['search']); - $did_you_mean['display'] = array_merge($did_you_mean['display'], $temp_excluded['display']); - - // Provide the potential correct spelling term in the param - $suggestion_param = $this->compileURLparams($did_you_mean['search']); - $suggestion_display = implode(' ', $did_you_mean['display']); - } - } - - /** - * Delete logs of previous searches - * - * @param int $id_search - the id of the search to delete from logs - */ - public function clearCacheResults($id_search) - { - $this->_db_search->search_query('delete_log_search_results', ' - DELETE FROM {db_prefix}log_search_results - WHERE id_search = {int:search_id}', - array( - 'search_id' => $id_search, - ) - ); - } - - /** - * Grabs results when the search is performed only within the subject - * - * @param int $id_search - the id of the search - * @param int $humungousTopicPosts - Message length used to tweak messages - * relevance of the results. - * - * @return int - number of results otherwise - */ - public function getSubjectResults($id_search, $humungousTopicPosts) - { - global $modSettings; - - // We do this to try and avoid duplicate keys on databases not supporting INSERT IGNORE. - foreach ($this->_searchWords as $words) - { - $subject_query_params = array(); - $subject_query = array( - 'from' => '{db_prefix}topics AS t', - 'inner_join' => array(), - 'left_join' => array(), - 'where' => array(), - ); - - if ($modSettings['postmod_active']) - { - $subject_query['where'][] = 't.approved = {int:is_approved}'; - } - - $numTables = 0; - $prev_join = 0; - $numSubjectResults = 0; - foreach ($words['subject_words'] as $subjectWord) - { - $numTables++; - if (in_array($subjectWord, $this->_excludedSubjectWords)) - { - $subject_query['left_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.word ' . (empty($modSettings['search_match_words']) ? 'LIKE {string:subject_words_' . $numTables . '_wild}' : '= {string:subject_words_' . $numTables . '}') . ' AND subj' . $numTables . '.id_topic = t.id_topic)'; - $subject_query['where'][] = '(subj' . $numTables . '.word IS NULL)'; - } - else - { - $subject_query['inner_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.id_topic = ' . ($prev_join === 0 ? 't' : 'subj' . $prev_join) . '.id_topic)'; - $subject_query['where'][] = 'subj' . $numTables . '.word ' . (empty($modSettings['search_match_words']) ? 'LIKE {string:subject_words_' . $numTables . '_wild}' : '= {string:subject_words_' . $numTables . '}'); - $prev_join = $numTables; - } - - $subject_query_params['subject_words_' . $numTables] = $subjectWord; - $subject_query_params['subject_words_' . $numTables . '_wild'] = '%' . $subjectWord . '%'; - } - - if (!empty($this->_userQuery)) - { - $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_topic = t.id_topic)'; - $subject_query['where'][] = $this->_userQuery; - } - - if (!empty($this->_search_params['topic'])) - { - $subject_query['where'][] = 't.id_topic = ' . $this->_search_params['topic']; - } - - if (!empty($this->_minMsgID)) - { - $subject_query['where'][] = 't.id_first_msg >= ' . $this->_minMsgID; - } - - if (!empty($this->_maxMsgID)) - { - $subject_query['where'][] = 't.id_last_msg <= ' . $this->_maxMsgID; - } - - if (!empty($this->_boardQuery)) - { - $subject_query['where'][] = 't.id_board ' . $this->_boardQuery; - } - - if (!empty($this->_excludedPhrases)) - { - $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; - - $count = 0; - foreach ($this->_excludedPhrases as $phrase) - { - $subject_query['where'][] = 'm.subject NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? 'LIKE' : 'RLIKE') . ' {string:excluded_phrases_' . $count . '}'; - $subject_query_params['excluded_phrases_' . ($count++)] = $this->_searchAPI->prepareWord($phrase, $this->noRegexp()); - } - } - - // Build the search query - $subject_query['select'] = array( - 'id_search' => '{int:id_search}', - 'id_topic' => 't.id_topic', - 'relevance' => $this->_build_relevance(), - 'id_msg' => empty($this->_userQuery) ? 't.id_first_msg' : 'm.id_msg', - 'num_matches' => 1, - ); - - $subject_query['parameters'] = array_merge($subject_query_params, array( - 'id_search' => $id_search, - 'min_msg' => $this->_minMsg, - 'recent_message' => $this->_recentMsg, - 'huge_topic_posts' => $humungousTopicPosts, - 'is_approved' => 1, - 'limit' => empty($modSettings['search_max_results']) ? 0 : $modSettings['search_max_results'] - $numSubjectResults, - )); - - call_integration_hook('integrate_subject_only_search_query', array(&$subject_query, &$subject_query_params)); - - $numSubjectResults += $this->_build_search_results_log($subject_query, 'insert_log_search_results_subject'); - - if (!empty($modSettings['search_max_results']) && $numSubjectResults >= $modSettings['search_max_results']) - { - break; - } - } - - return empty($numSubjectResults) ? 0 : $numSubjectResults; - } - - /** - * Grabs results when the search is performed in subjects and bodies - * - * @param int $id_search - the id of the search - * @param int $humungousTopicPosts - Message length used to tweak messages relevance of the results. - * @param int $maxMessageResults - Maximum number of results - * - * @return bool|int - boolean (false) in case of errors, number of results otherwise - */ - public function getResults($id_search, $humungousTopicPosts, $maxMessageResults) - { - global $modSettings; - - $num_results = 0; - - $main_query = array( - 'select' => array( - 'id_search' => $id_search, - 'relevance' => '0', - ), - 'weights' => array(), - 'from' => '{db_prefix}topics AS t', - 'inner_join' => array( - '{db_prefix}messages AS m ON (m.id_topic = t.id_topic)' - ), - 'left_join' => array(), - 'where' => array(), - 'group_by' => array(), - 'parameters' => array( - 'min_msg' => $this->_minMsg, - 'recent_message' => $this->_recentMsg, - 'huge_topic_posts' => $humungousTopicPosts, - 'is_approved' => 1, - 'limit' => $modSettings['search_max_results'], - ), - ); - - if (empty($this->_search_params['topic']) && empty($this->_search_params['show_complete'])) - { - $main_query['select']['id_topic'] = 't.id_topic'; - $main_query['select']['id_msg'] = 'MAX(m.id_msg) AS id_msg'; - $main_query['select']['num_matches'] = 'COUNT(*) AS num_matches'; - $main_query['weights'] = $this->_weight_factors; - $main_query['group_by'][] = 't.id_topic'; - } - else - { - // This is outrageous! - $main_query['select']['id_topic'] = 'm.id_msg AS id_topic'; - $main_query['select']['id_msg'] = 'm.id_msg'; - $main_query['select']['num_matches'] = '1 AS num_matches'; - - $main_query['weights'] = array( - 'age' => array( - 'search' => '((m.id_msg - t.id_first_msg) / CASE WHEN t.id_last_msg = t.id_first_msg THEN 1 ELSE t.id_last_msg - t.id_first_msg END)', - ), - 'first_message' => array( - 'search' => 'CASE WHEN m.id_msg = t.id_first_msg THEN 1 ELSE 0 END', - ), - ); - - if (!empty($this->_search_params['topic'])) - { - $main_query['where'][] = 't.id_topic = {int:topic}'; - $main_query['parameters']['topic'] = $this->param('topic'); - } - - if (!empty($this->_search_params['show_complete'])) - { - $main_query['group_by'][] = 'm.id_msg, t.id_first_msg, t.id_last_msg'; - } - } - - // *** Get the subject results. - $numSubjectResults = $this->_log_search_subjects($id_search); - - if ($numSubjectResults !== 0) - { - $main_query['weights']['subject']['search'] = 'CASE WHEN MAX(lst.id_topic) IS NULL THEN 0 ELSE 1 END'; - $main_query['left_join'][] = '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics AS lst ON (' . ($this->_createTemporary ? '' : 'lst.id_search = {int:id_search} AND ') . 'lst.id_topic = t.id_topic)'; - if (!$this->_createTemporary) - { - $main_query['parameters']['id_search'] = $id_search; - } - } - - // We building an index? - if (is_callable(array($this->_searchAPI, 'indexedWordQuery'))) - { - $indexedResults = $this->_prepare_word_index($id_search, $maxMessageResults); - - if (empty($indexedResults) && empty($numSubjectResults) && !empty($modSettings['search_force_index'])) - { - return false; - } - elseif (!empty($indexedResults)) - { - $main_query['inner_join'][] = '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_messages AS lsm ON (lsm.id_msg = m.id_msg)'; - - if (!$this->_createTemporary) - { - $main_query['where'][] = 'lsm.id_search = {int:id_search}'; - $main_query['parameters']['id_search'] = $id_search; - } - } - } - // Not using an index? All conditions have to be carried over. - else - { - $orWhere = array(); - $count = 0; - foreach ($this->_searchWords as $words) - { - $where = array(); - foreach ($words['all_words'] as $regularWord) - { - $where[] = 'm.body' . (in_array($regularWord, $this->_excludedWords) ? ' NOT' : '') . (empty($modSettings['search_match_words']) || $this->noRegexp() ? ' LIKE ' : ' RLIKE ') . '{string:all_word_body_' . $count . '}'; - if (in_array($regularWord, $this->_excludedWords)) - { - $where[] = 'm.subject NOT' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? ' LIKE ' : ' RLIKE ') . '{string:all_word_body_' . $count . '}'; - } - $main_query['parameters']['all_word_body_' . ($count++)] = $this->_searchAPI->prepareWord($regularWord, $this->noRegexp()); - } - - if (!empty($where)) - { - $orWhere[] = count($where) > 1 ? '(' . implode(' AND ', $where) . ')' : $where[0]; - } - } - - if (!empty($orWhere)) - { - $main_query['where'][] = count($orWhere) > 1 ? '(' . implode(' OR ', $orWhere) . ')' : $orWhere[0]; - } - - if (!empty($this->_userQuery)) - { - $main_query['where'][] = '{raw:user_query}'; - $main_query['parameters']['user_query'] = $this->_userQuery; - } - - if (!empty($this->_search_params['topic'])) - { - $main_query['where'][] = 'm.id_topic = {int:topic}'; - $main_query['parameters']['topic'] = $this->param('topic'); - } - - if (!empty($this->_minMsgID)) - { - $main_query['where'][] = 'm.id_msg >= {int:min_msg_id}'; - $main_query['parameters']['min_msg_id'] = $this->_minMsgID; - } - - if (!empty($this->_maxMsgID)) - { - $main_query['where'][] = 'm.id_msg <= {int:max_msg_id}'; - $main_query['parameters']['max_msg_id'] = $this->_maxMsgID; - } - - if (!empty($this->_boardQuery)) - { - $main_query['where'][] = 'm.id_board {raw:board_query}'; - $main_query['parameters']['board_query'] = $this->_boardQuery; - } - } - call_integration_hook('integrate_main_search_query', array(&$main_query)); - - // Did we either get some indexed results, or otherwise did not do an indexed query? - if (!empty($indexedResults) || !is_callable(array($this->_searchAPI, 'indexedWordQuery'))) - { - $relevance = $this->_build_relevance($main_query['weights']); - $main_query['select']['relevance'] = $relevance; - $num_results += $this->_build_search_results_log($main_query, 'insert_log_search_results_no_index'); - } - - // Insert subject-only matches. - if ($num_results < $modSettings['search_max_results'] && $numSubjectResults !== 0) - { - $subject_query = array( - 'select' => array( - 'id_search' => '{int:id_search}', - 'id_topic' => 't.id_topic', - 'relevance' => $this->_build_relevance(), - 'id_msg' => 't.id_first_msg', - 'num_matches' => 1, - ), - 'from' => '{db_prefix}topics AS t', - 'inner_join' => array( - '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics AS lst ON (lst.id_topic = t.id_topic)' - ), - 'where' => array( - $this->_createTemporary ? '1=1' : 'lst.id_search = {int:id_search}', - ), - 'parameters' => array( - 'id_search' => $id_search, - 'min_msg' => $this->_minMsg, - 'recent_message' => $this->_recentMsg, - 'huge_topic_posts' => $humungousTopicPosts, - 'limit' => empty($modSettings['search_max_results']) ? 0 : $modSettings['search_max_results'] - $num_results, - ), - ); - - $num_results += $this->_build_search_results_log($subject_query, 'insert_log_search_results_sub_only', true); - } - elseif ($num_results == -1) - { - $num_results = 0; - } - - return $num_results; - } - - /** - * Determines and add the relevance to the results - * - * @param mixed[] $topics - The search results (passed by reference) - * @param int $id_search - the id of the search - * @param int $start - Results are shown starting from here - * @param int $limit - No more results than this - * - * @return bool[] - */ - public function addRelevance(&$topics, $id_search, $start, $limit) - { - // *** Retrieve the results to be shown on the page - $participants = array(); - $request = $this->_db_search->search_query('', ' - SELECT ' . (empty($this->_search_params['topic']) ? 'lsr.id_topic' : $this->param('topic') . ' AS id_topic') . ', lsr.id_msg, lsr.relevance, lsr.num_matches - FROM {db_prefix}log_search_results AS lsr' . ($this->param('sort') === 'num_replies' ? ' - INNER JOIN {db_prefix}topics AS t ON (t.id_topic = lsr.id_topic)' : '') . ' - WHERE lsr.id_search = {int:id_search} - ORDER BY {raw:sort} {raw:sort_dir} - LIMIT {int:start}, {int:limit}', - array( - 'id_search' => $id_search, - 'sort' => $this->param('sort'), - 'sort_dir' => $this->param('sort_dir'), - 'start' => $start, - 'limit' => $limit, - ) - ); - while ($row = $this->_db->fetch_assoc($request)) - { - $topics[$row['id_msg']] = array( - 'relevance' => round($row['relevance'] / 10, 1) . '%', - 'num_matches' => $row['num_matches'], - 'matches' => array(), - ); - // By default they didn't participate in the topic! - $participants[$row['id_topic']] = false; - } - $this->_db->free_result($request); - - return $participants; - } - - /** - * Finds the posters of the messages - * - * @param int[] $msg_list - All the messages we want to find the posters - * @param int $limit - There are only so much topics - * - * @return int[] - array of members id - */ - public function loadPosters($msg_list, $limit) - { - // Load the posters... - $request = $this->_db->query('', ' - SELECT - id_member - FROM {db_prefix}messages - WHERE id_member != {int:no_member} - AND id_msg IN ({array_int:message_list}) - LIMIT {int:limit}', - array( - 'message_list' => $msg_list, - 'limit' => $limit, - 'no_member' => 0, - ) - ); - $posters = array(); - while ($row = $this->_db->fetch_assoc($request)) - $posters[] = $row['id_member']; - $this->_db->free_result($request); - - return $posters; - } - - /** - * Finds the posters of the messages - * - * @param int[] $msg_list - All the messages we want to find the posters - * @param int $limit - There are only so much topics - * - * @return resource - */ - public function loadMessagesRequest($msg_list, $limit) - { - global $modSettings; - - $request = $this->_db->query('', ' - SELECT - m.id_msg, m.subject, m.poster_name, m.poster_email, m.poster_time, m.id_member, - m.icon, m.poster_ip, m.body, m.smileys_enabled, m.modified_time, m.modified_name, - first_m.id_msg AS first_msg, first_m.subject AS first_subject, first_m.icon AS first_icon, first_m.poster_time AS first_poster_time, - first_mem.id_member AS first_member_id, COALESCE(first_mem.real_name, first_m.poster_name) AS first_member_name, - last_m.id_msg AS last_msg, last_m.poster_time AS last_poster_time, last_mem.id_member AS last_member_id, - COALESCE(last_mem.real_name, last_m.poster_name) AS last_member_name, last_m.icon AS last_icon, last_m.subject AS last_subject, - t.id_topic, t.is_sticky, t.locked, t.id_poll, t.num_replies, t.num_views, t.num_likes, - b.id_board, b.name AS board_name, c.id_cat, c.name AS cat_name - FROM {db_prefix}messages AS m - INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic) - INNER JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board) - INNER JOIN {db_prefix}categories AS c ON (c.id_cat = b.id_cat) - INNER JOIN {db_prefix}messages AS first_m ON (first_m.id_msg = t.id_first_msg) - INNER JOIN {db_prefix}messages AS last_m ON (last_m.id_msg = t.id_last_msg) - LEFT JOIN {db_prefix}members AS first_mem ON (first_mem.id_member = first_m.id_member) - LEFT JOIN {db_prefix}members AS last_mem ON (last_mem.id_member = first_m.id_member) - WHERE m.id_msg IN ({array_int:message_list})' . ($modSettings['postmod_active'] ? ' - AND m.approved = {int:is_approved}' : '') . ' - ORDER BY FIND_IN_SET(m.id_msg, {string:message_list_in_set}) - LIMIT {int:limit}', - array( - 'message_list' => $msg_list, - 'is_approved' => 1, - 'message_list_in_set' => implode(',', $msg_list), - 'limit' => $limit, - ) - ); - - return $request; - } - - /** - * Did the user find any message at all? - * - * @param resource $messages_request holds a query result - * - * @return boolean - */ - public function noMessages($messages_request) - { - return $this->_db->num_rows($messages_request) == 0; - } - - /** - * If searching in topics only (?), inserts results in log_search_topics - * - * @param int $id_search - the id of the search to delete from logs - * - * @return int - the number of search results - */ - private function _log_search_subjects($id_search) - { - global $modSettings; - - if (!empty($this->_search_params['topic'])) - { - return 0; - } - - $inserts = array(); - $numSubjectResults = 0; - - // Clean up some previous cache. - if (!$this->_createTemporary) - { - $this->_db_search->search_query('delete_log_search_topics', ' - DELETE FROM {db_prefix}log_search_topics - WHERE id_search = {int:search_id}', - array( - 'search_id' => $id_search, - ) - ); - } - - foreach ($this->_searchWords as $words) - { - $subject_query = array( - 'from' => '{db_prefix}topics AS t', - 'inner_join' => array(), - 'left_join' => array(), - 'where' => array(), - 'params' => array(), - ); - - $numTables = 0; - $prev_join = 0; - $count = 0; - foreach ($words['subject_words'] as $subjectWord) - { - $numTables++; - if (in_array($subjectWord, $this->_excludedSubjectWords)) - { - $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; - $subject_query['left_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.word ' . (empty($modSettings['search_match_words']) ? 'LIKE {string:subject_not_' . $count . '}' : '= {string:subject_not_' . $count . '}') . ' AND subj' . $numTables . '.id_topic = t.id_topic)'; - $subject_query['params']['subject_not_' . $count] = empty($modSettings['search_match_words']) ? '%' . $subjectWord . '%' : $subjectWord; - - $subject_query['where'][] = '(subj' . $numTables . '.word IS NULL)'; - $subject_query['where'][] = 'm.body NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? ' LIKE ' : ' RLIKE ') . '{string:body_not_' . $count . '}'; - $subject_query['params']['body_not_' . ($count++)] = $this->_searchAPI->prepareWord($subjectWord, $this->noRegexp()); - } - else - { - $subject_query['inner_join'][] = '{db_prefix}log_search_subjects AS subj' . $numTables . ' ON (subj' . $numTables . '.id_topic = ' . ($prev_join === 0 ? 't' : 'subj' . $prev_join) . '.id_topic)'; - $subject_query['where'][] = 'subj' . $numTables . '.word LIKE {string:subject_like_' . $count . '}'; - $subject_query['params']['subject_like_' . ($count++)] = empty($modSettings['search_match_words']) ? '%' . $subjectWord . '%' : $subjectWord; - $prev_join = $numTables; - } - } - - if (!empty($this->_userQuery)) - { - $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; - $subject_query['where'][] = '{raw:user_query}'; - $subject_query['params']['user_query'] = $this->_userQuery; - } - - if (!empty($this->_search_params['topic'])) - { - $subject_query['where'][] = 't.id_topic = {int:topic}'; - $subject_query['params']['topic'] = $this->param('topic'); - } - - if (!empty($this->_minMsgID)) - { - $subject_query['where'][] = 't.id_first_msg >= {int:min_msg_id}'; - $subject_query['params']['min_msg_id'] = $this->_minMsgID; - } - - if (!empty($this->_maxMsgID)) - { - $subject_query['where'][] = 't.id_last_msg <= {int:max_msg_id}'; - $subject_query['params']['max_msg_id'] = $this->_maxMsgID; - } - - if (!empty($this->_boardQuery)) - { - $subject_query['where'][] = 't.id_board {raw:board_query}'; - $subject_query['params']['board_query'] = $this->_boardQuery; - } - - if (!empty($this->_excludedPhrases)) - { - $subject_query['inner_join'][] = '{db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)'; - $count = 0; - foreach ($this->_excludedPhrases as $phrase) - { - $subject_query['where'][] = 'm.subject NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? 'LIKE' : 'RLIKE') . ' {string:exclude_phrase_' . $count . '}'; - $subject_query['where'][] = 'm.body NOT ' . (empty($modSettings['search_match_words']) || $this->noRegexp() ? 'LIKE' : 'RLIKE') . ' {string:exclude_phrase_' . $count . '}'; - $subject_query['params']['exclude_phrase_' . ($count++)] = $this->_searchAPI->prepareWord($phrase, $this->noRegexp()); - } - } - - call_integration_hook('integrate_subject_search_query', array(&$subject_query)); - - // Nothing to search for? - if (empty($subject_query['where'])) - { - continue; - } - - $ignoreRequest = $this->_db_search->search_query('insert_log_search_topics', ($this->_db->support_ignore() ? (' - INSERT IGNORE INTO {db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics - (' . ($this->_createTemporary ? '' : 'id_search, ') . 'id_topic)') : '') . ' - SELECT ' . ($this->_createTemporary ? '' : $id_search . ', ') . 't.id_topic - FROM ' . $subject_query['from'] . (empty($subject_query['inner_join']) ? '' : ' - INNER JOIN ' . implode(' - INNER JOIN ', array_unique($subject_query['inner_join']))) . (empty($subject_query['left_join']) ? '' : ' - LEFT JOIN ' . implode(' - LEFT JOIN ', array_unique($subject_query['left_join']))) . ' - WHERE ' . implode(' - AND ', array_unique($subject_query['where'])) . (empty($modSettings['search_max_results']) ? '' : ' - LIMIT ' . ($modSettings['search_max_results'] - $numSubjectResults)), - $subject_query['params'] - ); - - // Don't do INSERT IGNORE? Manually fix this up! - if (!$this->_db->support_ignore()) - { - while ($row = $this->_db->fetch_row($ignoreRequest)) - { - $ind = $this->_createTemporary ? 0 : 1; - - // No duplicates! - if (isset($inserts[$row[$ind]])) - { - continue; - } - - $inserts[$row[$ind]] = $row; - } - $this->_db->free_result($ignoreRequest); - $numSubjectResults = count($inserts); - } - else - { - $numSubjectResults += $this->_db->affected_rows(); - } - - if (!empty($modSettings['search_max_results']) && $numSubjectResults >= $modSettings['search_max_results']) - { - break; - } - } - - // Got some non-MySQL data to plonk in? - if (!empty($inserts)) - { - $this->_db->insert('', - ('{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_topics'), - $this->_createTemporary ? array('id_topic' => 'int') : array('id_search' => 'int', 'id_topic' => 'int'), - $inserts, - $this->_createTemporary ? array('id_topic') : array('id_search', 'id_topic') - ); - } - - return $numSubjectResults; - } - - /** - * Populates log_search_messages - * - * @param int $id_search - the id of the search to delete from logs - * @param int $maxMessageResults - the maximum number of messages to index - * - * @return int - the number of indexed results - */ - private function _prepare_word_index($id_search, $maxMessageResults) - { - $indexedResults = 0; - $inserts = array(); - - // Clear, all clear! - if (!$this->_createTemporary) - { - $this->_db_search->search_query('delete_log_search_messages', ' - DELETE FROM {db_prefix}log_search_messages - WHERE id_search = {int:id_search}', - array( - 'id_search' => $id_search, - ) - ); - } - - foreach ($this->_searchWords as $words) - { - // Search for this word, assuming we have some words! - if (!empty($words['indexed_words'])) - { - // Variables required for the search. - $search_data = array( - 'insert_into' => ($this->_createTemporary ? 'tmp_' : '') . 'log_search_messages', - 'no_regexp' => $this->noRegexp(), - 'max_results' => $maxMessageResults, - 'indexed_results' => $indexedResults, - 'params' => array( - 'id_search' => !$this->_createTemporary ? $id_search : 0, - 'excluded_words' => $this->_excludedWords, - 'user_query' => !empty($this->_userQuery) ? $this->_userQuery : '', - 'board_query' => !empty($this->_boardQuery) ? $this->_boardQuery : '', - 'topic' => !empty($this->_search_params['topic']) ? $this->param('topic') : 0, - 'min_msg_id' => (int) $this->_minMsgID, - 'max_msg_id' => (int) $this->_maxMsgID, - 'excluded_phrases' => $this->_excludedPhrases, - 'excluded_index_words' => $this->_excludedIndexWords, - 'excluded_subject_words' => $this->_excludedSubjectWords, - ), - ); - - $ignoreRequest = $this->_searchAPI->indexedWordQuery($words, $search_data); - - if (!$this->_db->support_ignore()) - { - while ($row = $this->_db->fetch_row($ignoreRequest)) - { - // No duplicates! - if (isset($inserts[$row[0]])) - { - continue; - } - - $inserts[$row[0]] = $row; - } - $this->_db->free_result($ignoreRequest); - $indexedResults = count($inserts); - } - else - { - $indexedResults += $this->_db->affected_rows(); - } - - if (!empty($maxMessageResults) && $indexedResults >= $maxMessageResults) - { - break; - } - } - } - - // More non-MySQL stuff needed? - if (!empty($inserts)) - { - $this->_db->insert('', - '{db_prefix}' . ($this->_createTemporary ? 'tmp_' : '') . 'log_search_messages', - $this->_createTemporary ? array('id_msg' => 'int') : array('id_msg' => 'int', 'id_search' => 'int'), - $inserts, - $this->_createTemporary ? array('id_msg') : array('id_msg', 'id_search') - ); - } - - return $indexedResults; - } - - /** - * Build the search relevance query - * - * @param null|int[] $factors - is factors are specified that array will - * be used to build the relevance value, otherwise the function will use - * $this->_weight_factors - * - * @return string - */ - private function _build_relevance($factors = null) - { - $relevance = '1000 * ('; - - if ($factors !== null && is_array($factors)) - { - $weight_total = 0; - foreach ($factors as $type => $value) - { - $relevance .= $this->_weight[$type]; - if (!empty($value['search'])) - { - $relevance .= ' * ' . $value['search']; - } - - $relevance .= ' + '; - $weight_total += $this->_weight[$type]; - } - } - else - { - $weight_total = $this->_weight_total; - foreach ($this->_weight_factors as $type => $value) - { - if (isset($value['results'])) - { - $relevance .= $this->_weight[$type]; - if (!empty($value['results'])) - { - $relevance .= ' * ' . $value['results']; - } - - $relevance .= ' + '; - } - } - } - - $relevance = substr($relevance, 0, -3) . ') / ' . $weight_total . ' AS relevance'; - - return $relevance; - } - - /** - * Inserts the data into log_search_results - * - * @param mixed[] $main_query - An array holding all the query parts. - * Structure: - * 'select' => string[] - the select columns - * 'from' => string - the table for the FROM clause - * 'inner_join' => string[] - any INNER JOIN - * 'left_join' => string[] - any LEFT JOIN - * 'where' => string[] - the conditions - * 'group_by' => string[] - the fields to group by - * 'parameters' => mixed[] - any parameter required by the query - * @param string $query_identifier - a string to identify the query - * @param bool $use_old_ids - if true the topic ids retrieved by a previous - * call to this function will be used to identify duplicates - * - * @return int - the number of rows affected by the query - */ - private function _build_search_results_log($main_query, $query_identifier, $use_old_ids = false) + public function getParticipants() { - static $usedIDs; - - $ignoreRequest = $this->_db_search->search_query($query_identifier, ($this->_db->support_ignore() ? (' - INSERT IGNORE INTO {db_prefix}log_search_results - (' . implode(', ', array_keys($main_query['select'])) . ')') : '') . ' - SELECT - ' . implode(', - ', $main_query['select']) . ' - FROM ' . $main_query['from'] . (!empty($main_query['inner_join']) ? ' - INNER JOIN ' . implode(' - INNER JOIN ', array_unique($main_query['inner_join'])) : '') . (!empty($main_query['left_join']) ? ' - LEFT JOIN ' . implode(' - LEFT JOIN ', array_unique($main_query['left_join'])) : '') . (!empty($main_query['where']) ? ' - WHERE ' : '') . implode(' - AND ', array_unique($main_query['where'])) . (!empty($main_query['group_by']) ? ' - GROUP BY ' . implode(', ', array_unique($main_query['group_by'])) : '') . (!empty($main_query['parameters']['limit']) ? ' - LIMIT {int:limit}' : ''), - $main_query['parameters'] - ); - - // If the database doesn't support IGNORE to make this fast we need to do some tracking. - if (!$this->_db->support_ignore()) - { - $inserts = array(); - - while ($row = $this->_db->fetch_assoc($ignoreRequest)) - { - // No duplicates! - if ($use_old_ids) - { - if (isset($usedIDs[$row['id_topic']])) - { - continue; - } - } - else - { - if (isset($inserts[$row['id_topic']])) - { - continue; - } - } - - $usedIDs[$row['id_topic']] = true; - foreach ($row as $key => $value) - $inserts[$row['id_topic']][] = (int) $row[$key]; - } - $this->_db->free_result($ignoreRequest); - - // Now put them in! - if (!empty($inserts)) - { - $query_columns = array(); - foreach ($main_query['select'] as $k => $v) - $query_columns[$k] = 'int'; - - $this->_db->insert('', - '{db_prefix}log_search_results', - $query_columns, - $inserts, - array('id_search', 'id_topic') - ); - } - $num_results = count($inserts); - } - else - { - $num_results = $this->_db->affected_rows(); - } - - return $num_results; + return $this->_participants; } } diff --git a/sources/subs/Search/SearchApiWrapper.php b/sources/subs/Search/SearchApiWrapper.php new file mode 100644 index 0000000000..c2af76705e --- /dev/null +++ b/sources/subs/Search/SearchApiWrapper.php @@ -0,0 +1,267 @@ +register(SUBSDIR . '/Search', '\\ElkArte\\Search'); + if (!is_object($config)) + { + $config = new \ElkArte\ValuesContainer((array) $config); + } + $this->load($config->search_index, $config, $searchParams); + } + + /** + * Wrapper for postCreated of the SearchAPI + * + * @param mixed[] $msgOptions + * @param mixed[] $topicOptions + * @param mixed[] $posterOptions + */ + public function postCreated($msgOptions, $topicOptions, $posterOptions) + { + if (is_callable(array($this->_searchAPI, 'postCreated'))) + { + $this->_searchAPI->postCreated($msgOptions, $topicOptions, $posterOptions); + } + } + + /** + * Wrapper for postModified of the SearchAPI + * + * @param mixed[] $msgOptions + * @param mixed[] $topicOptions + * @param mixed[] $posterOptions + */ + public function postModified($msgOptions, $topicOptions, $posterOptions) + { + if (is_callable(array($this->_searchAPI, 'postModified'))) + { + $this->_searchAPI->postModified($msgOptions, $topicOptions, $posterOptions); + } + } + + /** + * Wrapper for topicSplit of the SearchAPI + * + * @param int $split2_ID_TOPIC + * @param int[] $splitMessages + */ + public function topicSplit($split2_ID_TOPIC, $splitMessages) + { + if (is_callable(array($this->_searchAPI, 'topicSplit'))) + { + $this->_searchAPI->topicSplit($split2_ID_TOPIC, $splitMessages); + } + } + + /** + * Wrapper for topicMerge of the SearchAPI + * + * @param int $id_topic + * @param mixed[] $topics + * @param int[] $affected_msgs + * @param string[] $subject array($response_prefix, $target_subject) + */ + public function topicMerge($id_topic, $topics, $affected_msgs, $subject) + { + if (is_callable(array($this->_searchAPI, 'topicMerge'))) + { + $this->_searchAPI->topicMerge($id_topic, $topics, $affected_msgs, $subject); + } + } + + /** + * Wrapper for searchSettings of the SearchAPI + * + * @param mixed[] $config_vars + */ + public function searchSettings(&$config_vars) + { + if (is_callable(array($this->_searchAPI, 'searchSettings'))) + { + $this->_searchAPI->searchSettings($config_vars); + } + } + + /** + * Wrapper for searchQuery of the SearchAPI + * @param string[] $search_words + * @param string[] $excluded_words + * @param bool[] $participants + * @param string[] $search_results + * + * @return mixed[] + */ + public function searchQuery($search_words, $excluded_words, &$participants, &$search_results) + { + return $this->_searchAPI->searchQuery($search_words, $excluded_words, $participants, $search_results); + } + + /** + * Wrapper for prepareWord of the SearchAPI + * + * @return string + */ + public function prepareWord($phrase, $no_regexp) + { + return $this->_searchAPI->prepareWord($phrase, $no_regexp); + } + + /** + * Wrapper for setExcludedPhrases of the SearchAPI + * + * @param string[] $phrases An array of phrases to exclude + */ + public function setExcludedPhrases($phrase) + { + $this->_searchAPI->setExcludedPhrases($phrase); + } + + /** + * Wrapper for setExcludedWords of the SearchAPI + * + * @param string[] $words An array of words to exclude + */ + public function setExcludedWords($words) + { + $this->_searchAPI->setExcludedWords($words); + } + + /** + * Wrapper for setSearchArray of the SearchAPI + * + * @param \ElkArte\Search\SearchArray $searchArray + */ + public function setSearchArray(\ElkArte\Search\SearchArray $searchArray) + { + $this->_searchAPI->setSearchArray($searchArray); + } + + /** + * Wrapper for prepareIndexes of the SearchAPI + * + * @param string $word + * @param string $wordsSearch + * @param string $wordsExclude + * @param string $isExcluded + * @param string $excludedSubjectWords + */ + public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords) + { + $this->_searchAPI->prepareIndexes($word, $wordsSearch, $wordsExclude, $isExcluded, $excludedSubjectWords); + } + + /** + * Wrapper for searchSort of the SearchAPI + * + * @param string $a Word A + * @param string $b Word B + * @return int An integer indicating how the words should be sorted (-1, 0 1) + */ + public function searchSort($a, $b) + { + return $this->_searchAPI->searchSort($a, $b); + } + + /** + * Wrapper for setWeightFactors of the SearchAPI + * + * @param \ElkArte\Search\WeightFactors $weights + */ + public function setWeightFactors(\ElkArte\Search\WeightFactors $weights) + { + $this->_searchAPI->setWeightFactors($weights); + } + + /** + * Wrapper for useTemporary of the SearchAPI + * + * @param bool $use + */ + public function useTemporary($use = false) + { + $this->_searchAPI->useTemporary($use); + } + + /** + * Returns the number of results obtained from the query. + * + * @return int + */ + public function getNumResults() + { + return $this->_searchAPI->getNumResults(); + } + + /** + * Creates a search API and returns the object. + * + * @param string $searchClass + * @param \ElkArte\ValuesContainer $config + */ + protected function load($searchClass, $config, $searchParams) + { + global $txt; + + require_once(SUBSDIR . '/Package.subs.php'); + + // Load up the search API we are going to use. + if (empty($searchClass)) + { + $searchClass = self::DEFAULT_API; + } + + // Try to initialize the API + $fqcn = '\\ElkArte\\Search\\API\\' . ucfirst($searchClass); + if (class_exists($fqcn) && is_a($fqcn, '\\ElkArte\\Search\\API\\SearchAPI', true)) + { + // Create an instance of the search API and check it is valid for this version of the software. + $this->_searchAPI = new $fqcn($config, $searchParams); + } + + // An invalid Search API? Log the error and set it to use the standard API + if (!$this->_searchAPI || (!$this->_searchAPI->isValid()) || !matchPackageVersion(Search::FORUM_VERSION, $this->_searchAPI->min_elk_version . '-' . $this->_searchAPI->version_compatible)) + { + // Log the error. + theme()->getTemplates()->loadLanguageFile('Errors'); + \Errors::instance()->log_error(sprintf($txt['search_api_not_compatible'], $fqcn), 'critical'); + + $this->_searchAPI = new API\Standard($config, $searchParams); + } + } +} diff --git a/sources/subs/Search/SearchArray.php b/sources/subs/Search/SearchArray.php new file mode 100644 index 0000000000..543c1d1d2d --- /dev/null +++ b/sources/subs/Search/SearchArray.php @@ -0,0 +1,236 @@ +_search_string = $search_string; + $this->_search_simple_fulltext = $search_simple_fulltext; + $this->_blacklisted_words = $blacklisted_words; + $this->searchArray(); + } + + /** + * Builds the search array + * + * @return array + */ + protected function searchArray() + { + // Change non-word characters into spaces. + $stripped_query = preg_replace('~(?:[\x0B\0\x{A0}\t\r\s\n(){}\\[\\]<>!@$%^*.,:+=`\~\?/\\\\]+|&(?:amp|lt|gt|quot);)+~u', ' ', $this->_search_string); + + // Make the query lower case. It's gonna be case insensitive anyway. + $stripped_query = un_htmlspecialchars(\Util::strtolower($stripped_query)); + + // This option will do fulltext searching in the most basic way. + if ($this->_search_simple_fulltext) + { + $stripped_query = strtr($stripped_query, array('"' => '')); + } + + $this->_no_regexp = preg_match('~&#(?:\d{1,7}|x[0-9a-fA-F]{1,6});~', $stripped_query) === 1; + + // Extract phrase parts first (e.g. some words "this is a phrase" some more words.) + preg_match_all('/(?:^|\s)([-]?)"([^"]+)"(?:$|\s)/', $stripped_query, $matches, PREG_PATTERN_ORDER); + $phraseArray = $matches[2]; + + // Remove the phrase parts and extract the words. + $wordArray = preg_replace('~(?:^|\s)(?:[-]?)"(?:[^"]+)"(?:$|\s)~u', ' ', $this->_search_string); + $wordArray = explode(' ', \Util::htmlspecialchars(un_htmlspecialchars($wordArray), ENT_QUOTES)); + + // A minus sign in front of a word excludes the word.... so... + // .. first, we check for things like -"some words", but not "-some words". + $phraseArray = $this->_checkExcludePhrase($matches[1], $phraseArray); + + // Now we look for -test, etc.... normaller. + $wordArray = $this->_checkExcludeWord($wordArray); + + // The remaining words and phrases are all included. + $this->_searchArray = array_merge($phraseArray, $wordArray); + + // Trim everything and make sure there are no words that are the same. + foreach ($this->_searchArray as $index => $value) + { + // Skip anything practically empty. + if (($this->_searchArray[$index] = trim($value, '-_\' ')) === '') + { + unset($this->_searchArray[$index]); + } + // Skip blacklisted words. Make sure to note we skipped them in case we end up with nothing. + elseif (in_array($this->_searchArray[$index], $this->_blacklisted_words)) + { + $this->_foundBlackListedWords = true; + unset($this->_searchArray[$index]); + } + // Don't allow very, very short words. + elseif (\Util::strlen($value) < 2) + { + $this->_ignored[] = $value; + unset($this->_searchArray[$index]); + } + } + + $this->_searchArray = array_slice(array_unique($this->_searchArray), 0, 10); + + return $this->_searchArray; + } + + public function getSearchArray() + { + return $this->_searchArray; + } + + public function getExcludedWords() + { + return $this->_excludedWords; + } + + public function getNoRegexp() + { + return $this->_no_regexp; + } + + public function foundBlackListedWords() + { + return $this->_foundBlackListedWords; + } + + public function getIgnored() + { + return $this->_ignored; + } + + /** + * Looks for phrases that should be excluded from results + * + * - Check for things like -"some words", but not "-some words" + * - Prevents redundancy with blacklisted words + * + * @param string[] $matches + * @param string[] $phraseArray + * + * @return string[] + */ + private function _checkExcludePhrase($matches, $phraseArray) + { + foreach ($matches as $index => $word) + { + if ($word === '-') + { + if (($word = trim($phraseArray[$index], '-_\' ')) !== '' && !in_array($word, $this->_blacklisted_words)) + { + $this->_excludedWords[] = $word; + } + + unset($phraseArray[$index]); + } + } + + return $phraseArray; + } + + /** + * Looks for words that should be excluded in the results (-word) + * + * - Look for -test, etc + * - Prevents excluding blacklisted words since it is redundant + * + * @param string[] $wordArray + * + * @return string[] + */ + private function _checkExcludeWord($wordArray) + { + foreach ($wordArray as $index => $word) + { + if (strpos(trim($word), '-') === 0) + { + if (($word = trim($word, '-_\' ')) !== '' && !in_array($word, $this->_blacklisted_words)) + { + $this->_excludedWords[] = $word; + } + + unset($wordArray[$index]); + } + } + + return $wordArray; + } +} \ No newline at end of file diff --git a/sources/subs/Search/SearchParams.php b/sources/subs/Search/SearchParams.php new file mode 100644 index 0000000000..9fd500e390 --- /dev/null +++ b/sources/subs/Search/SearchParams.php @@ -0,0 +1,489 @@ +_db = database(); + $this->_search_string = $string; + $this->prepare(); + $this->data = &$this->_search_params; + } + + /** + * Encodes search params ($this->_search_params) in an URL-compatible way + * + * @param array $search build param index with specific search term (did you mean?) + * + * @return string - the encoded string to be appended to the URL + */ + public function compileURL($search = array()) + { + $temp_params = $this->_search_params; + $encoded = array(); + + if (!empty($search)) + { + $temp_params['search'] = implode(' ', $search); + } + + // *** Encode all search params + // All search params have been checked, let's compile them to a single string... made less simple by PHP 4.3.9 and below. + if (isset($temp_params['brd'])) + { + $temp_params['brd'] = implode(',', $temp_params['brd']); + } + + foreach ($temp_params as $k => $v) + $encoded[] = $k . '|\'|' . $v; + + if (!empty($encoded)) + { + // Due to old IE's 2083 character limit, we have to compress long search strings + $params = @gzcompress(implode('|"|', $encoded)); + + // Gzcompress failed, use try non-gz + if (empty($params)) + { + $params = implode('|"|', $encoded); + } + + // Base64 encode, then replace +/= with uri safe ones that can be reverted + $encoded = str_replace(array('+', '/', '='), array('-', '_', '.'), base64_encode($params)); + } + else + { + $encoded = ''; + } + + return $encoded; + } + + /** + * Extract search params from a string + */ + protected function prepare() + { + // Due to IE's 2083 character limit, we have to compress long search strings + $temp_params = base64_decode(str_replace(array('-', '_', '.'), array('+', '/', '='), $this->_search_string)); + + // Test for gzuncompress failing + $temp_params2 = @gzuncompress($temp_params); + $temp_params = explode('|"|', (!empty($temp_params2) ? $temp_params2 : $temp_params)); + + foreach ($temp_params as $i => $data) + { + list($k, $v) = array_pad(explode('|\'|', $data), 2, ''); + $this->_search_params[$k] = $v; + } + + if (isset($this->_search_params['brd'])) + { + $this->_search_params['brd'] = empty($this->_search_params['brd']) ? array() : explode(',', $this->_search_params['brd']); + } + } + + /** + * Merge search params extracted with SearchParams::prepare + * with those present in the $param array (usually $_REQUEST['params']) + * + * @param mixed[] $params - An array of search parameters + * @param int $recentPercentage - A coefficient to calculate the lowest + * message id to start search from + * @param int $maxMembersToSearch - The maximum number of members to consider + * when multiple are found + * + * @throws \Elk_Exception topic_gone + */ + public function merge($params, $recentPercentage, $maxMembersToSearch) + { + global $user_info, $modSettings, $context; + + // Store whether simple search was used (needed if the user wants to do another query). + if (!isset($this->_search_params['advanced'])) + { + $this->_search_params['advanced'] = empty($params['advanced']) ? 0 : 1; + } + + // 1 => 'allwords' (default, don't set as param) / 2 => 'anywords'. + if (!empty($this->_search_params['searchtype']) || (!empty($params['searchtype']) && $params['searchtype'] == 2)) + { + $this->_search_params['searchtype'] = 2; + } + + // Minimum age of messages. Default to zero (don't set param in that case). + if (!empty($this->_search_params['minage']) || (!empty($params['minage']) && $params['minage'] > 0)) + { + $this->_search_params['minage'] = !empty($this->_search_params['minage']) ? (int) $this->_search_params['minage'] : (int) $params['minage']; + } + + // Maximum age of messages. Default to infinite (9999 days: param not set). + if (!empty($this->_search_params['maxage']) || (!empty($params['maxage']) && $params['maxage'] < 9999)) + { + $this->_search_params['maxage'] = !empty($this->_search_params['maxage']) ? (int) $this->_search_params['maxage'] : (int) $params['maxage']; + } + + // Searching a specific topic? + if (!empty($params['topic']) || (!empty($params['search_selection']) && $params['search_selection'] === 'topic')) + { + $this->_search_params['topic'] = empty($params['search_selection']) ? (int) $params['topic'] : (isset($params['sd_topic']) ? (int) $params['sd_topic'] : ''); + $this->_search_params['show_complete'] = true; + } + elseif (!empty($this->_search_params['topic'])) + { + $this->_search_params['topic'] = (int) $this->_search_params['topic']; + } + + if (!empty($this->_search_params['minage']) || !empty($this->_search_params['maxage'])) + { + $request = $this->_db->query('', ' + SELECT ' . (empty($this->_search_params['maxage']) ? '0, ' : 'COALESCE(MIN(id_msg), -1), ') . (empty($this->_search_params['minage']) ? '0' : 'COALESCE(MAX(id_msg), -1)') . ' + FROM {db_prefix}messages + WHERE 1=1' . ($modSettings['postmod_active'] ? ' + AND approved = {int:is_approved_true}' : '') . (empty($this->_search_params['minage']) ? '' : ' + AND poster_time <= {int:timestamp_minimum_age}') . (empty($this->_search_params['maxage']) ? '' : ' + AND poster_time >= {int:timestamp_maximum_age}'), + array( + 'timestamp_minimum_age' => empty($this->_search_params['minage']) ? 0 : time() - 86400 * $this->_search_params['minage'], + 'timestamp_maximum_age' => empty($this->_search_params['maxage']) ? 0 : time() - 86400 * $this->_search_params['maxage'], + 'is_approved_true' => 1, + ) + ); + list ($this->_minMsgID, $this->_maxMsgID) = $this->_db->fetch_row($request); + if ($this->_minMsgID < 0 || $this->_maxMsgID < 0) + { + $context['search_errors']['no_messages_in_time_frame'] = true; + } + $this->_db->free_result($request); + } + + // Default the user name to a wildcard matching every user (*). + if (!empty($this->_search_params['userspec']) || (!empty($params['userspec']) && $params['userspec'] != '*')) + { + $this->_search_params['userspec'] = isset($this->_search_params['userspec']) ? $this->_search_params['userspec'] : $params['userspec']; + } + + // If there's no specific user, then don't mention it in the main query. + if (empty($this->_search_params['userspec'])) + { + $this->_userQuery = ''; + } + else + { + $userString = strtr(\Util::htmlspecialchars($this->_search_params['userspec'], ENT_QUOTES), array('"' => '"')); + $userString = strtr($userString, array('%' => '\%', '_' => '\_', '*' => '%', '?' => '_')); + + preg_match_all('~"([^"]+)"~', $userString, $matches); + $possible_users = array_merge($matches[1], explode(',', preg_replace('~"[^"]+"~', '', $userString))); + + for ($k = 0, $n = count($possible_users); $k < $n; $k++) + { + $possible_users[$k] = trim($possible_users[$k]); + + if (strlen($possible_users[$k]) == 0) + { + unset($possible_users[$k]); + } + } + + // Create a list of database-escaped search names. + $realNameMatches = array(); + foreach ($possible_users as $possible_user) + $realNameMatches[] = $this->_db->quote( + '{string:possible_user}', + array( + 'possible_user' => $possible_user + ) + ); + + // Retrieve a list of possible members. + $request = $this->_db->query('', ' + SELECT + id_member + FROM {db_prefix}members + WHERE {raw:match_possible_users}', + array( + 'match_possible_users' => 'real_name LIKE ' . implode(' OR real_name LIKE ', $realNameMatches), + ) + ); + + // Simply do nothing if there're too many members matching the criteria. + if ($this->_db->num_rows($request) > $maxMembersToSearch) + { + $this->_userQuery = ''; + } + elseif ($this->_db->num_rows($request) == 0) + { + $this->_userQuery = $this->_db->quote( + 'm.id_member = {int:id_member_guest} AND ({raw:match_possible_guest_names})', + array( + 'id_member_guest' => 0, + 'match_possible_guest_names' => 'm.poster_name LIKE ' . implode(' OR m.poster_name LIKE ', $realNameMatches), + ) + ); + } + else + { + while ($row = $this->_db->fetch_assoc($request)) + { + $this->_memberlist[] = $row['id_member']; + } + + $this->_userQuery = $this->_db->quote( + '(m.id_member IN ({array_int:matched_members}) OR (m.id_member = {int:id_member_guest} AND ({raw:match_possible_guest_names})))', + array( + 'matched_members' => $this->_memberlist, + 'id_member_guest' => 0, + 'match_possible_guest_names' => 'm.poster_name LIKE ' . implode(' OR m.poster_name LIKE ', $realNameMatches), + ) + ); + } + $this->_db->free_result($request); + } + + // Ensure that boards are an array of integers (or nothing). + if (!empty($this->_search_params['brd']) && is_array($this->_search_params['brd'])) + { + $query_boards = array_map('intval', $this->_search_params['brd']); + } + elseif (!empty($params['brd']) && is_array($params['brd'])) + { + $query_boards = array_map('intval', $params['brd']); + } + elseif (!empty($params['brd'])) + { + $query_boards = array_map('intval', explode(',', $params['brd'])); + } + elseif (!empty($params['search_selection']) && $params['search_selection'] === 'board' && !empty($params['sd_brd']) && is_array($params['sd_brd'])) + { + $query_boards = array_map('intval', $params['sd_brd']); + } + elseif (!empty($params['search_selection']) && $params['search_selection'] === 'board' && isset($params['sd_brd']) && (int) $params['sd_brd'] !== 0) + { + $query_boards = array((int) $params['sd_brd']); + } + else + { + $query_boards = array(); + } + + // Special case for boards: searching just one topic? + if (!empty($this->_search_params['topic'])) + { + $request = $this->_db->query('', ' + SELECT + b.id_board + FROM {db_prefix}topics AS t + INNER JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board) + WHERE t.id_topic = {int:search_topic_id} + AND {query_see_board}' . ($modSettings['postmod_active'] ? ' + AND t.approved = {int:is_approved_true}' : '') . ' + LIMIT 1', + array( + 'search_topic_id' => $this->_search_params['topic'], + 'is_approved_true' => 1, + ) + ); + + if ($this->_db->num_rows($request) == 0) + { + throw new \Elk_Exception('topic_gone', false); + } + + $this->_search_params['brd'] = array(); + list ($this->_search_params['brd'][0]) = $this->_db->fetch_row($request); + $this->_db->free_result($request); + } + // Select all boards you've selected AND are allowed to see. + elseif ($user_info['is_admin'] && (!empty($this->_search_params['advanced']) || !empty($query_boards))) + { + $this->_search_params['brd'] = $query_boards; + } + else + { + require_once(SUBSDIR . '/Boards.subs.php'); + $this->_search_params['brd'] = array_keys(fetchBoardsInfo(array('boards' => $query_boards), array('include_recycle' => false, 'include_redirects' => false, 'wanna_see_board' => empty($this->_search_params['advanced'])))); + + // This error should pro'bly only happen for hackers. + if (empty($this->_search_params['brd'])) + { + $context['search_errors']['no_boards_selected'] = true; + } + } + + if (count($this->_search_params['brd']) != 0) + { + foreach ($this->_search_params['brd'] as $k => $v) + $this->_search_params['brd'][$k] = (int) $v; + + // If we've selected all boards, this parameter can be left empty. + require_once(SUBSDIR . '/Boards.subs.php'); + $num_boards = countBoards(); + + if (count($this->_search_params['brd']) == $num_boards) + { + $this->_boardQuery = ''; + } + elseif (count($this->_search_params['brd']) == $num_boards - 1 && !empty($modSettings['recycle_board']) && !in_array($modSettings['recycle_board'], $this->_search_params['brd'])) + { + $this->_boardQuery = '!= ' . $modSettings['recycle_board']; + } + else + { + $this->_boardQuery = 'IN (' . implode(', ', $this->_search_params['brd']) . ')'; + } + } + else + { + $this->_boardQuery = ''; + } + + $this->_search_params['show_complete'] = !empty($this->_search_params['show_complete']) || !empty($params['show_complete']); + $this->_search_params['subject_only'] = !empty($this->_search_params['subject_only']) || !empty($params['subject_only']); + + // Get the sorting parameters right. Default to sort by relevance descending. + $sort_columns = array( + 'relevance', + 'num_replies', + 'id_msg', + ); + + // Allow integration to add additional sort columns + call_integration_hook('integrate_search_sort_columns', array(&$sort_columns)); + + if (empty($this->_search_params['sort']) && !empty($params['sort'])) + { + list ($this->_search_params['sort'], $this->_search_params['sort_dir']) = array_pad(explode('|', $params['sort']), 2, ''); + } + + $this->_search_params['sort'] = !empty($this->_search_params['sort']) && in_array($this->_search_params['sort'], $sort_columns) ? $this->_search_params['sort'] : 'relevance'; + + if (!empty($this->_search_params['topic']) && $this->_search_params['sort'] === 'num_replies') + { + $this->_search_params['sort'] = 'id_msg'; + } + + // Sorting direction: descending unless stated otherwise. + $this->_search_params['sort_dir'] = !empty($this->_search_params['sort_dir']) && $this->_search_params['sort_dir'] === 'asc' ? 'asc' : 'desc'; + + // Determine some values needed to calculate the relevance. + $this->_minMsg = (int) ((1 - $recentPercentage) * $modSettings['maxMsgID']); + $this->_recentMsg = $modSettings['maxMsgID'] - $this->_minMsg; + + // *** Parse the search query + call_integration_hook('integrate_search_params', array(&$this->_search_params)); + + // What are we searching for? + if (empty($this->_search_params['search'])) + { + if (isset($_GET['search'])) + { + $this->_search_params['search'] = un_htmlspecialchars($_GET['search']); + } + elseif (isset($_POST['search'])) + { + $this->_search_params['search'] = $_POST['search']; + } + else + { + $this->_search_params['search'] = ''; + } + } + } + + public function get() + { + return $this->_search_params; + } +} diff --git a/sources/subs/Search/WeightFactors.php b/sources/subs/Search/WeightFactors.php new file mode 100644 index 0000000000..47ede6ab5c --- /dev/null +++ b/sources/subs/Search/WeightFactors.php @@ -0,0 +1,137 @@ +_input_weights = $weights; + $this->_is_admin = (bool) $is_admin; + + $this->_setup_weight_factors(); + } + + public function getFactors() + { + return $this->_weight_factors; + } + + public function getWeight() + { + return $this->_weight; + } + + public function getTotal() + { + return $this->_weight_total; + } + + /** + * Prepares the weighting factors + */ + private function _setup_weight_factors() + { + global $modSettings; + + $default_factors = $this->_weight_factors = array( + 'frequency' => array( + 'search' => 'COUNT(*) / (MAX(t.num_replies) + 1)', + 'results' => '(t.num_replies + 1)', + ), + 'age' => array( + 'search' => 'CASE WHEN MAX(m.id_msg) < {int:min_msg} THEN 0 ELSE (MAX(m.id_msg) - {int:min_msg}) / {int:recent_message} END', + 'results' => 'CASE WHEN t.id_first_msg < {int:min_msg} THEN 0 ELSE (t.id_first_msg - {int:min_msg}) / {int:recent_message} END', + ), + 'length' => array( + 'search' => 'CASE WHEN MAX(t.num_replies) < {int:huge_topic_posts} THEN MAX(t.num_replies) / {int:huge_topic_posts} ELSE 1 END', + 'results' => 'CASE WHEN t.num_replies < {int:huge_topic_posts} THEN t.num_replies / {int:huge_topic_posts} ELSE 1 END', + ), + 'subject' => array( + 'search' => 0, + 'results' => 0, + ), + 'first_message' => array( + 'search' => 'CASE WHEN MIN(m.id_msg) = MAX(t.id_first_msg) THEN 1 ELSE 0 END', + ), + 'sticky' => array( + 'search' => 'MAX(t.is_sticky)', + 'results' => 't.is_sticky', + ), + 'likes' => array( + 'search' => 'MAX(t.num_likes)', + 'results' => 't.num_likes', + ), + ); + + // These are fallback weights in case of errors somewhere. + // Not intended to be passed to the hook + $default_weights = array( + 'search_weight_frequency' => 30, + 'search_weight_age' => 25, + 'search_weight_length' => 20, + 'search_weight_subject' => 15, + 'search_weight_first_message' => 10, + ); + + call_integration_hook('integrate_search_weights', array(&$this->_weight_factors)); + + // Set the weight factors for each area (frequency, age, etc) as defined in the ACP + $this->_calculate_weights($this->_weight_factors, $modSettings); + + // Zero weight. Weightless :P. + if (empty($this->_weight_total)) + { + // Admins can be bothered with a failure + if ($this->_is_admin) + { + throw new Elk_Exception('search_invalid_weights'); + } + + // Even if users will get an answer, the admin should know something is broken + Errors::instance()->log_lang_error('search_invalid_weights'); + + // Instead is better to give normal users and guests some kind of result + // using our defaults. + // Using a different variable here because it may be the hook is screwing + // things up + $this->_calculate_weights($default_factors, $default_weights); + } + } + + /** + * Fill the $_weight variable and calculate the total weight + * + * @param mixed[] $factors + * @param int[] $weights + */ + private function _calculate_weights($factors, $weights) + { + foreach ($factors as $weight_factor => $value) + { + $this->_weight[$weight_factor] = (int) ($weights['search_weight_' . $weight_factor] ?? 0); + $this->_weight_total += $this->_weight[$weight_factor]; + } + } +} \ No newline at end of file diff --git a/sources/subs/Topic.subs.php b/sources/subs/Topic.subs.php index 9a65057267..ad89f0a3b1 100644 --- a/sources/subs/Topic.subs.php +++ b/sources/subs/Topic.subs.php @@ -2393,7 +2393,7 @@ function postSplitRedirect($reason, $subject, $board_info, $new_topic) */ function splitTopic($split1_ID_TOPIC, $splitMessages, $new_subject) { - global $txt; + global $txt, $modSettings; $db = database(); @@ -2631,10 +2631,8 @@ function splitTopic($split1_ID_TOPIC, $splitMessages, $new_subject) sendNotifications($split1_ID_TOPIC, 'split'); // If there's a search index that needs updating, update it... - $search = new \ElkArte\Search\Search; - $searchAPI = $search->findSearchAPI(); - if (is_callable(array($searchAPI, 'topicSplit'))) - $searchAPI->topicSplit($split2_ID_TOPIC, $splitMessages); + $searchAPI = new \ElkArte\Search\SearchApiWrapper(!empty($modSettings['search_index']) ? $modSettings['search_index'] : ''); + $searchAPI->topicSplit($split2_ID_TOPIC, $splitMessages); // Return the ID of the newly created topic. return $split2_ID_TOPIC; diff --git a/sources/subs/TopicUtil.class.php b/sources/subs/TopicUtil.class.php index 75afce8b47..239c92ce88 100644 --- a/sources/subs/TopicUtil.class.php +++ b/sources/subs/TopicUtil.class.php @@ -118,6 +118,8 @@ public static function prepareContext($topics_info, $topicseen = false, $preview $url_fragment = ($row['num_replies'] == 0 ? '.0' : '.msg' . $row['id_last_msg']) . $topicseen . '#new'; } + $row['new_from'] = $row['new_from'] ?? 0; + // And build the array. $topics[$row['id_topic']] = array( 'id' => $row['id_topic'], @@ -134,7 +136,7 @@ public static function prepareContext($topics_info, $topicseen = false, $preview 'html_time' => htmlTime($row['first_poster_time']), 'timestamp' => forum_time(true, $row['first_poster_time']), 'subject' => $row['first_subject'], - 'preview' => trim($row['first_body']), + 'preview' => isset($row['first_body']) ? trim($row['first_body']) : '', 'icon' => $row['first_icon'], 'icon_url' => $icon_sources->{$row['first_icon']}, 'href' => $scripturl . '?topic=' . $row['id_topic'] . '.0' . $topicseen, @@ -153,7 +155,7 @@ public static function prepareContext($topics_info, $topicseen = false, $preview 'html_time' => htmlTime($row['last_poster_time']), 'timestamp' => forum_time(true, $row['last_poster_time']), 'subject' => $row['last_subject'], - 'preview' => trim($row['last_body']), + 'preview' => isset($row['last_body']) ? trim($row['last_body']) : '', 'icon' => $row['last_icon'], 'icon_url' => $icon_sources->{$row['last_icon']}, 'href' => $scripturl . '?topic=' . $row['id_topic'] . $url_fragment, @@ -180,8 +182,9 @@ public static function prepareContext($topics_info, $topicseen = false, $preview 'replies' => comma_format($row['num_replies']), 'views' => comma_format($row['num_views']), 'likes' => comma_format($row['num_likes']), - 'approved' => $row['approved'], + 'approved' => $row['approved'] ?? 1, 'unapproved_posts' => !empty($row['unapproved_posts']) ? $row['unapproved_posts'] : 0, + 'classes' => array(), ); if (!empty($row['id_board'])) diff --git a/sources/subs/TopicsMerge.class.php b/sources/subs/TopicsMerge.class.php index 1b6a313a3a..caa1e155ff 100644 --- a/sources/subs/TopicsMerge.class.php +++ b/sources/subs/TopicsMerge.class.php @@ -380,6 +380,8 @@ public function doMerge($details = array()) */ protected function _updateStats($affected_msgs, $id_topic, $target_subject, $enforce_subject) { + global $modSettings; + // Cycle through each board... foreach ($this->_boardTotals as $id_board => $stats) decrementBoard($id_board, $stats); @@ -406,10 +408,8 @@ protected function _updateStats($affected_msgs, $id_topic, $target_subject, $enf $response_prefix = response_prefix(); // If there's a search index that needs updating, update it... - $search = new \ElkArte\Search\Search; - $searchAPI = $search->findSearchAPI(); - if (is_callable(array($searchAPI, 'topicMerge'))) - $searchAPI->topicMerge($id_topic, $this->_topics, $affected_msgs, empty($enforce_subject) ? null : array($response_prefix, $target_subject)); + $searchAPI = new \ElkArte\Search\SearchApiWrapper(!empty($modSettings['search_index']) ? $modSettings['search_index'] : ''); + $searchAPI->topicMerge($id_topic, $this->_topics, $affected_msgs, empty($enforce_subject) ? null : array($response_prefix, $target_subject)); } /** diff --git a/sources/subs/VerificationControl/Captcha.php b/sources/subs/VerificationControl/Captcha.php index 69cb11081d..b693b07806 100644 --- a/sources/subs/VerificationControl/Captcha.php +++ b/sources/subs/VerificationControl/Captcha.php @@ -107,8 +107,10 @@ public function showVerification($sessionVal, $isNew, $force_refresh = true) { global $context, $modSettings, $scripturl; + $show_captcha = !empty($this->_options['override_visual']) || (!empty($modSettings['visual_verification_type']) && !isset($this->_options['override_visual'])); + // Some javascript ma'am? (But load it only once) - if (!empty($this->_options['override_visual']) || (!empty($modSettings['visual_verification_type']) && !isset($this->_options['override_visual'])) && empty($context['captcha_js_loaded'])) + if ($show_captcha && empty($context['captcha_js_loaded'])) { theme()->getTemplates()->load('VerificationControls'); loadJavascriptFile('jquery.captcha.js'); @@ -120,7 +122,7 @@ public function showVerification($sessionVal, $isNew, $force_refresh = true) // Requesting a new challenge, build the image link, seed the JS if ($isNew) { - $this->_show_captcha = !empty($this->_options['override_visual']) || (!empty($modSettings['visual_verification_type']) && !isset($this->_options['override_visual'])); + $this->_show_captcha = $show_captcha; if ($this->_show_captcha) { @@ -130,7 +132,9 @@ public function showVerification($sessionVal, $isNew, $force_refresh = true) } if ($isNew || $force_refresh) + { $this->createTest($sessionVal, $force_refresh); + } return $this->_show_captcha; } diff --git a/sources/subs/VerificationControl/Questions.php b/sources/subs/VerificationControl/Questions.php index 1ce56aea02..4972d26b0a 100644 --- a/sources/subs/VerificationControl/Questions.php +++ b/sources/subs/VerificationControl/Questions.php @@ -64,6 +64,13 @@ class Questions implements ControlInterface */ private $_incorrectQuestions = null; + /** + * Filters to use to load the questions + * + * @var null|\ElkArte\ValuesContainer + */ + private $_filter = null; + /** * On your mark * @@ -72,7 +79,10 @@ class Questions implements ControlInterface public function __construct($verificationOptions = null) { if (!empty($verificationOptions)) + { $this->_options = $verificationOptions; + } + $this->_filter = new \ElkArte\ValuesContainer(); } /** @@ -88,7 +98,9 @@ public function showVerification($sessionVal, $isNew, $force_refresh = true) // If we want questions do we have a cache of all the IDs? if (!empty($this->_number_questions) && empty($modSettings['question_id_cache'])) + { $this->_refreshQuestionsCache(); + } // Let's deal with languages // First thing we need to know what language the user wants and if there is at least one question @@ -99,10 +111,14 @@ public function showVerification($sessionVal, $isNew, $force_refresh = true) { // Not even in the forum default? What the heck are you doing?! if (empty($modSettings['question_id_cache'][$language])) + { $this->_number_questions = 0; + } // Fall back to the default else + { $this->_questions_language = $language; + } } // Do we have enough questions? @@ -113,7 +129,9 @@ public function showVerification($sessionVal, $isNew, $force_refresh = true) $this->_questionIDs = array(); if ($isNew || $force_refresh) + { $this->createTest($sessionVal, $force_refresh); + } } } @@ -135,17 +153,27 @@ public function createTest($sessionVal, $refresh = true) // Pick some random IDs if ($this->_number_questions == 1) + { $this->_questionIDs[] = $this->_possible_questions[array_rand($this->_possible_questions, $this->_number_questions)]; + } else + { foreach (array_rand($this->_possible_questions, $this->_number_questions) as $index) + { $this->_questionIDs[] = $this->_possible_questions[$index]; + } + } } // Same questions as before. else + { $this->_questionIDs = !empty($sessionVal['q']) ? $sessionVal['q'] : array(); + } if (empty($this->_questionIDs) && !$refresh) + { $this->createTest($sessionVal, true); + } } /** @@ -156,8 +184,9 @@ public function prepareContext($sessionVal) theme()->getTemplates()->load('VerificationControls'); $sessionVal['q'] = array(); + $this->_filter = new \ElkArte\ValuesContainer(array('type' => 'id_question', 'value' => $this->_questionIDs)); - $questions = $this->_loadAntispamQuestions(array('type' => 'id_question', 'value' => $this->_questionIDs)); + $questions = $this->_loadAntispamQuestions(); $asked_questions = array(); $parser = \BBC\ParserWrapper::instance(); @@ -186,10 +215,14 @@ public function prepareContext($sessionVal) public function doTest($sessionVal) { if ($this->_number_questions && (!isset($sessionVal['q']) || !isset($_REQUEST[$this->_options['id'] . '_vv']['q']))) + { throw new Elk_Exception('no_access', false); + } if (!$this->_verifyAnswers($sessionVal)) + { return 'wrong_verification_answer'; + } return true; } @@ -210,15 +243,13 @@ public function settings() global $txt, $context, $language; // Load any question and answers! - $filter = null; if (isset($_GET['language'])) { - $filter = array( + $this->_filter = new \ElkArte\ValuesContainer(array( 'type' => 'language', 'value' => $_GET['language'], - ); + )); } - $context['question_answers'] = $this->_loadAntispamQuestions($filter); $languages = getLanguages(); // Languages dropdown only if we have more than a lang installed, otherwise is plain useless @@ -226,66 +257,27 @@ public function settings() { $context['languages'] = $languages; foreach ($context['languages'] as &$lang) + { if ($lang['filename'] === $language) + { $lang['selected'] = true; + } + } } // Saving them? if (isset($_GET['save'])) { - // Handle verification questions. - $questionInserts = array(); - $count_questions = 0; - - foreach ($_POST['question'] as $id => $question) - { - $question = trim(\Util::htmlspecialchars($question, ENT_COMPAT)); - $answers = array(); - $question_lang = isset($_POST['language'][$id]) && isset($languages[$_POST['language'][$id]]) ? $_POST['language'][$id] : $language; - if (!empty($_POST['answer'][$id])) - foreach ($_POST['answer'][$id] as $answer) - { - $answer = trim(\Util::strtolower(\Util::htmlspecialchars($answer, ENT_COMPAT))); - if ($answer != '') - $answers[] = $answer; - } - - // Already existed? - if (isset($context['question_answers'][$id])) - { - $count_questions++; - - // Changed? - if ($question == '' || empty($answers)) - { - $this->_delete($id); - $count_questions--; - } - else - $this->_update($id, $question, $answers, $question_lang); - } - // It's so shiney and new! - elseif ($question != '' && !empty($answers)) - { - $questionInserts[] = array( - 'question' => $question, - // @todo: remotely possible that the serialized value is longer than 65535 chars breaking the update/insertion - 'answer' => serialize($answers), - 'language' => $question_lang, - ); - $count_questions++; - } - } - - // Any questions to insert? - if (!empty($questionInserts)) - $this->_insert($questionInserts); + $count_questions = $this->_saveSettings($_POST['question'], $_POST['answer'], $_POST['language']); if (empty($count_questions) || $_POST['qa_verification_number'] > $count_questions) + { $_POST['qa_verification_number'] = $count_questions; - + } } + $context['question_answers'] = $this->_loadAntispamQuestions(); + return array( // Clever Thomas, who is looking sheepy now? Not I, the mighty sword swinger did say. array('title', 'setup_verification_questions'), @@ -295,6 +287,73 @@ public function settings() ); } + /** + * Prepares the questions coming from the UI to be saved into the database. + * + * @param string[] $save_question + * @return boolean + */ + protected function _saveSettings($save_question, $save_answer, $save_language) + { + $existing_question_answers = $this->_loadAntispamQuestions(); + + // Handle verification questions. + $questionInserts = array(); + $count_questions = 0; + + foreach ($save_question as $id => $question) + { + $question = trim(\Util::htmlspecialchars($question, ENT_COMPAT)); + $answers = array(); + $question_lang = isset($save_language[$id]) && isset($languages[$save_language[$id]]) ? $save_language[$id] : $language; + if (!empty($save_answer[$id])) + { + foreach ($save_answer[$id] as $answer) + { + $answer = trim(\Util::strtolower(\Util::htmlspecialchars($answer, ENT_COMPAT))); + if ($answer != '') + $answers[] = $answer; + } + } + + // Already existed? + if (isset($existing_question_answers[$id])) + { + $count_questions++; + + // Changed? + if ($question == '' || empty($answers)) + { + $this->_delete($id); + $count_questions--; + } + else + { + $this->_update($id, $question, $answers, $question_lang); + } + } + // It's so shiney and new! + elseif ($question != '' && !empty($answers)) + { + $questionInserts[] = array( + 'question' => $question, + // @todo: remotely possible that the serialized value is longer than 65535 chars breaking the update/insertion + 'answer' => serialize($answers), + 'language' => $question_lang, + ); + $count_questions++; + } + } + + // Any questions to insert? + if (!empty($questionInserts)) + { + $this->_insert($questionInserts); + } + + return $count_questions; + } + /** * Checks if an the answers to anti-spam questions are correct * @@ -302,8 +361,9 @@ public function settings() */ private function _verifyAnswers($sessionVal) { + $this->_filter = new \ElkArte\ValuesContainer(array('type' => 'id_question', 'value' => $sessionVal['q'])); // Get the answers and see if they are all right! - $questions = $this->_loadAntispamQuestions(array('type' => 'id_question', 'value' => $sessionVal['q'])); + $questions = $this->_loadAntispamQuestions(); $this->_incorrectQuestions = array(); foreach ($questions as $row) { @@ -349,30 +409,28 @@ private function _refreshQuestionsCache() /** * Loads all the available antispam questions, or a subset based on a filter * - * @param array|null $filter if specified it myst be an array with two indexes: - * - 'type' => a valid filter, it can be 'language' or 'id_question' - * - 'value' => the value of the filter (i.e. the language) - * * @return array */ - private function _loadAntispamQuestions($filter = null) + private function _loadAntispamQuestions() { $db = database(); - $available_filters = array( - 'language' => 'language = {string:current_filter}', - 'id_question' => 'id_question IN ({array_int:current_filter})', - ); + $available_filters = new \ElkArte\ValuesContainer(array( + 'language' => ' + WHERE language = {string:current_filter}', + 'id_question' => ' + WHERE id_question IN ({array_int:current_filter})', + )); + $condition = (string) $available_filters[$this->_filter['type']]; // Load any question and answers! $question_answers = array(); $request = $db->query('', ' SELECT id_question, question, answer, language - FROM {db_prefix}antispam_questions' . ($filter === null || !isset($available_filters[$filter['type']]) ? '' : ' - WHERE ' . $available_filters[$filter['type']]), + FROM {db_prefix}antispam_questions' . $condition, array( - 'current_filter' => $filter['value'], + 'current_filter' => $this->_filter['value'], ) ); while ($row = $db->fetch_assoc($request)) diff --git a/sources/subs/VerificationControls.integrate.php b/sources/subs/VerificationControls.integrate.php index 974acf42d3..45a78c6197 100644 --- a/sources/subs/VerificationControls.integrate.php +++ b/sources/subs/VerificationControls.integrate.php @@ -97,7 +97,7 @@ public static function create(&$verificationOptions, $do_test = false) // Start with any testing. if ($do_test) { - $force_refresh = $instances->test($verification_errors); + $force_refresh = $instances->test($verification_errors, $max_errors); } // Are we refreshing then? diff --git a/sources/subs/VerificationControls.php b/sources/subs/VerificationControls.php index 064c026b63..6950e88586 100644 --- a/sources/subs/VerificationControls.php +++ b/sources/subs/VerificationControls.php @@ -50,7 +50,7 @@ public function __construct(SessionIndex $sessionVal, $settings = array(), $veri { $settings['known_verifications'] = self::discoverControls(); } - $this->_known_verifications = json_decode($settings['known_verifications']); + $this->_known_verifications = json_decode($settings['known_verifications'], true); $this->_verification_options = $verificationOptions; $this->_verification_options['render'] = false; $this->_sessionVal = $sessionVal; @@ -137,8 +137,9 @@ protected static function loadFSControls() * Runs the tests and populates the errors (if any) * * @param \ElkArte\Errors\ErrorContext $verification_errors + * @param int $max_errors */ - public function test($verification_errors) + public function test($verification_errors, $max_errors) { $increase_error_count = false; $force_refresh = false; @@ -167,7 +168,7 @@ public function test($verification_errors) $this->_sessionVal['errors'] = 0; } // Too many errors? - elseif ($this->_sessionVal['errors'] > $thisVerification['max_errors']) + elseif ($this->_sessionVal['errors'] > $max_errors) { $force_refresh = true; } diff --git a/tests/sources/subs/Search.class.Test.php b/tests/sources/subs/Search.class.Test.php new file mode 100644 index 0000000000..a43a2f9e19 --- /dev/null +++ b/tests/sources/subs/Search.class.Test.php @@ -0,0 +1,277 @@ +run_tests = false; + return; + } + + // This is here to cheat with allowedTo + $user_info['is_admin'] = true; + + // Create 2 boards + require_once(SUBSDIR . '/Boards.subs.php'); + $this->board_all_access = createBoard([ + 'board_name' => 'Search 1', + 'redirect' => '', + 'move_to' => 'bottom', + 'target_category' => 1, + 'access_groups' => [0,1], + 'deny_groups' => [], + ]); + $this->board_limited_access = createBoard([ + 'board_name' => 'Search 2', + 'redirect' => '', + 'move_to' => 'bottom', + 'target_category' => 1, + 'access_groups' => [1], + 'deny_groups' => [], + ]); + + // Create 3 users (actually 2) + // One user must have access to all the boards + // One user must have access to one board + // One user must have access to no boards (guests) + require_once(SUBSDIR . '/Members.subs.php'); + + $regOptions1 = [ + 'interface' => 'admin', + 'username' => 'Search User 1', + 'email' => 'search@email1.tld', + 'password' => 'password', + 'password_check' => 'password', + 'check_reserved_name' => false, + 'check_password_strength' => false, + 'check_email_ban' => false, + 'send_welcome_email' => false, + 'require' => 'nothing', + 'memberGroup' => 1, + 'ip' => '127.0.0.1', + 'ip2' => '127.0.0.1', + 'auth_method' => 'password', + ]; + $this->member_full_access = registerMember($regOptions1); + + $regOptions2 = [ + 'interface' => 'admin', + 'username' => 'Search User 2', + 'email' => 'search@email2.tld', + 'password' => 'password', + 'password_check' => 'password', + 'check_reserved_name' => false, + 'check_password_strength' => false, + 'check_email_ban' => false, + 'send_welcome_email' => false, + 'require' => 'nothing', + 'memberGroup' => 0, + 'ip' => '127.0.0.1', + 'ip2' => '127.0.0.1', + 'auth_method' => 'password', + ]; + $this->member_limited_access = registerMember($regOptions2); + + // Hopefully a guest doesn't have any access + $this->member_no_access = 0; + + // Create 2 topics, one for each board + require_once(SUBSDIR . '/Post.subs.php'); + $msgOptions = [ + 'id' => 0, + 'subject' => 'Visible search topic', + 'body' => 'Visible search topic', + 'icon' => '', + 'smileys_enabled' => true, + 'approved' => true, + ]; + $topicOptions = [ + 'id' => 0, + 'board' => $this->board_all_access, + 'lock_mode' => null, + 'sticky_mode' => null, + 'mark_as_read' => true, + 'is_approved' => true, + ]; + $posterOptions = [ + 'id' => $this->member_full_access, + 'name' => 'guestname', + 'email' => $regOptions1['email'], + 'update_post_count' => false, + ]; + createPost($msgOptions, $topicOptions, $posterOptions); + + $msgOptions = [ + 'id' => 0, + 'subject' => 'Hidden search topic', + 'body' => 'Hidden search topic', + 'icon' => '', + 'smileys_enabled' => true, + 'approved' => true, + ]; + $topicOptions = [ + 'id' => 0, + 'board' => $this->board_limited_access, + 'lock_mode' => null, + 'sticky_mode' => null, + 'mark_as_read' => true, + 'is_approved' => true, + ]; + $posterOptions = [ + 'id' => $this->member_full_access, + 'name' => 'guestname', + 'email' => $regOptions1['email'], + 'update_post_count' => false, + ]; + createPost($msgOptions, $topicOptions, $posterOptions); + + Elk_Autoloader::instance()->register(SUBSDIR . '/Search', '\\ElkArte\\Search'); + } + + /** + * Admins are able to find both topics + */ + public function testBasicAdminSearch() + { + global $cookiename; + + if ($this->run_tests === false) + { + return; + } + + $_COOKIE[$cookiename] = json_encode([$this->member_full_access, $this->_getSalt($this->member_full_access)]); + loadUserSettings(); + + $topics = $this->_performSearch(); + $this->assertEquals(2, count($topics), 'Admin search results not correct, found ' . count($topics) . ' instead of 2'); + } + + /** + * Normal users with access to one of the two boards are able to find + * only one topic + */ + public function testBasicMemberSearch() + { + global $cookiename; + + if ($this->run_tests === false) + { + return; + } + + $_COOKIE[$cookiename] = json_encode([$this->member_limited_access, $this->_getSalt($this->member_limited_access)]); + loadUserSettings(); + + $topics = $this->_performSearch(); + $this->assertEquals(1, count($topics), 'Normal member search results not correct, found ' . count($topics) . ' instead of 1'); + } + + /** + * Guests do not have access to the boards, so they cannot find any result + * + * @expectedException \Exception + * @expectedExceptionMessage query_not_specific_enough + */ + public function testBasicGuestSearch() + { + global $cookiename; + + if ($this->run_tests === false) + { + throw new \Exception('query_not_specific_enough'); + } + + $_COOKIE[$cookiename] = null; + loadUserSettings(); + + $topics = $this->_performSearch(); + $this->assertEquals(0, count($topics), 'Guest search results not correct, found ' . count($topics) . ' instead of 0'); + + } + + protected function _performSearch() + { + global $modSettings, $context; + + $recentPercentage = 0.30; + $maxMembersToSearch = 500; + $humungousTopicPosts = 200; + $maxMessageResults = empty($modSettings['search_max_results']) ? 0 : + $search_terms = [ + 'search' => 'search', + 'search_selection' => 'all', + 'advanced' => 0 + ]; + $_GET['search'] = $search_terms['search']; + $search = new \ElkArte\Search\Search(); + $search->setWeights(new \ElkArte\Search\WeightFactors($modSettings, 1)); + $search_params = new \ElkArte\Search\SearchParams(''); + $search_params->merge($search_terms, $recentPercentage, $maxMembersToSearch); + $search->setParams($search_params, !empty($modSettings['search_simple_fulltext'])); + $context['params'] = $search->compileURLparams(); + + $search_config = new \ElkArte\ValuesContainer(array( + 'humungousTopicPosts' => $humungousTopicPosts, + 'maxMessageResults' => $maxMessageResults, + 'search_index' => !empty($modSettings['search_index']) ? $modSettings['search_index'] : '', + 'banned_words' => empty($modSettings['search_banned_words']) ? array() : explode(',', $modSettings['search_banned_words']), + )); + $topics = $search->searchQuery( + new \ElkArte\Search\SearchApiWrapper($search_config, $search->getSearchParams()) + ); + + return $topics; + } + + protected function _getSalt($member) + { + $db = database(); + + if (empty($member)) + { + return ''; + } + + $res = $db->fetchQuery(' + SELECT passwd, password_salt + FROM {db_prefix}members + where id_member = {int:id_member}', + array( + 'id_member' => $member + ) + ); + + if (empty($res)) + { + return ''; + } + return hash('sha256', ($res[0]['passwd'] . $res[0]['password_salt'])); + } + + /** + * cleanup data we no longer need at the end of the tests in this class. + * + * tearDown() is run automatically by the testing framework after each test method. + */ + public function tearDown() + { + } +} diff --git a/themes/default/PersonalMessage.template.php b/themes/default/PersonalMessage.template.php index 4f598dcee9..344db1695a 100644 --- a/themes/default/PersonalMessage.template.php +++ b/themes/default/PersonalMessage.template.php @@ -84,13 +84,14 @@ function template_folder() $start = true; + $controller = $context['get_pmessage'][0]; // Do we have some messages to display? - if ($context['get_pmessage']('message', true)) + if ($controller->{$context['get_pmessage'][1]}(true)) { echo '
'; - while ($message = $context['get_pmessage']('message')) + while ($message = $controller->{$context['get_pmessage'][1]}()) { // Show the helpful titlebar - generally. if ($start && $context['display_mode'] != 1) @@ -424,8 +425,9 @@ function template_subject_list() ', $txt['pm_alert_none'], ' '; + $controller = $context['get_psubject'][0]; // Use the query callback to get the subject list - while ($message = $context['get_pmessage']('subject')) + while ($message = $controller->{$context['get_psubject'][1]}()) { $discussion_url = $context['display_mode'] == 0 || $context['current_pm'] == $message['id'] ? '' : ($scripturl . '?action=pm;pmid=' . $message['id'] . ';kstart;f=' . $context['folder'] . ';start=' . $context['start'] . ';sort=' . $context['sort_by'] . ($context['sort_direction'] == 'up' ? ';asc' : ';desc') . ($context['current_label_id'] != -1 ? ';l=' . $context['current_label_id'] : ''));