<?php
/**
 * This file is part of PluginLibrary for MyBB.
 * Copyright (C) 2011 Andreas Klauer <Andreas.Klauer@metamorpher.de>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */

// Disallow direct access to this file for security reasons
if(!defined("IN_MYBB"))
{
    die("Direct initialization of this file is not allowed.<br /><br />Please make sure IN_MYBB is defined.");
}

/* --- Plugin API: --- */

function pluginlibrary_info()
{
    return array(
        "name"          => "PluginLibrary",
        "description"   => "A collection of useful functions for other plugins.",
        "website"       => "http://mods.mybb.com/view/pluginlibrary",
        "author"        => "Andreas Klauer",
        "authorsite"    => "mailto:Andreas.Klauer@metamorpher.de",
        "version"       => "12",
        "guid"          => "839e9d72e2875a51fccbc0257dfeda03",
        "compatibility" => "18*"
        );
}

function pluginlibrary_is_installed()
{
    // Don't try this at home.
    return false;
}

function pluginlibrary_install()
{
    // Avoid unnecessary activation as a plugin with a friendly success message.
    flash_message("The selected plugin does not have to be activated.", 'success');
    admin_redirect("index.php?module=config-plugins");
}

function pluginlibrary_uninstall()
{
}

function pluginlibrary_activate()
{
}

function pluginlibrary_deactivate()
{
}

/* --- PluginLibrary class: --- */

class PluginLibrary
{
    /**
     * Version number.
     */
    public $version = 12;

    /**
     * Cache handler.
     */
    public $cachehandler;

    /* --- Setting groups and settings: --- */

    /**
     * Create and/or update setting group and settings.
     *
     * @param string Internal unique group name and setting prefix.
     * @param string Group title that will be shown to the admin.
     * @param string Group description that will show up in the group overview.
     * @param array The list of settings to be added to that group.
     * @param bool Generate language file. (Developer option, default false)
     */
    function settings($name, $title, $description, $list, $makelang=false)
    {
        global $db;

        /* Setting group: */

        if($makelang)
        {
            header("Content-Type: text/plain; charset=UTF-8");
            echo "<?php\n/**\n * Settings language file generated by PluginLibrary.\n *\n */\n\n";
            echo "\$l['setting_group_{$name}'] = \"".addcslashes($title, '\\"$')."\";\n";
            echo "\$l['setting_group_{$name}_desc'] = \"".addcslashes($description, '\\"$')."\";\n";
        }

        // Group array for inserts/updates.
        $group = array('name' => $db->escape_string($name),
                       'title' => $db->escape_string($title),
                       'description' => $db->escape_string($description));

        // Check if the group already exists.
        $query = $db->simple_select("settinggroups", "gid", "name='${group['name']}'");

        if($row = $db->fetch_array($query))
        {
            // We already have a group. Update title and description.
            $gid = $row['gid'];
            $db->update_query("settinggroups", $group, "gid='{$gid}'");
        }

        else
        {
            // We don't have a group. Create one with proper disporder.
            $query = $db->simple_select("settinggroups", "MAX(disporder) AS disporder");
            $row = $db->fetch_array($query);
            $group['disporder'] = $row['disporder'] + 1;
            $gid = $db->insert_query("settinggroups", $group);
        }

        /* Settings: */

        // Deprecate all the old entries.
        $db->update_query("settings",
                          array("description" => "PLUGINLIBRARYDELETEMARKER"),
                          "gid='$gid'");

        // Create and/or update settings.
        foreach($list as $key => $setting)
        {
            // Prefix all keys with group name.
            $key = "{$name}_{$key}";

            if($makelang)
            {
                echo "\$l['setting_{$key}'] = \"".addcslashes($setting['title'], '\\"$')."\";\n";
                echo "\$l['setting_{$key}_desc'] = \"".addcslashes($setting['description'], '\\"$')."\";\n";
            }

            // Filter valid entries.
            $setting = array_intersect_key($setting,
                                           array(
                                               'title' => 0,
                                               'description' => 0,
                                               'optionscode' => 0,
                                               'value' => 0,
                                               ));

            // Escape input values.
            $setting = array_map(array($db, 'escape_string'), $setting);

            // Add missing default values.
            $disporder += 1;

            $setting = array_merge(
                array('description' => '',
                      'optionscode' => 'yesno',
                      'value' => '0',
                      'disporder' => $disporder),
                $setting);

            $setting['name'] = $db->escape_string($key);
            $setting['gid'] = $gid;

            // Check if the setting already exists.
            $query = $db->simple_select('settings', 'sid',
                                        "gid='$gid' AND name='{$setting['name']}'");

            if($row = $db->fetch_array($query))
            {
                // It exists, update it, but keep value intact.
                unset($setting['value']);
                $db->update_query("settings", $setting, "sid='{$row['sid']}'");
            }

            else
            {
                // It doesn't exist, create it.
                $db->insert_query("settings", $setting);
            }
        }

        if($makelang)
        {
            echo "\n?>\n";
            exit;
        }

        // Delete deprecated entries.
        $db->delete_query("settings",
                          "gid='$gid' AND description='PLUGINLIBRARYDELETEMARKER'");

        // Rebuild the settings file.
        rebuild_settings();
    }

