From a50b0faf4abfb1bba68e03d843c58f07f842cf12 Mon Sep 17 00:00:00 2001 From: Dhruv Goel Date: Fri, 4 May 2012 18:39:41 +0530 Subject: [PATCH] [feature/sphinx-fulltext-search] MOD by naderman installed in phpbb PHPBB3-10946 --- phpBB/includes/functions_sphinx.php | 507 +++++++++ phpBB/includes/search/fulltext_sphinx.php | 1166 +++++++++++++++++++ phpBB/includes/sphinxapi-0.9.8.php | 1202 ++++++++++++++++++++ phpBB/language/en/mods/fulltext_sphinx.php | 65 ++ 4 files changed, 2940 insertions(+) create mode 100644 phpBB/includes/functions_sphinx.php create mode 100644 phpBB/includes/search/fulltext_sphinx.php create mode 100644 phpBB/includes/sphinxapi-0.9.8.php create mode 100644 phpBB/language/en/mods/fulltext_sphinx.php diff --git a/phpBB/includes/functions_sphinx.php b/phpBB/includes/functions_sphinx.php new file mode 100644 index 0000000000..976f93f77c --- /dev/null +++ b/phpBB/includes/functions_sphinx.php @@ -0,0 +1,507 @@ +read($filename); + } + } + + /** + * Get a section object by its name + * + * @param string $name The name of the section that shall be returned + * @return sphinx_config_section The section object or null if none was found + */ + function &get_section_by_name($name) + { + for ($i = 0, $n = sizeof($this->sections); $i < $n; $i++) + { + // make sure this is really a section object and not a comment + if (is_a($this->sections[$i], 'sphinx_config_section') && $this->sections[$i]->get_name() == $name) + { + return $this->sections[$i]; + } + } + $null = null; + return $null; + } + + /** + * Appends a new empty section to the end of the config + * + * @param string $name The name for the new section + * @return sphinx_config_section The newly created section object + */ + function &add_section($name) + { + $this->sections[] = new sphinx_config_section($name, ''); + return $this->sections[sizeof($this->sections) - 1]; + } + + /** + * Parses the config file at the given path, which is stored in $this->loaded for later use + * + * @param string $filename The path to the config file + */ + function read($filename) + { + // split the file into lines, we'll process it line by line + $config_file = file($filename); + + $this->sections = array(); + + $section = null; + $found_opening_bracket = false; + $in_value = false; + + foreach ($config_file as $i => $line) + { + // if the value of a variable continues to the next line because the line break was escaped + // then we don't trim leading space but treat it as a part of the value + if ($in_value) + { + $line = rtrim($line); + } + else + { + $line = trim($line); + } + + // if we're not inside a section look for one + if (!$section) + { + // add empty lines and comments as comment objects to the section list + // that way they're not deleted when reassembling the file from the sections + if (!$line || $line[0] == '#') + { + $this->sections[] = new sphinx_config_comment($config_file[$i]); + continue; + } + else + { + // otherwise we scan the line reading the section name until we find + // an opening curly bracket or a comment + $section_name = ''; + $section_name_comment = ''; + $found_opening_bracket = false; + for ($j = 0, $n = strlen($line); $j < $n; $j++) + { + if ($line[$j] == '#') + { + $section_name_comment = substr($line, $j); + break; + } + + if ($found_opening_bracket) + { + continue; + } + + if ($line[$j] == '{') + { + $found_opening_bracket = true; + continue; + } + + $section_name .= $line[$j]; + } + + // and then we create the new section object + $section_name = trim($section_name); + $section = new sphinx_config_section($section_name, $section_name_comment); + } + } + else // if we're looking for variables inside a section + { + $skip_first = false; + + // if we're not in a value continuing over the line feed + if (!$in_value) + { + // then add empty lines and comments as comment objects to the variable list + // of this section so they're not deleted on reassembly + if (!$line || $line[0] == '#') + { + $section->add_variable(new sphinx_config_comment($config_file[$i])); + continue; + } + + // as long as we haven't yet actually found an opening bracket for this section + // we treat everything as comments so it's not deleted either + if (!$found_opening_bracket) + { + if ($line[0] == '{') + { + $skip_first = true; + $line = substr($line, 1); + $found_opening_bracket = true; + } + else + { + $section->add_variable(new sphinx_config_comment($config_file[$i])); + continue; + } + } + } + + // if we did not find a comment in this line or still add to the previous line's value ... + if ($line || $in_value) + { + if (!$in_value) + { + $name = ''; + $value = ''; + $comment = ''; + $found_assignment = false; + } + $in_value = false; + $end_section = false; + + // ... then we should prase this line char by char: + // - first there's the variable name + // - then an equal sign + // - the variable value + // - possibly a backslash before the linefeed in this case we need to continue + // parsing the value in the next line + // - a # indicating that the rest of the line is a comment + // - a closing curly bracket indicating the end of this section + for ($j = 0, $n = strlen($line); $j < $n; $j++) + { + if ($line[$j] == '#') + { + $comment = substr($line, $j); + break; + } + else if ($line[$j] == '}') + { + $comment = substr($line, $j + 1); + $end_section = true; + break; + } + else if (!$found_assignment) + { + if ($line[$j] == '=') + { + $found_assignment = true; + } + else + { + $name .= $line[$j]; + } + } + else + { + if ($line[$j] == '\\' && $j == $n - 1) + { + $value .= "\n"; + $in_value = true; + continue 2; // go to the next line and keep processing the value in there + } + $value .= $line[$j]; + } + } + + // if a name and an equal sign were found then we have append a new variable object to the section + if ($name && $found_assignment) + { + $section->add_variable(new sphinx_config_variable(trim($name), trim($value), ($end_section) ? '' : $comment)); + continue; + } + + // if we found a closing curly bracket this section has been completed and we can append it to the section list + // and continue with looking for the next section + if ($end_section) + { + $section->set_end_comment($comment); + $this->sections[] = $section; + $section = null; + continue; + } + } + + // if we did not find anything meaningful up to here, then just treat it as a comment + $comment = ($skip_first) ? "\t" . substr(ltrim($config_file[$i]), 1) : $config_file[$i]; + $section->add_variable(new sphinx_config_comment($comment)); + } + } + + // keep the filename for later use + $this->loaded = $filename; + } + + /** + * Writes the config data into a file + * + * @param string $filename The optional filename into which the config data shall be written. + * If it's not specified it will be written into the file that the config + * was originally read from. + */ + function write($filename = false) + { + if ($filename === false && $this->loaded) + { + $filename = $this->loaded; + } + + $data = ""; + foreach ($this->sections as $section) + { + $data .= $section->to_string(); + } + + $fp = fopen($filename, 'wb'); + fwrite($fp, $data); + fclose($fp); + } +} + +/** +* sphinx_config_section +* Represents a single section inside the sphinx configuration +*/ +class sphinx_config_section +{ + var $name; + var $comment; + var $end_comment; + var $variables = array(); + + /** + * Construct a new section + * + * @param string $name Name of the section + * @param string $comment Comment that should be appended after the name in the + * textual format. + */ + function sphinx_config_section($name, $comment) + { + $this->name = $name; + $this->comment = $comment; + $this->end_comment = ''; + } + + /** + * Add a variable object to the list of variables in this section + * + * @param sphinx_config_variable $variable The variable object + */ + function add_variable($variable) + { + $this->variables[] = $variable; + } + + /** + * Adds a comment after the closing bracket in the textual representation + */ + function set_end_comment($end_comment) + { + $this->end_comment = $end_comment; + } + + /** + * Getter for the name of this section + * + * @return string Section's name + */ + function get_name() + { + return $this->name; + } + + /** + * Get a variable object by its name + * + * @param string $name The name of the variable that shall be returned + * @return sphinx_config_section The first variable object from this section with the + * given name or null if none was found + */ + function &get_variable_by_name($name) + { + for ($i = 0, $n = sizeof($this->variables); $i < $n; $i++) + { + // make sure this is a variable object and not a comment + if (is_a($this->variables[$i], 'sphinx_config_variable') && $this->variables[$i]->get_name() == $name) + { + return $this->variables[$i]; + } + } + $null = null; + return $null; + } + + /** + * Deletes all variables with the given name + * + * @param string $name The name of the variable objects that are supposed to be removed + */ + function delete_variables_by_name($name) + { + for ($i = 0; $i < sizeof($this->variables); $i++) + { + // make sure this is a variable object and not a comment + if (is_a($this->variables[$i], 'sphinx_config_variable') && $this->variables[$i]->get_name() == $name) + { + array_splice($this->variables, $i, 1); + $i--; + } + } + } + + /** + * Create a new variable object and append it to the variable list of this section + * + * @param string $name The name for the new variable + * @param string $value The value for the new variable + * @return sphinx_config_variable Variable object that was created + */ + function &create_variable($name, $value) + { + $this->variables[] = new sphinx_config_variable($name, $value, ''); + return $this->variables[sizeof($this->variables) - 1]; + } + + /** + * Turns this object into a string which can be written to a config file + * + * @return string Config data in textual form, parsable for sphinx + */ + function to_string() + { + $content = $this->name . " " . $this->comment . "\n{\n"; + + // make sure we don't get too many newlines after the opening bracket + while (trim($this->variables[0]->to_string()) == "") + { + array_shift($this->variables); + } + + foreach ($this->variables as $variable) + { + $content .= $variable->to_string(); + } + $content .= '}' . $this->end_comment . "\n"; + + return $content; + } +} + +/** +* sphinx_config_variable +* Represents a single variable inside the sphinx configuration +*/ +class sphinx_config_variable +{ + var $name; + var $value; + var $comment; + + /** + * Constructs a new variable object + * + * @param string $name Name of the variable + * @param string $value Value of the variable + * @param string $comment Optional comment after the variable in the + * config file + */ + function sphinx_config_variable($name, $value, $comment) + { + $this->name = $name; + $this->value = $value; + $this->comment = $comment; + } + + /** + * Getter for the variable's name + * + * @return string The variable object's name + */ + function get_name() + { + return $this->name; + } + + /** + * Allows changing the variable's value + * + * @param string $value New value for this variable + */ + function set_value($value) + { + $this->value = $value; + } + + /** + * Turns this object into a string readable by sphinx + * + * @return string Config data in textual form + */ + function to_string() + { + return "\t" . $this->name . ' = ' . str_replace("\n", "\\\n", $this->value) . ' ' . $this->comment . "\n"; + } +} + + +/** +* sphinx_config_comment +* Represents a comment inside the sphinx configuration +*/ +class sphinx_config_comment +{ + var $exact_string; + + /** + * Create a new comment + * + * @param string $exact_string The content of the comment including newlines, leading whitespace, etc. + */ + function sphinx_config_comment($exact_string) + { + $this->exact_string = $exact_string; + } + + /** + * Simply returns the comment as it was created + * + * @return string The exact string that was specified in the constructor + */ + function to_string() + { + return $this->exact_string; + } +} + +?> \ No newline at end of file diff --git a/phpBB/includes/search/fulltext_sphinx.php b/phpBB/includes/search/fulltext_sphinx.php new file mode 100644 index 0000000000..e0c467df93 --- /dev/null +++ b/phpBB/includes/search/fulltext_sphinx.php @@ -0,0 +1,1166 @@ +id = $config['avatar_salt']; + $this->indexes = 'index_phpbb_' . $this->id . '_delta;index_phpbb_' . $this->id . '_main'; + + $this->sphinx = new SphinxClient (); + + if (!empty($config['fulltext_sphinx_configured'])) + { + if ($config['fulltext_sphinx_autorun'] && !file_exists($config['fulltext_sphinx_data_path'] . 'searchd.pid') && $this->index_created(true)) + { + $this->shutdown_searchd(); +// $cwd = getcwd(); +// chdir($config['fulltext_sphinx_bin_path']); + exec($config['fulltext_sphinx_bin_path'] . SEARCHD_NAME . ' --config ' . $config['fulltext_sphinx_config_path'] . 'sphinx.conf >> ' . $config['fulltext_sphinx_data_path'] . 'log/searchd-startup.log 2>&1 &'); +// chdir($cwd); + } + + // we only support localhost for now + $this->sphinx->SetServer('localhost', (isset($config['fulltext_sphinx_port']) && $config['fulltext_sphinx_port']) ? (int) $config['fulltext_sphinx_port'] : 3312); + } + + $config['fulltext_sphinx_min_word_len'] = 2; + $config['fulltext_sphinx_max_word_len'] = 400; + + $error = false; + } + + /** + * Checks permissions and paths, if everything is correct it generates the config file + */ + function init() + { + global $db, $user, $config; + + if ($db->sql_layer != 'mysql' && $db->sql_layer != 'mysql4' && $db->sql_layer != 'mysqli') + { + return $user->lang['FULLTEXT_SPHINX_WRONG_DATABASE']; + } + + if ($error = $this->config_updated()) + { + return $error; + } + + // move delta to main index each hour + set_config('search_gc', 3600); + + return false; + } + + function config_updated() + { + global $db, $user, $config, $phpbb_root_path, $phpEx; + + if ($config['fulltext_sphinx_autoconf']) + { + $paths = array('fulltext_sphinx_bin_path', 'fulltext_sphinx_config_path', 'fulltext_sphinx_data_path'); + + // check for completeness and add trailing slash if it's not present + foreach ($paths as $path) + { + if (empty($config[$path])) + { + return $user->lang['FULLTEXT_SPHINX_UNCONFIGURED']; + } + if ($config[$path] && substr($config[$path], -1) != '/') + { + set_config($path, $config[$path] . '/'); + } + } + } + + $executables = array( + $config['fulltext_sphinx_bin_path'] . INDEXER_NAME, + $config['fulltext_sphinx_bin_path'] . SEARCHD_NAME, + ); + + if ($config['fulltext_sphinx_autorun']) + { + foreach ($executables as $executable) + { + if (!file_exists($executable)) + { + return sprintf($user->lang['FULLTEXT_SPHINX_FILE_NOT_FOUND'], $executable); + } + + if (!function_exists('exec')) + { + return $user->lang['FULLTEXT_SPHINX_REQUIRES_EXEC']; + } + + $output = array(); + @exec($executable, $output); + + $output = implode("\n", $output); + if (strpos($output, 'Sphinx ') === false) + { + return sprintf($user->lang['FULLTEXT_SPHINX_FILE_NOT_EXECUTABLE'], $executable); + } + } + } + + $writable_paths = array( + $config['fulltext_sphinx_config_path'] => array('config' => 'fulltext_sphinx_autoconf', 'subdir' => false), + $config['fulltext_sphinx_data_path'] => array('config' => 'fulltext_sphinx_autorun', 'subdir' => 'log'), + $config['fulltext_sphinx_data_path'] . 'log/' => array('config' => 'fulltext_sphinx_autorun', 'subdir' => false), + ); + + foreach ($writable_paths as $path => $info) + { + if ($config[$info['config']]) + { + // make sure directory exists + // if we could drop the @ here and figure out whether the file really + // doesn't exist or whether open_basedir is in effect, would be nice + if (!@file_exists($path)) + { + return sprintf($user->lang['FULLTEXT_SPHINX_DIRECTORY_NOT_FOUND'], $path); + } + + // now check if it is writable by storing a simple file + $filename = $path . 'write_test'; + $fp = @fopen($filename, 'wb'); + if ($fp === false) + { + return sprintf($user->lang['FULLTEXT_SPHINX_FILE_NOT_WRITABLE'], $filename); + } + @fclose($fp); + + @unlink($filename); + + if ($info['subdir'] !== false) + { + if (!is_dir($path . $info['subdir'])) + { + mkdir($path . $info['subdir']); + } + } + } + } + + if ($config['fulltext_sphinx_autoconf']) + { + include ($phpbb_root_path . 'config.' . $phpEx); + + // now that we're sure everything was entered correctly, generate a config for the index + // we misuse the avatar_salt for this, as it should be unique ;-) + + if (!class_exists('sphinx_config')) + { + include($phpbb_root_path . 'includes/functions_sphinx.php'); + } + + if (!file_exists($config['fulltext_sphinx_config_path'] . 'sphinx.conf')) + { + $filename = $config['fulltext_sphinx_config_path'] . 'sphinx.conf'; + $fp = @fopen($filename, 'wb'); + if ($fp === false) + { + return sprintf($user->lang['FULLTEXT_SPHINX_FILE_NOT_WRITABLE'], $filename); + } + @fclose($fp); + } + + $config_object = new sphinx_config($config['fulltext_sphinx_config_path'] . 'sphinx.conf'); + + $config_data = array( + "source source_phpbb_{$this->id}_main" => array( + array('type', 'mysql'), + array('sql_host', $dbhost), + array('sql_user', $dbuser), + array('sql_pass', $dbpasswd), + array('sql_db', $dbname), + array('sql_port', $dbport), + array('sql_query_pre', 'SET NAMES utf8'), + array('sql_query_pre', 'REPLACE INTO ' . SPHINX_TABLE . ' SELECT 1, MAX(post_id) FROM ' . POSTS_TABLE . ''), + array('sql_query_range', 'SELECT MIN(post_id), MAX(post_id) FROM ' . POSTS_TABLE . ''), + array('sql_range_step', '5000'), + array('sql_query', 'SELECT + p.post_id AS id, + p.forum_id, + p.topic_id, + p.poster_id, + IF(p.post_id = t.topic_first_post_id, 1, 0) as topic_first_post, + p.post_time, + p.post_subject, + p.post_subject as title, + p.post_text as data, + t.topic_last_post_time, + 0 as deleted + FROM ' . POSTS_TABLE . ' p, ' . TOPICS_TABLE . ' t + WHERE + p.topic_id = t.topic_id + AND p.post_id >= $start AND p.post_id <= $end'), + array('sql_query_post', ''), + array('sql_query_post_index', 'REPLACE INTO ' . SPHINX_TABLE . ' ( counter_id, max_doc_id ) VALUES ( 1, $maxid )'), + array('sql_query_info', 'SELECT * FROM ' . POSTS_TABLE . ' WHERE post_id = $id'), + array('sql_attr_uint', 'forum_id'), + array('sql_attr_uint', 'topic_id'), + array('sql_attr_uint', 'poster_id'), + array('sql_attr_bool', 'topic_first_post'), + array('sql_attr_bool', 'deleted'), + array('sql_attr_timestamp' , 'post_time'), + array('sql_attr_timestamp' , 'topic_last_post_time'), + array('sql_attr_str2ordinal', 'post_subject'), + ), + "source source_phpbb_{$this->id}_delta : source_phpbb_{$this->id}_main" => array( + array('sql_query_pre', ''), + array('sql_query_range', ''), + array('sql_range_step', ''), + array('sql_query', 'SELECT + p.post_id AS id, + p.forum_id, + p.topic_id, + p.poster_id, + IF(p.post_id = t.topic_first_post_id, 1, 0) as topic_first_post, + p.post_time, + p.post_subject, + p.post_subject as title, + p.post_text as data, + t.topic_last_post_time, + 0 as deleted + FROM ' . POSTS_TABLE . ' p, ' . TOPICS_TABLE . ' t + WHERE + p.topic_id = t.topic_id + AND p.post_id >= ( SELECT max_doc_id FROM ' . SPHINX_TABLE . ' WHERE counter_id=1 )'), + ), + "index index_phpbb_{$this->id}_main" => array( + array('path', $config['fulltext_sphinx_data_path'] . "index_phpbb_{$this->id}_main"), + array('source', "source_phpbb_{$this->id}_main"), + array('docinfo', 'extern'), + array('morphology', 'none'), + array('stopwords', (file_exists($config['fulltext_sphinx_config_path'] . 'sphinx_stopwords.txt') && $config['fulltext_sphinx_stopwords']) ? $config['fulltext_sphinx_config_path'] . 'sphinx_stopwords.txt' : ''), + array('min_word_len', '2'), + array('charset_type', 'utf-8'), + array('charset_table', 'U+FF10..U+FF19->0..9, 0..9, U+FF41..U+FF5A->a..z, U+FF21..U+FF3A->a..z, A..Z->a..z, a..z, U+0149, U+017F, U+0138, U+00DF, U+00FF, U+00C0..U+00D6->U+00E0..U+00F6, U+00E0..U+00F6, U+00D8..U+00DE->U+00F8..U+00FE, U+00F8..U+00FE, U+0100->U+0101, U+0101, U+0102->U+0103, U+0103, U+0104->U+0105, U+0105, U+0106->U+0107, U+0107, U+0108->U+0109, U+0109, U+010A->U+010B, U+010B, U+010C->U+010D, U+010D, U+010E->U+010F, U+010F, U+0110->U+0111, U+0111, U+0112->U+0113, U+0113, U+0114->U+0115, U+0115, U+0116->U+0117, U+0117, U+0118->U+0119, U+0119, U+011A->U+011B, U+011B, U+011C->U+011D, U+011D, U+011E->U+011F, U+011F, U+0130->U+0131, U+0131, U+0132->U+0133, U+0133, U+0134->U+0135, U+0135, U+0136->U+0137, U+0137, U+0139->U+013A, U+013A, U+013B->U+013C, U+013C, U+013D->U+013E, U+013E, U+013F->U+0140, U+0140, U+0141->U+0142, U+0142, U+0143->U+0144, U+0144, U+0145->U+0146, U+0146, U+0147->U+0148, U+0148, U+014A->U+014B, U+014B, U+014C->U+014D, U+014D, U+014E->U+014F, U+014F, U+0150->U+0151, U+0151, U+0152->U+0153, U+0153, U+0154->U+0155, U+0155, U+0156->U+0157, U+0157, U+0158->U+0159, U+0159, U+015A->U+015B, U+015B, U+015C->U+015D, U+015D, U+015E->U+015F, U+015F, U+0160->U+0161, U+0161, U+0162->U+0163, U+0163, U+0164->U+0165, U+0165, U+0166->U+0167, U+0167, U+0168->U+0169, U+0169, U+016A->U+016B, U+016B, U+016C->U+016D, U+016D, U+016E->U+016F, U+016F, U+0170->U+0171, U+0171, U+0172->U+0173, U+0173, U+0174->U+0175, U+0175, U+0176->U+0177, U+0177, U+0178->U+00FF, U+00FF, U+0179->U+017A, U+017A, U+017B->U+017C, U+017C, U+017D->U+017E, U+017E, U+ß410..U+042F->U+0430..U+044F, U+0430..U+044F, U+4E00..U+9FFF'), + array('min_prefix_len', '0'), + array('min_infix_len', '0'), + ), + "index index_phpbb_{$this->id}_delta : index_phpbb_{$this->id}_main" => array( + array('path', $config['fulltext_sphinx_data_path'] . "index_phpbb_{$this->id}_delta"), + array('source', "source_phpbb_{$this->id}_delta"), + ), + 'indexer' => array( + array('mem_limit', $config['fulltext_sphinx_indexer_mem_limit'] . 'M'), + ), + 'searchd' => array( + array('address' , '127.0.0.1'), + array('port', ($config['fulltext_sphinx_port']) ? $config['fulltext_sphinx_port'] : '3312'), + array('log', $config['fulltext_sphinx_data_path'] . "log/searchd.log"), + array('query_log', $config['fulltext_sphinx_data_path'] . "log/sphinx-query.log"), + array('read_timeout', '5'), + array('max_children', '30'), + array('pid_file', $config['fulltext_sphinx_data_path'] . "searchd.pid"), + array('max_matches', (string) MAX_MATCHES), + ), + ); + + $non_unique = array('sql_query_pre' => true, 'sql_attr_uint' => true, 'sql_attr_timestamp' => true, 'sql_attr_str2ordinal' => true, 'sql_attr_bool' => true); + $delete = array('sql_group_column' => true, 'sql_date_column' => true, 'sql_str2ordinal_column' => true); + + foreach ($config_data as $section_name => $section_data) + { + $section = &$config_object->get_section_by_name($section_name); + if (!$section) + { + $section = &$config_object->add_section($section_name); + } + + foreach ($delete as $key => $void) + { + $section->delete_variables_by_name($key); + } + + foreach ($non_unique as $key => $void) + { + $section->delete_variables_by_name($key); + } + + foreach ($section_data as $entry) + { + $key = $entry[0]; + $value = $entry[1]; + + if (!isset($non_unique[$key])) + { + $variable = &$section->get_variable_by_name($key); + if (!$variable) + { + $variable = &$section->create_variable($key, $value); + } + else + { + $variable->set_value($value); + } + } + else + { + $variable = &$section->create_variable($key, $value); + } + } + } + + $config_object->write($config['fulltext_sphinx_config_path'] . 'sphinx.conf'); + } + + set_config('fulltext_sphinx_configured', '1'); + + $this->shutdown_searchd(); + $this->tidy(); + + return false; + } + + /** + * Splits keywords entered by a user into an array of words stored in $this->split_words + * Stores the tidied search query in $this->search_query + * + * @param string $keywords Contains the keyword as entered by the user + * @param string $terms is either 'all' or 'any' + * @return false if no valid keywords were found and otherwise true + */ + function split_keywords(&$keywords, $terms) + { + global $config; + + if ($terms == 'all') + { + $match = array('#\sand\s#i', '#\sor\s#i', '#\snot\s#i', '#\+#', '#-#', '#\|#', '#@#'); + $replace = array(' & ', ' | ', ' - ', ' +', ' -', ' |', ''); + + $replacements = 0; + $keywords = preg_replace($match, $replace, $keywords); + $this->sphinx->SetMatchMode(SPH_MATCH_EXTENDED); + } + else + { + $this->sphinx->SetMatchMode(SPH_MATCH_ANY); + } + + $match = array(); + // Keep quotes + $match[] = "#"#"; + // KeepNew lines + $match[] = "#[\n]+#"; + + $replace = array('"', " "); + + $keywords = str_replace(array('"', "\n"), array('"', ' '), trim($keywords)); + + if (strlen($keywords) > 0) + { + $this->search_query = str_replace('"', '"', $keywords); + return true; + } + + return false; + } + + /** + * Performs a search on keywords depending on display specific params. You have to run split_keywords() first. + * + * @param string $type contains either posts or topics depending on what should be searched for + * @param string $fields contains either titleonly (topic titles should be searched), msgonly (only message bodies should be searched), firstpost (only subject and body of the first post should be searched) or all (all post bodies and subjects should be searched) + * @param string $terms is either 'all' (use query as entered, words without prefix should default to "have to be in field") or 'any' (ignore search query parts and just return all posts that contain any of the specified words) + * @param array $sort_by_sql contains SQL code for the ORDER BY part of a query + * @param string $sort_key is the key of $sort_by_sql for the selected sorting + * @param string $sort_dir is either a or d representing ASC and DESC + * @param string $sort_days specifies the maximum amount of days a post may be old + * @param array $ex_fid_ary specifies an array of forum ids which should not be searched + * @param array $m_approve_fid_ary specifies an array of forum ids in which the searcher is allowed to view unapproved posts + * @param int $topic_id is set to 0 or a topic id, if it is not 0 then only posts in this topic should be searched + * @param array $author_ary an array of author ids if the author should be ignored during the search the array is empty + * @param string $author_name specifies the author match, when ANONYMOUS is also a search-match + * @param array &$id_ary passed by reference, to be filled with ids for the page specified by $start and $per_page, should be ordered + * @param int $start indicates the first index of the page + * @param int $per_page number of ids each page is supposed to contain + * @return boolean|int total number of results + * + * @access public + */ + function keyword_search($type, $fields, $terms, $sort_by_sql, $sort_key, $sort_dir, $sort_days, $ex_fid_ary, $m_approve_fid_ary, $topic_id, $author_ary, $author_name, &$id_ary, $start, $per_page) + { + global $config, $db, $auth; + + // No keywords? No posts. + if (!strlen($this->search_query) && !sizeof($author_ary)) + { + return false; + } + + $id_ary = array(); + + $join_topic = ($type == 'posts') ? false : true; + + // sorting + + if ($type == 'topics') + { + switch ($sort_key) + { + case 'a': + $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'poster_id ' . (($sort_dir == 'a') ? 'ASC' : 'DESC')); + break; + case 'f': + $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'forum_id ' . (($sort_dir == 'a') ? 'ASC' : 'DESC')); + break; + case 'i': + case 's': + $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'post_subject ' . (($sort_dir == 'a') ? 'ASC' : 'DESC')); + break; + case 't': + default: + $this->sphinx->SetGroupBy('topic_id', SPH_GROUPBY_ATTR, 'topic_last_post_time ' . (($sort_dir == 'a') ? 'ASC' : 'DESC')); + break; + } + } + else + { + switch ($sort_key) + { + case 'a': + $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'poster_id'); + break; + case 'f': + $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'forum_id'); + break; + case 'i': + case 's': + $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'post_subject'); + break; + case 't': + default: + $this->sphinx->SetSortMode(($sort_dir == 'a') ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC, 'post_time'); + break; + } + } + + // most narrow filters first + if ($topic_id) + { + $this->sphinx->SetFilter('topic_id', array($topic_id)); + } + + $search_query_prefix = ''; + + switch($fields) + { + case 'titleonly': + // only search the title + if ($terms == 'all') + { + $search_query_prefix = '@title '; + } + $this->sphinx->SetFieldWeights(array("title" => 5, "data" => 1)); // weight for the title + $this->sphinx->SetFilter('topic_first_post', array(1)); // 1 is first_post, 0 is not first post + break; + + case 'msgonly': + // only search the body + if ($terms == 'all') + { + $search_query_prefix = '@data '; + } + $this->sphinx->SetFieldWeights(array("title" => 1, "data" => 5)); // weight for the body + break; + + case 'firstpost': + $this->sphinx->SetFieldWeights(array("title" => 5, "data" => 1)); // more relative weight for the title, also search the body + $this->sphinx->SetFilter('topic_first_post', array(1)); // 1 is first_post, 0 is not first post + break; + + default: + $this->sphinx->SetFieldWeights(array("title" => 5, "data" => 1)); // more relative weight for the title, also search the body + break; + } + + if (sizeof($author_ary)) + { + $this->sphinx->SetFilter('poster_id', $author_ary); + } + + if (sizeof($ex_fid_ary)) + { + // All forums that a user is allowed to access + $fid_ary = array_unique(array_intersect(array_keys($auth->acl_getf('f_read', true)), array_keys($auth->acl_getf('f_search', true)))); + // All forums that the user wants to and can search in + $search_forums = array_diff($fid_ary, $ex_fid_ary); + + if (sizeof($search_forums)) + { + $this->sphinx->SetFilter('forum_id', $search_forums); + } + } + + $this->sphinx->SetFilter('deleted', array(0)); + + $this->sphinx->SetLimits($start, (int) $per_page, MAX_MATCHES); + $result = $this->sphinx->Query($search_query_prefix . str_replace('"', '"', $this->search_query), $this->indexes); + + // could be connection to localhost:3312 failed (errno=111, msg=Connection refused) during rotate, retry if so + $retries = CONNECT_RETRIES; + while (!$result && (strpos($this->sphinx->_error, "errno=111,") !== false) && $retries--) + { + usleep(CONNECT_WAIT_TIME); + $result = $this->sphinx->Query($search_query_prefix . str_replace('"', '"', $this->search_query), $this->indexes); + } + + $id_ary = array(); + if (isset($result['matches'])) + { + if ($type == 'posts') + { + $id_ary = array_keys($result['matches']); + } + else + { + foreach($result['matches'] as $key => $value) + { + $id_ary[] = $value['attrs']['topic_id']; + } + } + } + else + { + return false; + } + + $result_count = $result['total_found']; + + $id_ary = array_slice($id_ary, 0, (int) $per_page); + + return $result_count; + } + + /** + * Performs a search on an author's posts without caring about message contents. Depends on display specific params + * + * @param string $type contains either posts or topics depending on what should be searched for + * @param boolean $firstpost_only if true, only topic starting posts will be considered + * @param array $sort_by_sql contains SQL code for the ORDER BY part of a query + * @param string $sort_key is the key of $sort_by_sql for the selected sorting + * @param string $sort_dir is either a or d representing ASC and DESC + * @param string $sort_days specifies the maximum amount of days a post may be old + * @param array $ex_fid_ary specifies an array of forum ids which should not be searched + * @param array $m_approve_fid_ary specifies an array of forum ids in which the searcher is allowed to view unapproved posts + * @param int $topic_id is set to 0 or a topic id, if it is not 0 then only posts in this topic should be searched + * @param array $author_ary an array of author ids + * @param string $author_name specifies the author match, when ANONYMOUS is also a search-match + * @param array &$id_ary passed by reference, to be filled with ids for the page specified by $start and $per_page, should be ordered + * @param int $start indicates the first index of the page + * @param int $per_page number of ids each page is supposed to contain + * @return boolean|int total number of results + * + * @access public + */ + function author_search($type, $firstpost_only, $sort_by_sql, $sort_key, $sort_dir, $sort_days, $ex_fid_ary, $m_approve_fid_ary, $topic_id, $author_ary, $author_name, &$id_ary, $start, $per_page) + { + $this->search_query = ''; + + $this->sphinx->SetMatchMode(SPH_MATCH_FULLSCAN); + $fields = ($firstpost_only) ? 'firstpost' : 'all'; + $terms = 'all'; + return $this->keyword_search($type, $fields, $terms, $sort_by_sql, $sort_key, $sort_dir, $sort_days, $ex_fid_ary, $m_approve_fid_ary, $topic_id, $author_ary, $author_name, $id_ary, $start, $per_page); + } + + /** + * Updates wordlist and wordmatch tables when a message is posted or changed + * + * @param string $mode Contains the post mode: edit, post, reply, quote + * @param int $post_id The id of the post which is modified/created + * @param string &$message New or updated post content + * @param string &$subject New or updated post subject + * @param int $poster_id Post author's user id + * @param int $forum_id The id of the forum in which the post is located + * + * @access public + */ + function index($mode, $post_id, &$message, &$subject, $poster_id, $forum_id) + { + global $config, $db; + + if ($mode == 'edit') + { + $this->sphinx->UpdateAttributes($this->indexes, array('forum_id', 'poster_id'), array((int)$post_id => array((int)$forum_id, (int)$poster_id))); + } + else if ($mode != 'post' && $post_id) + { + // update topic_last_post_time for full topic + $sql = 'SELECT p1.post_id + FROM ' . POSTS_TABLE . ' p1 + LEFT JOIN ' . POSTS_TABLE . ' p2 ON (p1.topic_id = p2.topic_id) + WHERE p2.post_id = ' . $post_id; + $result = $db->sql_query($sql); + + $post_updates = array(); + $post_time = time(); + while ($row = $db->sql_fetchrow($result)) + { + $post_updates[(int)$row['post_id']] = array((int) $post_time); + } + $db->sql_freeresult($result); + + if (sizeof($post_updates)) + { + $this->sphinx->UpdateAttributes($this->indexes, array('topic_last_post_time'), $post_updates); + } + } + + if ($config['fulltext_sphinx_autorun']) + { + if ($this->index_created()) + { + $rotate = ($this->searchd_running()) ? ' --rotate' : ''; + + $cwd = getcwd(); + chdir($config['fulltext_sphinx_bin_path']); + exec('./' . INDEXER_NAME . $rotate . ' --config ' . $config['fulltext_sphinx_config_path'] . 'sphinx.conf index_phpbb_' . $this->id . '_delta >> ' . $config['fulltext_sphinx_data_path'] . 'log/indexer.log 2>&1 &'); + var_dump('./' . INDEXER_NAME . $rotate . ' --config ' . $config['fulltext_sphinx_config_path'] . 'sphinx.conf index_phpbb_' . $this->id . '_delta >> ' . $config['fulltext_sphinx_data_path'] . 'log/indexer.log 2>&1 &'); + chdir($cwd); + } + } + } + + /** + * Delete a post from the index after it was deleted + */ + function index_remove($post_ids, $author_ids, $forum_ids) + { + $values = array(); + foreach ($post_ids as $post_id) + { + $values[$post_id] = array(1); + } + + $this->sphinx->UpdateAttributes($this->indexes, array('deleted'), $values); + } + + /** + * Destroy old cache entries + */ + function tidy($create = false) + { + global $config; + + if ($config['fulltext_sphinx_autorun']) + { + if ($this->index_created() || $create) + { + $rotate = ($this->searchd_running()) ? ' --rotate' : ''; + + $cwd = getcwd(); + chdir($config['fulltext_sphinx_bin_path']); + exec('./' . INDEXER_NAME . $rotate . ' --config ' . $config['fulltext_sphinx_config_path'] . 'sphinx.conf index_phpbb_' . $this->id . '_main >> ' . $config['fulltext_sphinx_data_path'] . 'log/indexer.log 2>&1 &'); + exec('./' . INDEXER_NAME . $rotate . ' --config ' . $config['fulltext_sphinx_config_path'] . 'sphinx.conf index_phpbb_' . $this->id . '_delta >> ' . $config['fulltext_sphinx_data_path'] . 'log/indexer.log 2>&1 &'); + chdir($cwd); + } + } + + set_config('search_last_gc', time(), true); + } + + /** + * Create sphinx table + */ + function create_index($acp_module, $u_action) + { + global $db, $user, $config; + + $this->shutdown_searchd(); + + if (!isset($config['fulltext_sphinx_configured']) || !$config['fulltext_sphinx_configured']) + { + $user->add_lang('mods/fulltext_sphinx'); + + return $user->lang['FULLTEXT_SPHINX_CONFIGURE_FIRST']; + } + + if (!$this->index_created()) + { + $sql = 'CREATE TABLE IF NOT EXISTS ' . SPHINX_TABLE . ' ( + counter_id INT NOT NULL PRIMARY KEY, + max_doc_id INT NOT NULL + )'; + $db->sql_query($sql); + + $sql = 'TRUNCATE TABLE ' . SPHINX_TABLE; + $db->sql_query($sql); + } + + // start indexing process + $this->tidy(true); + + $this->shutdown_searchd(); + + return false; + } + + /** + * Drop sphinx table + */ + function delete_index($acp_module, $u_action) + { + global $db, $config; + + $this->shutdown_searchd(); + + if ($config['fulltext_sphinx_autorun']) + { + sphinx_unlink_by_pattern($config['fulltext_sphinx_data_path'], '#^index_phpbb_' . $this->id . '.*$#'); + } + + if (!$this->index_created()) + { + return false; + } + + $sql = 'DROP TABLE ' . SPHINX_TABLE; + $db->sql_query($sql); + + $this->shutdown_searchd(); + + return false; + } + + /** + * Returns true if the sphinx table was created + */ + function index_created($allow_new_files = true) + { + global $db, $config; + + $sql = 'SHOW TABLES LIKE \'' . SPHINX_TABLE . '\''; + $result = $db->sql_query($sql); + $row = $db->sql_fetchrow($result); + $db->sql_freeresult($result); + + $created = false; + + if ($row) + { + if ($config['fulltext_sphinx_autorun']) + { + if ((file_exists($config['fulltext_sphinx_data_path'] . 'index_phpbb_' . $this->id . '_main.spd') && file_exists($config['fulltext_sphinx_data_path'] . 'index_phpbb_' . $this->id . '_delta.spd')) || ($allow_new_files && file_exists($config['fulltext_sphinx_data_path'] . 'index_phpbb_' . $this->id . '_main.new.spd') && file_exists($config['fulltext_sphinx_data_path'] . 'index_phpbb_' . $this->id . '_delta.new.spd'))) + { + $created = true; + } + } + else + { + $created = true; + } + } + + return $created; + } + + /** + * Kills the searchd process and makes sure there's no locks left over + */ + function shutdown_searchd() + { + global $config; + + if ($config['fulltext_sphinx_autorun']) + { + if (!function_exists('exec')) + { + set_config('fulltext_sphinx_autorun', '0'); + return; + } + + exec('killall -9 ' . SEARCHD_NAME . ' >> /dev/null 2>&1 &'); + + if (file_exists($config['fulltext_sphinx_data_path'] . 'searchd.pid')) + { + unlink($config['fulltext_sphinx_data_path'] . 'searchd.pid'); + } + + sphinx_unlink_by_pattern($config['fulltext_sphinx_data_path'], '#^.*\.spl$#'); + } + } + + /** + * Checks whether searchd is running, if it's not running it makes sure there's no left over + * files by calling shutdown_searchd. + * + * @return boolean Whether searchd is running or not + */ + function searchd_running() + { + global $config; + + // if we cannot manipulate the service assume it is running + if (!$config['fulltext_sphinx_autorun']) + { + return true; + } + + if (file_exists($config['fulltext_sphinx_data_path'] . 'searchd.pid')) + { + $pid = trim(file_get_contents($config['fulltext_sphinx_data_path'] . 'searchd.pid')); + + if ($pid) + { + $output = array(); + $pidof_command = 'pidof'; + + exec('whereis -b pidof', $output); + if (sizeof($output) > 1) + { + $output = explode(' ', trim($output[0])); + $pidof_command = $output[1]; // 0 is pidof: + } + + $output = array(); + exec($pidof_command . ' ' . SEARCHD_NAME, $output); + if (sizeof($output) && (trim($output[0]) == $pid || trim($output[1]) == $pid)) + { + return true; + } + } + } + + // make sure it's really not running + $this->shutdown_searchd(); + + return false; + } + + /** + * Returns an associative array containing information about the indexes + */ + function index_stats() + { + global $user; + + if (empty($this->stats)) + { + $this->get_stats(); + } + + $user->add_lang('mods/fulltext_sphinx'); + + return array( + $user->lang['FULLTEXT_SPHINX_MAIN_POSTS'] => ($this->index_created()) ? $this->stats['main_posts'] : 0, + $user->lang['FULLTEXT_SPHINX_DELTA_POSTS'] => ($this->index_created()) ? $this->stats['total_posts'] - $this->stats['main_posts'] : 0, + $user->lang['FULLTEXT_MYSQL_TOTAL_POSTS'] => ($this->index_created()) ? $this->stats['total_posts'] : 0, + $user->lang['FULLTEXT_SPHINX_LAST_SEARCHES'] => nl2br($this->stats['last_searches']), + ); + } + + /** + * Collects stats that can be displayed on the index maintenance page + */ + function get_stats() + { + global $db, $config; + + if ($this->index_created()) + { + $sql = 'SELECT COUNT(post_id) as total_posts + FROM ' . POSTS_TABLE; + $result = $db->sql_query($sql); + $this->stats['total_posts'] = (int) $db->sql_fetchfield('total_posts'); + $db->sql_freeresult($result); + + $sql = 'SELECT COUNT(p.post_id) as main_posts + FROM ' . POSTS_TABLE . ' p, ' . SPHINX_TABLE . ' m + WHERE p.post_id <= m.max_doc_id + AND m.counter_id = 1'; + $result = $db->sql_query($sql); + $this->stats['main_posts'] = (int) $db->sql_fetchfield('main_posts'); + $db->sql_freeresult($result); + } + + $this->stats['last_searches'] = ''; + if ($config['fulltext_sphinx_autorun']) + { + if (file_exists($config['fulltext_sphinx_data_path'] . 'log/sphinx-query.log')) + { + $last_searches = explode("\n", utf8_htmlspecialchars(sphinx_read_last_lines($config['fulltext_sphinx_data_path'] . 'log/sphinx-query.log', 3))); + + foreach($last_searches as $i => $search) + { + if (strpos($search, '[' . $this->indexes . ']') !== false) + { + $last_searches[$i] = str_replace('[' . $this->indexes . ']', '', $search); + } + else + { + $last_searches[$i] = ''; + } + } + $this->stats['last_searches'] = implode("\n", $last_searches); + } + } + } + + /** + * Returns a list of options for the ACP to display + */ + function acp() + { + global $user, $config; + + $user->add_lang('mods/fulltext_sphinx'); + + $config_vars = array( + 'fulltext_sphinx_autoconf' => 'bool', + 'fulltext_sphinx_autorun' => 'bool', + 'fulltext_sphinx_config_path' => 'string', + 'fulltext_sphinx_data_path' => 'string', + 'fulltext_sphinx_bin_path' => 'string', + 'fulltext_sphinx_port' => 'int', + 'fulltext_sphinx_stopwords' => 'bool', + 'fulltext_sphinx_indexer_mem_limit' => 'int', + ); + + $defaults = array( + 'fulltext_sphinx_autoconf' => '1', + 'fulltext_sphinx_autorun' => '1', + 'fulltext_sphinx_indexer_mem_limit' => '512', + ); + + foreach ($config_vars as $config_var => $type) + { + if (!isset($config[$config_var])) + { + $default = ''; + if (isset($defaults[$config_var])) + { + $default = $defaults[$config_var]; + } + set_config($config_var, $default); + } + } + + $no_autoconf = false; + $no_autorun = false; + $bin_path = $config['fulltext_sphinx_bin_path']; + + // try to guess the path if it is empty + if (empty($bin_path)) + { + if (@file_exists('/usr/local/bin/' . INDEXER_NAME) && @file_exists('/usr/local/bin/' . SEARCHD_NAME)) + { + $bin_path = '/usr/local/bin/'; + } + else if (@file_exists('/usr/bin/' . INDEXER_NAME) && @file_exists('/usr/bin/' . SEARCHD_NAME)) + { + $bin_path = '/usr/bin/'; + } + else + { + $output = array(); + if (!function_exists('exec') || null === @exec('whereis -b ' . INDEXER_NAME, $output)) + { + $no_autorun = true; + } + else if (sizeof($output)) + { + $output = explode(' ', $output[0]); + array_shift($output); // remove indexer: + + foreach ($output as $path) + { + $path = dirname($path) . '/'; + + if (file_exists($path . INDEXER_NAME) && file_exists($path . SEARCHD_NAME)) + { + $bin_path = $path; + break; + } + } + } + } + } + + if ($no_autorun) + { + set_config('fulltext_sphinx_autorun', '0'); + } + + if ($no_autoconf) + { + set_config('fulltext_sphinx_autoconf', '0'); + } + + // rewrite config if fulltext sphinx is enabled + if ($config['fulltext_sphinx_autoconf'] && isset($config['fulltext_sphinx_configured']) && $config['fulltext_sphinx_configured']) + { + $this->config_updated(); + } + + // check whether stopwords file is available and enabled + if (@file_exists($config['fulltext_sphinx_config_path'] . 'sphinx_stopwords.txt')) + { + $stopwords_available = true; + $stopwords_active = $config['fulltext_sphinx_stopwords']; + } + else + { + $stopwords_available = false; + $stopwords_active = false; + set_config('fulltext_sphinx_stopwords', '0'); + } + + $tpl = ' + ' . $user->lang['FULLTEXT_SPHINX_CONFIGURE_BEFORE']. ' +
+

' . $user->lang['FULLTEXT_SPHINX_AUTOCONF_EXPLAIN'] . '
+
+
+
+

' . $user->lang['FULLTEXT_SPHINX_AUTORUN_EXPLAIN'] . '
+
+
+
+

' . $user->lang['FULLTEXT_SPHINX_CONFIG_PATH_EXPLAIN'] . '
+
+
+
+

' . $user->lang['FULLTEXT_SPHINX_BIN_PATH_EXPLAIN'] . '
+
+
+
+

' . $user->lang['FULLTEXT_SPHINX_DATA_PATH_EXPLAIN'] . '
+
+
+ ' . $user->lang['FULLTEXT_SPHINX_CONFIGURE_AFTER']. ' +
+

' . $user->lang['FULLTEXT_SPHINX_STOPWORDS_FILE_EXPLAIN'] . '
+
+
+
+

' . $user->lang['FULLTEXT_SPHINX_PORT_EXPLAIN'] . '
+
+
+
+

' . $user->lang['FULLTEXT_SPHINX_INDEXER_MEM_LIMIT_EXPLAIN'] . '
+
' . $user->lang['MIB'] . '
+
+ '; + + // These are fields required in the config table + return array( + 'tpl' => $tpl, + 'config' => $config_vars + ); + } +} + +/** +* Deletes all files from a directory that match a certain pattern +* +* @param string $path Path from which files shall be deleted +* @param string $pattern PCRE pattern that a file needs to match in order to be deleted +*/ +function sphinx_unlink_by_pattern($path, $pattern) +{ + $dir = opendir($path); + while (false !== ($file = readdir($dir))) + { + if (is_file($path . $file) && preg_match($pattern, $file)) + { + unlink($path . $file); + } + } + closedir($dir); +} + +/** +* Reads the last from a file +* +* @param string $file The filename from which the lines shall be read +* @param int $amount The number of lines to be read from the end +* @return string Last lines of the file +*/ +function sphinx_read_last_lines($file, $amount) +{ + $fp = fopen($file, 'r'); + fseek($fp, 0, SEEK_END); + + $c = ''; + $i = 0; + + while ($i < $amount) + { + fseek($fp, -2, SEEK_CUR); + $c = fgetc($fp); + if ($c == "\n") + { + $i++; + } + if (feof($fp)) + { + break; + } + } + + $string = fread($fp, 8192); + fclose($fp); + + return $string; +} + +?> \ No newline at end of file diff --git a/phpBB/includes/sphinxapi-0.9.8.php b/phpBB/includes/sphinxapi-0.9.8.php new file mode 100644 index 0000000000..6a7ea17760 --- /dev/null +++ b/phpBB/includes/sphinxapi-0.9.8.php @@ -0,0 +1,1202 @@ +=8 ) + { + $i = (int)$v; + return pack ( "NN", $i>>32, $i&((1<<32)-1) ); + } + + // x32 route, bcmath + $x = "4294967296"; + if ( function_exists("bcmul") ) + { + $h = bcdiv ( $v, $x, 0 ); + $l = bcmod ( $v, $x ); + return pack ( "NN", (float)$h, (float)$l ); // conversion to float is intentional; int would lose 31st bit + } + + // x32 route, 15 or less decimal digits + // we can use float, because its actually double and has 52 precision bits + if ( strlen($v)<=15 ) + { + $f = (float)$v; + $h = (int)($f/$x); + $l = (int)($f-$x*$h); + return pack ( "NN", $h, $l ); + } + + // x32 route, 16 or more decimal digits + // well, let me know if you *really* need this + die ( "INTERNAL ERROR: packing more than 15-digit numeric on 32-bit PHP is not implemented yet (contact support)" ); +} + + +/// portably unpack 64 unsigned bits, network order to numeric +function sphUnpack64 ( $v ) +{ + list($h,$l) = array_values ( unpack ( "N*N*", $v ) ); + + // x64 route + if ( PHP_INT_SIZE>=8 ) + { + if ( $h<0 ) $h += (1<<32); // because php 5.2.2 to 5.2.5 is totally fucked up again + if ( $l<0 ) $l += (1<<32); + return ($h<<32) + $l; + } + + // x32 route + $h = sprintf ( "%u", $h ); + $l = sprintf ( "%u", $l ); + $x = "4294967296"; + + // bcmath + if ( function_exists("bcmul") ) + return bcadd ( $l, bcmul ( $x, $h ) ); + + // no bcmath, 15 or less decimal digits + // we can use float, because its actually double and has 52 precision bits + if ( $h<1048576 ) + { + $f = ((float)$h)*$x + (float)$l; + return sprintf ( "%.0f", $f ); // builtin conversion is only about 39-40 bits precise! + } + + // x32 route, 16 or more decimal digits + // well, let me know if you *really* need this + die ( "INTERNAL ERROR: unpacking more than 15-digit numeric on 32-bit PHP is not implemented yet (contact support)" ); +} + + +/// sphinx searchd client class +class SphinxClient +{ + var $_host; ///< searchd host (default is "localhost") + var $_port; ///< searchd port (default is 3312) + var $_offset; ///< how many records to seek from result-set start (default is 0) + var $_limit; ///< how many records to return from result-set starting at offset (default is 20) + var $_mode; ///< query matching mode (default is SPH_MATCH_ALL) + var $_weights; ///< per-field weights (default is 1 for all fields) + var $_sort; ///< match sorting mode (default is SPH_SORT_RELEVANCE) + var $_sortby; ///< attribute to sort by (defualt is "") + var $_min_id; ///< min ID to match (default is 0, which means no limit) + var $_max_id; ///< max ID to match (default is 0, which means no limit) + var $_filters; ///< search filters + var $_groupby; ///< group-by attribute name + var $_groupfunc; ///< group-by function (to pre-process group-by attribute value with) + var $_groupsort; ///< group-by sorting clause (to sort groups in result set with) + var $_groupdistinct;///< group-by count-distinct attribute + var $_maxmatches; ///< max matches to retrieve + var $_cutoff; ///< cutoff to stop searching at (default is 0) + var $_retrycount; ///< distributed retries count + var $_retrydelay; ///< distributed retries delay + var $_anchor; ///< geographical anchor point + var $_indexweights; ///< per-index weights + var $_ranker; ///< ranking mode (default is SPH_RANK_PROXIMITY_BM25) + var $_maxquerytime; ///< max query time, milliseconds (default is 0, do not limit) + var $_fieldweights; ///< per-field-name weights + + var $_error; ///< last error message + var $_warning; ///< last warning message + + var $_reqs; ///< requests array for multi-query + var $_mbenc; ///< stored mbstring encoding + var $_arrayresult; ///< whether $result["matches"] should be a hash or an array + var $_timeout; ///< connect timeout + + ///////////////////////////////////////////////////////////////////////////// + // common stuff + ///////////////////////////////////////////////////////////////////////////// + + /// create a new client object and fill defaults + function SphinxClient () + { + // per-client-object settings + $this->_host = "localhost"; + $this->_port = 3312; + + // per-query settings + $this->_offset = 0; + $this->_limit = 20; + $this->_mode = SPH_MATCH_ALL; + $this->_weights = array (); + $this->_sort = SPH_SORT_RELEVANCE; + $this->_sortby = ""; + $this->_min_id = 0; + $this->_max_id = 0; + $this->_filters = array (); + $this->_groupby = ""; + $this->_groupfunc = SPH_GROUPBY_DAY; + $this->_groupsort = "@group desc"; + $this->_groupdistinct= ""; + $this->_maxmatches = 1000; + $this->_cutoff = 0; + $this->_retrycount = 0; + $this->_retrydelay = 0; + $this->_anchor = array (); + $this->_indexweights= array (); + $this->_ranker = SPH_RANK_PROXIMITY_BM25; + $this->_maxquerytime= 0; + $this->_fieldweights= array(); + + $this->_error = ""; // per-reply fields (for single-query case) + $this->_warning = ""; + $this->_reqs = array (); // requests storage (for multi-query case) + $this->_mbenc = ""; + $this->_arrayresult = false; + $this->_timeout = 0; + } + + /// get last error message (string) + function GetLastError () + { + return $this->_error; + } + + /// get last warning message (string) + function GetLastWarning () + { + return $this->_warning; + } + + /// set searchd host name (string) and port (integer) + function SetServer ( $host, $port ) + { + assert ( is_string($host) ); + assert ( is_int($port) ); + $this->_host = $host; + $this->_port = $port; + } + + /// set server connection timeout (0 to remove) + function SetConnectTimeout ( $timeout ) + { + assert ( is_numeric($timeout) ); + $this->_timeout = $timeout; + } + + ///////////////////////////////////////////////////////////////////////////// + + /// enter mbstring workaround mode + function _MBPush () + { + $this->_mbenc = ""; + if ( ini_get ( "mbstring.func_overload" ) & 2 ) + { + $this->_mbenc = mb_internal_encoding(); + mb_internal_encoding ( "latin1" ); + } + } + + /// leave mbstring workaround mode + function _MBPop () + { + if ( $this->_mbenc ) + mb_internal_encoding ( $this->_mbenc ); + } + + /// connect to searchd server + function _Connect ($allow_retry = true) + { + $errno = 0; + $errstr = ""; + if ( $this->_timeout<=0 ) + $fp = @fsockopen ( $this->_host, $this->_port, $errno, $errstr ); + else + $fp = @fsockopen ( $this->_host, $this->_port, $errno, $errstr, $this->_timeout ); + + if ( !$fp ) + { + $errstr = trim ( $errstr ); + $this->_error = "connection to {$this->_host}:{$this->_port} failed (errno=$errno, msg=$errstr)"; + return false; + } + + // check version + //list(,$v) = unpack ( "N*", fread ( $fp, 4 ) ); + $version_data = unpack ( "N*", fread ( $fp, 4 ) ); + if (!isset($version_data[1])) + { + // this should not happen, try to reconnect ONCE + if ($allow_retry) + { + return $this->_Connect(false); + } + else + { + $this->_error = "unexpected version data"; + return false; + } + } + $v = $version_data[1]; + $v = (int)$v; + if ( $v<1 ) + { + fclose ( $fp ); + $this->_error = "expected searchd protocol version 1+, got version '$v'"; + return false; + } + + // all ok, send my version + fwrite ( $fp, pack ( "N", 1 ) ); + return $fp; + } + + /// get and check response packet from searchd server + function _GetResponse ( $fp, $client_ver ) + { + $response = ""; + $len = 0; + + $header = fread ( $fp, 8 ); + if ( strlen($header)==8 ) + { + list ( $status, $ver, $len ) = array_values ( unpack ( "n2a/Nb", $header ) ); + $left = $len; + while ( $left>0 && !feof($fp) ) + { + $chunk = fread ( $fp, $left ); + if ( $chunk ) + { + $response .= $chunk; + $left -= strlen($chunk); + } + } + } + fclose ( $fp ); + + // check response + $read = strlen ( $response ); + if ( !$response || $read!=$len ) + { + $this->_error = $len + ? "failed to read searchd response (status=$status, ver=$ver, len=$len, read=$read)" + : "received zero-sized searchd response"; + return false; + } + + // check status + if ( $status==SEARCHD_WARNING ) + { + list(,$wlen) = unpack ( "N*", substr ( $response, 0, 4 ) ); + $this->_warning = substr ( $response, 4, $wlen ); + return substr ( $response, 4+$wlen ); + } + if ( $status==SEARCHD_ERROR ) + { + $this->_error = "searchd error: " . substr ( $response, 4 ); + return false; + } + if ( $status==SEARCHD_RETRY ) + { + $this->_error = "temporary searchd error: " . substr ( $response, 4 ); + return false; + } + if ( $status!=SEARCHD_OK ) + { + $this->_error = "unknown status code '$status'"; + return false; + } + + // check version + if ( $ver<$client_ver ) + { + $this->_warning = sprintf ( "searchd command v.%d.%d older than client's v.%d.%d, some options might not work", + $ver>>8, $ver&0xff, $client_ver>>8, $client_ver&0xff ); + } + + return $response; + } + + ///////////////////////////////////////////////////////////////////////////// + // searching + ///////////////////////////////////////////////////////////////////////////// + + /// set offset and count into result set, + /// and optionally set max-matches and cutoff limits + function SetLimits ( $offset, $limit, $max=0, $cutoff=0 ) + { + assert ( is_int($offset) ); + assert ( is_int($limit) ); + assert ( $offset>=0 ); + assert ( $limit>0 ); + assert ( $max>=0 ); + $this->_offset = $offset; + $this->_limit = $limit; + if ( $max>0 ) + $this->_maxmatches = $max; + if ( $cutoff>0 ) + $this->_cutoff = $cutoff; + } + + /// set maximum query time, in milliseconds, per-index + /// integer, 0 means "do not limit" + function SetMaxQueryTime ( $max ) + { + assert ( is_int($max) ); + assert ( $max>=0 ); + $this->_maxquerytime = $max; + } + + /// set matching mode + function SetMatchMode ( $mode ) + { + assert ( $mode==SPH_MATCH_ALL + || $mode==SPH_MATCH_ANY + || $mode==SPH_MATCH_PHRASE + || $mode==SPH_MATCH_BOOLEAN + || $mode==SPH_MATCH_EXTENDED + || $mode==SPH_MATCH_FULLSCAN + || $mode==SPH_MATCH_EXTENDED2 ); + $this->_mode = $mode; + } + + /// set ranking mode + function SetRankingMode ( $ranker ) + { + assert ( $ranker==SPH_RANK_PROXIMITY_BM25 + || $ranker==SPH_RANK_BM25 + || $ranker==SPH_RANK_NONE + || $ranker==SPH_RANK_WORDCOUNT ); + $this->_ranker = $ranker; + } + + /// set matches sorting mode + function SetSortMode ( $mode, $sortby="" ) + { + assert ( + $mode==SPH_SORT_RELEVANCE || + $mode==SPH_SORT_ATTR_DESC || + $mode==SPH_SORT_ATTR_ASC || + $mode==SPH_SORT_TIME_SEGMENTS || + $mode==SPH_SORT_EXTENDED || + $mode==SPH_SORT_EXPR ); + assert ( is_string($sortby) ); + assert ( $mode==SPH_SORT_RELEVANCE || strlen($sortby)>0 ); + + $this->_sort = $mode; + $this->_sortby = $sortby; + } + + /// bind per-field weights by order + /// DEPRECATED; use SetFieldWeights() instead + function SetWeights ( $weights ) + { + assert ( is_array($weights) ); + foreach ( $weights as $weight ) + assert ( is_int($weight) ); + + $this->_weights = $weights; + } + + /// bind per-field weights by name + function SetFieldWeights ( $weights ) + { + assert ( is_array($weights) ); + foreach ( $weights as $name=>$weight ) + { + assert ( is_string($name) ); + assert ( is_int($weight) ); + } + $this->_fieldweights = $weights; + } + + /// bind per-index weights by name + function SetIndexWeights ( $weights ) + { + assert ( is_array($weights) ); + foreach ( $weights as $index=>$weight ) + { + assert ( is_string($index) ); + assert ( is_int($weight) ); + } + $this->_indexweights = $weights; + } + + /// set IDs range to match + /// only match records if document ID is beetwen $min and $max (inclusive) + function SetIDRange ( $min, $max ) + { + assert ( is_numeric($min) ); + assert ( is_numeric($max) ); + assert ( $min<=$max ); + $this->_min_id = $min; + $this->_max_id = $max; + } + + /// set values set filter + /// only match records where $attribute value is in given set + function SetFilter ( $attribute, $values, $exclude=false ) + { + assert ( is_string($attribute) ); + assert ( is_array($values) ); + assert ( count($values) ); + + if ( is_array($values) && count($values) ) + { + foreach ( $values as $value ) + assert ( is_numeric($value) ); + + $this->_filters[] = array ( "type"=>SPH_FILTER_VALUES, "attr"=>$attribute, "exclude"=>$exclude, "values"=>$values ); + } + } + + /// set range filter + /// only match records if $attribute value is beetwen $min and $max (inclusive) + function SetFilterRange ( $attribute, $min, $max, $exclude=false ) + { + assert ( is_string($attribute) ); + assert ( is_int($min) ); + assert ( is_int($max) ); + assert ( $min<=$max ); + + $this->_filters[] = array ( "type"=>SPH_FILTER_RANGE, "attr"=>$attribute, "exclude"=>$exclude, "min"=>$min, "max"=>$max ); + } + + /// set float range filter + /// only match records if $attribute value is beetwen $min and $max (inclusive) + function SetFilterFloatRange ( $attribute, $min, $max, $exclude=false ) + { + assert ( is_string($attribute) ); + assert ( is_float($min) ); + assert ( is_float($max) ); + assert ( $min<=$max ); + + $this->_filters[] = array ( "type"=>SPH_FILTER_FLOATRANGE, "attr"=>$attribute, "exclude"=>$exclude, "min"=>$min, "max"=>$max ); + } + + /// setup anchor point for geosphere distance calculations + /// required to use @geodist in filters and sorting + /// latitude and longitude must be in radians + function SetGeoAnchor ( $attrlat, $attrlong, $lat, $long ) + { + assert ( is_string($attrlat) ); + assert ( is_string($attrlong) ); + assert ( is_float($lat) ); + assert ( is_float($long) ); + + $this->_anchor = array ( "attrlat"=>$attrlat, "attrlong"=>$attrlong, "lat"=>$lat, "long"=>$long ); + } + + /// set grouping attribute and function + function SetGroupBy ( $attribute, $func, $groupsort="@group desc" ) + { + assert ( is_string($attribute) ); + assert ( is_string($groupsort) ); + assert ( $func==SPH_GROUPBY_DAY + || $func==SPH_GROUPBY_WEEK + || $func==SPH_GROUPBY_MONTH + || $func==SPH_GROUPBY_YEAR + || $func==SPH_GROUPBY_ATTR + || $func==SPH_GROUPBY_ATTRPAIR ); + + $this->_groupby = $attribute; + $this->_groupfunc = $func; + $this->_groupsort = $groupsort; + } + + /// set count-distinct attribute for group-by queries + function SetGroupDistinct ( $attribute ) + { + assert ( is_string($attribute) ); + $this->_groupdistinct = $attribute; + } + + /// set distributed retries count and delay + function SetRetries ( $count, $delay=0 ) + { + assert ( is_int($count) && $count>=0 ); + assert ( is_int($delay) && $delay>=0 ); + $this->_retrycount = $count; + $this->_retrydelay = $delay; + } + + /// set result set format (hash or array; hash by default) + /// PHP specific; needed for group-by-MVA result sets that may contain duplicate IDs + function SetArrayResult ( $arrayresult ) + { + assert ( is_bool($arrayresult) ); + $this->_arrayresult = $arrayresult; + } + + ////////////////////////////////////////////////////////////////////////////// + + /// clear all filters (for multi-queries) + function ResetFilters () + { + $this->_filters = array(); + $this->_anchor = array(); + } + + /// clear groupby settings (for multi-queries) + function ResetGroupBy () + { + $this->_groupby = ""; + $this->_groupfunc = SPH_GROUPBY_DAY; + $this->_groupsort = "@group desc"; + $this->_groupdistinct= ""; + } + + ////////////////////////////////////////////////////////////////////////////// + + /// connect to searchd server, run given search query through given indexes, + /// and return the search results + function Query ( $query, $index="*", $comment="" ) + { + assert ( empty($this->_reqs) ); + + $this->AddQuery ( $query, $index, $comment ); + $results = $this->RunQueries (); + $this->_reqs = array (); // just in case it failed too early + + if ( !is_array($results) ) + return false; // probably network error; error message should be already filled + + $this->_error = $results[0]["error"]; + $this->_warning = $results[0]["warning"]; + if ( $results[0]["status"]==SEARCHD_ERROR ) + return false; + else + return $results[0]; + } + + /// helper to pack floats in network byte order + function _PackFloat ( $f ) + { + $t1 = pack ( "f", $f ); // machine order + list(,$t2) = unpack ( "L*", $t1 ); // int in machine order + return pack ( "N", $t2 ); + } + + /// add query to multi-query batch + /// returns index into results array from RunQueries() call + function AddQuery ( $query, $index="*", $comment="" ) + { + // mbstring workaround + $this->_MBPush (); + + // build request + $req = pack ( "NNNNN", $this->_offset, $this->_limit, $this->_mode, $this->_ranker, $this->_sort ); // mode and limits + $req .= pack ( "N", strlen($this->_sortby) ) . $this->_sortby; + $req .= pack ( "N", strlen($query) ) . $query; // query itself + $req .= pack ( "N", count($this->_weights) ); // weights + foreach ( $this->_weights as $weight ) + $req .= pack ( "N", (int)$weight ); + $req .= pack ( "N", strlen($index) ) . $index; // indexes + $req .= pack ( "N", 1 ); // id64 range marker + $req .= sphPack64 ( $this->_min_id ) . sphPack64 ( $this->_max_id ); // id64 range + + // filters + $req .= pack ( "N", count($this->_filters) ); + foreach ( $this->_filters as $filter ) + { + $req .= pack ( "N", strlen($filter["attr"]) ) . $filter["attr"]; + $req .= pack ( "N", $filter["type"] ); + switch ( $filter["type"] ) + { + case SPH_FILTER_VALUES: + $req .= pack ( "N", count($filter["values"]) ); + foreach ( $filter["values"] as $value ) + $req .= pack ( "N", floatval($value) ); // this uberhack is to workaround 32bit signed int limit on x32 platforms + break; + + case SPH_FILTER_RANGE: + $req .= pack ( "NN", $filter["min"], $filter["max"] ); + break; + + case SPH_FILTER_FLOATRANGE: + $req .= $this->_PackFloat ( $filter["min"] ) . $this->_PackFloat ( $filter["max"] ); + break; + + default: + assert ( 0 && "internal error: unhandled filter type" ); + } + $req .= pack ( "N", $filter["exclude"] ); + } + + // group-by clause, max-matches count, group-sort clause, cutoff count + $req .= pack ( "NN", $this->_groupfunc, strlen($this->_groupby) ) . $this->_groupby; + $req .= pack ( "N", $this->_maxmatches ); + $req .= pack ( "N", strlen($this->_groupsort) ) . $this->_groupsort; + $req .= pack ( "NNN", $this->_cutoff, $this->_retrycount, $this->_retrydelay ); + $req .= pack ( "N", strlen($this->_groupdistinct) ) . $this->_groupdistinct; + + // anchor point + if ( empty($this->_anchor) ) + { + $req .= pack ( "N", 0 ); + } else + { + $a =& $this->_anchor; + $req .= pack ( "N", 1 ); + $req .= pack ( "N", strlen($a["attrlat"]) ) . $a["attrlat"]; + $req .= pack ( "N", strlen($a["attrlong"]) ) . $a["attrlong"]; + $req .= $this->_PackFloat ( $a["lat"] ) . $this->_PackFloat ( $a["long"] ); + } + + // per-index weights + $req .= pack ( "N", count($this->_indexweights) ); + foreach ( $this->_indexweights as $idx=>$weight ) + $req .= pack ( "N", strlen($idx) ) . $idx . pack ( "N", $weight ); + + // max query time + $req .= pack ( "N", $this->_maxquerytime ); + + // per-field weights + $req .= pack ( "N", count($this->_fieldweights) ); + foreach ( $this->_fieldweights as $field=>$weight ) + $req .= pack ( "N", strlen($field) ) . $field . pack ( "N", $weight ); + + // comment + $req .= pack ( "N", strlen($comment) ) . $comment; + + // mbstring workaround + $this->_MBPop (); + + // store request to requests array + $this->_reqs[] = $req; + return count($this->_reqs)-1; + } + + /// connect to searchd, run queries batch, and return an array of result sets + function RunQueries () + { + if ( empty($this->_reqs) ) + { + $this->_error = "no queries defined, issue AddQuery() first"; + return false; + } + + // mbstring workaround + $this->_MBPush (); + + if (!( $fp = $this->_Connect() )) + { + $this->_MBPop (); + return false; + } + + //////////////////////////// + // send query, get response + //////////////////////////// + + $nreqs = count($this->_reqs); + $req = join ( "", $this->_reqs ); + $len = 4+strlen($req); + $req = pack ( "nnNN", SEARCHD_COMMAND_SEARCH, VER_COMMAND_SEARCH, $len, $nreqs ) . $req; // add header + + fwrite ( $fp, $req, $len+8 ); + if (!( $response = $this->_GetResponse ( $fp, VER_COMMAND_SEARCH ) )) + { + $this->_MBPop (); + return false; + } + + $this->_reqs = array (); + + ////////////////// + // parse response + ////////////////// + + $p = 0; // current position + $max = strlen($response); // max position for checks, to protect against broken responses + + $results = array (); + for ( $ires=0; $ires<$nreqs && $p<$max; $ires++ ) + { + $results[] = array(); + $result =& $results[$ires]; + + $result["error"] = ""; + $result["warning"] = ""; + + // extract status + list(,$status) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $result["status"] = $status; + if ( $status!=SEARCHD_OK ) + { + list(,$len) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $message = substr ( $response, $p, $len ); $p += $len; + + if ( $status==SEARCHD_WARNING ) + { + $result["warning"] = $message; + } else + { + $result["error"] = $message; + continue; + } + } + + // read schema + $fields = array (); + $attrs = array (); + + list(,$nfields) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + while ( $nfields-->0 && $p<$max ) + { + list(,$len) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $fields[] = substr ( $response, $p, $len ); $p += $len; + } + $result["fields"] = $fields; + + list(,$nattrs) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + while ( $nattrs-->0 && $p<$max ) + { + list(,$len) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $attr = substr ( $response, $p, $len ); $p += $len; + list(,$type) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $attrs[$attr] = $type; + } + $result["attrs"] = $attrs; + + // read match count + list(,$count) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + list(,$id64) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + + // read matches + $idx = -1; + while ( $count-->0 && $p<$max ) + { + // index into result array + $idx++; + + // parse document id and weight + if ( $id64 ) + { + $doc = sphUnpack64 ( substr ( $response, $p, 8 ) ); $p += 8; + list(,$weight) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + } else + { + list ( $doc, $weight ) = array_values ( unpack ( "N*N*", + substr ( $response, $p, 8 ) ) ); + $p += 8; + + if ( PHP_INT_SIZE>=8 ) + { + // x64 route, workaround broken unpack() in 5.2.2+ + if ( $doc<0 ) $doc += (1<<32); + } else + { + // x32 route, workaround php signed/unsigned braindamage + $doc = sprintf ( "%u", $doc ); + } + } + $weight = sprintf ( "%u", $weight ); + + // create match entry + if ( $this->_arrayresult ) + $result["matches"][$idx] = array ( "id"=>$doc, "weight"=>$weight ); + else + $result["matches"][$doc]["weight"] = $weight; + + // parse and create attributes + $attrvals = array (); + foreach ( $attrs as $attr=>$type ) + { + // handle floats + if ( $type==SPH_ATTR_FLOAT ) + { + list(,$uval) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + list(,$fval) = unpack ( "f*", pack ( "L", $uval ) ); + $attrvals[$attr] = $fval; + continue; + } + + // handle everything else as unsigned ints + list(,$val) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + if ( $type & SPH_ATTR_MULTI ) + { + $attrvals[$attr] = array (); + $nvalues = $val; + while ( $nvalues-->0 && $p<$max ) + { + list(,$val) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $attrvals[$attr][] = sprintf ( "%u", $val ); + } + } else + { + $attrvals[$attr] = sprintf ( "%u", $val ); + } + } + + if ( $this->_arrayresult ) + $result["matches"][$idx]["attrs"] = $attrvals; + else + $result["matches"][$doc]["attrs"] = $attrvals; + } + + list ( $total, $total_found, $msecs, $words ) = + array_values ( unpack ( "N*N*N*N*", substr ( $response, $p, 16 ) ) ); + $result["total"] = sprintf ( "%u", $total ); + $result["total_found"] = sprintf ( "%u", $total_found ); + $result["time"] = sprintf ( "%.3f", $msecs/1000 ); + $p += 16; + + while ( $words-->0 && $p<$max ) + { + list(,$len) = unpack ( "N*", substr ( $response, $p, 4 ) ); $p += 4; + $word = substr ( $response, $p, $len ); $p += $len; + list ( $docs, $hits ) = array_values ( unpack ( "N*N*", substr ( $response, $p, 8 ) ) ); $p += 8; + $result["words"][$word] = array ( + "docs"=>sprintf ( "%u", $docs ), + "hits"=>sprintf ( "%u", $hits ) ); + } + } + + $this->_MBPop (); + return $results; + } + + ///////////////////////////////////////////////////////////////////////////// + // excerpts generation + ///////////////////////////////////////////////////////////////////////////// + + /// connect to searchd server, and generate exceprts (snippets) + /// of given documents for given query. returns false on failure, + /// an array of snippets on success + function BuildExcerpts ( $docs, $index, $words, $opts=array() ) + { + assert ( is_array($docs) ); + assert ( is_string($index) ); + assert ( is_string($words) ); + assert ( is_array($opts) ); + + $this->_MBPush (); + + if (!( $fp = $this->_Connect() )) + { + $this->_MBPop(); + return false; + } + + ///////////////// + // fixup options + ///////////////// + + if ( !isset($opts["before_match"]) ) $opts["before_match"] = ""; + if ( !isset($opts["after_match"]) ) $opts["after_match"] = ""; + if ( !isset($opts["chunk_separator"]) ) $opts["chunk_separator"] = " ... "; + if ( !isset($opts["limit"]) ) $opts["limit"] = 256; + if ( !isset($opts["around"]) ) $opts["around"] = 5; + if ( !isset($opts["exact_phrase"]) ) $opts["exact_phrase"] = false; + if ( !isset($opts["single_passage"]) ) $opts["single_passage"] = false; + if ( !isset($opts["use_boundaries"]) ) $opts["use_boundaries"] = false; + if ( !isset($opts["weight_order"]) ) $opts["weight_order"] = false; + + ///////////////// + // build request + ///////////////// + + // v.1.0 req + $flags = 1; // remove spaces + if ( $opts["exact_phrase"] ) $flags |= 2; + if ( $opts["single_passage"] ) $flags |= 4; + if ( $opts["use_boundaries"] ) $flags |= 8; + if ( $opts["weight_order"] ) $flags |= 16; + $req = pack ( "NN", 0, $flags ); // mode=0, flags=$flags + $req .= pack ( "N", strlen($index) ) . $index; // req index + $req .= pack ( "N", strlen($words) ) . $words; // req words + + // options + $req .= pack ( "N", strlen($opts["before_match"]) ) . $opts["before_match"]; + $req .= pack ( "N", strlen($opts["after_match"]) ) . $opts["after_match"]; + $req .= pack ( "N", strlen($opts["chunk_separator"]) ) . $opts["chunk_separator"]; + $req .= pack ( "N", (int)$opts["limit"] ); + $req .= pack ( "N", (int)$opts["around"] ); + + // documents + $req .= pack ( "N", count($docs) ); + foreach ( $docs as $doc ) + { + assert ( is_string($doc) ); + $req .= pack ( "N", strlen($doc) ) . $doc; + } + + //////////////////////////// + // send query, get response + //////////////////////////// + + $len = strlen($req); + $req = pack ( "nnN", SEARCHD_COMMAND_EXCERPT, VER_COMMAND_EXCERPT, $len ) . $req; // add header + $wrote = fwrite ( $fp, $req, $len+8 ); + if (!( $response = $this->_GetResponse ( $fp, VER_COMMAND_EXCERPT ) )) + { + $this->_MBPop (); + return false; + } + + ////////////////// + // parse response + ////////////////// + + $pos = 0; + $res = array (); + $rlen = strlen($response); + for ( $i=0; $i $rlen ) + { + $this->_error = "incomplete reply"; + $this->_MBPop (); + return false; + } + $res[] = $len ? substr ( $response, $pos, $len ) : ""; + $pos += $len; + } + + $this->_MBPop (); + return $res; + } + + + ///////////////////////////////////////////////////////////////////////////// + // keyword generation + ///////////////////////////////////////////////////////////////////////////// + + /// connect to searchd server, and generate keyword list for a given query + /// returns false on failure, + /// an array of words on success + function BuildKeywords ( $query, $index, $hits ) + { + assert ( is_string($query) ); + assert ( is_string($index) ); + assert ( is_bool($hits) ); + + $this->_MBPush (); + + if (!( $fp = $this->_Connect() )) + { + $this->_MBPop(); + return false; + } + + ///////////////// + // build request + ///////////////// + + // v.1.0 req + $req = pack ( "N", strlen($query) ) . $query; // req query + $req .= pack ( "N", strlen($index) ) . $index; // req index + $req .= pack ( "N", (int)$hits ); + + //////////////////////////// + // send query, get response + //////////////////////////// + + $len = strlen($req); + $req = pack ( "nnN", SEARCHD_COMMAND_KEYWORDS, VER_COMMAND_KEYWORDS, $len ) . $req; // add header + $wrote = fwrite ( $fp, $req, $len+8 ); + if (!( $response = $this->_GetResponse ( $fp, VER_COMMAND_KEYWORDS ) )) + { + $this->_MBPop (); + return false; + } + + ////////////////// + // parse response + ////////////////// + + $pos = 0; + $res = array (); + $rlen = strlen($response); + list(,$nwords) = unpack ( "N*", substr ( $response, $pos, 4 ) ); + $pos += 4; + for ( $i=0; $i<$nwords; $i++ ) + { + list(,$len) = unpack ( "N*", substr ( $response, $pos, 4 ) ); $pos += 4; + $tokenized = $len ? substr ( $response, $pos, $len ) : ""; + $pos += $len; + + list(,$len) = unpack ( "N*", substr ( $response, $pos, 4 ) ); $pos += 4; + $normalized = $len ? substr ( $response, $pos, $len ) : ""; + $pos += $len; + + $res[] = array ( "tokenized"=>$tokenized, "normalized"=>$normalized ); + + if ( $hits ) + { + list($ndocs,$nhits) = array_values ( unpack ( "N*N*", substr ( $response, $pos, 8 ) ) ); + $pos += 8; + $res [$i]["docs"] = $ndocs; + $res [$i]["hits"] = $nhits; + } + + if ( $pos > $rlen ) + { + $this->_error = "incomplete reply"; + $this->_MBPop (); + return false; + } + } + + $this->_MBPop (); + return $res; + } + + function EscapeString ( $string ) + { + $from = array ( '(',')','|','-','!','@','~','"','&', '/' ); + $to = array ( '\(','\)','\|','\-','\!','\@','\~','\"', '\&', '\/' ); + + return str_replace ( $from, $to, $string ); + } + + ///////////////////////////////////////////////////////////////////////////// + // attribute updates + ///////////////////////////////////////////////////////////////////////////// + + /// update given attribute values on given documents in given indexes + /// returns amount of updated documents (0 or more) on success, or -1 on failure + function UpdateAttributes ( $index, $attrs, $values ) + { + // verify everything + assert ( is_string($index) ); + + assert ( is_array($attrs) ); + foreach ( $attrs as $attr ) + assert ( is_string($attr) ); + + assert ( is_array($values) ); + foreach ( $values as $id=>$entry ) + { + assert ( is_numeric($id) ); + assert ( is_array($entry) ); + assert ( count($entry)==count($attrs) ); + foreach ( $entry as $v ) + assert ( is_int($v) ); + } + + // build request + $req = pack ( "N", strlen($index) ) . $index; + + $req .= pack ( "N", count($attrs) ); + foreach ( $attrs as $attr ) + $req .= pack ( "N", strlen($attr) ) . $attr; + + $req .= pack ( "N", count($values) ); + foreach ( $values as $id=>$entry ) + { + $req .= sphPack64 ( $id ); + foreach ( $entry as $v ) + $req .= pack ( "N", $v ); + } + + // mbstring workaround + $this->_MBPush (); + + // connect, send query, get response + if (!( $fp = $this->_Connect() )) + { + $this->_MBPop (); + return -1; + } + + $len = strlen($req); + $req = pack ( "nnN", SEARCHD_COMMAND_UPDATE, VER_COMMAND_UPDATE, $len ) . $req; // add header + fwrite ( $fp, $req, $len+8 ); + + if (!( $response = $this->_GetResponse ( $fp, VER_COMMAND_UPDATE ) )) + { + $this->_MBPop (); + return -1; + } + + // parse response + list(,$updated) = unpack ( "N*", substr ( $response, 0, 4 ) ); + $this->_MBPop (); + return $updated; + } +} + +// +// $Id$ +// \ No newline at end of file diff --git a/phpBB/language/en/mods/fulltext_sphinx.php b/phpBB/language/en/mods/fulltext_sphinx.php new file mode 100644 index 0000000000..e06328afc8 --- /dev/null +++ b/phpBB/language/en/mods/fulltext_sphinx.php @@ -0,0 +1,65 @@ + 'Automatically configure Sphinx', + 'FULLTEXT_SPHINX_AUTOCONF_EXPLAIN' => 'This is the easiest way to install Sphinx, just select the settings here and a config file will be written for you. This requires write permissions on the configuration folder.', + 'FULLTEXT_SPHINX_AUTORUN' => 'Automatically run Sphinx', + 'FULLTEXT_SPHINX_AUTORUN_EXPLAIN' => 'This is the easiest way to run Sphinx. Select the paths in this dialogue and the Sphinx daemon will be started and stopped as needed. You can also create an index from the ACP. If your PHP installation forbids the use of exec you can disable this and run Sphinx manually.', + 'FULLTEXT_SPHINX_BIN_PATH' => 'Path to executables directory', + 'FULLTEXT_SPHINX_BIN_PATH_EXPLAIN' => 'Skip if autorun is disabled. If this path could not be determined automatically you have to enter the path to the directory in which the sphinx executables indexer and searchd reside.', + 'FULLTEXT_SPHINX_CONFIG_PATH' => 'Path to configuration directory', + 'FULLTEXT_SPHINX_CONFIG_PATH_EXPLAIN' => 'Skip if autoconf is disabled. You should create this config directory outside the web accessable directories. It has to be writable by the user as which your webserver is running (often www-data or nobody).', + 'FULLTEXT_SPHINX_CONFIGURE_FIRST' => 'Before you create an index you have to enable and configure sphinx under GENERAL -> SERVER CONFIGURATION -> Search settings.', + 'FULLTEXT_SPHINX_CONFIGURE_BEFORE' => 'Configure the following settings BEFORE activating Sphinx', + 'FULLTEXT_SPHINX_CONFIGURE_AFTER' => 'The following settings do not have to be configured before activating Sphinx', + 'FULLTEXT_SPHINX_DATA_PATH' => 'Path to data directory', + 'FULLTEXT_SPHINX_DATA_PATH_EXPLAIN' => 'Skip if autorun is disabled. You should create this directory outside the web accessable directories. It has to be writable by the user as which your webserver is running (often www-data or nobody). It will be used to store the indexes and log files.', + 'FULLTEXT_SPHINX_DELTA_POSTS' => 'Number of posts in frequently updated delta index', + 'FULLTEXT_SPHINX_DIRECTORY_NOT_FOUND' => 'The directory %s does not exist. Please correct your path settings.', + 'FULLTEXT_SPHINX_FILE_NOT_EXECUTABLE' => 'The file %s is not executable for the webserver.', + 'FULLTEXT_SPHINX_FILE_NOT_FOUND' => 'The file %s does not exist. Please correct your path settings.', + 'FULLTEXT_SPHINX_FILE_NOT_WRITABLE' => 'The file %s cannot be written by the webserver.', + 'FULLTEXT_SPHINX_INDEXER_MEM_LIMIT' => 'Indexer memory limit', + 'FULLTEXT_SPHINX_INDEXER_MEM_LIMIT_EXPLAIN' => 'This number should at all times be lower than the RAM available on your machine. If you experience periodic performance problems this might be due to the indexer consuming too many resources. It might help to lower the amount of memory available to the indexer.', + 'FULLTEXT_SPHINX_LAST_SEARCHES' => 'Recent search queries', + 'FULLTEXT_SPHINX_MAIN_POSTS' => 'Number of posts in main index', + 'FULLTEXT_SPHINX_PORT' => 'Sphinx search deamon port', + 'FULLTEXT_SPHINX_PORT_EXPLAIN' => 'Port on which the sphinx search deamon on localhost listens. Leave empty to use the default 3312', + 'FULLTEXT_SPHINX_REQUIRES_EXEC' => 'The sphinx plugin for phpBB requires PHP’s exec function which is disabled on your system.', + 'FULLTEXT_SPHINX_UNCONFIGURED' => 'Please set all necessary options in the "Fulltext Sphinx" section of the previous page before you try to activate the sphinx plugin.', + 'FULLTEXT_SPHINX_WRONG_DATABASE' => 'The sphinx plugin for phpBB currently only supports MySQL', + 'FULLTEXT_SPHINX_STOPWORDS_FILE' => 'Stopwords activated', + 'FULLTEXT_SPHINX_STOPWORDS_FILE_EXPLAIN' => 'This setting only works with autoconf enabled. You can place a file called sphinx_stopwords.txt containing one word in each line in your config directory. If this file is present these words will be excluded from the indexing process.', +)); + +?> \ No newline at end of file