plugins/search/admin_modules/yf_manage_sphinx.class.php

Summary

Maintainability
F
2 wks
Test Coverage
<?php

// TODO: connect subclasses from manage_sphinx/, do refactoring from subclasses into closures

class yf_manage_sphinx
{
    /** @var */
    public $CONF_PATH = '/usr/local/sphinx/etc/';
    /** @var */
    public $BIN_PATH = '/usr/local/sphinx/bin/';
    /** @var */
    public $DATA_PATH = '/usr/local/sphinx/data/';
    /** @var */
    public $LOG_PATH = '/usr/local/sphinx/log/';
    /** @var */
    public $INDEXER_NAME = 'indexer';
    /** @var */
    public $SEARCHD_NAME = 'searchd';
    /** @var */
    public $MAX_MATCHES = 1000;
    /** @var */
    public $CONNECT_RETRIES = 3;
    /** @var */
    public $CONNECT_WAIT_TIME = 300;
    /** @var */
    public $SPHINX_TABLE = 'sphinx';
    /**    @var */
    public $USE_STOP_WORDS = false;
    /** @var */
    public $MEM_LIMIT = 32; // In megabytes
    /** @var Add current configuration to the current config or replace it completely */
    public $REPLACE_CONFIG = true;
    /** @var */
    public $WIN32_SERVICE = 'sphinxsearch';


    public function _hook_settings(&$selected = [])
    {
        //        return array(
//            array('yes_no_box', 'manage_sphinx__USE_STOP_WORDS'),
//            array('number', 'manage_sphinx__MAX_MATCHES'),
//        );
    }

    /**
     * Framework constructor.
     */
    public function _init()
    {
        $this->SPHINX_TABLE = DB_PREFIX . $this->SPHINX_TABLE;

        if (OS_WINDOWS) {
            $this->CONF_PATH = 'd:/www/sphinx/';
            $this->BIN_PATH = 'd:/www/sphinx/bin/';
            $this->DATA_PATH = 'd:/www/sphinx/data/';
            $this->LOG_PATH = 'd:/www/sphinx/log/';
            $this->WIN32_SERVICE = 'sphinxsearch';
        }
    }

    /**
     * Default method.
     */
    public function show()
    {
        // TODO: more user-friendly interface :-)

        $this->_update_config();
    }

    /**
     * Get used countries from system sites.
     */
    public function _get_countries()
    {
        if (isset($this->_countries)) {
            return $this->_countries;
        }
        $countries = [];
        $Q = db()->query('SELECT DISTINCT(country) AS code FROM ' . db('sites') . " WHERE country != '' AND active='1' ORDER BY country ASC");
        while ($A = db()->fetch_assoc($Q)) {
            $countries[$A['code']] = $A['code'];
        }
        // Cache this
        $this->_countries = $countries;

        return $countries;
    }

