<?php
    /**
     * File: Helpers
     * Various functions used throughout Chyrp's code.
     */

    # Integer: $time_start
    # Times Chyrp.
    $time_start = 0;

    # Array: $l10n
    # Stores loaded gettext domains.
    $l10n = array();

    /**
     * Function: session
     * Begins Chyrp's custom session storage whatnots.
     */
    function session() {
        session_set_save_handler(array("Session", "open"),
                                 array("Session", "close"),
                                 array("Session", "read"),
                                 array("Session", "write"),
                                 array("Session", "destroy"),
                                 array("Session", "gc"));
        $domain = (substr_count($_SERVER['HTTP_HOST'], ".")) ? preg_replace("/^www\./", ".", $_SERVER['HTTP_HOST']) : "" ;
        session_set_cookie_params(60 * 60 * 24 * 30, "/", $domain);
        session_name("ChyrpSession");
        register_shutdown_function("session_write_close");
        session_start();
    }

    /**
     * Function: error
     * Shows an error message.
     *
     * Parameters:
     *     $title - The title for the error dialog.
     *     $body - The message for the error dialog.
     *     $backtrace - The trace of the error.
     */
    function error($title, $body, $backtrace = array()) {
        if (defined('MAIN_DIR') and !empty($backtrace))
            foreach ($backtrace as $index => &$trace)
                if (!isset($trace["file"]) or !isset($trace["line"]))
                    unset($backtrace[$index]);
                else
                    $trace["file"] = str_replace(MAIN_DIR."/", "", $trace["file"]);
                # $trace["file"] = isset($trace["file"]) ?
                #                       :
                #                      (isset($trace["function"]) ?
                #                          (isset($trace["class"]) ?
                #                              $trace["class"].$trace["type"] :
                #                              "").$trace["function"] :
                #                          "[internal]");

        # Clear all output sent before this error.
        if (($buffer = ob_get_contents()) !== false) {
            ob_end_clean();

            # Since the header might already be set to gzip, start output buffering again.
            if (extension_loaded("zlib") and !ini_get("zlib.output_compression") and
                isset($_SERVER['HTTP_ACCEPT_ENCODING']) and
                substr_count($_SERVER['HTTP_ACCEPT_ENCODING'], "gzip") and
                USE_ZLIB) {
                ob_start("ob_gzhandler");
                header("Content-Encoding: gzip");
            } else
                ob_start();
        } elseif (!UPGRADING) {
            # If output buffering is not started, assume this
            # is sent from the Session class or somewhere deep.
            error_log($title.": ".$body);

            foreach ($backtrace as $index => $trace)
                error_log("    ".($index + 1).": "._f("%s on line %d", array($trace["file"], $trace["line"])));

            exit;
        }

        if (TESTER)
            exit("ERROR: ".$body);

        if ($title == __("Access Denied"))
            $_SESSION['redirect_to'] = self_url();

        # Display the error.
        if (defined('THEME_DIR') and class_exists("Theme") and Theme::current()->file_exists("pages/error"))
            MainController::current()->display("pages/error",
                                               array("title" => $title,
                                                     "body" => $body,
                                                     "backtrace" => $backtrace));
        else
            require INCLUDES_DIR."/error.php";

        if ($buffer !== false)
            ob_end_flush();

        exit;
    }

    /**
     * Function: show_403
     * Shows an error message with a 403 HTTP header.
     *
     * Parameters:
     *     $title - The title for the error dialog.
     *     $body - The message for the error dialog.
     */
    function show_403($title, $body) {
        header("Status: 403");
        error($title, $body);
    }

    /**
     * Function: logged_in
     * Returns whether or not they are logged in by returning the <Visitor.$id> (which defaults to 0).
     */
    function logged_in() {
        return (class_exists("Visitor") and isset(Visitor::current()->id) and Visitor::current()->id != 0);
    }

    /**
     * Function: load_translator
     * Loads a .mo file for gettext translation.
     *
     * Parameters:
     *     $domain - The name for this translation domain.
     *     $mofile - The .mo file to read from.
     */
    function load_translator($domain, $mofile) {
        global $l10n;

        if (isset($l10n[$domain]))
            return;

        if (is_readable($mofile))
            $input = new CachedFileReader($mofile);
        else
            return;

        $l10n[$domain] = new gettext_reader($input);
    }

    /**
     * Function: __
     * Returns a translated string.
     *
     * Parameters:
     *     $text - The string to translate.
     *     $domain - The translation domain to read from.
     */
    function __($text, $domain = "chyrp") {
        global $l10n;
        return (isset($l10n[$domain])) ? $l10n[$domain]->translate($text) : $text ;
    }

    /**
     * Function: _p
     * Returns a plural (or not) form of a translated string.
     *
     * Parameters:
     *     $single - Singular string.
     *     $plural - Pluralized string.
     *     $number - The number to judge by.
     *     $domain - The translation domain to read from.
     */
    function _p($single, $plural, $number, $domain = "chyrp") {
        global $l10n;
        return isset($l10n[$domain]) ?
                     $l10n[$domain]->ngettext($single, $plural, $number) :
                   (($number != 1) ? $plural : $single) ;
    }

    /**
     * Function: _f
     * Returns a formatted translated string.
     *
     * Parameters:
     *     $string - String to translate and format.
     *     $args - One arg or an array of arguments to format with.
     *     $domain - The translation domain to read from.
     */
    function _f($string, $args = array(), $domain = "chyrp") {
        $args = (array) $args;
        array_unshift($args, __($string, $domain));
        return call_user_func_array("sprintf", $args);
    }

    /**
     * Function: redirect
     * Redirects to the given URL and exits immediately.
     *
     * Parameters:
     *     $url - The URL to redirect to. If it begins with @/@ it will be relative to the @Config.chyrp_url@.
     *     $use_chyrp_url - Use the @Config.chyrp_url@ instead of @Config.url@ for $urls beginning with @/@?
     */
    function redirect($url, $use_chyrp_url = false) {
        # Handle URIs without domain
        if ($url[0] == "/")
            $url = (ADMIN or $use_chyrp_url) ?
                       Config::current()->chyrp_url.$url :
                       Config::current()->url.$url ;
        elseif (file_exists(INCLUDES_DIR."/config.yaml.php") and class_exists("Route") and !substr_count($url, "://"))
            $url = url($url);

        header("Location: ".html_entity_decode($url));
        exit;
    }

    /**
     * Function: url
     * Mask for Route->url().
     */
    function url($url, $controller = null) {
        return Route::current()->url($url, $controller);
    }

    /**
     * Function: pluralize
     * Returns a pluralized string. This is a port of Rails's pluralizer.
     *
     * Parameters:
     *     $string - The string to pluralize.
     *     $number - If passed, and this number is 1, it will not pluralize.
     */
    function pluralize($string, $number = null) {
        $uncountable = array("moose", "sheep", "fish", "series", "species",
                             "rice", "money", "information", "equipment", "piss");

        if (in_array($string, $uncountable) or $number == 1)
            return $string;

        $replacements = array("/person/i" => "people",
                              "/man/i" => "men",
                              "/child/i" => "children",
                              "/cow/i" => "kine",
                              "/goose/i" => "geese",
                              "/(penis)$/i" => "\\1es", # Take that, Rails!
                              "/(ax|test)is$/i" => "\\1es",
                              "/(octop|vir)us$/i" => "\\1ii",
                              "/(cact)us$/i" => "\\1i",
                              "/(alias|status)$/i" => "\\1es",
                              "/(bu)s$/i" => "\\1ses",
                              "/(buffal|tomat)o$/i" => "\\1oes",
                              "/([ti])um$/i" => "\\1a",
                              "/sis$/i" => "ses",
                              "/(hive)$/i" => "\\1s",
                              "/([^aeiouy]|qu)y$/i" => "\\1ies",
                              "/^(ox)$/i" => "\\1en",
                              "/(matr|vert|ind)(?:ix|ex)$/i" => "\\1ices",
                              "/(x|ch|ss|sh)$/i" => "\\1es",
                              "/([m|l])ouse$/i" => "\\1ice",
                              "/(quiz)$/i" => "\\1zes");

        $replaced = preg_replace(array_keys($replacements), array_values($replacements), $string, 1);

        if ($replaced == $string)
            return $string."s";
        else
            return $replaced;
    }

    /**
     * Function: depluralize
     * Returns a depluralized string. This is the inverse of <pluralize>.
     *
     * Parameters:
     *     $string - The string to depluralize.
     *     $number - If passed, and this number is not 1, it will not depluralize.
     */
    function depluralize($string, $number = null) {
        if (isset($number) and $number != 1)
            return $string;

        $replacements = array("/people/i" => "person",
                              "/^men/i" => "man",
                              "/children/i" => "child",
                              "/kine/i" => "cow",
                              "/geese/i" => "goose",
                              "/(penis)es$/i" => "\\1",
                              "/(ax|test)es$/i" => "\\1is",
                              "/(octopi|viri|cact)i$/i" => "\\1us",
                              "/(alias|status)es$/i" => "\\1",
                              "/(bu)ses$/i" => "\\1s",
                              "/(buffal|tomat)oes$/i" => "\\1o",
                              "/([ti])a$/i" => "\\1um",
                              "/ses$/i" => "sis",
                              "/(hive)s$/i" => "\\1",
                              "/([^aeiouy]|qu)ies$/i" => "\\1y",
                              "/^(ox)en$/i" => "\\1",
                              "/(vert|ind)ices$/i" => "\\1ex",
                              "/(matr)ices$/i" => "\\1ix",
                              "/(x|ch|ss|sh)es$/i" => "\\1",
                              "/([ml])ice$/i" => "\\1ouse",
                              "/(quiz)zes$/i" => "\\1");

        $replaced = preg_replace(array_keys($replacements), array_values($replacements), $string, 1);

        if ($replaced == $string and substr($string, -1) == "s")
            return substr($string, 0, -1);
        else
            return $replaced;
    }

    /**
     * Function: truncate
     * Truncates a string to the given length, optionally taking into account HTML tags, and/or keeping words in tact.
     *
     * Parameters:
     *     $text - String to shorten.
     *     $length - Length to truncate to.
     *     $ending - What to place at the end, e.g. "...".
     *     $exact - Break words?
     *     $html - Auto-close cut-off HTML tags?
     *
     * Author:
     *     CakePHP team, code style modified.
     */
    function truncate($text, $length = 100, $ending = "...", $exact = false, $html = false) {
        if (is_array($ending))
            extract($ending);

        if ($html) {
            if (strlen(preg_replace("/<[^>]+>/", "", $text)) <= $length)
                return $text;

            $totalLength = strlen($ending);
            $openTags = array();
            $truncate = "";
            preg_match_all("/(<\/?([\w+]+)[^>]*>)?([^<>]*)/", $text, $tags, PREG_SET_ORDER);
            foreach ($tags as $tag) {
                if (!preg_match('/img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param/s', $tag[2])
                    and preg_match('/<[\w]+[^>]*>/s', $tag[0]))
                    array_unshift($openTags, $tag[2]);
                elseif (preg_match('/<\/([\w]+)[^>]*>/s', $tag[0], $closeTag)) {
                    $pos = array_search($closeTag[1], $openTags);
                    if ($pos !== false)
                        array_splice($openTags, $pos, 1);
                }

                $truncate .= $tag[1];

                $contentLength = strlen(preg_replace("/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i", " ", $tag[3]));
                if ($contentLength + $totalLength > $length) {
                    $left = $length - $totalLength;
                    $entitiesLength = 0;
                    if (preg_match_all("/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i", $tag[3], $entities, PREG_OFFSET_CAPTURE))
                        foreach ($entities[0] as $entity)
                            if ($entity[1] + 1 - $entitiesLength <= $left) {
                                $left--;
                                $entitiesLength += strlen($entity[0]);
                            } else
                                break;

                    $truncate .= substr($tag[3], 0 , $left + $entitiesLength);

                    break;
                } else {
                    $truncate .= $tag[3];
                    $totalLength += $contentLength;
                }

                if ($totalLength >= $length)
                    break;
            }
        } else {
            if (strlen($text) <= $length)
                return $text;
            else
                $truncate = substr($text, 0, $length - strlen($ending));
        }

        if (!$exact) {
            $spacepos = strrpos($truncate, " ");

            if (isset($spacepos)) {
                if ($html) {
                    $bits = substr($truncate, $spacepos);
                    preg_match_all('/<\/([a-z]+)>/', $bits, $droppedTags, PREG_SET_ORDER);
                    if (!empty($droppedTags))
                        foreach ($droppedTags as $closingTag)
                            if (!in_array($closingTag[1], $openTags))
                                array_unshift($openTags, $closingTag[1]);
                }

                $truncate = substr($truncate, 0, $spacepos);
            }
        }

        $truncate .= $ending;

        if ($html)
            foreach ($openTags as $tag)
                $truncate .= '</'.$tag.'>';

        return $truncate;
    }

    /**
     * Function: when
     * Returns date formatting for a string that isn't a regular time() value
     *
     * Parameters:
     *     $formatting - The formatting for date().
     *     $when - Time to base on. If it is not numeric it will be run through strtotime.
     *     $strftime - Use @strftime@ instead of @date@?
     */
    function when($formatting, $when, $strftime = false) {
        $time = (is_numeric($when)) ? $when : strtotime($when) ;

        if ($strftime)
            return strftime($formatting, $time);
        else
            return date($formatting, $time);
    }

    /**
     * Function: datetime
     * Returns a standard datetime string based on either the passed timestamp or their time offset, usually for MySQL inserts.
     *
     * Parameters:
     *     $when - An optional timestamp.
     */
    function datetime($when = null) {
        fallback($when, time());

        $time = (is_numeric($when)) ? $when : strtotime($when) ;

        return date("Y-m-d H:i:s", $time);
    }

    /**
     * Function: fix
     * Returns a HTML-sanitized version of a string.
     *
     * Parameters:
     *     $string - String to fix.
     *     $quotes - Encode quotes?
     */
    function fix($string, $quotes = false) {
        $quotes = ($quotes) ? ENT_QUOTES : ENT_NOQUOTES ;
        return htmlspecialchars($string, $quotes, "utf-8");
    }

    /**
     * Function: unfix
     * Returns the reverse of fix().
     *
     * Parameters:
     *     $string - String to unfix.
     */
    function unfix($string) {
        return htmlspecialchars_decode($string, ENT_QUOTES, "utf-8");
    }

    /**
     * Function: lang_code
     * Returns the passed language code (e.g. en_US) to the human-readable text (e.g. English (US))
     *
     * Parameters:
     *     $code - The language code to convert
     *
     * Author:
     *     TextPattern devs, modified to fit with Chyrp.
     */
    function lang_code($code) {
        $langs = array("ar_DZ" => "جزائري عربي",
                       "ca_ES" => "Català",
                       "cs_CZ" => "Čeština",
                       "da_DK" => "Dansk",
                       "de_DE" => "Deutsch",
                       "el_GR" => "Ελληνικά",
                       "en_GB" => "English (GB)",
                       "en_US" => "English (US)",
                       "es_ES" => "Español",
                       "et_EE" => "Eesti",
                       "fi_FI" => "Suomi",
                       "fr_FR" => "Français",
                       "gl_GZ" => "Galego (Galiza)",
                       "he_IL" => "עברית",
                       "hu_HU" => "Magyar",
                       "id_ID" => "Bahasa Indonesia",
                       "is_IS" => "Íslenska",
                       "it_IT" => "Italiano",
                       "ja_JP" => "日本語",
                       "lv_LV" => "Latviešu",
                       "nl_NL" => "Nederlands",
                       "no_NO" => "Norsk",
                       "pl_PL" => "Polski",
                       "pt_PT" => "Português",
                       "ro_RO" => "Română",
                       "ru_RU" => "Русский",
                       "sk_SK" => "Slovenčina",
                       "sv_SE" => "Svenska",
                       "th_TH" => "ไทย",
                       "uk_UA" => "Українська",
                       "vi_VN" => "Tiếng Việt",
                       "zh_CN" => "中文(简体)",
                       "zh_TW" => "中文(繁體)",
                       "bg_BG" => "Български");
        return (isset($langs[$code])) ? str_replace(array_keys($langs), array_values($langs), $code) : $code ;
    }

    /**
     * Function: sanitize
     * Returns a sanitized string, typically for URLs.
     *
     * Parameters:
     *     $string - The string to sanitize.
     *     $force_lowercase - Force the string to lowercase?
     *     $anal - If set to *true*, will remove all non-alphanumeric characters.
     *     $trunc - Number of characters to truncate to (default 100, 0 to disable).
     */
    function sanitize($string, $force_lowercase = true, $anal = false, $trunc = 100) {
        $strip = array("~", "`", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "=", "+", "[", "{", "]",
                       "}", "\\", "|", ";", ":", "\"", "'", "&#8216;", "&#8217;", "&#8220;", "&#8221;", "&#8211;", "&#8212;",
                       "—", "–", ",", "<", ".", ">", "/", "?");
        $clean = trim(str_replace($strip, "", strip_tags($string)));
        $clean = preg_replace('/\s+/', "-", $clean);
        $clean = ($anal ? preg_replace("/[^a-zA-Z0-9]/", "", $clean) : $clean);
        $clean = ($trunc ? substr($clean, 0, $trunc) : $clean);
        return ($force_lowercase) ?
            (function_exists('mb_strtolower')) ?
                mb_strtolower($clean, 'UTF-8') :
                strtolower($clean) :
            $clean;
    }

    /**
     * Function: trackback_respond
     * Responds to a trackback request.
     *
     * Parameters:
     *     $error - Is this an error?
     *     $message - Message to return.
     */
    function trackback_respond($error = false, $message = "") {
        header("Content-Type: text/xml; charset=utf-8");

        if ($error) {
            echo '<?xml version="1.0" encoding="utf-8"?'.">\n";
            echo "<response>\n";
            echo "<error>1</error>\n";
            echo "<message>".$message."</message>\n";
            echo "</response>";
            exit;
        } else {
            echo '<?xml version="1.0" encoding="utf-8"?'.">\n";
            echo "<response>\n";
            echo "<error>0</error>\n";
            echo "</response>";
        }

        exit;
    }

    /**
     * Function: trackback_send
     * Sends a trackback request.
     *
     * Parameters:
     *     $post - The post we're sending from.
     *     $target - The URL we're sending to.
     */
    function trackback_send($post, $target) {
        if (empty($target)) return false;

        $target = parse_url($target);
        $title = $post->title();
        fallback($title, ucfirst($post->feather)." Post #".$post->id);
        $excerpt = strip_tags(truncate($post->excerpt(), 255));

        if (!empty($target["query"])) $target["query"] = "?".$target["query"];
        if (empty($target["port"])) $target["port"] = 80;

        $connect = fsockopen($target["host"], $target["port"]);
        if (!$connect) return false;

        $config = Config::current();
        $query = "url=".rawurlencode($post->url())."&".
                 "title=".rawurlencode($title)."&".
                 "blog_name=".rawurlencode($config->name)."&".
                 "excerpt=".rawurlencode($excerpt);

        fwrite($connect, "POST ".$target["path"].$target["query"]." HTTP/1.1\n");
        fwrite($connect, "Host: ".$target["host"]."\n");
        fwrite($connect, "Content-type: application/x-www-form-urlencoded\n");
        fwrite($connect, "Content-length: ". strlen($query)."\n");
        fwrite($connect, "Connection: close\n\n");
        fwrite($connect, $query);

        fclose($connect);

        return true;
    }

    /**
     * Function: send_pingbacks
     * Sends pingback requests to the URLs in a string.
     *
     * Parameters:
     *     $string - The string to crawl for pingback URLs.
     *     $post - The post we're sending from.
     */
    function send_pingbacks($string, $post) {
        foreach (grab_urls($string) as $url)
            if ($ping_url = pingback_url($url)) {
                require_once INCLUDES_DIR."/lib/ixr.php";

                $client = new IXR_Client($ping_url);
                $client->timeout = 3;
                $client->useragent.= " -- Chyrp/".CHYRP_VERSION;
                $client->query("pingback.ping", $post->url(), $url);
            }
    }

    /**
     * Function: grab_urls
     * Crawls a string for links.
     *
     * Parameters:
     *     $string - The string to crawl.
     *
     * Returns:
     *     An array of all URLs found in the string.
     */
    function grab_urls($string) {
        $regexp = "/<a[^>]+href=[\"|']([^\"]+)[\"|']>[^<]+<\/a>/";
        preg_match_all(Trigger::current()->filter($regexp, "link_regexp"), stripslashes($string), $matches);
        $matches = $matches[1];
        return $matches;
    }

    /**
     * Function: pingback_url
     * Checks if a URL is pingback-capable.
     *
     * Parameters:
     *     $url - The URL to check.
     *
     * Returns:
     *     The pingback target, if the URL is pingback-capable.
     */
    function pingback_url($url) {
        extract(parse_url($url), EXTR_SKIP);
        if (!isset($host)) return false;

        $path = (!isset($path)) ? '/' : $path ;
        if (isset($query)) $path.= '?'.$query;
        $port = (isset($port)) ? $port : 80 ;

        # Connect
        $connect = @fsockopen($host, $port, $errno, $errstr, 2);
        if (!$connect) return false;

        # Send the GET headers
        fwrite($connect, "GET $path HTTP/1.1\r\n");
        fwrite($connect, "Host: $host\r\n");
        fwrite($connect, "User-Agent: Chyrp/".CHYRP_VERSION."\r\n\r\n");

        # Check for X-Pingback header
        $headers = "";
        while (!feof($connect)) {
            $line = fgets($connect, 512);
            if (trim($line) == "") break;
            $headers.= trim($line)."\n";

            if (preg_match("/X-Pingback: (.+)/i", $line, $matches))
                return trim($matches[1]);

            # Nothing's found so far, so grab the content-type
            # for the <link> search afterwards
            if (preg_match("/Content-Type: (.+)/i", $headers, $matches))
                $content_type = trim($matches[1]);
        }

        # No header found, check for <link>
        if (preg_match('/(image|audio|video|model)/i', $content_type)) return false;
        $size = 0;
        while (!feof($connect)) {
            $line = fgets($connect, 1024);
            if (preg_match("/<link rel=[\"|']pingback[\"|'] href=[\"|']([^\"]+)[\"|'] ?\/?>/i", $line, $link))
                return $link[1];
            $size += strlen($line);
            if ($size > 2048) return false;
        }

        fclose($connect);

        return false;
    }

    /**
     * Function: camelize
     * Converts a given string to camel-case.
     *
     * Parameters:
     *     $string - The string to camelize.
     *     $keep_spaces - Whether or not to convert underscores to spaces or remove them.
     *
     * Returns:
     *     A CamelCased string.
     *
     * See Also:
     *     <decamelize>
     */
    function camelize($string, $keep_spaces = false) {
        $lower = strtolower($string);
        $deunderscore = str_replace("_", " ", $lower);
        $dehyphen = str_replace("-", " ", $deunderscore);
        $final = ucwords($dehyphen);

        if (!$keep_spaces)
            $final = str_replace(" ", "", $final);

        return $final;
    }

    /**
     * Function: decamelize
     * Decamelizes a string.
     *
     * Parameters:
     *     $string - The string to decamelize.
     *
     * Returns:
     *     A de_camel_cased string.
     *
     * See Also:
     *     <camelize>
     */
    function decamelize($string) {
        return strtolower(preg_replace("/([a-z])([A-Z])/", "\\1_\\2", $string));
    }

    /**
     * Function: selected
     * If $val1 == $val2, outputs or returns @ selected="selected"@
     *
     * Parameters:
     *     $val1 - First value.
     *     $val2 - Second value.
     *     $return - Return @ selected="selected"@ instead of outputting it
     */
    function selected($val1, $val2, $return = false) {
        if ($val1 == $val2)
            if ($return)
                return ' selected="selected"';
            else
                echo ' selected="selected"';
    }

    /**
     * Function: checked
     * If $val == 1 (true), outputs ' checked="checked"'
     *
     * Parameters:
     *     $val - Value to check.
     */
    function checked($val) {
        if ($val == 1) echo ' checked="checked"';
    }

    /**
     * Function: module_enabled
     * Returns whether the given module is enabled or not.
     *
     * Parameters:
     *     $name - The folder name of the module.
     *
     * Returns:
     *     Whether or not the requested module is enabled.
     */
    function module_enabled($name) {
        $config = Config::current();
        return in_array($name, $config->enabled_modules);
    }

    /**
     * Function: feather_enabled
     * Returns whether the given feather is enabled or not.
     *
     * Parameters:
     *     $name - The folder name of the feather.
     *
     * Returns:
     *     Whether or not the requested feather is enabled.
     */
    function feather_enabled($name) {
        $config = Config::current();
        return in_array($name, $config->enabled_feathers);
    }

    /**
     * Function: fallback
     * Sets a given variable if it is not set.
     *
     * The last of the arguments or the first non-empty value will be used.
     *
     * Parameters:
     *     &$variable - The variable to return or set.
     *
     * Returns:
     *     The value of whatever was chosen.
     */
    function fallback(&$variable) {
        if (is_bool($variable))
            return $variable;

        $set = (!isset($variable) or (is_string($variable) and trim($variable) === "") or $variable === array());

        $args = func_get_args();
        array_shift($args);
        if (count($args) > 1) {
            foreach ($args as $arg) {
                $fallback = $arg;

                if (isset($arg) and (!is_string($arg) or (is_string($arg) and trim($arg) !== "")) and $arg !== array())
                    break;
            }
        } else
            $fallback = isset($args[0]) ? $args[0] : null ;

        if ($set)
            $variable = $fallback;

        return $set ? $fallback : $variable ;
    }

    /**
     * Function: oneof
     * Returns the first argument that is set and non-empty.
     *
     * It will guess where to stop based on the types of the arguments, e.g. "" has priority over array() but not 1.
     */
    function oneof() {
        $last = null;
        $args = func_get_args();
        foreach ($args as $index => $arg) {
            if (!isset($arg) or (is_string($arg) and trim($arg) === "") or $arg === array() or (is_object($arg) and empty($arg)) or ($arg === "0000-00-00 00:00:00"))
                $last = $arg;
            else
                return $arg;

            if ($index + 1 == count($args))
                break;

            $next = $args[$index + 1];

            $incomparable = ((is_array($arg) and !is_array($next)) or        # This is a big check but it should cover most "incomparable" cases.
                             (!is_array($arg) and is_array($next)) or        # Using simple type comparison wouldn't work too well, for example
                             (is_object($arg) and !is_object($next)) or      # when "" would take priority over 1 in oneof("", 1) because they're
                             (!is_object($arg) and is_object($next)) or      # different types.
                             (is_resource($arg) and !is_resource($next)) or
                             (!is_resource($arg) and is_resource($next)));

            if (isset($arg) and isset($next) and $incomparable)
                return $arg;
        }

        return $last;
    }

    /**
     * Function: random
     * Returns a random string.
     *
     * Parameters:
     *     $length - How long the string should be.
     *     $specialchars - Use special characters in the resulting string?
     */
    function random($length, $specialchars = false) {
        $pattern = "1234567890abcdefghijklmnopqrstuvwxyz";

        if ($specialchars)
            $pattern.= "!@#$%^&*()?~";

        $len = strlen($pattern) - 1;

        $key = "";
        for($i = 0; $i < $length; $i++)
            $key.= $pattern[rand(0, $len)];

        return $key;
    }

    /**
     * Function: unique_filename
     * Makes a given filename unique for the uploads directory.
     *
     * Parameters:
     *     $name - The name to check.
     *     $path - Path to check in.
     *     $num - Number suffix from which to start increasing if the filename exists.
     *
     * Returns:
     *     A unique version of the given $name.
     */
    function unique_filename($name, $path = "", $num = 2) {
        $path = rtrim($path, "/");
        if (!file_exists(MAIN_DIR.Config::current()->uploads_path.$path."/".$name))
            return $name;

        $name = explode(".", $name);

        # Handle common double extensions
        foreach (array("tar.gz", "tar.bz", "tar.bz2") as $extension) {
            list($first, $second) = explode(".", $extension);
            $file_first =& $name[count($name) - 2];
            if ($file_first == $first and end($name) == $second) {
                $file_first = $first.".".$second;
                array_pop($name);
            }
        }

        $ext = ".".array_pop($name);

        $try = implode(".", $name)."-".$num.$ext;
        if (!file_exists(MAIN_DIR.Config::current()->uploads_path.$path."/".$try))
            return $try;

        return unique_filename(implode(".", $name).$ext, $path, $num + 1);
    }

    /**
     * Function: upload
     * Moves an uploaded file to the uploads directory.
     *
     * Parameters:
     *     $file - The $_FILES value.
     *     $extension - An array of valid extensions (case-insensitive).
     *     $path - A sub-folder in the uploads directory (optional).
     *     $put - Use copy() instead of move_uploaded_file()?
     *
     * Returns:
     *     The resulting filename from the upload.
     */
    function upload($file, $extension = null, $path = "", $put = false) {
        $file_split = explode(".", $file['name']);
        $path = rtrim($path, "/");
        $dir = MAIN_DIR.Config::current()->uploads_path.$path;

        if (!file_exists($dir))
            mkdir($dir, 0777, true);

        $original_ext = end($file_split);

        # Handle common double extensions
        foreach (array("tar.gz", "tar.bz", "tar.bz2") as $ext) {
            list($first, $second) = explode(".", $ext);
            $file_first =& $file_split[count($file_split) - 2];
            if ($file_first == $first and end($file_split) == $second) {
                $file_first = $first.".".$second;
                array_pop($file_split);
            }
        }

        $file_ext = end($file_split);

        if (is_array($extension)) {
            if (!in_array(strtolower($file_ext), $extension) and !in_array(strtolower($original_ext), $extension)) {
                $list = "";
                for ($i = 0; $i < count($extension); $i++) {
                    $comma = "";
                    if (($i + 1) != count($extension)) $comma = ", ";
                    if (($i + 2) == count($extension)) $comma = ", and ";
                    $list.= "<code>*.".$extension[$i]."</code>".$comma;
                }
                error(__("Invalid Extension"), _f("Only %s files are accepted.", array($list)));
            }
        } elseif (isset($extension) and
                  strtolower($file_ext) != strtolower($extension) and
                  strtolower($original_ext) != strtolower($extension))
            error(__("Invalid Extension"), _f("Only %s files are supported.", array("*.".$extension)));

        array_pop($file_split);
        $file_clean = implode(".", $file_split);
        $file_clean = sanitize($file_clean, false).".".$file_ext;
        $filename = unique_filename($file_clean, $path);

        $message = __("Couldn't upload file. CHMOD <code>".$dir."</code> to 777 and try again. If this problem persists, it's probably timing out; in which case, you must contact your system administrator to increase the maximum POST and upload sizes.");

        if ($put) {
            if (!@copy($file['tmp_name'], $dir."/".$filename))
                error(__("Error"), $message);
        } elseif (!@move_uploaded_file($file['tmp_name'], $dir."/".$filename))
            error(__("Error"), $message);

        return ($path ? $path."/".$filename : $filename);
    }

    /**
     * Function: upload_from_url
     * Copy a file from a specified URL to their upload directory.
     *
     * Parameters:
     *     $url - The URL to copy.
     *     $extension - An array of valid extensions (case-insensitive).
     *     $path - A sub-folder in the uploads directory (optional).
     *
     * See Also:
     *     <upload>
     */
    function upload_from_url($url, $extension = null, $path = "") {
        $file = tempnam(null, "chyrp");
        file_put_contents($file, get_remote($url));

        $fake_file = array("name" => basename(parse_url($url, PHP_URL_PATH)),
                           "tmp_name" => $file);

        return upload($fake_file, $extension, $path, true);
    }

    /**
     * Function: uploaded
     * Returns a URL to an uploaded file.
     *
     * Parameters:
     *     $file - Filename relative to the uploads directory.
     */
    function uploaded($file, $url = true) {
        if (empty($file))
            return "";

        $config = Config::current();
        return ($url ? $config->chyrp_url.$config->uploads_path.$file : MAIN_DIR.$config->uploads_path.$file);
    }

    /**
     * Function: timer_start
     * Starts the timer.
     */
    function timer_start() {
        global $time_start;
        $mtime = explode(" ", microtime());
        $mtime = $mtime[1] + $mtime[0];
        $time_start = $mtime;
    }

    /**
     * Function: timer_stop
     * Stops the timer and returns the total time.
     *
     * Parameters:
     *     $precision - Number of decimals places to round to.
     *
     * Returns:
     *     A formatted number with the given $precision.
     */
    function timer_stop($precision = 3) {
        global $time_start;
        $mtime = microtime();
        $mtime = explode(" ", $mtime);
        $mtime = $mtime[1] + $mtime[0];
        $time_end = $mtime;
        $time_total = $time_end - $time_start;
        return number_format($time_total, $precision);
    }

    /**
     * Function: normalize
     * Attempts to normalize all newlines and whitespace into single spaces.
     *
     * Returns:
     *     The normalized string.
     */
    function normalize($string) {
        $trimmed = trim($string);
        $newlines = str_replace("\n\n", " ", $trimmed);
        $newlines = str_replace("\n", "", $newlines);
        $normalized = preg_replace("/[\s\n\r\t]+/", " ", $newlines);
        return $normalized;
    }

    /**
     * Function: get_remote
     * Grabs the contents of a website/location.
     *
     * Parameters:
     *     $url - The URL of the location to grab.
     *
     * Returns:
     *     The response from the remote URL.
     */
    function get_remote($url) {
        extract(parse_url($url), EXTR_SKIP);

        if (ini_get("allow_url_fopen")) {
            $content = @file_get_contents($url);
            if ($http_response_header[0] != "HTTP/1.1 200 OK")
                $content = "Server returned a message: $http_response_header[0]";
        } elseif (function_exists("curl_init")) {
            $handle = curl_init();
            curl_setopt($handle, CURLOPT_URL, $url);
            curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, 1);
            curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
            curl_setopt($handle, CURLOPT_TIMEOUT, 60);
            $content = curl_exec($handle);
            $status = curl_getinfo($handle, CURLINFO_HTTP_CODE);
            curl_close($handle);
            if ($status != 200)
                $content = "Server returned a message: $status";
        } else {
            $path = (!isset($path)) ? '/' : $path ;
            if (isset($query)) $path.= '?'.$query;
            $port = (isset($port)) ? $port : 80 ;

            $connect = @fsockopen($host, $port, $errno, $errstr, 2);
            if (!$connect) return false;

            # Send the GET headers
            fwrite($connect, "GET ".$path." HTTP/1.1\r\n");
            fwrite($connect, "Host: ".$host."\r\n");
            fwrite($connect, "User-Agent: Chyrp/".CHYRP_VERSION."\r\n\r\n");

            $content = "";
            while (!feof($connect)) {
                $line = fgets($connect, 128);
                if (preg_match("/\r\n/", $line)) continue;

                $content.= $line;
            }

            fclose($connect);
        }

        return $content;
    }

    /**
     * Function: self_url
     * Returns the current URL.
     */
    function self_url() {
        $split = explode("/", $_SERVER['SERVER_PROTOCOL']);
        $protocol = strtolower($split[0]);
        $default_port = ($protocol == "http") ? 80 : 443 ;
        $port = ($_SERVER['SERVER_PORT'] == $default_port) ? "" : ":".$_SERVER['SERVER_PORT'] ;
        return $protocol."://".$_SERVER['SERVER_NAME'].$port.$_SERVER['REQUEST_URI'];
    }

    /**
     * Function: show_404
     * Shows a 404 error message and immediately exits.
     *
     * Parameters:
     *     $scope - An array of values to extract into the scope.
     */
     function show_404() {
        header("HTTP/1.1 404 Not Found");

        if (!defined('CHYRP_VERSION'))
            exit("404 Not Found");

        $theme = Theme::current();
        $main = MainController::current();

        Trigger::current()->call("not_found");

        if ($theme->file_exists("pages/404"))
            $main->display("pages/404", array(), "404");
        else
            error(__("404 Not Found"), __("The requested page could not be located."));

        exit;
    }

    /**
     * Function: set_locale
     * Set locale in a platform-independent way
     *
     * Parameters:
     *     $locale - the locale name (@en_US@, @uk_UA@, @fr_FR@ etc.)
     *
     * Returns:
     *     The encoding name used by locale-aware functions.
     */
    function set_locale($locale) { # originally via http://www.onphp5.com/article/22; heavily modified
        if ($locale == "en_US") return; # en_US is the default in Chyrp; their system may have
                                        # its own locale setting and no Chyrp translation available
                                        # for their locale, so let's just leave it alone.

        list($lang, $cty) = explode("_", $locale);
        $locales = array($locale.".UTF-8", $lang, "en_US.UTF-8", "en");
        $result = setlocale(LC_ALL, $locales);

        return (!strpos($result, 'UTF-8')) ? "CP".preg_replace('~\.(\d+)$~', "\\1", $result) : "UTF-8" ;
    }

    /**
     * Function: sanitize_input
     * Makes sure no inherently broken ideas such as magic_quotes break our application
     *
     * Parameters:
     *     $data - The array to be sanitized, usually one of @$_GET@, @$_POST@, @$_COOKIE@, or @$_REQUEST@
     */
    function sanitize_input(&$data) {
        foreach ($data as &$value)
            if (is_array($value))
                sanitize_input($value);
            else
                $value = get_magic_quotes_gpc() ? stripslashes($value) : $value ;
    }

    /**
     * Function: match
     * Try to match a string against an array of regular expressions.
     *
     * Parameters:
     *     $try - An array of regular expressions, or a single regular expression.
     *     $haystack - The string to test.
     *
     * Returns:
     *     Whether or not the match succeeded.
     */
    function match($try, $haystack) {
        if (is_string($try))
            return (bool) preg_match($try, $haystack);

        foreach ($try as $needle)
            if (preg_match($needle, $haystack))
                return true;

        return false;
    }

    /**
     * Function: cancel_module
     * Temporarily removes a module from $config->enabled_modules.
     *
     * Parameters:
     *     $target - Module name to disable.
     */
     function cancel_module($target) {
        $this_disabled = array();

        if (isset(Modules::$instances[$target]))
            Modules::$instances[$target]->cancelled = true;

        $config = Config::current();
        foreach ($config->enabled_modules as $module)
            if ($module != $target)
                $this_disabled[] = $module;

        return $config->enabled_modules = $this_disabled;
    }

    /**
     * Function: time_in_timezone
     * Returns the appropriate time() for representing a timezone.
     */
    function time_in_timezone($timezone) {
        $orig = get_timezone();
        set_timezone($timezone);
        $time = date("F jS, Y, g:i A");
        set_timezone($orig);
        return strtotime($time);
    }

    /**
     * Function: timezones
     * Returns an array of timezones that have unique offsets. Doesn't count deprecated timezones.
     */
    function timezones() {
        $zones = array();

        $deprecated = array("Brazil/Acre", "Brazil/DeNoronha", "Brazil/East", "Brazil/West", "Canada/Atlantic", "Canada/Central", "Canada/East-Saskatchewan", "Canada/Eastern", "Canada/Mountain", "Canada/Newfoundland", "Canada/Pacific", "Canada/Saskatchewan", "Canada/Yukon", "CET", "Chile/Continental", "Chile/EasterIsland", "CST6CDT", "Cuba", "EET", "Egypt", "Eire", "EST", "EST5EDT", "Etc/GMT", "Etc/GMT+0", "Etc/GMT+1", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12", "Etc/GMT+2", "Etc/GMT+3", "Etc/GMT+4", "Etc/GMT+5", "Etc/GMT+6", "Etc/GMT+7", "Etc/GMT+8", "Etc/GMT+9", "Etc/GMT-0", "Etc/GMT-1", "Etc/GMT-10", "Etc/GMT-11", "Etc/GMT-12", "Etc/GMT-13", "Etc/GMT-14", "Etc/GMT-2", "Etc/GMT-3", "Etc/GMT-4", "Etc/GMT-5", "Etc/GMT-6", "Etc/GMT-7", "Etc/GMT-8", "Etc/GMT-9", "Etc/GMT0", "Etc/Greenwich", "Etc/UCT", "Etc/Universal", "Etc/UTC", "Etc/Zulu", "Factory", "GB", "GB-Eire", "GMT", "GMT+0", "GMT-0", "GMT0", "Greenwich", "Hongkong", "HST", "Iceland", "Iran", "Israel", "Jamaica", "Japan", "Kwajalein", "Libya", "MET", "Mexico/BajaNorte", "Mexico/BajaSur", "Mexico/General", "MST", "MST7MDT", "Navajo", "NZ", "NZ-CHAT", "Poland", "Portugal", "PRC", "PST8PDT", "ROC", "ROK", "Singapore", "Turkey", "UCT", "Universal", "US/Alaska", "US/Aleutian", "US/Arizona", "US/Central", "US/East-Indiana", "US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan", "US/Mountain", "US/Pacific", "US/Pacific-New", "US/Samoa", "UTC", "W-SU", "WET", "Zulu");

        foreach (timezone_identifiers_list() as $zone)
            if (!in_array($zone, $deprecated))
                $zones[] = array("name" => $zone,
                                 "now" => time_in_timezone($zone));

        function by_time($a, $b) {
            return (int) ($a["now"] > $b["now"]);
        }

        usort($zones, "by_time");

        return $zones;
    }

    /**
     * Function: set_timezone
     * Sets the timezone.
     *
     * Parameters:
     *     $timezone - The timezone to set.
     */
    function set_timezone($timezone) {
        if (function_exists("date_default_timezone_set"))
            date_default_timezone_set($timezone);
        else
            ini_set("date.timezone", $timezone);
    }

    /**
     * Function: get_timezone()
     * Returns the current timezone.
     */
    function get_timezone() {
        if (function_exists("date_default_timezone_set"))
            return date_default_timezone_get();
        else
            return ini_get("date.timezone");
    }

    /**
     * Function: error_panicker
     * Exits and states where the error occurred.
     */
    function error_panicker($errno, $message, $file, $line) {
        if (error_reporting() === 0)
            return; # Suppressed error.

        exit("ERROR: ".$message." (".$file." on line ".$line.")");
    }

    /**
     * Function: keywords
     * Handle keyword-searching.
     *
     * Parameters:
     *     $query - The query to parse.
     *     $plain - WHERE syntax to search for non-keyword queries.
     *     $table - If specified, the keywords will be checked against this table's columns for validity.
     *
     * Returns:
     *     An array containing the "WHERE" queries and the corresponding parameters.
     */
    function keywords($query, $plain, $table = null) {
        if (!trim($query))
            return array(array(), array());

        $search  = array();
        $matches = array();
        $where   = array();
        $params  = array();

        if ($table)
            $columns = SQL::current()->select($table)->fetch();

        $queries = explode(" ", $query);
        foreach ($queries as $query)
            if (!preg_match("/([a-z0-9_]+):(.+)/", $query))
                $search[] = $query;
            else
                $matches[] = $query;

        $times = array("year", "month", "day", "hour", "minute", "second");

        foreach ($matches as $match) {
            list($test, $equals,) = explode(":", $match);

            if ($equals[0] == '"') {
                if (substr($equals, -1) != '"')
                    foreach ($search as $index => $part) {
                        $equals.= " ".$part;

                        unset($search[$index]);

                        if (substr($part, -1) == '"')
                            break;
                    }

                $equals = ltrim(trim($equals, '"'), '"');
            }

            if (in_array($test, $times)) {
                if ($equals == "today")
                    $where["created_at like"] = date("%Y-m-d %");
                elseif ($equals == "yesterday")
                    $where["created_at like"] = date("%Y-m-d %", now("-1 day"));
                elseif ($equals == "tomorrow")
                    error(__("Error"), "Unfortunately our flux capacitor is currently having issues. Try again yesterday.");
                else
                    $where[strtoupper($test)."(created_at)"] = $equals;
            } elseif ($test == "author") {
                $user = new User(array("login" => $equals));
                if ($user->no_results and $equals == "me") {
                  !($table == "users") ? $where["user_id"] = Visitor::current()->id : $where["id"] = Visitor::current()->id;
                } else
                    !($table == "users") ? $where["user_id"] = $user->id : $where["id"] = $user->id;
            } elseif ($test == "group") {
                $group = new Group(array("name" => $equals));
                $where["group_id"] = $equals = ($group->no_results) ? 0 : $group->id;
            } else
                $where[$test] = $equals;
        }

        if ($table)
            foreach ($where as $col => $val)
                if (!isset($where[$col])) {
                    if ($table == "posts") {
                        $where["post_attributes.name"] = $col;
                        $where["post_attributes.value like"] = "%".$val."%";
                    }

                    unset($where[$col]);
                }

        if (!empty($search)) {
            $where[] = $plain;
            $params[":query"] = "%".join(" ", $search)."%";
        }

        $keywords = array($where, $params);

        Trigger::current()->filter($keywords, "keyword_search", $query, $plain);

        return $keywords;
    }

    /**
     * Function: init_extensions
     * Initialize all Modules and Feathers.
     */
    function init_extensions() {
        $config = Config::current();

        # Instantiate all Modules.
        foreach ($config->enabled_modules as $index => $module) {
            if (!file_exists(MODULES_DIR."/".$module."/".$module.".php")) {
                unset($config->enabled_modules[$index]);
                continue;
            }

            if (file_exists(MODULES_DIR."/".$module."/locale/".$config->locale.".mo"))
                load_translator($module, MODULES_DIR."/".$module."/locale/".$config->locale.".mo");

            require MODULES_DIR."/".$module."/".$module.".php";

            $camelized = camelize($module);
            if (!class_exists($camelized))
                continue;

            Modules::$instances[$module] = new $camelized;
            Modules::$instances[$module]->safename = $module;

            foreach (YAML::load(MODULES_DIR."/".$module."/info.yaml") as $key => $val)
                Modules::$instances[$module]->$key = (is_string($val)) ? __($val, $module) : $val ;
        }

        # Instantiate all Feathers.
        foreach ($config->enabled_feathers as $index => $feather) {
            if (!file_exists(FEATHERS_DIR."/".$feather."/".$feather.".php")) {
                unset($config->enabled_feathers[$index]);
                continue;
            }

            if (file_exists(FEATHERS_DIR."/".$feather."/locale/".$config->locale.".mo"))
                load_translator($feather, FEATHERS_DIR."/".$feather."/locale/".$config->locale.".mo");

            require FEATHERS_DIR."/".$feather."/".$feather.".php";

            $camelized = camelize($feather);
            if (!class_exists($camelized))
                continue;

            Feathers::$instances[$feather] = new $camelized;
            Feathers::$instances[$feather]->safename = $feather;

            foreach (YAML::load(FEATHERS_DIR."/".$feather."/info.yaml") as $key => $val)
                Feathers::$instances[$feather]->$key = (is_string($val)) ? __($val, $feather) : $val ;
        }

        # Initialize all modules.
        foreach (Feathers::$instances as $feather)
            if (method_exists($feather, "__init"))
                $feather->__init();

        foreach (Modules::$instances as $module)
            if (method_exists($module, "__init"))
                $module->__init();
    }

    /**
     * Function: xml2arr
     * Recursively converts a SimpleXML object (and children) to an array.
     *
     * Parameters:
     *     $parse - The SimpleXML object to convert into an array.
     */
    function xml2arr($parse) {
        if (empty($parse))
            return "";

        $parse = (array) $parse;

        foreach ($parse as &$val)
            if (get_class($val) == "SimpleXMLElement")
                $val = xml2arr($val);

        return $parse;
    }

    /**
     * Function: arr2xml
     * Recursively adds an array (or object I guess) to a SimpleXML object.
     *
     * Parameters:
     *     &$object - The SimpleXML object to modify.
     *     $data - The data to add to the SimpleXML object.
     */
    function arr2xml(&$object, $data) {
        foreach ($data as $key => $val) {
            if (is_int($key) and (empty($val) or (is_string($val) and trim($val) == ""))) {
                unset($data[$key]);
                continue;
            }

            if (is_array($val)) {
                if (in_array(0, array_keys($val))) { # Numeric-indexed things need to be added as duplicates
                    foreach ($val as $dup) {
                        $xml = $object->addChild($key);
                        arr2xml($xml, $dup);
                    }
                } else {
                    $xml = $object->addChild($key);
                    arr2xml($xml, $val);
                }
            } else
                $object->addChild($key, fix($val, false, false));
        }
    }

    /**
     * Function: relative_time
     * Returns the difference between the given timestamps or now.
     *
     * Parameters:
     *     $time - Timestamp to compare to.
     *     $from - Timestamp to compare from. If not specified, defaults to now.
     *
     * Returns:
     *     A string formatted like "3 days ago" or "3 days from now".
     */
    function relative_time($when, $from = null) {
        fallback($from, time());

        $time = (is_numeric($when)) ? $when : strtotime($when) ;

        $difference = $from - $time;

        if ($difference < 0) {
            $word = "from now";
            $difference = -$difference;
        } elseif ($difference > 0)
            $word = "ago";
        else
            return "just now";

        $units = array("second"     => 1,
                       "minute"     => 60,
                       "hour"       => 60 * 60,
                       "day"        => 60 * 60 * 24,
                       "week"       => 60 * 60 * 24 * 7,
                       "month"      => 60 * 60 * 24 * 30,
                       "year"       => 60 * 60 * 24 * 365,
                       "decade"     => 60 * 60 * 24 * 365 * 10,
                       "century"    => 60 * 60 * 24 * 365 * 100,
                       "millennium" => 60 * 60 * 24 * 365 * 1000);

        $possible_units = array();
        foreach ($units as $name => $val)
            if (($name == "week" and $difference >= ($val * 2)) or # Only say "weeks" after two have passed.
                ($name != "week" and $difference >= $val))
                $unit = $possible_units[] = $name;

        $precision = (int) in_array("year", $possible_units);
        $amount = round($difference / $units[$unit], $precision);

        return $amount." ".pluralize($unit, $amount)." ".$word;
    }

    /**
     * Function: list_notate
     * Notates an array as a list of things.
     *
     * Parameters:
     *     $array - An array of things to notate.
     *     $quotes - Wrap quotes around strings?
     *
     * Returns:
     *     A string like "foo, bar, and baz".
     */
    function list_notate($array, $quotes = false) {
        $count = 0;
        $items = array();
        foreach ($array as $item) {
            $string = (is_string($item) and $quotes) ? "&#8220;".$item."&#8221;" : $item ;
            if (count($array) == ++$count and $count !== 1)
                $items[] = __("and ").$string;
            else
                $items[] = $string;
        }

        return (count($array) == 2) ? implode(" ", $items) : implode(", ", $items) ;
    }

    /**
     * Function: email
     * Send an email. Function arguments are exactly the same as the PHP mail() function.
     *
     * This is intended so that modules can provide an email method if the server cannot use mail().
     */
    function email() {
        $function = "mail";
        Trigger::current()->filter($function, "send_mail");
        $args = func_get_args(); # Looks redundant, but it must be so in order to meet PHP's retardation requirements.
        return call_user_func_array($function, $args);
    }

    /**
     * Function: now
     * Alias to strtotime, for prettiness like now("+1 day").
     */
    function now($when) {
        return strtotime($when);
    }

    /**
     * Function: comma_sep
     * Convert a comma-seperated string into an array of the listed values.
     */
    function comma_sep($string) {
        $commas = explode(",", $string);
        $trimmed = array_map("trim", $commas);
        $cleaned = array_diff(array_unique($trimmed), array(""));
        return $cleaned;
    }