    /**
     * Delete setting groups and settings.
     *
     * @param string Internal unique group name.
     * @param bool Also delete groups starting with name_.
     */
    function settings_delete($name, $greedy=false)
    {
        global $db;

        $name = $db->escape_string($name);
        $where = "name='{$name}'";

        if($greedy)
        {
            $lname = strtr($name, array('=' => '==', '_' => '=_', '%' => '=%'));
            $where .= " OR name LIKE '{$lname}=_%' ESCAPE '='";
        }

        // Query the setting groups.
        $query = $db->simple_select('settinggroups', 'gid', $where);

        // Delete the group and all its settings.
        while($gid = $db->fetch_field($query, 'gid'))
        {
            $db->delete_query('settinggroups', "gid='{$gid}'");
            $db->delete_query('settings', "gid='{$gid}'");
        }

        // Rebuild the settings file.
        rebuild_settings();
    }

    /* --- Template groups and templates: --- */

    /**
     * Create and update template group and templates.
     *
     * @param string Prefix for the template group
     * @param string Title for the template group
     * @param array List of templates to be added to this group.
     */
    function templates($prefix, $title, $list)
    {
        global $db;

        // Template prefix must not be empty, and must not contain _
        if(!strlen($prefix) || strpos($prefix, '_') !== false)
        {
            trigger_error("Invalid template prefix", E_USER_ERROR);
        }

        $group = array('prefix' => $db->escape_string($prefix),
                       'title' => $db->escape_string($title));

        // Update or create template group:
        $query = $db->simple_select('templategroups', 'prefix', "prefix='{$group['prefix']}'");

        if($db->fetch_array($query))
        {
            $db->update_query('templategroups', $group, "prefix='{$group['prefix']}'");
        }

        else
        {
            $db->insert_query('templategroups', $group);
        }

        // Query already existing templates.
        $query = $db->simple_select('templates', 'tid,title,template',
                                    "sid=-2 AND (title='{$group['prefix']}' OR title LIKE '{$group['prefix']}=_%' ESCAPE '=')");

        $templates = array();
        $duplicates = array();

        while($row = $db->fetch_array($query))
        {
            $title = $row['title'];

            if(isset($templates[$title]))
            {
                // PluginLibrary had a bug that caused duplicated templates.
                $duplicates[] = $row['tid'];
                $templates[$title]['template'] = false; // force update later
            }

            else
            {
                $templates[$title] = $row;
            }
        }

        // Delete duplicated master templates, if they exist.
        if($duplicates)
        {
            $db->delete_query('templates', 'tid IN ('.implode(",", $duplicates).')');
        }

        // Update or create templates.
        foreach($list as $name => $code)
        {
            if(strlen($name))
            {
                $name = "{$prefix}_{$name}";
            }

            else
            {
                $name = "{$prefix}";
            }

            $template = array('title' => $db->escape_string($name),
                              'template' => $db->escape_string($code),
                              'version' => 1,
                              'sid' => -2,
                              'dateline' => TIME_NOW);

            // Update
            if(isset($templates[$name]))
            {
                if($templates[$name]['template'] !== $code)
                {
                    // Update version for custom templates if present
                    $db->update_query('templates', array('version' => 0), "title='{$template['title']}'");

                    // Update master template
                    $db->update_query('templates', $template, "tid={$templates[$name]['tid']}");
                }
            }

            // Create
            else
            {
                $db->insert_query('templates', $template);
            }

            // Remove this template from the earlier queried list.
            unset($templates[$name]);
        }

        // Remove no longer used templates.
        foreach($templates as $name => $row)
        {
            $name = $db->escape_string($name);
            $db->delete_query('templates', "title='{$name}'");
        }
    }