    /**
     * Current config creation here.
     */
    public function _current_config()
    {
        $config_data = [];

        $source_main = [
            ['type',        'mysql'],
            ['sql_host',    DB_HOST],
            ['sql_user',    DB_USER],
            ['sql_pass',    DB_PSWD],
            ['sql_db',        DB_NAME],
            ['sql_port',    3306],
        ];

        $config_data = [
            'indexer' => [
                ['mem_limit',            $this->MEM_LIMIT . 'M'],
            ],
            'searchd' => [
                ['log',                $this->LOG_PATH . 'searchd.log'],
                ['query_log',            $this->LOG_PATH . 'query.log'],
                ['read_timeout',        '5'],
                ['max_children',        '200'],
                ['pid_file',            $this->LOG_PATH . 'searchd.pid'],
                ['max_matches',        (string) $this->MAX_MATCHES],
            ],
        ];
        /*
                $source_homes = array(
                    // prevent main table locking when indexing
                    array("sql_query_range",    "SELECT MIN(id), MAX(id) FROM %%DB_TABLE_VERTICAL%%"),
                    array("sql_range_step",        5000),
                    array("sql_query",
                        "SELECT id, title, content, date, site_id, source_id, city, region,
                            price, type, floor_area, bedrooms, bathrooms,
                            (price / floor_area) AS sq_price, IF(pictures > 0, 1, 0) AS with_photos
                        FROM %%DB_TABLE_VERTICAL%%
                        WHERE id >= \$start AND id <= \$end
                    "),
                    array("sql_query_info",        "SELECT id, title, content FROM %%DB_TABLE_VERTICAL%% WHERE id=\$id"),
                    array("sql_attr_timestamp",    "date"),
                    array("sql_attr_uint",        "site_id"),
                    array("sql_attr_uint",        "source_id"),
                    array("sql_attr_uint",        "city"),
                    array("sql_attr_uint",        "region"),
                    array("sql_attr_uint",        "price"),
                    array("sql_attr_uint",        "type"),
                    array("sql_attr_uint",        "floor_area"),
                    array("sql_attr_uint",        "bedrooms"),
                    array("sql_attr_uint",        "bathrooms"),
                    array("sql_attr_float",        "sq_price"),
                    array("sql_attr_bool",        "with_photos"),
                );

                $source_cars = array(
                    // prevent main table locking when indexing
                    array("sql_query_range",    "SELECT MIN(id), MAX(id) FROM %%DB_TABLE_VERTICAL%%"),
                    array("sql_range_step",        5000),
                    array("sql_query",
                        "SELECT id, title, content, date, site_id, source_id, city, region,
                            price, make, model, fuel, year, doors, mileage, IF(pictures > 0, 1, 0) AS with_photos
                        FROM %%DB_TABLE_VERTICAL%%
                        WHERE id >= \$start AND id <= \$end
                    "),
                    array("sql_query_info",        "SELECT id, title, content FROM %%DB_TABLE_VERTICAL%% WHERE id=\$id"),
                    array("sql_attr_timestamp",    "date"),
                    array("sql_attr_uint",        "site_id"),
                    array("sql_attr_uint",        "source_id"),
                    array("sql_attr_uint",        "city"),
                    array("sql_attr_uint",        "region"),
                    array("sql_attr_uint",        "price"),
                    array("sql_attr_uint",        "make"),
                    array("sql_attr_uint",        "model"),
                    array("sql_attr_uint",        "fuel"),
                    array("sql_attr_uint",        "year"),
                    array("sql_attr_uint",        "doors"),
                    array("sql_attr_uint",        "mileage"),
                    array("sql_attr_bool",        "with_photos"),
                );

                $source_jobs = array(
                    // prevent main table locking when indexing
                    array("sql_query_range",    "SELECT MIN(id), MAX(id) FROM %%DB_TABLE_VERTICAL%%"),
                    array("sql_range_step",        5000),
                    array("sql_query",
                        "SELECT id, title, content, date, site_id, source_id, city, region,
                            salary
                        FROM %%DB_TABLE_VERTICAL%%
                        WHERE id >= \$start AND id <= \$end
                    "),
                    array("sql_query_info",        "SELECT id, title, content FROM %%DB_TABLE_VERTICAL%% WHERE id=\$id"),
                    array("sql_attr_timestamp",    "date"),
                    array("sql_attr_uint",        "site_id"),
                    array("sql_attr_uint",        "source_id"),
                    array("sql_attr_uint",        "city"),
                    array("sql_attr_uint",        "region"),
                    array("sql_attr_uint",        "salary"),
                );

                $index_share = array(
                    array("docinfo",            "extern"),
                    array("morphology",            "none"),
                    array("stopwords",            (file_exists($this->CONF_PATH . "sphinx_stopwords.txt") && $this->USE_STOP_WORDS) ? $this->CONF_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+4E00..U+9FFF"),
                    array("min_prefix_len",        "0"),
                    array("min_infix_len",        "0"),
                );

                // Get used countries from system sites
                $countries = $this->_get_countries();

                $sources = array("source source_main" => $source_main);
                foreach ((array)$countries as $code) {
                    $tmp = $source_homes;
                    foreach ((array)$tmp as $k => $v) {
                        $tmp[$k][1] = str_replace("%%DB_TABLE_VERTICAL%%", DB_PREFIX."homes_".$code, $v[1]);
                    }
                    $sources["source source_homes_".$code." : source_main"]    = $tmp;

                    $tmp = $source_cars;
                    foreach ((array)$tmp as $k => $v) {
                        $tmp[$k][1] = str_replace("%%DB_TABLE_VERTICAL%%", DB_PREFIX."cars_".$code, $v[1]);
                    }
                    $sources["source source_cars_".$code." : source_main"]    = $tmp;

                    $tmp = $source_jobs;
                    foreach ((array)$tmp as $k => $v) {
                        $tmp[$k][1] = str_replace("%%DB_TABLE_VERTICAL%%", DB_PREFIX."jobs_".$code, $v[1]);
                    }
                    $sources["source source_jobs_".$code." : source_main"]    = $tmp;
                }

                $indexes = array();
                foreach ((array)$countries as $code) {
                    $indexes["index homes_".$code]    = array_merge(array(
                        array("source",    "source_homes_".$code),
                        array("path",    $this->DATA_PATH . "index_homes_".$code),
                    ), $index_share);

                    $indexes["index cars_".$code]    = array_merge(array(
                        array("source",    "source_cars_".$code),
                        array("path",    $this->DATA_PATH . "index_cars_".$code),
                    ), $index_share);

                    $indexes["index jobs_".$code]    = array_merge(array(
                        array("source",    "source_jobs_".$code),
                        array("path",    $this->DATA_PATH . "index_jobs_".$code),
                    ), $index_share);
                }
        */
        $config_data = array_merge($sources, $indexes, $config_data);

        return $config_data;
    }

    /**
     * Do update config and start/restart sphinx.
     * @param mixed $name
     * @param mixed $no_indexing
     * @param mixed $no_write
     */
    public function _update_config($name = '', $no_indexing = false, $no_write = false)
    {
        $conf_file_path = $this->CONF_PATH . 'sphinx.conf';
        if (file_exists($conf_file_path) && ! is_readable($conf_file_path)) {
            return _e('Error!. File ' . $conf_file_path . ' is not readable! Please check permissions.');
        }

        $config_object = new sphinx_config($this->CONF_PATH . 'sphinx.conf');

        $non_unique = [
            'sql_group_column' => true,
            'sql_date_column' => true,
            'sql_str2ordinal_column' => true,
            'sql_attr_uint' => true,
            'sql_attr_float' => true,
            'sql_attr_bool' => true,
            'sql_attr_string' => true,
            'sql_attr_multi' => true,
            'sql_attr_timestamp' => true,
            'sql_attr_str2ordinal' => true,
            'sql_query_pre' => true,
            'sql_field_string' => true,
        ];

        $config_data = $this->_current_config();

        // Empty current loaded config
        if ($this->REPLACE_CONFIG) {
            $config_object->sections = null;
        }

        foreach ((array) $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 ((array) $non_unique as $key => $void) {
                $section->delete_variables_by_name($key);
            }

            foreach ((array) $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);
                }
            }
        }

        if ($no_write) {
            return $config_object->to_string();
        }
        if (file_exists($conf_file_path) && ! is_writable($conf_file_path)) {
            return _e('Error!. File ' . $conf_file_path . ' is not writeable! Please check permissions.')
                . "\n" . $config_object->to_string();
        }

        $config_object->write($conf_file_path);

        if ( ! $no_indexing) {
            $this->_shutdown_searchd();
            $this->_create_index($name);
            $this->_start_searchd();
        }

        return true;
    }

    /**
     * Destroy old cache entries and create new if said.
     * @param mixed $create
     * @param mixed $single_index
     */
    public function _tidy($create = false, $single_index = '')
    {
        $indexes = '--all';
        if ($single_index) {
            $indexes = $single_index;
        }
        if ($this->_index_created() || $create) {
            //            $rotate = ($this->_searchd_running()) ? " --rotate" : "";
            $rotate = ' --rotate';

            $log_file_path = $this->LOG_PATH . 'indexer.log';
            $conf_file_path = $this->CONF_PATH . 'sphinx.conf';

            $cwd = getcwd();
            chdir($this->BIN_PATH);
            exec('echo ' . date('Y-m-d H:i:s') . ' >> ' . $log_file_path);
            if (OS_WINDOWS) {
                exec($this->INDEXER_NAME . $rotate . ' --config ' . $conf_file_path . ' ' . $indexes . ' >> ' . $log_file_path);
            } else {
                exec('./' . $this->INDEXER_NAME . $rotate . ' --config ' . $conf_file_path . ' ' . $indexes . ' >> ' . $log_file_path . ' 2>&1 &');
            }
            chdir($cwd);
        }
    }

    /**
     * Start searchd process.
     */
    public function _start_searchd()
    {
        $pid_path = $this->LOG_PATH . 'searchd.pid';
        if ( ! file_exists($pid_path)) {
            $this->_shutdown_searchd();

            if (OS_WINDOWS) {
                exec('net start ' . $this->WIN32_SERVICE);
            } else {
                $cwd = getcwd();
                chdir($this->BIN_PATH);
                exec('./' . $this->SEARCHD_NAME . ' --config ' . $this->CONF_PATH . 'sphinx.conf >> ' . $this->LOG_PATH . 'searchd-startup.log 2>&1 &');
                chdir($cwd);
            }
        }
    }

    /**
     * Kills the searchd process and makes sure there's no locks left over.
     */
    public function _shutdown_searchd()
    {
        if (OS_WINDOWS) {
            exec('net stop ' . $this->WIN32_SERVICE);
        } else {
            exec('killall -9 ' . $this->SEARCHD_NAME . ' >> /dev/null 2>&1 &');
        }

        $pid_path = $this->LOG_PATH . 'searchd.pid';

        if (file_exists($pid_path) && is_writable($pid_path)) {
            unlink($pid_path);
        }

        $this->_unlink_by_pattern($this->LOG_PATH, "#^index_.*\.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    bool    Whether searchd is running or not
     */
    public function _searchd_running()
    {
        $pid_path = $this->LOG_PATH . 'searchd.pid';

        if (file_exists($pid_path)) {
            $pid = file_get_contents($pid_path);
        }
        if ($pid) {
            if (OS_WINDOWS) {
                if ( ! is_writable($pid_path)) {
                    return true;
                }
            } else {
                $output = [];
                $pidof_command = 'pidof';

                exec('whereis -b pidof', $output);
                if (count($output) > 1) {
                    $output = explode(' ', $output[0]);
                    $pidof_command = $output[1]; // 0 is pidof:
                }

                exec($pidof_command . ' ' . $this->SEARCHD_NAME, $output);
                if ($output && $output[0] == $pid) {
                    return true;
                }
            }
        }

        // make sure it's really not running
        $this->_shutdown_searchd();

        return false;
    }

    /**
     * Create sphinx table.
     * @param mixed $name
     */
    public function _create_index($name = '')
    {
        $this->_shutdown_searchd();
        /*
                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, $name);

        $this->_shutdown_searchd();

        return false;
    }

    /**
     * Returns true if the sphinx table was created.
     * @param mixed $allow_new_files
     */
    public function _index_created($allow_new_files = true)
    {
        $created = false;

        foreach ((array) $this->_get_countries() as $code) {
            $i_homes = $this->DATA_PATH . 'index_homes_' . $code . '.spd';
            $i_cars = $this->DATA_PATH . 'index_cars_' . $code . '.spd';
            $i_jobs = $this->DATA_PATH . 'index_jobs_' . $code . '.spd';
            if (file_exists($i_homes) && file_exists($i_cars) && file_exists($i_jobs)) {
                $created = true;
            } else {
                $created = false;
                break;
            }
        }
        return $created;
    }

    /**
     * Collects stats that can be displayed on the index maintenance page.
     */
    public function _get_stats()
    {
        $this->stats['last_searches'] = '';

        $file_path = $this->LOG_PATH . 'query.log';

        if (file_exists($file_path)) {
            if ( ! is_readable($file_path)) {
                return _e('Error!. File ' . $file_path . ' is not readable! Please check permissions.');
            }
            $last_searches = explode("\n", _prepare_html($this->_read_last_lines($file_path, 50)));

            $this->stats['last_searches'] = implode("\n", $last_searches);
        }
        return $this->stats;
    }

    /**
     * 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
     */
    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 p2.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 ($this->_index_created())
                {
                    $rotate = ($this->_searchd_running()) ? ' --rotate' : '';

                    $cwd = getcwd();
                    chdir($this->BIN_PATH);
                    exec("./" . $this->INDEXER_NAME . $rotate . " --config " . $this->CONF_PATH . "sphinx.conf index_phpbb_" . $this->id . "_delta >> " . $this->LOG_PATH . "indexer.log 2>&1 &");
                    chdir($cwd);
                }
        */
    }

    /**
     * Delete a post from the index after it was deleted.
     * @param mixed $post_ids
     * @param mixed $author_ids
     * @param mixed $forum_ids
     */
    public function _index_remove($post_ids, $author_ids, $forum_ids)
    {
        /*
                $values = array();
                foreach ((array)$post_ids as $post_id)
                {
                    $values[$post_id] = array(1);
                }

                $this->sphinx->UpdateAttributes($this->indexes, array("deleted"), $values);
        */
    }

    /**
     * Drop sphinx table.
     * @param mixed $acp_module
     * @param mixed $u_action
     */
    public function _delete_index($acp_module, $u_action)
    {
        $this->_shutdown_searchd();

        $this->_unlink_by_pattern($this->DATA_PATH, '#^index_.*$#');

        if ( ! $this->_index_created()) {
            return false;
        }

        $this->_shutdown_searchd();

        return false;
    }

    /**
     * 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
     */
    public function _unlink_by_pattern($path, $pattern)
    {
        $dir = opendir($path);
        while (false !== ($file = readdir($dir))) {
            if (is_file($path . $file) && preg_match($pattern, $file) && is_writable($path . $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
     */
    public function _read_last_lines($file, $amount)
    {
        if ( ! is_readable($file)) {
            return _e('Error!. File ' . $file . ' is not readable! Please check permissions.');
        }
        $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;
    }
}

/**
 * sphinx_config
 * An object representing the sphinx configuration
 * Can read it from file and write it back out after modification.
 */
class sphinx_config
{
    public $loaded = false;
    public $sections = [];

    /**
     * Constructor which optionally loads data from a file.
     *
     * @param    string    $filename    The path to a file containing the sphinx configuration
     */
    public function __construct($filename = false)
    {
        if ($filename !== false && file_exists($filename)) {
            $this->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
     */
    public function &get_section_by_name($name)
    {
        for ($i = 0, $n = count($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
     */
    public function &add_section($name)
    {
        $this->sections[] = new sphinx_config_section($name, '');
        return $this->sections[count($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
     */
    public function read($filename)
    {
        // split the file into lines, we'll process it line by line
        $config_file = file($filename);

        $this->sections = [];

        $section = null;
        $found_opening_bracket = false;
        $in_value = false;

        foreach ((array) $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);
            }
            $line = str_replace("\r", '', $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;
                }


                // 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;
                        } elseif ($line[$j] == '}') {
                            $comment = substr($line, $j + 1);
                            $end_section = true;
                            break;
                        } elseif ( ! $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.
     */
    public function write($filename = false)
    {
        if ($filename === false && $this->loaded) {
            $filename = $this->loaded;
        }

        $data = '';
        foreach ((array) $this->sections as $section) {
            $data .= $section->to_string();
        }

        $fp = fopen($filename, 'wb');
        fwrite($fp, $data);
        fclose($fp);
    }

    /**
     * Return the config data as string.
     *
     * @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.
     */
    public function to_string()
    {
        $data = '';
        foreach ((array) $this->sections as $section) {
            $data .= $section->to_string();
        }
        return $data;
    }
}

/**
 * sphinx_config_section
 * Represents a single section inside the sphinx configuration.
 */
class sphinx_config_section
{
    public $name;
    public $comment;
    public $end_comment;
    public $variables = [];

    /**
     * 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.
     */
    public function __construct($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
     */
    public function add_variable($variable)
    {
        $this->variables[] = $variable;
    }

    /**
     * Adds a comment after the closing bracket in the textual representation.
     * @param mixed $end_comment
     */
    public function set_end_comment($end_comment)
    {
        $this->end_comment = $end_comment;
    }

    /**
     * Getter for the name of this section.
     *
     * @return    string    Section's name
     */
    public 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
     */
    public function &get_variable_by_name($name)
    {
        for ($i = 0, $n = count($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
     */
    public function delete_variables_by_name($name)
    {
        for ($i = 0; $i < count($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
     */
    public function &create_variable($name, $value)
    {
        $this->variables[] = new sphinx_config_variable($name, $value, '');
        return $this->variables[count($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
     */
    public function to_string()
    {
        $content = $this->name . ' ' . $this->comment . "\n{\n";

        // make sure we don't get too many newlines after the opening bracket
        while ($this->variables && trim($this->variables[0]->to_string()) == '') {
            array_shift($this->variables);
        }
        foreach ((array) $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
{
    public $name;
    public $value;
    public $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
     */
    public function __construct($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
     */
    public function get_name()
    {
        return $this->name;
    }

    /**
     * Allows changing the variable's value.
     *
     * @param    string    $value    New value for this variable
     */
    public function set_value($value)
    {
        $this->value = $value;
    }

    /**
     * Turns this object into a string readable by sphinx.
     *
     * @return    string    Config data in textual form
     */
    public function to_string()
    {
        return "\t" . $this->name . ' = ' . str_replace("\n", "\\\n", str_replace("\r", '', $this->value)) . ' ' . $this->comment . "\n";
    }
}

/**
 * sphinx_config_comment
 * Represents a comment inside the sphinx configuration.
 */
class sphinx_config_comment
{
    public $exact_string;

    /**
     * Create a new comment.
     *
     * @param    string    $exact_string    The content of the comment including newlines, leading whitespace, etc.
     */
    public function __construct($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
     */
    public function to_string()
    {
        return $this->exact_string;
    }
}