    /**
     * Delete template group(s) and templates.
     *
     * @param string Prefix of the template group.
     * @param bool Also delete other groups starting with the prefix.
     */
    function templates_delete($prefix, $greedy=false)
    {
        global $db;

        $prefix = $db->escape_string($prefix);
        $where = "prefix='{$prefix}'";

        if($greedy)
        {
            $where .= " OR prefix LIKE '{$prefix}%'";
        }

        // Query the template groups
        $query = $db->simple_select('templategroups', 'prefix', $where);

        // Build where string for templates
        $twhere = array();

        while($row = $db->fetch_array($query))
        {
            $tprefix = $db->escape_string($row['prefix']);
            $twhere[] = "title='{$tprefix}' OR title LIKE '{$tprefix}=_%' ESCAPE '='";
        }

        if($twhere) // else there are no groups to delete
        {
            // Delete template groups.
            $db->delete_query('templategroups', $where);

            // Delete templates belonging to template groups.
            $db->delete_query('templates', implode(' OR ', $twhere));
        }
    }

    /* --- Stylesheets: --- */

    /**
     * build CSS string out of an [selector => [property => value]] array
     */
    function _build_css($styles)
    {
        if(is_array($styles))
        {
            $css = "";

            foreach($styles as $selector => $properties)
            {
                $rule = "{$selector} {\n";

                if(is_array($properties))
                {
                    foreach($properties as $property => $value)
                    {
                        $rule .= "\t{$property}: {$value};\n";
                    }
                }

                else
                {
                    $rule .= "\t{$properties}\n";
                }

                $rule .= "}\n\n";
                $css .= $rule;
            }

            $styles = $css;
        }

        return $styles;
    }

    /**
     * build attachedto string out of an [file => [action, ]] array
     */
    function _build_attachedto($attachedto)
    {
        if(is_array($attachedto))
        {
            $result = array();

            foreach($attachedto as $file => $actions)
            {
                if(is_array($actions))
                {
                    $actions = implode(",", $actions);
                }

                if($actions)
                {
                    $file = "{$file}?{$actions}";
                }

                $result[] = $file;
            }

            $attachedto = implode("|", $result);
        }

        return $attachedto;
    }

    /**
     * Update stylesheet metadata.
     *
     */
    function _update_themes_stylesheets($stylesheet=false)
    {
        global $mybb;
        $tid = 1; // MyBB Master Style
        require_once MYBB_ROOT.$mybb->config['admin_dir'].'/inc/functions_themes.php';

        if($stylesheet)
        {
            cache_stylesheet($stylesheet['tid'], $stylesheet['cachefile'], $stylesheet['stylesheet']);
        }

        update_theme_stylesheet_list($tid, false, true); // includes all children
    }

    /**
     * Add, update or activate a stylesheet
     * @param string Name of the stylesheet - lowercase version used for cache file.
     * @param string Stylesheet content.
     * @param string The files/actions the stylesheet is attached to. For global attachment, don't include this parameter.
     */
    function stylesheet($name, $styles, $attachedto="")
    {
        global $db;

        // Build stylesheet data.
        $tid = 1; // MyBB Master Style
        if(substr($name, -4) != ".css")
        {
            $name .= '.css';
        }
        $styles = $this->_build_css($styles);
        $attachedto = $this->_build_attachedto($attachedto);

        $stylesheet = array(
            'name' => $name,
            'tid' => $tid,
            'attachedto' => $attachedto,
            'stylesheet' => $styles,
            'cachefile' => $name,
            'lastmodified' => TIME_NOW,
            );

        $dbstylesheet = array_map(array($db, 'escape_string'), $stylesheet);

        // Activate children, if present.
        $db->update_query('themestylesheets',
                          array('attachedto' => $dbstylesheet['attachedto']),
                          "name='{$dbstylesheet['name']}'");

        // Update or insert parent stylesheet.
        $query = $db->simple_select('themestylesheets',
                                    'sid',
                                    "tid='{$tid}' AND cachefile='{$name}'");
        $sid = intval($db->fetch_field($query, 'sid'));

        if($sid)
        {
            $db->update_query('themestylesheets', $dbstylesheet, "sid='$sid'");
        }

        else
        {
            $sid = $db->insert_query('themestylesheets', $dbstylesheet);
            $stylesheet['sid'] = intval($sid);
        }

        $this->_update_themes_stylesheets($stylesheet);
    }

    /**
     * Remove a stylesheet
     * @param string Stylesheet name
     */
    function stylesheet_delete($name, $greedy=false, $delete=true)
    {
        global $db;

        // Check $name ends in .css and if not append it
        $tid = 1; // MyBB Master Style
        if(substr($name, -4) == ".css")
        {
            $name = substr($name, 0, -4);
        }

        // Query all stylesheets matching $name
        $dbname = $db->escape_string($name);

        $where = "name='{$dbname}.css'";

        if($greedy)
        {
            $ldbname = strtr($dbname, array('=' => '==', '_' => '=_', '%' => '=%'));
            $where .= " OR name LIKE '{$ldbname}=_%.css' ESCAPE '='";
        }

        // Delete stylesheets.
        if($delete)
        {
            $query = $db->simple_select('themestylesheets', 'tid,name', $where);

            while($stylesheet = $db->fetch_array($query))
            {
                @unlink(MYBB_ROOT."cache/themes/{$stylesheet['tid']}_{$stylesheet['name']}");
                @unlink(MYBB_ROOT."cache/themes/theme{$stylesheet['tid']}/{$stylesheet['name']}");
            }

            $db->delete_query('themestylesheets', $where);
        }

        else
        {
            // Deactivate stylesheets.
            $db->update_query('themestylesheets',
                              array('attachedto' => '-'),
                              $where);
        }

        $this->_update_themes_stylesheets();
    }

    /**
     * Deactivate stylesheets without deleting them.
     *
     */
    function stylesheet_deactivate($name, $greedy=false)
    {
        $this->stylesheet_delete($name, $greedy, false);
    }

    /* --- Cache: --- */

    /**
     * Obtain a non-database cache handler.
     */
    function _cache_handler()
    {
        global $cache;

        if(is_object($cache->handler))
        {
            return $cache->handler;
        }

        if(is_object($this->cachehandler))
        {
            return $this->cachehandler;
        }

        // Fall back to disk handler.
        require_once MYBB_ROOT.'/inc/cachehandlers/disk.php';
        $this->cachehandler = new diskCacheHandler();
        return $this->cachehandler;
    }

    /**
     * Read on-demand cache.
     */
    function cache_read($name)
    {
        global $cache;

        if(isset($cache->cache[$name]))
        {
            return $cache->cache[$name];
        }

        $handler = $this->_cache_handler();
        $contents = $handler->fetch($name);
        $cache->cache[$name] = $contents;

        return $contents;
    }

    /**
     * Write on-demand cache.
     */
    function cache_update($name, $contents)
    {
        global $cache;

        $handler = $this->_cache_handler();
        $cache->cache[$name] = $contents;

        return $handler->put($name, $contents);
    }

    /**
     * Delete cache.
     *
     * @param string Cache name or title.
     * @param bool Also delete caches starting with name_.
     */
    function cache_delete($name, $greedy=false)
    {
        global $db, $cache;

        // Prepare for database query.
        $dbname = $db->escape_string($name);
        $where = "title='{$dbname}'";

        // Delete on-demand or handler cache.
        $handler = $this->_cache_handler();
        $handler->delete($name);

        // Greedy?
        if($greedy)
        {
            // Collect possible additional names...
            $names = array();
            $name .= '_';

            // ...from the currently loaded cache...
            $keys = array_keys($cache->cache);

            foreach($keys as $key)
            {
                if(strpos($key, $name) === 0)
                {
                    $names[$key] = 0;
                }
            }

            // ...from the database...
            $ldbname = strtr($dbname, array('%' => '=%',
                                            '=' => '==',
                                            '_' => '=_'));
            $where .= " OR title LIKE '{$ldbname}=_%' ESCAPE '='";
            $query = $db->simple_select('datacache', 'title', $where);

            while($row = $db->fetch_array($query))
            {
                $names[$row['title']] = 0;
            }

            // ...from the filesystem...
            $start = strlen(MYBB_ROOT."cache/");
            foreach((array)@glob(MYBB_ROOT."cache/{$name}*.php") as $filename)
            {
                if($filename)
                {
                    $filename = substr($filename, $start, strlen($filename)-4-$start);
                    $names[$filename] = 0;
                }
            }

            // ...and delete them all.
            foreach($names as $key=>$val)
            {
                $handler->delete($key);
            }
        }

        // Delete database caches too.
        $db->delete_query('datacache', $where);
    }

    /* --- Corefile edits: --- */

    /**
     * insert comment at the beginning of each line
     */
    function _comment($comment, $code)
    {
        if(is_array($code))
        {
            $code = implode("\n", $code);
        }

        if(!is_string($code) || !strlen($code))
        {
            return "";
        }

        if(substr($code, -1) == "\n")
        {
            $code = substr($code, 0, -1);
        }

        $code = str_replace("\n", "\n{$comment}", "\n{$code}");

        return substr($code, 1)."\n";
    }

    /**
     * remove comment at the beginning of each line
     */
    function _uncomment($comment, $code)
    {
        if(!strlen($code))
        {
            return "";
        }

        $code = "\n{$code}";
        $code = str_replace("\n{$comment}", "\n", $code);

        return substr($code, 1);
    }

    /**
     * remove lines with comment at the beginning entirely
     */
    function _zapcomment($comment, $code)
    {
        return preg_replace("#^".preg_quote($comment, "#").".*\n?#m", "", $code);
    }

    /**
     * align start and stop to newline characters in text
     */
    function _align($text, &$start, &$stop)
    {
        // Align start to line boundary.
        $nl = strrpos($text, "\n", -strlen($text)+$start);
        $start = ($nl === false ? 0 : $nl + 1);

        // Align stop to line boundary.
        $nl = strpos($text, "\n", $stop);
        $stop = ($nl === false ? strlen($text) : $nl + 1);
    }

    /**
     * in text find the smallest first match for a series of search strings
     */
    function _match($text, $search, &$start)
    {
        $stop = $start;

        // forward search (determine smallest stop)
        foreach($search as $needle)
        {
            $stop = strpos($text, $needle, $stop);

            if($stop === false)
            {
                // we did not find out needle, so this does not match
                return false;
            }

            $stop += strlen($needle);
        }

        // backward search (determine largest start)
        $start = $stop;

        foreach(array_reverse($search) as $needle)
        {
            $start = strrpos($text, $needle, -strlen($text)+$start-strlen($needle));
        }

        return $stop;
    }

    /**
     * dissect text based on a series of edits
     */
    function _dissect($text, &$edits)
    {
        $matches = array();

        foreach($edits as &$edit)
        {
            $search = (array)$edit['search'];
            $start = 0;
            $edit['matches'] = array();

            while(($stop = $this->_match($text, $search, $start)) !== false)
            {
                $pos = $stop;
                $this->_align($text, $start, $stop);

                // to count the matches, and help debugging
                $edit['matches'][] = array($start, $stop,
                                           substr($text, $start, $stop-$start));

                if(isset($matches[$start]))
                {
                    $matches[$start][1]['error'] = 'match collides with another edit';
                    $edit['error'] = 'match collides with another edit';
                    return false;
                }

                else if(count($edit['matches']) > 1 && !$edit['multi'])
                {
                    $edit['error'] = 'multiple matches not allowed for this edit';
                    return false;
                }

                $matches[$start] = array($stop, &$edit);
                $start = $pos;
            }

            if(!count($edit['matches']) && !$edit['none'])
            {
                $edit['error'] = 'zero matches not allowed for this edit';
                return false;
            }
        }

        ksort($matches);
        return $matches;
    }

    /**
     * edit text (perform the actual string modification)
     */
    function _edit($text, &$edits, $ins='/**/', $del='/*/*')
    {
        $matches = $this->_dissect($text, $edits);

        if($matches === false)
        {
            return false;
        }

        $result = array();
        $pos = 0;

        foreach($matches as $start => $val)
        {
            $stop = $val[0];
            $edit = &$val[1];

            if($start < $pos)
            {
                $edit['error'] = 'match overlaps with another edit';
                $previous_edit['error'] = 'match overlaps with another edit';
                return false;
            }

            // Keep previous edit for overlapping detection
            $previous_edit = &$edit;

            // unmodified text before match
            $result[] = substr($text, $pos, $start-$pos);

            // insert before
            $result[] = $this->_comment($ins, $edit['before']);

            // original matched text
            $match = substr($text, $start, $stop-$start);
            $pos = $stop;

            $dirty = 0;

            if($edit['replace']
               || is_string($edit['replace']) || is_array($edit['replace']))
            {
                // insert match (commented out)
                $result[] = $this->_comment($del, $match);
                $result[] = $this->_comment($ins, $edit['replace']);

                if(!strlen($result[count($result)-1]))
                {
                    $dirty = 1; // still a comment open
                }
            }

            else
            {
                // insert match unmodified
                $result[] = $match;
            }

            // insert after
            $result[] = $this->_comment($ins, $edit['after']);

            if($dirty && !strlen($result[count($result)-1]))
            {
                // close open comment
                $result[] = "{$ins}\n";
            }
        }

        // insert rest
        $result[] = substr($text, $pos);

        return implode("", $result);
    }

    /**
     * edit core
     */
    function edit_core($name, $file, $edits=array(), $apply=false, &$debug=null)
    {
        $ins = "/* + PL:{$name} + */ ";
        $del = "/* - PL:{$name} - /* ";

        $text = file_get_contents(MYBB_ROOT.$file);
        $result = $text;

        if($text === false)
        {
            return false;
        }

        if(count($edits) && !count($edits[0]))
        {
            $edits = array($edits);
        }

        // Step 1: remove old comments, if present.
        $result = $this->_zapcomment($ins, $result);
        $result = $this->_uncomment($del, $result);

        // Step 2: prevent colliding edits by adding conditions.
        $edits[] = array('search' => array('/* + PL:'),
                         'multi' => true,
                         'none' => true);
        $edits[] = array('search' => array('/* - PL:'),
                         'multi' => true,
                         'none' => true);

        // Step 3: perform edits.
        $result = $this->_edit($result, $edits, $ins, $del);

        // call_time_pass_reference :-(
        $debug = $edits;

        if($result === false)
        {
            // edits couldn't be performed
            return false;
        }

        if($result == $text)
        {
            // edit made no changes
            return true;
        }

        // try to write the file
        if($apply && @file_put_contents(MYBB_ROOT.$file, $result) !== false)
        {
            // changes successfully applied
            return true;
        }

        // return the string
        return $result;
    }

    /* --- Group memberships: --- */

    /**
     * is_member
     */
    function is_member($groups, $user=false)
    {
        global $mybb;

        // Default to current user.
        if($user === false)
        {
            $user = $mybb->user;
        }

        else if(is_array($user))
        {
            // do nothing
        }

        else
        {
            // assume it's a UID
            $user = get_user($user);
        }

        // Collect the groups the user is in.
        $memberships = explode(',', $user['additionalgroups']);
        $memberships[] = $user['usergroup'];

        // Convert search to an array of group ids
        if(is_array($groups))
        {
            // already an array, do nothing
        }

        else if(is_string($groups))
        {
            $groups = explode(',', $groups);
        }

        else
        {
            // probably a single number
            $groups = (array)$groups;
        }

        // Make sure we're comparing numbers.
        $groups = array_map('intval', $groups);
        $memberships = array_map('intval', $memberships);

        // Remove 0 if present.
        $groups = array_filter($groups);

        // Return the group intersection.
        return array_intersect($groups, $memberships);
    }

    /* --- String functions: --- */

    /**
     * url_append
     */
    function url_append($url, $params, $sep="&amp;", $encode=true)
    {
        if(strpos($url, '?') === false)
        {
            $separator = '?';
        }

        else
        {
            $separator = $sep;
        }

        $append = '';

        foreach($params as $key => $value)
        {
            if($encode)
            {
                $value = urlencode($value);
            }

            $append .= "{$separator}{$key}={$value}";
            $separator = $sep;
        }

        $pos = strpos($url, '#');

        if($pos === false)
        {
            $pos = strlen($url);
        }

        return substr_replace($url, $append, $pos, 0);
    }

    /**
     * _xml_element
     */
    function _xml_tag($tag, $content, $indent=0)
    {
        $nl = "\n" . str_repeat(' ', $indent);
        $result = '';

        if(is_string($content))
        {
            // We can either htmlspecialchars,
            $a = htmlspecialchars($content);

            // or cdata (properly escaped),
            $b = '<![CDATA['
                .str_replace(']]>', ']]]]><![CDATA[>', $content)
                .']]>';

            // just pick whatever is shorter
            $content = (strlen($a) < strlen($b) ? $a : $b);

            $result .= "{$nl}<{$tag}>{$content}</{$tag}>";
        }

        else if(is_bool($content))
        {
            $result .= "{$nl}<{$tag} type=\"BOOL\">{$content}</{$tag}>";
        }

        else if(is_int($content))
        {
            $result .= "{$nl}<{$tag} type=\"INT\">{$content}</{$tag}>";
        }

        else if(is_float($content))
        {
            $result .= "{$nl}<{$tag} type=\"FLOAT\">{$content}</{$tag}>";
        }

        else if(is_array($content))
        {
            $result .= "{$nl}<{$tag}>".$this->_xml_array($content, $indent+2)."{$nl}</{$tag}>";
        }

        return $result;
    }

    /**
     * _xml_array
     */
    function _xml_array($array, $indent=0)
    {
        $nl = "\n".str_repeat(' ', $indent);
        $nl2 = $nl.'  ';
        $result = '';

        foreach($array as $key => $value)
        {
            $key = $this->_xml_tag('key', $key, $indent+4);

            if($key)
            {
                $value = $this->_xml_tag('value', $value, $indent+4);

                if($value)
                {
                    $result .= "{$nl2}<element>{$key}{$value}{$nl2}</element>";
                }
            }
        }

        return "{$nl}<array>{$result}{$nl}</array>";
    }

    /**
     * xml_export
     */
    function xml_export($data, $filename=false, $comment='MyBB PluginLibrary XML-Export :: {time}', $endcomment='End of file.')
    {
        $result = '';

        if(is_array($data))
        {
            $xml = $this->_xml_array($data);
        }

        else
        {
            $xml = $this->_xml_tag('value', $data);
        }

        if($xml)
        {
            $result = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
            $time = date('c', TIME_NOW);

            if($comment)
            {
                $comment = str_replace('{time}', $time, $comment);
                $result .= "<!-- {$comment} -->";
            }

            $result .= $xml."\n";

            if($endcomment)
            {
                $endcomment = str_replace('{time}', $time, $endcomment);
                $result .= "<!-- {$endcomment} -->\n";
            }

            if($filename)
            {
                // Filename encoding sucks.
                $filename = trim(basename('/'.$filename));

                // Output the XML directly.
                @header('Content-Type: application/xml; charset=UTF-8');
                @header('Expires: Sun, 20 Feb 2011 13:47:47 GMT'); // past
                @header('Last-Modified: '.gmdate('D, d M Y H:i:s T'));
                @header('Pragma: no-cache');
                @header('Content-Disposition: attachment; filename="'.$filename.'"');
                @header('Content-Length: '.strlen($result));
                echo $result;
                exit;
            }
        }

        return $result;
    }

    /**
     * xml_import
     */
    function xml_import($xml, &$error=null)
    {
        $parser = xml_parser_create();
        $stack = array();

        if(xml_parse_into_struct($parser, $xml, $values))
        {
            foreach($values as $value)
            {
                // Convert data
                switch(strtoupper($value['attributes']['TYPE']))
                {
                    case 'BOOL':
                        $value['value'] = $value['value'] && true;
                        break;
                    case 'INT':
                        $value['value'] = intval($value['value']);
                        break;
                    case 'FLOAT':
                        $value['value'] = floatval($value['value']);
                        break;
                    default:
                        // Assume string. Mainly for NULL => ''.
                        $value['value'] = strval($value['value']);
                        break;
                }

                $input = strtolower("{$value['tag']}-{$value['type']}");

                // Parse XML element (sloppy)
                switch($input)
                {
                    case 'array-complete':
                    case 'array-open':
                        // Put array on stack
                        array_unshift($stack, array());
                        break;

                    case 'element-close':
                        // Put key, value in array
                        // Remove value, key from stack
                        $stack[2][$stack[1]] = $stack[0];
                        array_shift($stack);
                        array_shift($stack);
                        break;

                    case 'element-open':
                        // Put key, value on stack
                        array_unshift($stack, null, null);
                        break;

                    case 'key-complete':
                        // Set key
                        $stack[1] = $value['value'];
                        break;

                    case 'value-complete':
                        // Set value
                        $stack[0] = $value['value'];
                        break;

                    case 'value-open':
                        // Remove value from stack (new array should follow)
                        array_shift($stack);
                        break;

                    default:
                        // Ignore others.
                        break;
                }

                // If there is something wrong, quit early.
                if(!sizeof($stack))
                {
                    break;
                }
            }

            // The stack should contain a single value now
            if(sizeof($stack) == 1)
            {
                $result = $stack[0];
            }

            else
            {
                $error = array('line' => -1,
                               'code' => -1,
                               'error' => -1,
                               'message' => 'XML is valid, but there is no data to import.');
            }
        }

        else
        {
            // collect error information for debugging purposes
            $lines = explode("\n", $xml);
            $error = array('line' => xml_get_current_line_number($parser),
                           'code' => $lines[xml_get_current_line_number($parser)-1],
                           'error' => xml_get_error_code($parser),
                           'message' => xml_error_string(xml_get_error_code($parser)));
        }

        xml_parser_free($parser);
        return $result;
    }
}

global $PL;
$PL = new PluginLibrary();

/* --- End of file. --- */
?>
