<?PHP
#
#   FILE:  ApplicationFramework.php
#
#   Part of the ScoutLib application support library
#   Copyright 2009-2016 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu
#

/**
* Top-level framework for web applications.
* \nosubgrouping
*/
class ApplicationFramework
{

    # ---- PUBLIC INTERFACE --------------------------------------------------

    /** @name Application Framework */ /*@(*/

    /** @cond */
    /**
    * Object constructor.
    **/
    public function __construct()
    {
        # save execution start time
        $this->ExecutionStartTime = microtime(TRUE);

        # set up default object file search locations
        self::AddObjectDirectory("local/interface/%ACTIVEUI%/objects");
        self::AddObjectDirectory("interface/%ACTIVEUI%/objects");
        self::AddObjectDirectory("local/interface/%DEFAULTUI%/objects");
        self::AddObjectDirectory("interface/%DEFAULTUI%/objects");
        self::AddObjectDirectory("local/objects");
        self::AddObjectDirectory("objects");

        # set up object file autoloader
        spl_autoload_register(array($this, "AutoloadObjects"));

        # set up function to output any buffered text in case of crash
        register_shutdown_function(array($this, "OnCrash"));

        # if we were not invoked via command line interface
        if (php_sapi_name() !== "cli")
        {
            # build cookie domain string
            $SessionDomain = isset($_SERVER["SERVER_NAME"]) ? $_SERVER["SERVER_NAME"]
                    : isset($_SERVER["HTTP_HOST"]) ? $_SERVER["HTTP_HOST"]
                    : php_uname("n");

            # if it appears our session storage area is writable
            if (is_writable(session_save_path()))
            {
                # store our session files in a subdirectory to avoid
                #   accidentally sharing sessions with other installations
                #   on the same domain
                $SessionStorage = session_save_path()
                        ."/".self::$AppName."_".md5($SessionDomain.dirname(__FILE__));

                # create session storage subdirectory if not found
                if (!is_dir($SessionStorage)) {  mkdir($SessionStorage, 0700 );  }

                # if session storage subdirectory is writable
                if (is_writable($SessionStorage))
                {
                    # save parameters of our session storage as instance variables
                    #   for later use
                    $this->SessionGcProbability =
                        ini_get("session.gc_probability") / ini_get("session.gc_divisor");
                    # require a gc probability of at least MIN_GC_PROBABILITY
                    if ($this->SessionGcProbability < self::MIN_GC_PROBABILITY)
                    {
                        $this->SessionGcProbability = self::MIN_GC_PROBABILITY;
                    }

                    $this->SessionStorage = $SessionStorage;

                    # set the new session storage location
                    session_save_path($SessionStorage);

                    # disable PHP's garbage collection, as it does not handle
                    #   subdirectories (instead, we'll do the cleanup as we run
                    #   background tasks)
                    ini_set("session.gc_probability", 0);
                }
            }

            # set garbage collection max period to our session lifetime
            ini_set("session.gc_maxlifetime", self::$SessionLifetime);
            session_set_cookie_params(
                    self::$SessionLifetime, "/", $SessionDomain);

            # attempt to start session
            $SessionStarted = @session_start();

            # if session start failed
            if (!$SessionStarted)
            {
                # regenerate session ID and attempt to start session again
                session_regenerate_id(TRUE);
                session_start();
            }
        }

        # set up our internal environment
        $this->DB = new Database();

        # set up our exception handler
        set_exception_handler(array($this, "GlobalExceptionHandler"));

        # perform any work needed to undo PHP magic quotes
        $this->UndoMagicQuotes();

        # load our settings from database
        $this->LoadSettings();

        # set PHP maximum execution time
        ini_set("max_execution_time", $this->Settings["MaxExecTime"]);
        set_time_limit($this->Settings["MaxExecTime"]);

        # register events we handle internally
        $this->RegisterEvent($this->PeriodicEvents);
        $this->RegisterEvent($this->UIEvents);

        # attempt to create SCSS cache directory if needed and it does not exist
        if ($this->ScssSupportEnabled() && !is_dir(self::$ScssCacheDir))
                {  @mkdir(self::$ScssCacheDir, 0777, TRUE);  }

        # attempt to create minimized JS cache directory if needed and it does not exist
        if ($this->UseMinimizedJavascript()
                && $this->JavascriptMinimizationEnabled()
                && !is_dir(self::$JSMinCacheDir))
        {
            @mkdir(self::$JSMinCacheDir, 0777, TRUE);
        }
    }
    /** @endcond */

    /** @cond */
    /**
    * Object destructor.
    **/
    public function __destruct()
    {
        # if template location cache is flagged to be saved
        if ($this->SaveTemplateLocationCache)
        {
            # write template location cache out and update cache expiration
            $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                    ." SET TemplateLocationCache = '"
                            .addslashes(serialize(
                                    $this->TemplateLocationCache))."',"
                    ." TemplateLocationCacheExpiration = '"
                            .date("Y-m-d H:i:s",
                            $this->TemplateLocationCacheExpiration)."'");
        }

        # if object location cache is flagged to be saved
        if (self::$SaveObjectLocationCache)
        {
            # write object location cache out and update cache expiration
            $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                    ." SET ObjectLocationCache = '"
                            .addslashes(serialize(
                                    self::$ObjectLocationCache))."',"
                    ." ObjectLocationCacheExpiration = '"
                            .date("Y-m-d H:i:s",
                            self::$ObjectLocationCacheExpiration)."'");
        }
    }
    /** @endcond */

    /** @cond */
    /**
    * Default top-level exception handler.
    * @param object $Exception Exception to be handled.
    **/
    public function GlobalExceptionHandler($Exception)
    {
        # display exception info
        $Location = str_replace(getcwd()."/", "",
                $Exception->getFile()."[".$Exception->getLine()."]");
        ?><table width="100%" cellpadding="5"
                style="border: 2px solid #666666;  background: #CCCCCC;
                font-family: Courier New, Courier, monospace;
                margin-top: 10px;"><tr><td>
        <div style="color: #666666;">
            <span style="font-size: 150%;">
                    <b>Uncaught Exception</b></span><br />
            <b>Message:</b> <i><?PHP  print $Exception->getMessage();  ?></i><br />
            <b>Location:</b> <i><?PHP  print $Location;  ?></i><br />
            <b>Trace:</b>
            <blockquote><pre><?PHP  print preg_replace(
                    ":(#[0-9]+) ".getcwd()."/".":", "$1 ",
                    $Exception->getTraceAsString());
                    ?></pre></blockquote>
        </div>
        </td></tr></table><?PHP

        # log exception if possible
        $TraceString = $Exception->getTraceAsString();
        $TraceString = str_replace("\n", ", ", $TraceString);
        $TraceString = preg_replace(":(#[0-9]+) ".getcwd()."/".":", "$1 ", $TraceString);
        $LogMsg = "Uncaught exception (".$Exception->getMessage().") at "
                .$Location.".  TRACE: ".$TraceString."  URL: ".$this->FullUrl();
        $this->LogError(self::LOGLVL_ERROR, $LogMsg);
    }
    /** @endcond */

    /**
    * Add directory to be searched for object files when autoloading.
    * Directories are searched in the order they are added.  The token
    * "%ACTIVEUI%" may be included in the directory names, and will be
    * replaced with the canonical name of the currently active UI when
    * searching for files.
    * @param string $Dir Directory to be searched.
    * @param string $Prefix Leading prefix to be stripped from file names
    *      when comparing them against objects (e.g. "Axis--").  (OPTIONAL)
    * @param mixed $ClassPattern Pattern string or array of pattern
    *      strings to run on class name via preg_replace().  (OPTIONAL)
    * @param mixed $ClassReplacement Replacement string or array of
    *      replacement strings to run on class name via preg_replace().  (OPTIONAL)
    */
    public static function AddObjectDirectory(
            $Dir, $Prefix = "", $ClassPattern = NULL, $ClassReplacement = NULL)
    {
        # make sure directory has trailing slash
        $Dir = $Dir.((substr($Dir, -1) != "/") ? "/" : "");

        # add directory to directory list
        self::$ObjectDirectories[$Dir] = array(
                "Prefix" => $Prefix,
                "ClassPattern" => $ClassPattern,
                "ClassReplacement" => $ClassReplacement,
                );
    }

    /**
    * Add additional directory(s) to be searched for image files.  Specified
    * directory(s) will be searched, in order, before the default directories
    * or any other directories previously specified.  If a directory is already
    * present in the list, it will be moved to front to be searched first (or
    * to the end to be searched last, if SearchLast is set).  SearchLast only
    * affects whether added directories are searched before or after those
    * currently in the list;  when multiple directories are added, they are
    * always searched in the order they appear in the array.  The token
    * "%ACTIVEUI%" may be included in the directory names, and will be replaced
    * with the canonical name of the currently active UI when searching for files.
    * @param string $Dir String with directory or array with directories to be searched.
    * @param bool $SearchLast If TRUE, the directory(s) are searched after the entries
    *       current in the list, instead of before.  (OPTIONAL, defaults to FALSE)
    * @param bool $SkipSlashCheck If TRUE, check for trailing slash will be omitted.
    *       (OPTIONAL, defaults to FALSE)
    * @see ApplicationFramework::GUIFile()
    * @see ApplicationFramework::PUIFile()
    */
    public function AddImageDirectories(
            $Dir, $SearchLast = FALSE, $SkipSlashCheck = FALSE)
    {
        # add directories to existing image directory list
        $this->ImageDirList = $this->AddToDirList(
                $this->ImageDirList, $Dir, $SearchLast, $SkipSlashCheck);
    }

    /**
    * Add additional directory(s) to be searched for user interface include
    * (CSS, JavaScript, common PHP, common HTML, etc) files.  Specified
    * directory(s) will be searched, in order, before the default directories
    * or any other directories previously specified.  If a directory is already
    * present in the list, it will be moved to front to be searched first (or
    * to the end to be searched last, if SearchLast is set).  SearchLast only
    * affects whether added directories are searched before or after those
    * currently in the list;  when multiple directories are added, they are
    * always searched in the order they appear in the array.  The token
    * "%ACTIVEUI%" may be included in the directory names, and will be replaced
    * with the canonical name of the currently active UI when searching for files.
    * @param string $Dir String with directory or array with directories to be searched.
    * @param bool $SearchLast If TRUE, the directory(s) are searched after the entries
    *       current in the list, instead of before.  (OPTIONAL, defaults to FALSE)
    * @param bool $SkipSlashCheck If TRUE, check for trailing slash will be omitted.
    *       (OPTIONAL, defaults to FALSE)
    * @see ApplicationFramework::GUIFile()
    * @see ApplicationFramework::PUIFile()
    */
    public function AddIncludeDirectories(
            $Dir, $SearchLast = FALSE, $SkipSlashCheck = FALSE)
    {
        # add directories to existing image directory list
        $this->IncludeDirList = $this->AddToDirList(
                $this->IncludeDirList, $Dir, $SearchLast, $SkipSlashCheck);
    }

    /**
    * Add additional directory(s) to be searched for user interface (HTML/TPL)
    * files.  Specified directory(s) will be searched, in order, before the default
    * directories or any other directories previously specified.  If a directory
    * is already present in the list, it will be moved to front to be searched first
    * (or to the end to be searched last, if SearchLast is set).  SearchLast only
    * affects whether added directories are searched before or after those
    * currently in the list;  when multiple directories are added, they are
    * always searched in the order they appear in the array.  The token
    * "%ACTIVEUI%" may be included in the directory names, and will be replaced
    * with the canonical name of the currently active UI when searching for files.
    * @param string $Dir String with directory or array with directories to be searched.
    * @param bool $SearchLast If TRUE, the directory(s) are searched after the entries
    *       current in the list, instead of before.  (OPTIONAL, defaults to FALSE)
    * @param bool $SkipSlashCheck If TRUE, check for trailing slash will be omitted.
    *       (OPTIONAL, defaults to FALSE)
    * @see ApplicationFramework::GUIFile()
    * @see ApplicationFramework::PUIFile()
    */
    public function AddInterfaceDirectories(
            $Dir, $SearchLast = FALSE, $SkipSlashCheck = FALSE)
    {
        # add directories to existing image directory list
        $this->InterfaceDirList = $this->AddToDirList(
                $this->InterfaceDirList, $Dir, $SearchLast, $SkipSlashCheck);
    }

    /**
    * Add additional directory(s) to be searched for function ("F-") files.
    * Specified directory(s) will be searched, in order, before the default
    * directories or any other directories previously specified.  If a directory
    * is already present in the list, it will be moved to front to be searched
    * first (or to the end to be searched last, if SearchLast is set).  SearchLast
    * only affects whether added directories are searched before or after those
    * currently in the list;  when multiple directories are added, they are
    * always searched in the order they appear in the array.  The token
    * "%ACTIVEUI%" may be included in the directory names, and will be replaced
    * with the canonical name of the currently active UI when searching for files.
    * @param string $Dir String with directory or array with directories to be searched.
    * @param bool $SearchLast If TRUE, the directory(s) are searched after the entries
    *       current in the list, instead of before.  (OPTIONAL, defaults to FALSE)
    * @param bool $SkipSlashCheck If TRUE, check for trailing slash will be omitted.
    *       (OPTIONAL, defaults to FALSE)
    * @see ApplicationFramework::GUIFile()
    * @see ApplicationFramework::PUIFile()
    */
    public function AddFunctionDirectories(
            $Dir, $SearchLast = FALSE, $SkipSlashCheck = FALSE)
    {
        # add directories to existing image directory list
        $this->FunctionDirList = $this->AddToDirList(
                $this->FunctionDirList, $Dir, $SearchLast, $SkipSlashCheck);
    }

    /**
    * Specify function to use to detect the web browser type.  Function should
    * return an array of browser names.
    * @param callback $DetectionFunc Browser detection function callback.
    */
    public function SetBrowserDetectionFunc($DetectionFunc)
    {
        $this->BrowserDetectFunc = $DetectionFunc;
    }

    /**
    * Add a callback that will be executed after buffered content has
    * been output and that won't have its output buffered.
    * @param callback $Callback Callback to add.
    * @param array $Parameters Callback parameters in an array.  (OPTIONAL)
    */
    public function AddUnbufferedCallback($Callback, $Parameters=array())
    {
        if (is_callable($Callback))
        {
            $this->UnbufferedCallbacks[] = array($Callback, $Parameters);
        }
    }

    /**
    * Get/set UI template location cache expiration period in minutes.  An
    * expiration period of 0 disables caching.
    * @param int $NewInterval New expiration period in minutes.  (OPTIONAL)
    * @return Current expiration period in minutes.
    */
    public function TemplateLocationCacheExpirationInterval($NewInterval = DB_NOVALUE)
    {
        return $this->UpdateSetting("TemplateLocationCacheInterval", $NewInterval);
    }

    /**
    * Clear template location cache.
    */
    public function ClearTemplateLocationCache()
    {
        $this->TemplateLocationCache = array();
        $this->SaveTemplateLocationCache = TRUE;
    }

    /**
    * Get/set object file location cache expiration period in minutes.  An
    * expiration period of 0 disables caching.
    * @param int $NewInterval New expiration period in minutes.  (OPTIONAL)
    * @return Current expiration period in minutes.
    */
    public function ObjectLocationCacheExpirationInterval($NewInterval = DB_NOVALUE)
    {
        return $this->UpdateSetting("ObjectLocationCacheInterval", $NewInterval);
    }

    /**
    * Clear object (class) file location cache.
    */
    public function ClearObjectLocationCache()
    {
        self::$ObjectLocationCache = array();
        self::$SaveObjectLocationCache = TRUE;
    }

    /**
    * Get/set whether URL fingerprinting is enabled.  (Initially defaults to
    * enabled on installation.)
    * @param bool $NewValue TRUE to enable, or FALSE to disable.  (OPTIONAL)
    * @return bool TRUE if enabled, otherwise FALSE.
    */
    public function UrlFingerprintingEnabled($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("UrlFingerprintingEnabled", $NewValue);
    }

    /**
    * Get/set whether SCSS compilation support is enabled.  (Initially defaults
    * to enabled on installation.)
    * @param bool $NewValue TRUE to enable, or FALSE to disable.  (OPTIONAL)
    * @return bool TRUE if enabled, otherwise FALSE.
    * @see ApplicationFramework::GenerateCompactCss()
    */
    public function ScssSupportEnabled($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("ScssSupportEnabled", $NewValue);
    }

    /**
    * Get/set whether generating compact CSS (when compiling SCSS) is enabled.
    * (Initially defaults to enabled on installation.)  If SCSS compilation is
    * not enabled, this setting has no effect.
    * @param bool $NewValue TRUE to enable, or FALSE to disable.  (OPTIONAL)
    * @return bool TRUE if enabled, otherwise FALSE.
    * @see ApplicationFramework::ScssSupportEnabled()
    */
    public function GenerateCompactCss($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("GenerateCompactCss", $NewValue);
    }

    /**
    * Get/set whether minimized JavaScript will be searched for and used if
    * found.  (Initially defaults to enabled on installation.)  Minimized
    * files end with ".min.js".
    * @param bool $NewValue TRUE to enable, or FALSE to disable.  (OPTIONAL)
    * @return bool TRUE if enabled, otherwise FALSE.
    * @see ApplicationFramework::JavascriptMinimizationEnabled()
    */
    public function UseMinimizedJavascript($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("UseMinimizedJavascript", $NewValue);
    }

    /**
    * Get/set whether the application framework will attempt to generate
    * minimized JavaScript.  (Initially defaults to enabled on installation.)
    * This setting has no effect if UseMinimizedJavascript() is set to FALSE.
    * @param bool $NewValue TRUE to enable, or FALSE to disable.  (OPTIONAL)
    * @return bool TRUE if enabled, otherwise FALSE.
    * @see ApplicationFramework::UseMinimizedJavascript()
    */
    public function JavascriptMinimizationEnabled($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("JavascriptMinimizationEnabled", $NewValue);
    }

    /**
    * Record the current execution context in case of crash.  The current
    * context (backtrace) will be saved with the crash info in case a task
    * crashes.  This is primarily intended as a debugging tool, to help
    * determine the circumstances under which a background task is crashing.
    * The $BacktraceLimit parameter is only supported in PHP 5.4 and later.
    * (You can still supply it for earlier PHP versions, but it will be ignored.)
    * @param int $BacktraceOptions Option flags to pass to debug_backtrace()
    *       when retrieving context.  (OPTIONAL, defaults to 0, which records
    *       function/method arguments but not objects)
    * @param int $BacktraceLimit Maximum number of stack frames to record.
    *       (OPTIONAL, defaults to recording all stack frames)
    */
    public function RecordContextInCaseOfCrash(
            $BacktraceOptions = 0, $BacktraceLimit = 0)
    {
        if (version_compare(PHP_VERSION, "5.4.0", ">="))
        {
            $this->SavedContext = debug_backtrace(
                    $BacktraceOptions, $BacktraceLimit);
        }
        else
        {
            $this->SavedContext = debug_backtrace($BacktraceOptions);
        }
        array_shift($this->SavedContext);
    }

    /**
    * Load page PHP and HTML/TPL files.
    * @param string $PageName Name of page to be loaded (e.g. "BrowseResources").
    */
    public function LoadPage($PageName)
    {
        # perform any clean URL rewriting
        $PageName = $this->RewriteCleanUrls($PageName);

        # sanitize incoming page name and save local copy
        $PageName = preg_replace("/[^a-zA-Z0-9_.-]/", "", $PageName);
        $this->PageName = $PageName;

        # if page caching is turned on
        if ($this->PageCacheEnabled())
        {
            # if we have a cached page
            $CachedPage = $this->CheckForCachedPage($PageName);
            if ($CachedPage !== NULL)
            {
                # set header to indicate cache hit was found
                header("X-ScoutAF-Cache: HIT");

                # display cached page and exit
                print $CachedPage;
                return;
            }
            else
            {
                # set header to indicate no cache hit was found
                header("X-ScoutAF-Cache: MISS");
            }
        }

        # buffer any output from includes or PHP file
        ob_start();

        # include any files needed to set up execution environment
        foreach ($this->EnvIncludes as $IncludeFile)
        {
            include($IncludeFile);
        }

        # signal page load
        $this->SignalEvent("EVENT_PAGE_LOAD", array("PageName" => $PageName));

        # signal PHP file load
        $SignalResult = $this->SignalEvent("EVENT_PHP_FILE_LOAD", array(
                "PageName" => $PageName));

        # if signal handler returned new page name value
        $NewPageName = $PageName;
        if (($SignalResult["PageName"] != $PageName)
                && strlen($SignalResult["PageName"]))
        {
            # if new page name value is page file
            if (file_exists($SignalResult["PageName"]))
            {
                # use new value for PHP file name
                $PageFile = $SignalResult["PageName"];
            }
            else
            {
                # use new value for page name
                $NewPageName = $SignalResult["PageName"];
            }

            # update local copy of page name
            $this->PageName = $NewPageName;
        }

        # if we do not already have a PHP file
        if (!isset($PageFile))
        {
            # look for PHP file for page
            $OurPageFile = "pages/".$NewPageName.".php";
            $LocalPageFile = "local/pages/".$NewPageName.".php";
            $PageFile = file_exists($LocalPageFile) ? $LocalPageFile
                    : (file_exists($OurPageFile) ? $OurPageFile
                    : "pages/".$this->DefaultPage.".php");
        }

        # load PHP file
        include($PageFile);

        # save buffered output to be displayed later after HTML file loads
        $PageOutput = ob_get_contents();
        ob_end_clean();

        # signal PHP file load is complete
        ob_start();
        $Context["Variables"] = get_defined_vars();
        $this->SignalEvent("EVENT_PHP_FILE_LOAD_COMPLETE",
                array("PageName" => $PageName, "Context" => $Context));
        $PageCompleteOutput = ob_get_contents();
        ob_end_clean();

        # set up for possible TSR (Terminate and Stay Resident :))
        $ShouldTSR = $this->PrepForTSR();

        # if PHP file indicated we should autorefresh to somewhere else
        if (($this->JumpToPage) && ($this->JumpToPageDelay == 0))
        {
            if (!strlen(trim($PageOutput)))
            {
                # if client supports HTTP/1.1, use a 303 as it is most accurate
                if ($_SERVER["SERVER_PROTOCOL"] == "HTTP/1.1")
                {
                    header("HTTP/1.1 303 See Other");
                    header("Location: ".$this->JumpToPage);
                }
                else
                {
                    # if the request was an HTTP/1.0 GET or HEAD, then
                    # use a 302 response code.

                    # NB: both RFC 2616 (HTTP/1.1) and RFC1945 (HTTP/1.0)
                    # explicitly prohibit automatic redirection via a 302
                    # if the request was not GET or HEAD.
                    if ($_SERVER["SERVER_PROTOCOL"] == "HTTP/1.0" &&
                        ($_SERVER["REQUEST_METHOD"] == "GET" ||
                         $_SERVER["REQUEST_METHOD"] == "HEAD") )
                    {
                        header("HTTP/1.0 302 Found");
                        header("Location: ".$this->JumpToPage);
                    }

                    # otherwise, fall back to a meta refresh
                    else
                    {
                        print '<html><head><meta http-equiv="refresh" '
                            .'content="0; URL='.$this->JumpToPage.'">'
                            .'</head><body></body></html>';
                    }
                }
            }
        }
        # else if HTML loading is not suppressed
        elseif (!$this->SuppressHTML)
        {
            # set content-type to get rid of diacritic errors
            header("Content-Type: text/html; charset="
                .$this->HtmlCharset, TRUE);

            # load common HTML file (defines common functions) if available
            $CommonHtmlFile = $this->FindFile($this->IncludeDirList,
                    "Common", array("tpl", "html"));
            if ($CommonHtmlFile) {  include($CommonHtmlFile);  }

            # load UI functions
            $this->LoadUIFunctions();

            # begin buffering content
            ob_start();

            # signal HTML file load
            $SignalResult = $this->SignalEvent("EVENT_HTML_FILE_LOAD", array(
                    "PageName" => $PageName));

            # if signal handler returned new page name value
            $NewPageName = $PageName;
            $PageContentFile = NULL;
            if (($SignalResult["PageName"] != $PageName)
                    && strlen($SignalResult["PageName"]))
            {
                # if new page name value is HTML file
                if (file_exists($SignalResult["PageName"]))
                {
                    # use new value for HTML file name
                    $PageContentFile = $SignalResult["PageName"];
                }
                else
                {
                    # use new value for page name
                    $NewPageName = $SignalResult["PageName"];
                }
            }

            # load page content HTML file if available
            if ($PageContentFile === NULL)
            {
                $PageContentFile = $this->FindFile(
                        $this->InterfaceDirList, $NewPageName,
                        array("tpl", "html"));
            }
            if ($PageContentFile)
            {
                include($PageContentFile);
            }
            else
            {
                print "<h2>ERROR:  No HTML/TPL template found"
                        ." for this page (".$NewPageName.").</h2>";
            }

            # signal HTML file load complete
            $SignalResult = $this->SignalEvent("EVENT_HTML_FILE_LOAD_COMPLETE");

            # stop buffering and save output
            $PageContentOutput = ob_get_contents();
            ob_end_clean();

            # load page start HTML file if available
            ob_start();
            $PageStartFile = $this->FindFile($this->IncludeDirList, "Start",
                    array("tpl", "html"), array("StdPage", "StandardPage"));
            if ($PageStartFile) {  include($PageStartFile);  }
            $PageStartOutput = ob_get_contents();
            ob_end_clean();

            # if page auto-refresh requested
            if ($this->JumpToPage)
            {
                $RefreshLine = '<meta http-equiv="refresh" content="'
                        .$this->JumpToPageDelay.'; url='.$this->JumpToPage.'">';
                $PageStartOutput = str_replace("<head>", "<head>\n".$RefreshLine,
                        $PageStartOutput);
            }

            # load page end HTML file if available
            ob_start();
            $PageEndFile = $this->FindFile($this->IncludeDirList, "End",
                    array("tpl", "html"), array("StdPage", "StandardPage"));
            if ($PageEndFile) {  include($PageEndFile);  }
            $PageEndOutput = ob_get_contents();
            ob_end_clean();

            # get list of any required files not loaded
            $RequiredFiles = $this->GetRequiredFilesNotYetLoaded($PageContentFile);

            # if a browser detection function has been made available
            if (is_callable($this->BrowserDetectFunc))
            {
                # call function to get browser list
                $Browsers = call_user_func($this->BrowserDetectFunc);

                # for each required file
                $NewRequiredFiles = array();
                foreach ($RequiredFiles as $File)
                {
                    # if file name includes browser keyword
                    if (preg_match("/%BROWSER%/", $File))
                    {
                        # for each browser
                        foreach ($Browsers as $Browser)
                        {
                            # substitute in browser name and add to new file list
                            $NewRequiredFiles[] = preg_replace(
                                    "/%BROWSER%/", $Browser, $File);
                        }
                    }
                    else
                    {
                        # add to new file list
                        $NewRequiredFiles[] = $File;
                    }
                }
                $RequiredFiles = $NewRequiredFiles;
            }
            else
            {
                # filter out any files with browser keyword in their name
                $NewRequiredFiles = array();
                foreach ($RequiredFiles as $File)
                {
                    if (!preg_match("/%BROWSER%/", $File))
                    {
                        $NewRequiredFiles[] = $File;
                    }
                }
                $RequiredFiles = $NewRequiredFiles;
            }

            # for each required file
            foreach ($RequiredFiles as $File)
            {
                # locate specific file to use
                $FilePath = $this->GUIFile($File);

                # if file was found
                if ($FilePath)
                {
                    # generate tag for file
                    $Tag = $this->GetUIFileLoadingTag($FilePath);

                    # add file to HTML output based on file type
                    $FileType = $this->GetFileType($FilePath);
                    switch ($FileType)
                    {
                        case self::FT_CSS:
                            $PageStartOutput = preg_replace(
                                    "#</head>#i", $Tag."\n</head>", $PageStartOutput, 1);
                            break;

                        case self::FT_JAVASCRIPT:
                            $PageEndOutput = preg_replace(
                                    "#</body>#i", $Tag."\n</body>", $PageEndOutput, 1);
                            break;
                    }
                }
            }

            # assemble full page
            $FullPageOutput = $PageStartOutput.$PageContentOutput.$PageEndOutput;

            # perform any regular expression replacements in output
            $NewFullPageOutput = preg_replace($this->OutputModificationPatterns,
                    $this->OutputModificationReplacements, $FullPageOutput);

            # check to make sure replacements didn't fail
            $FullPageOutput = $this->CheckOutputModification(
                    $FullPageOutput, $NewFullPageOutput,
                    "regular expression replacements");

            # for each registered output modification callback
            foreach ($this->OutputModificationCallbacks as $Info)
            {
                # set up data for callback
                $this->OutputModificationCallbackInfo = $Info;

                # perform output modification
                $NewFullPageOutput = preg_replace_callback($Info["SearchPattern"],
                        array($this, "OutputModificationCallbackShell"),
                        $FullPageOutput);

                # check to make sure modification didn't fail
                $ErrorInfo = "callback info: ".print_r($Info, TRUE);
                $FullPageOutput = $this->CheckOutputModification(
                        $FullPageOutput, $NewFullPageOutput, $ErrorInfo);
            }

            # provide the opportunity to modify full page output
            $SignalResult = $this->SignalEvent("EVENT_PAGE_OUTPUT_FILTER", array(
                    "PageOutput" => $FullPageOutput));
            if (isset($SignalResult["PageOutput"])
                    && strlen(trim($SignalResult["PageOutput"])))
            {
                $FullPageOutput = $SignalResult["PageOutput"];
            }

            # if relative paths may not work because we were invoked via clean URL
            if ($this->CleanUrlRewritePerformed || self::WasUrlRewritten())
            {
                # if using the <base> tag is okay
                $BaseUrl = $this->BaseUrl();
                if ($this->UseBaseTag)
                {
                    # add <base> tag to header
                    $PageStartOutput = str_replace("<head>",
                            "<head><base href=\"".$BaseUrl."\" />",
                            $PageStartOutput);

                    # re-assemble full page with new header
                    $FullPageOutput = $PageStartOutput.$PageContentOutput.$PageEndOutput;

                    # the absolute URL to the current page
                    $FullUrl = $BaseUrl . $this->GetPageLocation();

                    # make HREF attribute values with just a fragment ID
                    # absolute since they don't work with the <base> tag because
                    # they are relative to the current page/URL, not the site
                    # root
                    $NewFullPageOutput = preg_replace(
                            array("%href=\"(#[^:\" ]+)\"%i", "%href='(#[^:' ]+)'%i"),
                            array("href=\"".$FullUrl."$1\"", "href='".$FullUrl."$1'"),
                            $FullPageOutput);

                    # check to make sure HREF cleanup didn't fail
                    $FullPageOutput = $this->CheckOutputModification(
                            $FullPageOutput, $NewFullPageOutput,
                            "HREF cleanup");
                }
                else
                {
                    # try to fix any relative paths throughout code
                    $RelativePathPatterns = array(
                            "%src=\"/?([^?*:;{}\\\\\" ]+)\.(js|css|gif|png|jpg)\"%i",
                            "%src='/?([^?*:;{}\\\\' ]+)\.(js|css|gif|png|jpg)'%i",
                            # don't rewrite HREF attributes that are just
                            # fragment IDs because they are relative to the
                            # current page/URL, not the site root
                            "%href=\"/?([^#][^:\" ]*)\"%i",
                            "%href='/?([^#][^:' ]*)'%i",
                            "%action=\"/?([^#][^:\" ]*)\"%i",
                            "%action='/?([^#][^:' ]*)'%i",
                            "%@import\s+url\(\"/?([^:\" ]+)\"\s*\)%i",
                            "%@import\s+url\('/?([^:\" ]+)'\s*\)%i",
                            "%src:\s+url\(\"/?([^:\" ]+)\"\s*\)%i",
                            "%src:\s+url\('/?([^:\" ]+)'\s*\)%i",
                            "%@import\s+\"/?([^:\" ]+)\"\s*%i",
                            "%@import\s+'/?([^:\" ]+)'\s*%i",
                            );
                    $RelativePathReplacements = array(
                            "src=\"".$BaseUrl."$1.$2\"",
                            "src=\"".$BaseUrl."$1.$2\"",
                            "href=\"".$BaseUrl."$1\"",
                            "href=\"".$BaseUrl."$1\"",
                            "action=\"".$BaseUrl."$1\"",
                            "action=\"".$BaseUrl."$1\"",
                            "@import url(\"".$BaseUrl."$1\")",
                            "@import url('".$BaseUrl."$1')",
                            "src: url(\"".$BaseUrl."$1\")",
                            "src: url('".$BaseUrl."$1')",
                            "@import \"".$BaseUrl."$1\"",
                            "@import '".$BaseUrl."$1'",
                            );
                    $NewFullPageOutput = preg_replace($RelativePathPatterns,
                            $RelativePathReplacements, $FullPageOutput);

                    # check to make sure relative path fixes didn't fail
                    $FullPageOutput = $this->CheckOutputModification(
                            $FullPageOutput, $NewFullPageOutput,
                            "relative path fixes");
                }
            }

            # handle any necessary alternate domain rewriting
            $FullPageOutput = $this->RewriteAlternateDomainUrls($FullPageOutput);

            # update page cache for this page
            $this->UpdatePageCache($PageName, $FullPageOutput);

            # write out full page
            print $FullPageOutput;
        }

        # run any post-processing routines
        foreach ($this->PostProcessingFuncs as $Func)
        {
            call_user_func_array($Func["FunctionName"], $Func["Arguments"]);
        }

        # write out any output buffered from page code execution
        if (strlen($PageOutput))
        {
            if (!$this->SuppressHTML)
            {
                ?><table width="100%" cellpadding="5"
                        style="border: 2px solid #666666;  background: #CCCCCC;
                        font-family: Courier New, Courier, monospace;
                        margin-top: 10px;"><tr><td><?PHP
            }
            if ($this->JumpToPage)
            {
                ?><div style="color: #666666;"><span style="font-size: 150%;">
                <b>Page Jump Aborted</b></span>
                (because of error or other unexpected output)<br />
                <b>Jump Target:</b>
                <i><?PHP  print($this->JumpToPage);  ?></i></div><?PHP
            }
            print $PageOutput;
            if (!$this->SuppressHTML)
            {
                ?></td></tr></table><?PHP
            }
        }

        # write out any output buffered from the page code execution complete signal
        if (!$this->JumpToPage && !$this->SuppressHTML && strlen($PageCompleteOutput))
        {
            print $PageCompleteOutput;
        }

        # log slow page loads
        if ($this->LogSlowPageLoads()
                && !$this->DoNotLogSlowPageLoad
                && ($this->GetElapsedExecutionTime()
                        >= ($this->SlowPageLoadThreshold())))
        {
            $RemoteHost = gethostbyaddr($_SERVER["REMOTE_ADDR"]);
            if ($RemoteHost === FALSE)
            {
                $RemoteHost = $_SERVER["REMOTE_ADDR"];
            }
            elseif ($RemoteHost != $_SERVER["REMOTE_ADDR"])
            {
                $RemoteHost .= " (".$_SERVER["REMOTE_ADDR"].")";
            }
            $SlowPageLoadMsg = "Slow page load ("
                    .intval($this->GetElapsedExecutionTime())."s) for "
                    .$this->FullUrl()." from ".$RemoteHost;
            $this->LogMessage(self::LOGLVL_INFO, $SlowPageLoadMsg);
        }

        # execute callbacks that should not have their output buffered
        foreach ($this->UnbufferedCallbacks as $Callback)
        {
            call_user_func_array($Callback[0], $Callback[1]);
        }

        # log high memory usage
        if (function_exists("memory_get_peak_usage"))
        {
            $MemoryThreshold = ($this->HighMemoryUsageThreshold()
                    * $this->GetPhpMemoryLimit()) / 100;
            if ($this->LogHighMemoryUsage()
                    && (memory_get_peak_usage() >= $MemoryThreshold))
            {
                $HighMemUsageMsg = "High peak memory usage ("
                        .intval(memory_get_peak_usage()).") for "
                        .$this->FullUrl()." from "
                        .$_SERVER["REMOTE_ADDR"];
                $this->LogMessage(self::LOGLVL_INFO, $HighMemUsageMsg);
            }
        }

        # terminate and stay resident (TSR!) if indicated and HTML has been output
        # (only TSR if HTML has been output because otherwise browsers will misbehave)
        if ($ShouldTSR) {  $this->LaunchTSR();  }
    }

    /**
    * Get name of page being loaded.  The page name will not include an extension.
    * This call is only meaningful once LoadPage() has been called.
    * @return Page name.
    */
    public function GetPageName()
    {
        return $this->PageName;
    }

    /**
    * Get the URL path to the page without the base path, if present.  Case
    * is ignored when looking for a base path to strip off.
    * @return string URL path without the base path
    */
    public function GetPageLocation()
    {
        # retrieve current URL
        $Url = self::GetScriptUrl();

        # remove the base path if present
        $BasePath = $this->Settings["BasePath"];
        if (stripos($Url, $BasePath) === 0)
        {
            $Url = substr($Url, strlen($BasePath));
        }

        # if we're being accessed via an alternate domain,
        #  add the appropriate prefix in
        if ($this->HtaccessSupport() &&
            self::$RootUrlOverride !== NULL)
        {
            $VHost = $_SERVER["SERVER_NAME"];
            if (isset($this->AlternateDomainPrefixes[$VHost]))
            {
                $ThisPrefix = $this->AlternateDomainPrefixes[$VHost];
                $Url = $ThisPrefix."/".$Url;
            }
        }

        return $Url;
    }

    /**
    * Get the full URL to the page.
    * @return The full URL to the page.
    * @see ApplicationFramework::RootUrlOverride()
    */
    public function GetPageUrl()
    {
        return self::BaseUrl() . $this->GetPageLocation();
    }

    /**
    * Set URL of page to autoload after PHP page file is executed.  The HTML/TPL
    * file will never be loaded if this is set.  Pass in NULL to clear any autoloading.
    * @param string $Page URL of page to jump to (autoload).  If the URL does not appear
    *       to point to a PHP or HTML file then "index.php?P=" will be prepended to it.
    * @param int $Delay If non-zero, the page HTML will be generated and displayed,
    *       and the jump (refresh) will not occur until the specified delay in
    *       seconds has elapsed.  (OPTIONAL, defaults to 0)
    * @param bool $IsLiteral If TRUE, do not attempt to prepend "index.php?P=" to page.
    *       (OPTIONAL, defaults to FALSE)
    */
    public function SetJumpToPage($Page, $Delay = 0, $IsLiteral = FALSE)
    {
        if (!is_null($Page)
                && (!$IsLiteral)
                && (strpos($Page, "?") === FALSE)
                && ((strpos($Page, "=") !== FALSE)
                    || ((stripos($Page, ".php") === FALSE)
                        && (stripos($Page, ".htm") === FALSE)
                        && (strpos($Page, "/") === FALSE)))
                && (stripos($Page, "http://") !== 0)
                && (stripos($Page, "https://") !== 0))
        {
            $this->JumpToPage = self::BaseUrl() . "index.php?P=".$Page;
        }
        else
        {
            $this->JumpToPage = $Page;
        }
        $this->JumpToPageDelay = $Delay;
    }

    /**
    * Report whether a page to autoload has been set.
    * @return TRUE if page is set to autoload, otherwise FALSE.
    */
    public function JumpToPageIsSet()
    {
        return ($this->JumpToPage === NULL) ? FALSE : TRUE;
    }

    /**
    * Get/set HTTP character encoding value.  This is set for the HTTP header and
    * may be queried and set in the HTML header by the active user interface.
    * The default charset is UTF-8.
    * A list of valid character set values can be found at
    * http://www.iana.org/assignments/character-sets
    * @param string $NewSetting New character encoding value string (e.g. "ISO-8859-1").
    * @return Current character encoding value.
    */
    public function HtmlCharset($NewSetting = NULL)
    {
        if ($NewSetting !== NULL) {  $this->HtmlCharset = $NewSetting;  }
        return $this->HtmlCharset;
    }

    /**
    * Specify file(s) to not attempt to minimize.  File names can include
    * paths, in which case only files that exactly match that path will be
    * excluded, or can be just the base file name, in which case any file
    * with that name will be excluded.  This does not prevent minimized
    * versions of files from being used if found in the interface directories,
    * just local (cached) minimized versions being generated and/or used.
    * @param mixed $File File name or array of file names.
    */
    public function DoNotMinimizeFile($File)
    {
        if (!is_array($File)) {  $File = array($File);  }
        $this->DoNotMinimizeList = array_merge($this->DoNotMinimizeList, $File);
    }

    /**
    * Get/set whether or not to use the "base" tag to ensure relative URL
    * paths are correct.  (Without the "base" tag, an attempt will be made
    * to dynamically rewrite relative URLs where needed.)  Using the "base"
    * tag may be problematic because it also affects relative anchor
    * references and empty target references, which some third-party
    * JavaScript libraries may rely upon.
    * @param bool $NewValue TRUE to enable use of tag, or FALSE to disable.  (OPTIONAL)
    * @return TRUE if tag is currently used, otherwise FALSE..
    */
    public function UseBaseTag($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  $this->UseBaseTag = $NewValue ? TRUE : FALSE;  }
        return $this->UseBaseTag;
    }

    /**
    * Suppress loading of HTML files.  This is useful when the only output from a
    * page is intended to come from the PHP page file.
    * @param bool $NewSetting TRUE to suppress HTML output, FALSE to not suppress HTML
    *       output.  (OPTIONAL, defaults to TRUE)
    */
    public function SuppressHTMLOutput($NewSetting = TRUE)
    {
        $this->SuppressHTML = $NewSetting;
    }

    /**
    * Get/set name of current default user interface.
    * @param string $UIName Name of new default user interface.  (OPTIONAL)
    * @return Name of currently default user interface.
    */
    public static function DefaultUserInterface($UIName = NULL)
    {
        if ($UIName !== NULL)
        {
            self::$DefaultUI = $UIName;
        }
        return self::$DefaultUI;
    }

    /**
    * Get/set name of current active user interface.  Any "SPTUI--" prefix is
    * stripped out for backward compatibility in CWIS.
    * @param string $UIName Name of new active user interface.  (OPTIONAL)
    * @return Name of currently active user interface.
    */
    public static function ActiveUserInterface($UIName = NULL)
    {
        if ($UIName !== NULL)
        {
            self::$ActiveUI = preg_replace("/^SPTUI--/", "", $UIName);
        }
        return self::$ActiveUI;
    }

    /**
    * Get list of available user interfaces and their labels.  Labels are
    * taken from the file "NAME" in the base directory for the interface,
    * if available.  IF a NAME file isn't available, the canonical name is
    * used for the label.
    * @param string $FilterExp If this regular expression (preg_match() format)
    *       is specified, only interfaces whose directory path matches the
    *       expression will be returned.  (OPTIONAL)
    * @return array List of users interfaces (canonical name => label).
    */
    public function GetUserInterfaces($FilterExp = NULL)
    {
        static $Interfaces;

        if (!isset($Interfaces[$FilterExp]))
        {
            # retrieve paths to user interface directories
            $Paths = $this->GetUserInterfacePaths($FilterExp);

            # start out with an empty list
            $Interfaces[$FilterExp] = array();

            # for each possible UI directory
            foreach ($Paths as $CanonicalName => $Path)
            {
                # if name file available
                $LabelFile = $Path."/NAME";
                if (is_readable($LabelFile))
                {
                    # read the UI name
                    $Label = file_get_contents($LabelFile);

                    # if the UI name looks reasonable
                    if (strlen(trim($Label)))
                    {
                        # use read name
                        $Interfaces[$FilterExp][$CanonicalName] = $Label;
                    }
                }

                # if we do not have a name yet
                if (!isset($Interfaces[$FilterExp][$CanonicalName]))
                {
                    # use base directory for name
                    $Interfaces[$FilterExp][$CanonicalName] = basename($Path);
                }
            }
        }

        # return list to caller
        return $Interfaces[$FilterExp];
    }

    /**
    * Get list of available user interfaces and the relative paths to the
    * base directory for each interface.
    * @param string $FilterExp If this regular expression (preg_match() format)
    *       is specified, only interfaces whose directory path matches the
    *       expression will be returned.  (OPTIONAL)
    * @return array List of users interface paths (canonical name => interface path)
    */
    public function GetUserInterfacePaths($FilterExp = NULL)
    {
        static $InterfacePaths;

        if (!isset($InterfacePaths[$FilterExp]))
        {
            # extract possible UI directories from interface directory list
            $InterfaceDirs = array();
            foreach ($this->InterfaceDirList as $Dir)
            {
                $Matches = array();
                if (preg_match("#([a-zA-Z0-9/]*interface)/[a-zA-Z0-9%/]*#",
                        $Dir, $Matches))
                {
                    $Dir = $Matches[1];
                    if (!in_array($Dir, $InterfaceDirs))
                    {
                        $InterfaceDirs[] = $Dir;
                    }
                }
            }

            # reverse order of interface directories so that the directory
            #       returned is the base directory for the interface
            $InterfaceDirs = array_reverse($InterfaceDirs);

            # start out with an empty list
            $InterfacePaths[$FilterExp] = array();
            $InterfacesFound = array();

            # for each possible UI directory
            foreach ($InterfaceDirs as $InterfaceDir)
            {
                $Dir = dir($InterfaceDir);

                # for each file in current directory
                while (($DirEntry = $Dir->read()) !== FALSE)
                {
                    $InterfacePath = $InterfaceDir."/".$DirEntry;

                    # skip anything we have already found
                    #   or that doesn't have a name in the required format
                    #   or that isn't a directory
                    #   or that doesn't match the filter regex (if supplied)
                    if (in_array($DirEntry, $InterfacesFound)
                            || !preg_match('/^[a-zA-Z0-9]+$/', $DirEntry)
                            || !is_dir($InterfacePath)
                            || (($FilterExp !== NULL)
                                    && !preg_match($FilterExp, $InterfacePath)))
                    {
                        continue;
                    }

                    # add interface to list
                    $InterfacePaths[$FilterExp][$DirEntry] = $InterfacePath;
                    $InterfacesFound[] = $DirEntry;
                }

                $Dir->close();
            }
        }

        # return list to caller
        return $InterfacePaths[$FilterExp];
    }

    /**
    * Add function to be called after HTML has been loaded.  The arguments are
    * optional and are saved as references so that any changes to their value
    * that occured while loading the HTML will be recognized.
    * @param string $FunctionName Name of function to be called.
    * @param mixed $Arg1 First argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg2 Second argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg3 Third argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg4 Fourth argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg5 FifthFirst argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg6 Sixth argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg7 Seventh argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg8 Eighth argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    * @param mixed $Arg9 Ninth argument to be passed to function.
    *       (OPTIONAL, REFERENCE)
    */
    public function AddPostProcessingCall($FunctionName,
            &$Arg1 = self::NOVALUE, &$Arg2 = self::NOVALUE, &$Arg3 = self::NOVALUE,
            &$Arg4 = self::NOVALUE, &$Arg5 = self::NOVALUE, &$Arg6 = self::NOVALUE,
            &$Arg7 = self::NOVALUE, &$Arg8 = self::NOVALUE, &$Arg9 = self::NOVALUE)
    {
        $FuncIndex = count($this->PostProcessingFuncs);
        $this->PostProcessingFuncs[$FuncIndex]["FunctionName"] = $FunctionName;
        $this->PostProcessingFuncs[$FuncIndex]["Arguments"] = array();
        $Index = 1;
        while (isset(${"Arg".$Index}) && (${"Arg".$Index} !== self::NOVALUE))
        {
            $this->PostProcessingFuncs[$FuncIndex]["Arguments"][$Index]
                    =& ${"Arg".$Index};
            $Index++;
        }
    }

    /**
    * Add file to be included to set up environment.  This file is loaded right
    * before the PHP file.
    * @param string $FileName Name of file to be included.
    */
    public function AddEnvInclude($FileName)
    {
        $this->EnvIncludes[] = $FileName;
    }

    /**
    * Search UI directories for specified image or CSS file and return name
    * of correct file.
    * @param string $FileName Base file name.
    * @return Full relative path name of file or NULL if file not found.
    */
    public function GUIFile($FileName)
    {
        # determine which location to search based on file suffix
        $FileType = $this->GetFileType($FileName);
        $DirList = ($FileType == self::FT_IMAGE)
                ? $this->ImageDirList : $this->IncludeDirList;

        # if directed to use minimized JavaScript file
        if (($FileType == self::FT_JAVASCRIPT) && $this->UseMinimizedJavascript())
        {
            # look for minimized version of file
            $MinimizedFileName = substr_replace($FileName, ".min", -3, 0);
            $FoundFileName = $this->FindFile($DirList, $MinimizedFileName);

            # if minimized file was not found
            if (is_null($FoundFileName))
            {
                # look for unminimized file
                $FoundFileName = $this->FindFile($DirList, $FileName);

                # if unminimized file found
                if (!is_null($FoundFileName))
                {
                    # if minimization enabled and supported
                    if ($this->JavascriptMinimizationEnabled()
                            && self::JsMinRewriteSupport())
                    {
                        # attempt to create minimized file
                        $MinFileName = $this->MinimizeJavascriptFile(
                                $FoundFileName);

                        # if minimization succeeded
                        if ($MinFileName !== NULL)
                        {
                            # use minimized version
                            $FoundFileName = $MinFileName;

                            # save file modification time if needed for fingerprinting
                            if ($this->UrlFingerprintingEnabled())
                            {
                                $FileMTime = filemtime($FoundFileName);
                            }

                            # strip off the cache location, allowing .htaccess
                            #       to handle that for us
                            $FoundFileName = str_replace(
                                    self::$JSMinCacheDir."/", "", $FoundFileName);
                        }
                    }
                }
            }
        }
        # else if directed to use SCSS files
        elseif (($FileType == self::FT_CSS) && $this->ScssSupportEnabled())
        {
            # look for SCSS version of file
            $SourceFileName = preg_replace("/.css$/", ".scss", $FileName);
            $FoundSourceFileName = $this->FindFile($DirList, $SourceFileName);

            # if SCSS file not found
            if ($FoundSourceFileName === NULL)
            {
                # look for CSS file
                $FoundFileName = $this->FindFile($DirList, $FileName);
            }
            else
            {
                # compile SCSS file (if updated) and return resulting CSS file
                $FoundFileName = $this->CompileScssFile($FoundSourceFileName);

                # save file modification time if needed for fingerprinting
                if ($this->UrlFingerprintingEnabled())
                {
                    $FileMTime = filemtime($FoundFileName);
                }

                # strip off the cache location, allowing .htaccess to handle that for us
                if (self::ScssRewriteSupport())
                {
                    $FoundFileName = str_replace(
                            self::$ScssCacheDir."/", "", $FoundFileName);
                }
            }
        }
        # otherwise just search for the file
        else
        {
            $FoundFileName = $this->FindFile($DirList, $FileName);
        }

        # add non-image files to list of found files (used for required files loading)
        if ($FileType != self::FT_IMAGE)
                {  $this->FoundUIFiles[] = basename($FoundFileName);  }

        # if UI file fingerprinting is enabled and supported
        if ($this->UrlFingerprintingEnabled()
                && self::UrlFingerprintingRewriteSupport()
                && (isset($FileMTime) || file_exists($FoundFileName)))
        {
            # if file does not appear to be a server-side inclusion
            if (!preg_match('/\.(html|php)$/i', $FoundFileName))
            {
                # for each URL fingerprinting blacklist entry
                $OnBlacklist = FALSE;
                foreach ($this->UrlFingerprintBlacklist as $BlacklistEntry)
                {
                    # if entry looks like a regular expression pattern
                    if ($BlacklistEntry[0] == substr($BlacklistEntry, -1))
                    {
                        # check file name against regular expression
                        if (preg_match($BlacklistEntry, $FoundFileName))
                        {
                            $OnBlacklist = TRUE;
                            break;
                        }
                    }
                    else
                    {
                        # check file name directly against entry
                        if (basename($FoundFileName) == $BlacklistEntry)
                        {
                            $OnBlacklist = TRUE;
                            break;
                        }
                    }
                }

                # if file was not on blacklist
                if (!$OnBlacklist)
                {
                    # get file modification time if not already retrieved
                    if (!isset($FileMTime))
                    {
                        $FileMTime = filemtime($FoundFileName);
                    }

                    # add timestamp fingerprint to file name
                    $Fingerprint = sprintf("%06X",
                            ($FileMTime % 0xFFFFFF));
                    $FoundFileName = preg_replace("/^(.+)\.([a-z]+)$/",
                            "$1.".$Fingerprint.".$2",
                            $FoundFileName);
                }
            }
        }

        # return file name to caller
        return $FoundFileName;
    }

    /**
    * Search UI directories for specified interface (image, CSS, JavaScript
    * etc) file and print name of correct file with leading path.  If the file
    * is not found, nothing is printed.  This is intended to be called from
    * within interface HTML files to ensure that the correct file is loaded,
    * regardless of which interface the file is in.
    * @param string $FileName Base file name (without leading path).
    */
    public function PUIFile($FileName)
    {
        $FullFileName = $this->GUIFile($FileName);
        if ($FullFileName) {  print($FullFileName);  }
    }

    /**
    * Search UI directories for specified JavaScript or CSS file and
    * print HTML tag to load file, using name of correct file
    * with leading path.  If the file is not found, nothing is printed.
    * This is intended to be called from within interface HTML files to
    * ensure that the correct file is loaded, regardless of which interface
    * the file is in.  An override version of the file (with "-Override" at
    * the end of the name, before the suffix) is also searched for and will
    * be included if found.
    * @param mixed $FileNames File name or array of file names,
    *       without leading path.
    * @param string $AdditionalAttributes Any additional attributes that
    *       should be included in HTML tag.  (OPTIONAL)
    */
    public function IncludeUIFile($FileNames, $AdditionalAttributes = NULL)
    {
        # convert file name to array if necessary
        if (!is_array($FileNames)) {  $FileNames = array($FileNames);  }

        # pad additional attributes if supplied
        $AddAttribs = $AdditionalAttributes ? " ".$AdditionalAttributes : "";

        # for each file
        foreach ($FileNames as $BaseFileName)
        {
            # retrieve full file name
            $FileName = $this->GUIFile($BaseFileName);

            # if file was found
            if ($FileName)
            {
                # print appropriate tag
                print $this->GetUIFileLoadingTag(
                        $FileName, $AdditionalAttributes);
            }

            # if we are not already loading an override file
            if (!preg_match("/-Override.(css|scss|js)$/", $BaseFileName))
            {
                # attempt to load override file if available
                $FileType = $this->GetFileType($BaseFileName);
                switch ($FileType)
                {
                    case self::FT_CSS:
                        $OverrideFileName = preg_replace(
                                "/\.(css|scss)$/", "-Override.$1",
                                $BaseFileName);
                        $this->IncludeUIFile($OverrideFileName,
                                $AdditionalAttributes);
                        break;

                    case self::FT_JAVASCRIPT:
                        $OverrideFileName = preg_replace(
                                "/\.js$/", "-Override.js",
                                $BaseFileName);
                        $this->IncludeUIFile($OverrideFileName,
                                $AdditionalAttributes);
                        break;
                }
            }
        }
    }

    /**
    * Specify file or file name pattern to exclude from URL fingerprinting.
    * The argument is treated as a file name unless the first and last
    * characters are the same.
    * @param string $Pattern File name or file name pattern.
    */
    public function DoNotUrlFingerprint($Pattern)
    {
        $this->UrlFingerprintBlacklist[] = $Pattern;
    }

    /**
    * Add file to list of required UI files.  This is used to make sure a
    * particular JavaScript or CSS file is loaded.  Only files loaded with
    * ApplicationFramework::GUIFile() or ApplicationFramework::PUIFile()
    * are considered when deciding if a file has already been loaded.
    * @param string $FileName Base name (without path) of required file.
    */
    public function RequireUIFile($FileName)
    {
        $this->AdditionalRequiredUIFiles[] = $FileName;
    }

    /**
    * Determine type of specified file based on the file name.
    * @param string $FileName Name of file.
    * @return File type (FT_ enumerated value).
    */
    public static function GetFileType($FileName)
    {
        static $FileTypeCache;
        if (isset($FileTypeCache[$FileName]))
        {
            return $FileTypeCache[$FileName];
        }

        $FileSuffix = strtolower(substr($FileName, -3));
        if ($FileSuffix == "css")
        {
            $FileTypeCache[$FileName] = self::FT_CSS;
        }
        elseif ($FileSuffix == ".js")
        {
            $FileTypeCache[$FileName] = self::FT_JAVASCRIPT;
        }
        elseif (($FileSuffix == "gif")
                || ($FileSuffix == "jpg")
                || ($FileSuffix == "png"))
        {
            $FileTypeCache[$FileName] = self::FT_IMAGE;
        }
        else
        {
            $FileTypeCache[$FileName] = self::FT_OTHER;
        }

        return $FileTypeCache[$FileName];
    }
    /** File type other than CSS, image, or JavaScript. */
    const FT_OTHER = 0;
    /** CSS file type. */
    const FT_CSS = 1;
    /** Image (GIF/JPG/PNG) file type. */
    const FT_IMAGE = 2;
    /** JavaScript file type. */
    const FT_JAVASCRIPT = 3;

    /**
    * Attempt to load code for function or method if not currently available.
    * Function code to be loaded should be located in a file named "F-XXX.php",
    * where "XXX" is the function name.  The file may reside in "local/include",
    * any of the interface "include" directories, or any of the object directories.
    * @param callback $Callback Function or method info.
    * @return TRUE if function/method is now available, else FALSE.
    */
    public function LoadFunction($Callback)
    {
        # if specified function is not currently available
        if (!is_callable($Callback))
        {
            # if function info looks legal
            if (is_string($Callback) && strlen($Callback))
            {
                # start with function directory list
                $Locations = $this->FunctionDirList;

                # add object directories to list
                $Locations = array_merge(
                        $Locations, array_keys(self::$ObjectDirectories));

                # look for function file
                $FunctionFileName = $this->FindFile($Locations, "F-".$Callback,
                        array("php", "html"));

                # if function file was found
                if ($FunctionFileName)
                {
                    # load function file
                    include_once($FunctionFileName);
                }
                else
                {
                    # log error indicating function load failed
                    $this->LogError(self::LOGLVL_ERROR, "Unable to load function"
                            ." for callback \"".$Callback."\".");
                }
            }
            else
            {
                # log error indicating specified function info was bad
                $this->LogError(self::LOGLVL_ERROR, "Unloadable callback value"
                        ." (".$Callback.")"
                        ." passed to AF::LoadFunction() by "
                        .StdLib::GetMyCaller().".");
            }
        }

        # report to caller whether function load succeeded
        return is_callable($Callback);
    }

    /**
    * Get time elapsed since constructor was called.
    * @return Elapsed execution time in seconds (as a float).
    */
    public function GetElapsedExecutionTime()
    {
        return microtime(TRUE) - $this->ExecutionStartTime;
    }

    /**
    * Get remaining available (PHP) execution time.
    * @return Number of seconds remaining before script times out (as a float).
    */
    public function GetSecondsBeforeTimeout()
    {
        return $this->MaxExecutionTime() - $this->GetElapsedExecutionTime();
    }

    /*@)*/ /* Application Framework */


    # ---- Page Caching ------------------------------------------------------

    /** @name Page Caching */ /*@(*/

    /**
    * Enable/disable page caching.  Page caching is disabled by default.
    * @param bool $NewValue TRUE to enable caching, or FALSE to disable.  (OPTIONAL)
    * @return bool Current setting.
    * @see ApplicationFramework::DoNotCacheCurrentPage()
    */
    public function PageCacheEnabled($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("PageCacheEnabled", $NewValue);
    }

    /**
    * Get/set page cache expiration period in seconds.  The default is ten
    * minutes (600 seconds).
    * @param int $NewValue Expiration period in seconds.  (OPTIONAL)
    * @return int Current setting.
    */
    public function PageCacheExpirationPeriod($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("PageCacheExpirationPeriod", $NewValue);
    }

    /**
    * Prevent the current page from being cached.
    * @see ApplicationFramework::PageCachingEnabled()
    */
    public function DoNotCacheCurrentPage()
    {
        $this->CacheCurrentPage = FALSE;
    }

    /**
    * Add caching tag for current page or specified pages.
    * @param string $Tag Tag string to add.
    * @param array $Pages List of pages.  (OPTIONAL, defaults to current page)
    * @see ApplicationFramework::ClearPageCacheForTag()
    */
    public function AddPageCacheTag($Tag, $Pages = NULL)
    {
        # normalize tag
        $Tag = strtolower($Tag);

        # if pages were supplied
        if ($Pages !== NULL)
        {
            # add pages to list for this tag
            if (isset($this->PageCacheTags[$Tag]))
            {
                $this->PageCacheTags[$Tag] = array_merge(
                        $this->PageCacheTags[$Tag], $Pages);
            }
            else
            {
                $this->PageCacheTags[$Tag] = $Pages;
            }
        }
        else
        {
            # add current page to list for this tag
            $this->PageCacheTags[$Tag][] = "CURRENT";
        }
    }

    /**
    * Clear all cached pages associated with specified tag.
    * @param string $Tag Tag to clear pages for.
    * @see ApplicationFramework::AddPageCacheTag()
    */
    public function ClearPageCacheForTag($Tag)
    {
        # get tag ID
        $TagId = $this->GetPageCacheTagId($Tag);

        # delete pages and tag/page connections for specified tag
        $this->DB->Query("DELETE CP, CPTI"
                ." FROM AF_CachedPages CP, AF_CachedPageTagInts CPTI"
                ." WHERE CPTI.TagId = ".intval($TagId)
                ." AND CP.CacheId = CPTI.CacheId");
    }

    /**
    * Clear all pages from page cache.
    */
    public function ClearPageCache()
    {
        # clear all page cache tables
        $this->DB->Query("TRUNCATE TABLE AF_CachedPages");
        $this->DB->Query("TRUNCATE TABLE AF_CachedPageTags");
        $this->DB->Query("TRUNCATE TABLE AF_CachedPageTagInts");
    }

    /**
    * Get page cache information.
    * @return array Associative array of cache info, with entries for
    *       "NumberOfEntries" and "OldestTimestamp" (date on oldest cache
    *       entry as a Unix timestamp).
    */
    public function GetPageCacheInfo()
    {
        $Length = $this->DB->Query("SELECT COUNT(*) AS CacheLen"
                ." FROM AF_CachedPages", "CacheLen");
        $Oldest = $this->DB->Query("SELECT CachedAt FROM AF_CachedPages"
                ." ORDER BY CachedAt ASC LIMIT 1", "CachedAt");
        return array(
                "NumberOfEntries" => $Length,
                "OldestTimestamp" => strtotime($Oldest),
                );
    }

    /*@)*/ /* Page Caching */


    # ---- Logging -----------------------------------------------------------

    /** @name Logging */ /*@(*/

    /**
    * Get/set whether logging of long page load times is enabled.  When
    * enabled, pages that take more than the time specified via
    * SlowPageLoadThreshold() (default 10 seconds) are logged via LogMessage()
    * with a level of LOGLVL_INFO.  (This will not, of course, catch pages that
    * take so long to load that the PHP execution timeout is reached.)
    * @param bool $NewValue TRUE to enable logging or FALSE to disable.  (OPTIONAL)
    * @param bool $Persistent If TRUE the new value will be saved (i.e.
    *       persistent across page loads), otherwise the value will apply to
    *       just the current page load.  (OPTIONAL, defaults to FALSE)
    * @return bool TRUE if logging is enabled, otherwise FALSE.
    * @see SlowPageLoadThreshold()
    */
    public function LogSlowPageLoads(
            $NewValue = DB_NOVALUE, $Persistent = FALSE)
    {
        return $this->UpdateSetting(
                "LogSlowPageLoads", $NewValue, $Persistent);
    }

    /**
    * Get/set how long a page load can take before it should be considered
    * "slow" and may be logged.  (Defaults to 10 seconds.)
    * @param int $NewValue Threshold in seconds.  (OPTIONAL)
    * @param bool $Persistent If TRUE the new value will be saved (i.e.
    *       persistent across page loads), otherwise the value will apply to
    *       just the current page load.  (OPTIONAL, defaults to FALSE)
    * @return int Current threshold in seconds.
    * @see LogSlowPageLoads()
    */
    public function SlowPageLoadThreshold(
            $NewValue = DB_NOVALUE, $Persistent = FALSE)
    {
        return $this->UpdateSetting(
                "SlowPageLoadThreshold", $NewValue, $Persistent);
    }

    /**
    * Get/set whether logging of high memory usage is enabled.  When
    * enabled, pages that use more than the percentage of max memory
    * specified via HighMemoryUsageThreshold() (default 90%) are logged
    * via LogMessage() with a level of LOGLVL_INFO.  (This will not, of
    * course, catch pages that crash because PHP's memory limit is reached.)
    * @param bool $NewValue TRUE to enable logging or FALSE to disable.  (OPTIONAL)
    * @param bool $Persistent If TRUE the new value will be saved (i.e.
    *       persistent across page loads), otherwise the value will apply to
    *       just the current page load.  (OPTIONAL, defaults to FALSE)
    * @return bool TRUE if logging is enabled, otherwise FALSE.
    * @see HighMemoryUsageThreshold()
    */
    public function LogHighMemoryUsage(
            $NewValue = DB_NOVALUE, $Persistent = FALSE)
    {
        return $this->UpdateSetting(
                "LogHighMemoryUsage", $NewValue, $Persistent);
    }

    /**
    * Get/set what percentage of max memory (set via the memory_limit PHP
    * configuration directive) a page load can use before it should be
    * considered to be using high memory and may be logged.  (Defaults to 90%.)
    * @param int $NewValue Threshold percentage.  (OPTIONAL)
    * @param bool $Persistent If TRUE the new value will be saved (i.e.
    *       persistent across page loads), otherwise the value will apply to
    *       just the current page load.  (OPTIONAL, defaults to FALSE)
    * @return int Current threshold percentage.
    * @see LogHighMemoryUsage()
    */
    public function HighMemoryUsageThreshold(
            $NewValue = DB_NOVALUE, $Persistent = FALSE)
    {
        return $this->UpdateSetting(
                "HighMemoryUsageThreshold", $NewValue, $Persistent);
    }

    /**
    * Write error message to log.  The difference between this and LogMessage
    * is the way that an inability to write to the log is handled.  If this
    * method is unable to log the error and the error level was LOGLVL_ERROR
    * or LOGLVL_FATAL, an exception is thrown.
    * @param int $Level Current message logging must be at or above specified
    *       level for error message to be written.  (See LoggingLevel() for
    *       definitions of the error logging levels.)
    * @param string $Msg Error message text.
    * @return TRUE if message was logged, otherwise FALSE.
    * @see ApplicationFramework::LoggingLevel()
    * @see ApplicationFramework::LogMessage()
    */
    public function LogError($Level, $Msg)
    {
        # if error level is at or below current logging level
        if ($this->Settings["LoggingLevel"] >= $Level)
        {
            # attempt to log error message
            $Result = $this->LogMessage($Level, $Msg);

            # if logging attempt failed and level indicated significant error
            if (($Result === FALSE) && ($Level <= self::LOGLVL_ERROR))
            {
                # throw exception about inability to log error
                static $AlreadyThrewException = FALSE;
                if (!$AlreadyThrewException)
                {
                    $AlreadyThrewException = TRUE;
                    throw new Exception("Unable to log error (".$Level.": ".$Msg
                            .") to ".$this->LogFileName);
                }
            }

            # report to caller whether message was logged
            return $Result;
        }
        else
        {
            # report to caller that message was not logged
            return FALSE;
        }
    }

    /**
    * Write status message to log.  The difference between this and LogError
    * is the way that an inability to write to the log is handled.
    * @param int $Level Current message logging must be at or above specified
    *       level for message to be written.  (See LoggingLevel() for
    *       definitions of the error logging levels.)
    * @param string $Msg Message text.
    * @return TRUE if message was logged, otherwise FALSE.
    * @see ApplicationFramework::LoggingLevel()
    * @see ApplicationFramework::LogError()
    */
    public function LogMessage($Level, $Msg)
    {
        # if message level is at or below current logging level
        if ($this->Settings["LoggingLevel"] >= $Level)
        {
            # attempt to open log file
            $FHndl = @fopen($this->LogFileName, "a");

            # if log file could not be open
            if ($FHndl === FALSE)
            {
                # report to caller that message was not logged
                return FALSE;
            }
            else
            {
                # format log entry
                $ErrorAbbrevs = array(
                        self::LOGLVL_FATAL => "FTL",
                        self::LOGLVL_ERROR => "ERR",
                        self::LOGLVL_WARNING => "WRN",
                        self::LOGLVL_INFO => "INF",
                        self::LOGLVL_DEBUG => "DBG",
                        self::LOGLVL_TRACE => "TRC",
                        );
                $Msg = str_replace(array("\n", "\t", "\r"), " ", $Msg);
                $Msg = substr(trim($Msg), 0, self::LOGFILE_MAX_LINE_LENGTH);
                $LogEntry = date("Y-m-d H:i:s")
                        ." ".($this->RunningInBackground ? "B" : "F")
                        ." ".$ErrorAbbrevs[$Level]
                        ." ".$Msg;

                # write entry to log
                $Success = fwrite($FHndl, $LogEntry."\n");

                # close log file
                fclose($FHndl);

                # report to caller whether message was logged
                return ($Success === FALSE) ? FALSE : TRUE;
            }
        }
        else
        {
            # report to caller that message was not logged
            return FALSE;
        }
    }

    /**
    * Get/set logging level.  Status and error messages are only written if
    * their associated level is at or below this value.  The six levels of
    * log messages are, in increasing level of severity:
    *   6: TRACE - Very detailed logging, usually only used when attempting
    *       to diagnose a problem in one specific section of code.
    *   5: DEBUG - Information that is diagnostically helpful when debugging.
    *   4: INFO - Generally-useful information, that may come in handy but
    *       to which little attention is normally paid.  (This should not be
    *       used for events that routinely occur with every page load.)
    *   3: WARNING - An event that may potentially cause problems, but is
    *       automatically recovered from.
    *   2: ERROR - Any error which is fatal to the operation currently being
    *       performed, but does not result in overall application shutdown or
    *       persistent data corruption.
    *   1: FATAL - Any error which results in overall application shutdown or
    *       persistent data corruption.
    * @param int $NewValue New error logging level.  (OPTIONAL)
    * @return Current error logging level.
    * @see ApplicationFramework::LogError()
    */
    public function LoggingLevel($NewValue = DB_NOVALUE)
    {
        # constrain new level (if supplied) to within legal bounds
        if ($NewValue !== DB_NOVALUE)
        {
            $NewValue = max(min($NewValue, 6), 1);
        }

        # set new logging level (if supplied) and return current level to caller
        return $this->UpdateSetting("LoggingLevel", $NewValue);
    }

    /**
    * Get/set log file name.  The log file location defaults to
    * "local/logs/site.log", but can be changed via this method.
    * @param string $NewValue New log file name.  (OPTIONAL)
    * @return string Current log file name.
    */
    public function LogFile($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  $this->LogFileName = $NewValue;  }
        return $this->LogFileName;
    }

    /**
    * Get log entries, in reverse chronological order.
    * @param int $Limit Maximum number of entries to return.  (OPTIONAL,
    *       defaults to returning all entries)
    * @return array Associative array with entry data, with the indexes
    *       "Time" (Unix timestamp), "Background" (TRUE if error was
    *       logged when running background task), "Level" (logging level
    *       constant), and "Message".
    */
    public function GetLogEntries($Limit = 0)
    {
        # return no entries if there isn't a log file or we can't read it
        if (!is_readable($this->LogFile()))
        {
            return array();
        }

        # if max number of entries specified
        if ($Limit > 0)
        {
            # load lines from file
            $FHandle = fopen($this->LogFile(), "r");
            $FileSize = filesize($this->LogFile());
            $SeekPosition = max(0,
                    ($FileSize - (self::LOGFILE_MAX_LINE_LENGTH * ($Limit + 1))));
            fseek($FHandle, $SeekPosition);
            $Block = fread($FHandle, ($FileSize - $SeekPosition));
            fclose($FHandle);
            $Lines = explode(PHP_EOL, $Block);
            array_shift($Lines);

            # prune array back to requested number of entries
            $Lines = array_slice($Lines, (0 - $Limit));
        }
        else
        {
            # load all lines from log file
            $Lines = file($this->LogFile(), FILE_IGNORE_NEW_LINES);
            if ($Lines === FALSE)
            {
                return array();
            }
        }

        # reverse line order
        $Lines = array_reverse($Lines);

        # for each log file line
        $Entries = array();
        foreach ($Lines as $Line)
        {
            # attempt to parse line into component parts
            $Pieces = explode(" ", $Line, 5);
            $Date = isset($Pieces[0]) ? $Pieces[0] : "";
            $Time = isset($Pieces[1]) ? $Pieces[1] : "";
            $Back = isset($Pieces[2]) ? $Pieces[2] : "";
            $Level = isset($Pieces[3]) ? $Pieces[3] : "";
            $Msg = isset($Pieces[4]) ? $Pieces[4] : "";

            # skip line if it looks invalid
            $ErrorAbbrevs = array(
                        "FTL" => self::LOGLVL_FATAL,
                        "ERR" => self::LOGLVL_ERROR,
                        "WRN" => self::LOGLVL_WARNING,
                        "INF" => self::LOGLVL_INFO,
                        "DBG" => self::LOGLVL_DEBUG,
                        "TRC" => self::LOGLVL_TRACE,
                        );
            if ((($Back != "F") && ($Back != "B"))
                    || !array_key_exists($Level, $ErrorAbbrevs)
                    || !strlen($Msg))
            {
                continue;
            }

            # convert parts into appropriate values and add to entries
            $Entries[] = array(
                    "Time" => strtotime($Date." ".$Time),
                    "Background" => ($Back == "B") ? TRUE : FALSE,
                    "Level" => $ErrorAbbrevs[$Level],
                    "Message" => $Msg,
                    );
        }

        # return entries to caller
        return $Entries;
    }

    /**
    * TRACE error logging level.  Very detailed logging, usually only used
    * when attempting to diagnose a problem in one specific section of code.
    */
    const LOGLVL_TRACE = 6;
    /**
    * DEBUG error logging level.  Information that is diagnostically helpful
    * when debugging.
    */
    const LOGLVL_DEBUG = 5;
    /**
    * INFO error logging level.  Generally-useful information, that may
    * come in handy but to which little attention is normally paid.  (This
    * should not be used for events that routinely occur with every page load.)
    */
    const LOGLVL_INFO = 4;
    /**
    * WARNING error logging level.  An event that may potentially cause
    * problems, but is automatically recovered from.
    */
    const LOGLVL_WARNING = 3;
    /**
    * ERROR error logging level.  Any error which is fatal to the operation
    * currently being performed, but does not result in overall application
    * shutdown or persistent data corruption.
    */
    const LOGLVL_ERROR = 2;
    /**
    * FATAL error logging level.  Any error which results in overall
    * application shutdown or persistent data corruption.
    */
    const LOGLVL_FATAL = 1;

    /**
    * Maximum length for a line in the log file.
    */
    const LOGFILE_MAX_LINE_LENGTH = 2048;

    /*@)*/ /* Logging */


    # ---- Event Handling ----------------------------------------------------

    /** @name Event Handling */ /*@(*/

    /**
    * Default event type.  Any handler return values are ignored.
    */
    const EVENTTYPE_DEFAULT = 1;
    /**
    * Result chaining event type.  For this type the parameter array to each
    * event handler is the return value from the previous handler, and the
    * final return value is sent back to the event signaller.
    */
    const EVENTTYPE_CHAIN = 2;
    /**
    * First response event type.  For this type event handlers are called
    * until one returns a non-NULL result, at which point no further handlers
    * are called and that last result is passed back to the event signaller.
    */
    const EVENTTYPE_FIRST = 3;
    /**
    * Named result event type.  Return values from each handler are placed into an
    * array with the handler (function or class::method) name as the index, and
    * that array is returned to the event signaller.  The handler name for
    * class methods is the class name plus "::" plus the method name.
    * are called and that last result is passed back to the event signaller.
    */
    const EVENTTYPE_NAMED = 4;

    /** Run hooked function first (i.e. before ORDER_MIDDLE events). */
    const ORDER_FIRST = 1;
    /** Run hooked function after ORDER_FIRST and before ORDER_LAST events. */
    const ORDER_MIDDLE = 2;
    /** Run hooked function last (i.e. after ORDER_MIDDLE events). */
    const ORDER_LAST = 3;

    /**
    * Register one or more events that may be signaled.
    * @param array|string $EventsOrEventName Name of event (string).  To register multiple
    *       events, this may also be an array, with the event names as the index
    *       and the event types as the values.
    * @param int $EventType Type of event (constant).  (OPTIONAL if EventsOrEventName
    *       is an array of events)
    */
    public function RegisterEvent($EventsOrEventName, $EventType = NULL)
    {
        # convert parameters to array if not already in that form
        $Events = is_array($EventsOrEventName) ? $EventsOrEventName
                : array($EventsOrEventName => $EventType);

        # for each event
        foreach ($Events as $Name => $Type)
        {
            # store event information
            $this->RegisteredEvents[$Name]["Type"] = $Type;
            $this->RegisteredEvents[$Name]["Hooks"] = array();
        }
    }

    /**
    * Check if event has been registered (is available to be signaled).
    * @param string $EventName Name of event (string).
    * @return TRUE if event is registered, otherwise FALSE.
    * @see IsHookedEvent()
    */
    public function IsRegisteredEvent($EventName)
    {
        return array_key_exists($EventName, $this->RegisteredEvents)
                ? TRUE : FALSE;
    }

    /**
    * Check if an event is registered and is hooked to.
    * @param string $EventName Name of event.
    * @return Returns TRUE if the event is hooked, otherwise FALSE.
    * @see IsRegisteredEvent()
    */
    public function IsHookedEvent($EventName)
    {
        # the event isn't hooked to if it isn't even registered
        if (!$this->IsRegisteredEvent($EventName))
        {
            return FALSE;
        }

        # return TRUE if there is at least one callback hooked to the event
        return count($this->RegisteredEvents[$EventName]["Hooks"]) > 0;
    }

    /**
    * Hook one or more functions to be called when the specified event is
    * signaled.  The callback parameter is of the PHP type "callback", which
    * allows object methods to be passed.
    * @param array|string $EventsOrEventName Name of the event to hook.  To hook multiple
    *       events, this may also be an array, with the event names as the index
    *       and the callbacks as the values.
    * @param callback $Callback Function to be called when event is signaled.  (OPTIONAL
    *       if EventsOrEventName is an array of events)
    * @param int $Order Preference for when function should be called, primarily for
    *       CHAIN and FIRST events.  (OPTIONAL, defaults to ORDER_MIDDLE)
    * @return TRUE if all callbacks were successfully hooked, otherwise FALSE.
    */
    public function HookEvent(
            $EventsOrEventName, $Callback = NULL, $Order = self::ORDER_MIDDLE)
    {
        # convert parameters to array if not already in that form
        $Events = is_array($EventsOrEventName) ? $EventsOrEventName
                : array($EventsOrEventName => $Callback);

        # for each event
        $Success = TRUE;
        foreach ($Events as $EventName => $EventCallback)
        {
            # if callback is valid
            if (is_callable($EventCallback))
            {
                # if this is a periodic event we process internally
                if (isset($this->PeriodicEvents[$EventName]))
                {
                    # process event now
                    $this->ProcessPeriodicEvent($EventName, $EventCallback);
                }
                # if specified event has been registered
                elseif (isset($this->RegisteredEvents[$EventName]))
                {
                    # add callback for event
                    $this->RegisteredEvents[$EventName]["Hooks"][]
                            = array("Callback" => $EventCallback, "Order" => $Order);

                    # sort callbacks by order
                    if (count($this->RegisteredEvents[$EventName]["Hooks"]) > 1)
                    {
                        usort($this->RegisteredEvents[$EventName]["Hooks"],
                                function ($A, $B) {
                                    return StdLib::SortCompare(
                                            $A["Order"], $B["Order"]);
                                });
                    }
                }
                else
                {
                    $Success = FALSE;
                }
            }
            else
            {
                $Success = FALSE;
            }
        }

        # report to caller whether all callbacks were hooked
        return $Success;
    }

    /**
    * Unhook one or more functions that were previously hooked to be called
    * when the specified event is signaled.  The callback parameter is of the
    * PHP type "callback", which allows object methods to be passed.
    * @param array|string $EventsOrEventName Name of the event to unhook.  To
    *       unhook multiple events, this may also be an array, with the event
    *       names as the index and the callbacks as the values.
    * @param callback $Callback Function to be called when event is signaled.
    *       (OPTIONAL if EventsOrEventName is an array of events)
    * @param int $Order Preference for when function should be called, primarily for
    *       CHAIN and FIRST events.  (OPTIONAL, defaults to ORDER_MIDDLE)
    * @return int Number of event/callback pairs unhooked.
    */
    public function UnhookEvent(
            $EventsOrEventName, $Callback = NULL, $Order = self::ORDER_MIDDLE)
    {
        # convert parameters to array if not already in that form
        $Events = is_array($EventsOrEventName) ? $EventsOrEventName
                : array($EventsOrEventName => $Callback);

        # for each event
        $UnhookCount = 0;
        foreach ($Events as $EventName => $EventCallback)
        {
            # if this event has been registered and hooked
            if (isset($this->RegisteredEvents[$EventName])
                    && count($this->RegisteredEvents[$EventName]))
            {
                # if this callback has been hooked for this event
                $CallbackData = array("Callback" => $EventCallback, "Order" => $Order);
                if (in_array($CallbackData,
                        $this->RegisteredEvents[$EventName]["Hooks"]))
                {
                    # unhook callback
                    $HookIndex = array_search($CallbackData,
                            $this->RegisteredEvents[$EventName]["Hooks"]);
                    unset($this->RegisteredEvents[$EventName]["Hooks"][$HookIndex]);
                    $UnhookCount++;
                }
            }
        }

        # report number of callbacks unhooked to caller
        return $UnhookCount;
    }

    /**
    * Signal that an event has occured.
    * @param string $EventName Name of event being signaled.
    * @param array $Parameters Associative array of parameters for event,
    *       with CamelCase names as indexes.  The order of the array
    *       MUST correspond to the order of the parameters expected by the
    *       signal handlers.  (OPTIONAL)
    * @return Appropriate return value for event type.  Returns NULL if no event
    *       with specified name was registered and for EVENTTYPE_DEFAULT events.
    */
    public function SignalEvent($EventName, $Parameters = NULL)
    {
        $ReturnValue = NULL;

        # if event has been registered
        if (isset($this->RegisteredEvents[$EventName]))
        {
            # set up default return value (if not NULL)
            switch ($this->RegisteredEvents[$EventName]["Type"])
            {
                case self::EVENTTYPE_CHAIN:
                    $ReturnValue = $Parameters;
                    break;

                case self::EVENTTYPE_NAMED:
                    $ReturnValue = array();
                    break;
            }

            # for each callback for this event
            foreach ($this->RegisteredEvents[$EventName]["Hooks"] as $Hook)
            {
                # invoke callback
                $Callback = $Hook["Callback"];
                $Result = ($Parameters !== NULL)
                        ? call_user_func_array($Callback, $Parameters)
                        : call_user_func($Callback);

                # process return value based on event type
                switch ($this->RegisteredEvents[$EventName]["Type"])
                {
                    case self::EVENTTYPE_CHAIN:
                        if ($Result !== NULL)
                        {
                            foreach ($Parameters as $Index => $Value)
                            {
                                if (array_key_exists($Index, $Result))
                                {
                                    $Parameters[$Index] = $Result[$Index];
                                }
                            }
                            $ReturnValue = $Parameters;
                        }
                        break;

                    case self::EVENTTYPE_FIRST:
                        if ($Result !== NULL)
                        {
                            $ReturnValue = $Result;
                            break 2;
                        }
                        break;

                    case self::EVENTTYPE_NAMED:
                        $CallbackName = is_array($Callback)
                                ? (is_object($Callback[0])
                                        ? get_class($Callback[0])
                                        : $Callback[0])."::".$Callback[1]
                                : $Callback;
                        $ReturnValue[$CallbackName] = $Result;
                        break;

                    default:
                        break;
                }
            }
        }
        else
        {
            $this->LogError(self::LOGLVL_WARNING,
                    "Unregistered event (".$EventName.") signaled by "
                            .StdLib::GetMyCaller().".");
        }

        # return value if any to caller
        return $ReturnValue;
    }

    /**
    * Report whether specified event only allows static callbacks.
    * @param string $EventName Name of event to check.
    * @return TRUE if specified event only allows static callbacks, otherwise FALSE.
    */
    public function IsStaticOnlyEvent($EventName)
    {
        return isset($this->PeriodicEvents[$EventName]) ? TRUE : FALSE;
    }

    /**
    * Get date/time a periodic event will next run.  This is when the event
    * should next go into the event queue, so it is the earliest time the
    * event might run.  Actual execution time will depend on whether there
    * are other events already in the queue.
    * @param string $EventName Periodic event name (e.g. "EVENT_DAILY").
    * @param callback $Callback Event callback.
    * @return int Next run time as a timestamp, or FALSE if event was not
    *       a periodic event or was not previously run.
    */
    public function EventWillNextRunAt($EventName, $Callback)
    {
        # if event is not a periodic event report failure to caller
        if (!array_key_exists($EventName, $this->EventPeriods)) {  return FALSE;  }

        # retrieve last execution time for event if available
        $Signature = self::GetCallbackSignature($Callback);
        $LastRunTime = $this->DB->Query("SELECT LastRunAt FROM PeriodicEvents"
                ." WHERE Signature = '".addslashes($Signature)."'", "LastRunAt");

        # if event was not found report failure to caller
        if ($LastRunTime === NULL) {  return FALSE;  }

        # calculate next run time based on event period
        $NextRunTime = strtotime($LastRunTime) + $this->EventPeriods[$EventName];

        # report next run time to caller
        return $NextRunTime;
    }

    /**
    * Get list of known periodic events.  This returns a list with information
    * about periodic events that have been hooked this invocation, and when they
    * are next expected to run.  The array returned has the following values:
    * - Callback - Callback for event.
    * - Period - String containing "EVENT_" followed by period.
    * - LastRun - Timestamp for when the event was last run or FALSE if event
    *       was never run or last run time is not known.  This value is always
    *       FALSE for periodic events.
    * - NextRun - Timestamp for the earliest time when the event will next run
    *       or FALSE if next run time is not known.
    * - Parameters - (present for compatibility but always NULL)
    *
    * @return array List of info about known periodic events.
    */
    public function GetKnownPeriodicEvents()
    {
        # retrieve last execution times
        $this->DB->Query("SELECT * FROM PeriodicEvents");
        $LastRunTimes = $this->DB->FetchColumn("LastRunAt", "Signature");

        # for each known event
        $Events = array();
        foreach ($this->KnownPeriodicEvents as $Signature => $Info)
        {
            # if last run time for event is available
            if (array_key_exists($Signature, $LastRunTimes))
            {
                # calculate next run time for event
                $LastRun = strtotime($LastRunTimes[$Signature]);
                $NextRun = $LastRun + $this->EventPeriods[$Info["Period"]];
                if ($Info["Period"] == "EVENT_PERIODIC") {  $LastRun = FALSE;  }
            }
            else
            {
                # set info to indicate run times are not known
                $LastRun = FALSE;
                $NextRun = FALSE;
            }

            # add event info to list
            $Events[$Signature] = $Info;
            $Events[$Signature]["LastRun"] = $LastRun;
            $Events[$Signature]["NextRun"] = $NextRun;
            $Events[$Signature]["Parameters"] = NULL;
        }

        # return list of known events to caller
        return $Events;
    }

    /*@)*/ /* Event Handling */


    # ---- Task Management ---------------------------------------------------

    /** @name Task Management */ /*@(*/

    /**  Highest priority. */
    const PRIORITY_HIGH = 1;
    /**  Medium (default) priority. */
    const PRIORITY_MEDIUM = 2;
    /**  Lower priority. */
    const PRIORITY_LOW = 3;
    /**  Lowest priority. */
    const PRIORITY_BACKGROUND = 4;

    /**
    * Add task to queue.  The Callback parameters is the PHP "callback" type.
    * If $Callback refers to a function (rather than an object method) that function
    * must be available in a global scope on all pages or must be loadable by
    * ApplicationFramework::LoadFunction().
    * @param callback $Callback Function or method to call to perform task.
    * @param array $Parameters Array containing parameters to pass to function or
    *       method.  (OPTIONAL, pass NULL for no parameters)
    * @param int $Priority Priority to assign to task.  (OPTIONAL, defaults
    *       to PRIORITY_LOW)
    * @param string $Description Text description of task.  (OPTIONAL)
    */
    public function QueueTask($Callback, $Parameters = NULL,
            $Priority = self::PRIORITY_LOW, $Description = "")
    {
        # pack task info and write to database
        if ($Parameters === NULL) {  $Parameters = array();  }
        $this->DB->Query("INSERT INTO TaskQueue"
                ." (Callback, Parameters, Priority, Description)"
                ." VALUES ('".addslashes(serialize($Callback))."', '"
                .addslashes(serialize($Parameters))."', ".intval($Priority).", '"
                .addslashes($Description)."')");
    }

    /**
    * Add task to queue if not already in queue or currently running.
    * If task is already in queue with a lower priority than specified, the task's
    * priority will be increased to the new value.
    * The Callback parameter is the PHP "callback" type.
    * If $Callback refers to a function (rather than an object method) that function
    * must be available in a global scope on all pages or must be loadable by
    * ApplicationFramework::LoadFunction().
    * @param callback $Callback Function or method to call to perform task.
    * @param array $Parameters Array containing parameters to pass to function or
    *       method.  (OPTIONAL, pass NULL for no parameters)
    * @param int $Priority Priority to assign to task.  (OPTIONAL, defaults
    *       to PRIORITY_LOW)
    * @param string $Description Text description of task.  (OPTIONAL)
    * @return TRUE if task was added, otherwise FALSE.
    * @see ApplicationFramework::TaskIsInQueue()
    */
    public function QueueUniqueTask($Callback, $Parameters = NULL,
            $Priority = self::PRIORITY_LOW, $Description = "")
    {
        if ($this->TaskIsInQueue($Callback, $Parameters))
        {
            $QueryResult = $this->DB->Query("SELECT TaskId,Priority FROM TaskQueue"
                    ." WHERE Callback = '".addslashes(serialize($Callback))."'"
                    .($Parameters ? " AND Parameters = '"
                            .addslashes(serialize($Parameters))."'" : ""));
            if ($QueryResult !== FALSE)
            {
                $Record = $this->DB->FetchRow();
                if ($Record["Priority"] > $Priority)
                {
                    $this->DB->Query("UPDATE TaskQueue"
                            ." SET Priority = ".intval($Priority)
                            ." WHERE TaskId = ".intval($Record["TaskId"]));
                }
            }
            return FALSE;
        }
        else
        {
            $this->QueueTask($Callback, $Parameters, $Priority, $Description);
            return TRUE;
        }
    }

    /**
    * Check if task is already in queue or currently running.
    * When no $Parameters value is specified the task is checked against
    * any other entries with the same $Callback.
    * @param callback $Callback Function or method to call to perform task.
    * @param array $Parameters Array containing parameters to pass to function or
    *       method.  (OPTIONAL)
    * @return TRUE if task is already in queue, otherwise FALSE.
    */
    public function TaskIsInQueue($Callback, $Parameters = NULL)
    {
        $QueuedCount = $this->DB->Query(
                "SELECT COUNT(*) AS FoundCount FROM TaskQueue"
                ." WHERE Callback = '".addslashes(serialize($Callback))."'"
                .($Parameters ? " AND Parameters = '"
                        .addslashes(serialize($Parameters))."'" : ""),
                "FoundCount");
        $RunningCount = $this->DB->Query(
                "SELECT COUNT(*) AS FoundCount FROM RunningTasks"
                ." WHERE Callback = '".addslashes(serialize($Callback))."'"
                .($Parameters ? " AND Parameters = '"
                        .addslashes(serialize($Parameters))."'" : ""),
                "FoundCount");
        $FoundCount = $QueuedCount + $RunningCount;
        return ($FoundCount ? TRUE : FALSE);
    }

    /**
    * Retrieve current number of tasks in queue.
    * @param int $Priority Priority of tasks.  (OPTIONAL, defaults to all priorities)
    * @return int Number of tasks currently in queue.
    */
    public function GetTaskQueueSize($Priority = NULL)
    {
        return $this->GetQueuedTaskCount(NULL, NULL, $Priority);
    }

    /**
    * Retrieve list of tasks currently in queue.
    * @param int $Count Number to retrieve.  (OPTIONAL, defaults to 100)
    * @param int $Offset Offset into queue to start retrieval.  (OPTIONAL)
    * @return Array with task IDs for index and task info for values.  Task info
    *       is stored as associative array with "Callback" and "Parameter" indices.
    */
    public function GetQueuedTaskList($Count = 100, $Offset = 0)
    {
        return $this->GetTaskList("SELECT * FROM TaskQueue"
                ." ORDER BY Priority, TaskId ", $Count, $Offset);
    }

    /**
    * Get number of queued tasks that match supplied values.  Tasks will
    * not be counted if the values do not match exactly, so callbacks with
    * methods for different objects (even of the same class) will not match.
    * @param callback $Callback Function or method to call to perform task.
    *       (OPTIONAL)
    * @param array $Parameters Array containing parameters to pass to function
    *       or method.  Pass in empty array to match tasks with no parameters.
    *       (OPTIONAL)
    * @param int $Priority Priority to assign to task.  (OPTIONAL)
    * @param string $Description Text description of task.  (OPTIONAL)
    * @return int Number of tasks queued that match supplied parameters.
    */
    public function GetQueuedTaskCount($Callback = NULL,
            $Parameters = NULL, $Priority = NULL, $Description = NULL)
    {
        $Query = "SELECT COUNT(*) AS TaskCount FROM TaskQueue";
        $Sep = " WHERE";
        if ($Callback !== NULL)
        {
            $Query .= $Sep." Callback = '".addslashes(serialize($Callback))."'";
            $Sep = " AND";
        }
        if ($Parameters !== NULL)
        {
            $Query .= $Sep." Parameters = '".addslashes(serialize($Parameters))."'";
            $Sep = " AND";
        }
        if ($Priority !== NULL)
        {
            $Query .= $Sep." Priority = ".intval($Priority);
            $Sep = " AND";
        }
        if ($Description !== NULL)
        {
            $Query .= $Sep." Description = '".addslashes($Description)."'";
        }
        return $this->DB->Query($Query, "TaskCount");
    }

    /**
    * Retrieve list of tasks currently in queue.
    * @param int $Count Number to retrieve.  (OPTIONAL, defaults to 100)
    * @param int $Offset Offset into queue to start retrieval.  (OPTIONAL)
    * @return Array with task IDs for index and task info for values.  Task info
    *       is stored as associative array with "Callback" and "Parameter" indices.
    */
    public function GetRunningTaskList($Count = 100, $Offset = 0)
    {
        return $this->GetTaskList("SELECT * FROM RunningTasks"
                ." WHERE StartedAt >= '".date("Y-m-d H:i:s",
                        (time() - $this->MaxExecutionTime()))."'"
                ." ORDER BY StartedAt", $Count, $Offset);
    }

    /**
    * Retrieve list of tasks currently in queue.
    * @param int $Count Number to retrieve.  (OPTIONAL, defaults to 100)
    * @param int $Offset Offset into queue to start retrieval.  (OPTIONAL)
    * @return Array with task IDs for index and task info for values.  Task info
    *       is stored as associative array with "Callback" and "Parameter" indices.
    */
    public function GetOrphanedTaskList($Count = 100, $Offset = 0)
    {
        return $this->GetTaskList("SELECT * FROM RunningTasks"
                ." WHERE StartedAt < '".date("Y-m-d H:i:s",
                        (time() - $this->MaxExecutionTime()))."'"
                ." ORDER BY StartedAt", $Count, $Offset);
    }

    /**
    * Retrieve current number of orphaned tasks.
    * @return Number of orphaned tasks.
    */
    public function GetOrphanedTaskCount()
    {
        return $this->DB->Query("SELECT COUNT(*) AS Count FROM RunningTasks"
                ." WHERE StartedAt < '".date("Y-m-d H:i:s",
                        (time() - $this->MaxExecutionTime()))."'",
                "Count");
    }

    /**
    * Move orphaned task back into queue.
    * @param int $TaskId Task ID.
    * @param int $NewPriority New priority for task being requeued.  (OPTIONAL)
    */
    public function ReQueueOrphanedTask($TaskId, $NewPriority = NULL)
    {
        $this->DB->Query("LOCK TABLES TaskQueue WRITE, RunningTasks WRITE");
        $this->DB->Query("INSERT INTO TaskQueue"
                ." (Callback,Parameters,Priority,Description) "
                ."SELECT Callback, Parameters, Priority, Description"
                ." FROM RunningTasks WHERE TaskId = ".intval($TaskId));
        if ($NewPriority !== NULL)
        {
            $NewTaskId = $this->DB->LastInsertId();
            $this->DB->Query("UPDATE TaskQueue SET Priority = "
                            .intval($NewPriority)
                    ." WHERE TaskId = ".intval($NewTaskId));
        }
        $this->DB->Query("DELETE FROM RunningTasks WHERE TaskId = ".intval($TaskId));
        $this->DB->Query("UNLOCK TABLES");
    }

    /**
    * Remove task from task queues.
    * @param int $TaskId Task ID.
    */
    public function DeleteTask($TaskId)
    {
        $this->DB->Query("DELETE FROM TaskQueue WHERE TaskId = ".intval($TaskId));
        $this->DB->Query("DELETE FROM RunningTasks WHERE TaskId = ".intval($TaskId));
    }

    /**
    * Retrieve task info from queue (either running or queued tasks).
    * @param int $TaskId Task ID.
    * @return Array with task info for values or NULL if task is not found.
    *       Task info is stored as associative array with "Callback" and
    *       "Parameter" indices.
    */
    public function GetTask($TaskId)
    {
        # assume task will not be found
        $Task = NULL;

        # look for task in task queue
        $this->DB->Query("SELECT * FROM TaskQueue WHERE TaskId = ".intval($TaskId));

        # if task was not found in queue
        if (!$this->DB->NumRowsSelected())
        {
            # look for task in running task list
            $this->DB->Query("SELECT * FROM RunningTasks WHERE TaskId = "
                    .intval($TaskId));
        }

        # if task was found
        if ($this->DB->NumRowsSelected())
        {
            # if task was periodic
            $Row = $this->DB->FetchRow();
            if ($Row["Callback"] ==
                    serialize(array("ApplicationFramework", "PeriodicEventWrapper")))
            {
                # unpack periodic task callback
                $WrappedCallback = unserialize($Row["Parameters"]);
                $Task["Callback"] = $WrappedCallback[1];
                $Task["Parameters"] = $WrappedCallback[2];
            }
            else
            {
                # unpack task callback and parameters
                $Task["Callback"] = unserialize($Row["Callback"]);
                $Task["Parameters"] = unserialize($Row["Parameters"]);
            }
        }

        # return task to caller
        return $Task;
    }

    /**
    * Get/set whether automatic task execution is enabled.  (This does not
    * prevent tasks from being manually executed.)
    * @param bool $NewValue TRUE to enable or FALSE to disable.  (OPTIONAL)
    * @return Returns TRUE if automatic task execution is enabled or
    *       otherwise FALSE.
    */
    public function TaskExecutionEnabled($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("TaskExecutionEnabled", $NewValue);
    }

    /**
    * Get/set maximum number of tasks to have running simultaneously.
    * @param int $NewValue New setting for max number of tasks.  (OPTIONAL)
    * @return Current maximum number of tasks to run at once.
    */
    public function MaxTasks($NewValue = DB_NOVALUE)
    {
        return $this->UpdateSetting("MaxTasksRunning", $NewValue);
    }

    /**
    * Get printable synopsis for task callback.  Any string values in the
    * callback parameter list will be escaped with htmlspecialchars().
    * @param array $TaskInfo Array of task info as returned by GetTask().
    * @return string Task callback synopsis string.
    * @see ApplicationFramework::GetTask()
    */
    public static function GetTaskCallbackSynopsis($TaskInfo)
    {
        # if task callback is function use function name
        $Callback = $TaskInfo["Callback"];
        $Name = "";
        if (!is_array($Callback))
        {
            $Name = $Callback;
        }
        else
        {
            # if task callback is object
            if (is_object($Callback[0]))
            {
                # if task callback is encapsulated ask encapsulation for name
                if (method_exists($Callback[0], "GetCallbackAsText"))
                {
                    $Name = $Callback[0]->GetCallbackAsText();
                }
                # else assemble name from object
                else
                {
                    $Name = get_class($Callback[0]) . "::" . $Callback[1];
                }
            }
            # else assemble name from supplied info
            else
            {
                $Name= $Callback[0] . "::" . $Callback[1];
            }
        }

        # if parameter array was supplied
        $Parameters = $TaskInfo["Parameters"];
        $ParameterString = "";
        if (is_array($Parameters))
        {
            # assemble parameter string
            $Separator = "";
            foreach ($Parameters as $Parameter)
            {
                $ParameterString .= $Separator;
                if (is_int($Parameter) || is_float($Parameter))
                {
                    $ParameterString .= $Parameter;
                }
                else if (is_string($Parameter))
                {
                    $ParameterString .= "\"".htmlspecialchars($Parameter)."\"";
                }
                else if (is_array($Parameter))
                {
                    $ParameterString .= "ARRAY";
                }
                else if (is_object($Parameter))
                {
                    $ParameterString .= "OBJECT";
                }
                else if (is_null($Parameter))
                {
                    $ParameterString .= "NULL";
                }
                else if (is_bool($Parameter))
                {
                    $ParameterString .= $Parameter ? "TRUE" : "FALSE";
                }
                else if (is_resource($Parameter))
                {
                    $ParameterString .= get_resource_type($Parameter);
                }
                else
                {
                    $ParameterString .= "????";
                }
                $Separator = ", ";
            }
        }

        # assemble name and parameters and return result to caller
        return $Name."(".$ParameterString.")";
    }

    /**
    * Determine whether currently running inside a background task.
    * @return bool TRUE if running in background, otherwise FALSE.
    */
    public function IsRunningInBackground()
    {
        return $this->RunningInBackground;
    }

    /**
    * Determine current priority if running in background.
    * @return int Current background priority (PRIORITY_ value), or NULL
    *       if not currently running in background.
    */
    public function GetCurrentBackgroundPriority()
    {
        return isset($this->RunningTask)
                ? $this->RunningTask["Priority"] : NULL;
    }

    /**
    * Get next higher possible background task priority.  If already at the
    * highest priority, the same value is returned.
    * @param int $Priority Background priority (PRIORITY_ value).  (OPTIONAL,
    *       defaults to current priority if running in background, or NULL if
    *       running in foreground)
    * @return int Next higher background priority.
    */
    public function GetNextHigherBackgroundPriority($Priority = NULL)
    {
        if ($Priority === NULL)
        {
            $Priority = $this->GetCurrentBackgroundPriority();
            if ($Priority === NULL)
            {
                return NULL;
            }
        }
        return ($Priority > self::PRIORITY_HIGH)
                ? ($Priority - 1) : self::PRIORITY_HIGH;
    }

    /**
    * Get next lower possible background task priority.  If already at the
    * lowest priority, the same value is returned.
    * @param int $Priority Background priority (PRIORITY_ value).  (OPTIONAL,
    *       defaults to current priority if running in background, or NULL if
    *       running in foreground)
    * @return int Next lower background priority.
    */
    public function GetNextLowerBackgroundPriority($Priority = NULL)
    {
        if ($Priority === NULL)
        {
            $Priority = $this->GetCurrentBackgroundPriority();
            if ($Priority === NULL)
            {
                return NULL;
            }
        }
        return ($Priority < self::PRIORITY_BACKGROUND)
                ? ($Priority + 1) : self::PRIORITY_BACKGROUND;
    }

    /*@)*/ /* Task Management */


    # ---- Clean URL Support -------------------------------------------------

    /** @name Clean URL Support */ /*@(*/

    /**
    * Add clean URL mapping.  This method allows a "clean URL" (usually a
    * purely structural URL that does not contain a query string and is
    * more human-friendly) to be specified and mapped to a particular page,
    * with segments of the clean URL being extracted and put into $_GET
    * variables, as if they had been in a query string.  IMPORTANT: If the
    * $Template parameter is used to automagically swap in clean URLs in page
    * output, the number of variables specified by $GetVars should be limited,
    * as X variables causes X! regular expression replacements to be performed
    * on the output.
    * @param string $Pattern Regular expression to match against clean URL,
    *       with starting and ending delimiters.
    * @param string $Page Page (P= value) to load if regular expression matches.
    * @param array $GetVars Array of $_GET variables to set using matches from
    *       regular expression, with variable names for the array indexes and
    *       variable value templates (with $N as appropriate, for captured
    *       subpatterns from matching) for the array values.  (OPTIONAL)
    * @param mixed $Template Template to use to insert clean URLs in
    *       HTML output.  $_GET variables value locations should be specified
    *       in the template via the variable name preceded by a "$".  This
    *       value may alternatively be a callback, in which case the callback
    *       will be called in a fashion similar to preg_replace_callback(),
    *       except with a second parameter containing the original $Pattern,
    *       a third parameter containing $Page, and a fourth parameter
    *       containing the full pattern (with "href=" etc) being matched.
    */
    public function AddCleanUrl($Pattern, $Page, $GetVars = NULL, $Template = NULL)
    {
        # save clean URL mapping parameters
        $this->CleanUrlMappings[] = array(
                "Pattern" => $Pattern,
                "Page" => $Page,
                "GetVars" => $GetVars,
                );

        # if replacement template specified
        if ($Template !== NULL)
        {
            # if GET parameters specified
            if (count($GetVars))
            {
                # retrieve all possible permutations of GET parameters
                $GetPerms = $this->ArrayPermutations(array_keys($GetVars));

                # for each permutation of GET parameters
                foreach ($GetPerms as $VarPermutation)
                {
                    # construct search pattern for permutation
                    $SearchPattern = "/href=([\"'])index\\.php\\?P=".$Page;
                    $GetVarSegment = "";
                    foreach ($VarPermutation as $GetVar)
                    {
                        if (preg_match("%\\\$[0-9]+%", $GetVars[$GetVar]))
                        {
                            $GetVarSegment .= "&amp;".$GetVar."=((?:(?!\\1)[^&])+)";
                        }
                        else
                        {
                            $GetVarSegment .= "&amp;".$GetVar."=".$GetVars[$GetVar];
                        }
                    }
                    $SearchPattern .= $GetVarSegment."\\1/i";

                    # if template is actually a callback
                    if (is_callable($Template))
                    {
                        # add pattern to HTML output mod callbacks list
                        $this->OutputModificationCallbacks[] = array(
                                "Pattern" => $Pattern,
                                "Page" => $Page,
                                "SearchPattern" => $SearchPattern,
                                "Callback" => $Template,
                                );
                    }
                    else
                    {
                        # construct replacement string for permutation
                        $Replacement = $Template;
                        $Index = 2;
                        foreach ($VarPermutation as $GetVar)
                        {
                            $Replacement = str_replace(
                                    "\$".$GetVar, "\$".$Index, $Replacement);
                            $Index++;
                        }
                        $Replacement = "href=\"".$Replacement."\"";

                        # add pattern to HTML output modifications list
                        $this->OutputModificationPatterns[] = $SearchPattern;
                        $this->OutputModificationReplacements[] = $Replacement;
                    }
                }
            }
            else
            {
                # construct search pattern
                $SearchPattern = "/href=\"index\\.php\\?P=".$Page."\"/i";

                # if template is actually a callback
                if (is_callable($Template))
                {
                    # add pattern to HTML output mod callbacks list
                    $this->OutputModificationCallbacks[] = array(
                            "Pattern" => $Pattern,
                            "Page" => $Page,
                            "SearchPattern" => $SearchPattern,
                            "Callback" => $Template,
                            );
                }
                else
                {
                    # add simple pattern to HTML output modifications list
                    $this->OutputModificationPatterns[] = $SearchPattern;
                    $this->OutputModificationReplacements[] = "href=\"".$Template."\"";
                }
            }
        }
    }

    /**
    * Report whether clean URL has already been mapped.
    * @param string $Path Relative URL path to test against.
    * @return TRUE if pattern is already mapped, otherwise FALSE.
    */
    public function CleanUrlIsMapped($Path)
    {
        foreach ($this->CleanUrlMappings as $Info)
        {
            if (preg_match($Info["Pattern"], $Path))
            {
                return TRUE;
            }
        }
        return FALSE;
    }

    /**
    * Get the clean URL mapped for a path.  This only works for clean URLs
    * where a replacement template for insertion into output (the $Template
    * parameter to AddCleanUrl()) was specified.
    * @param string $Path Unclean path, e.g., index.php?P=FullRecord&ID=123.
    * @return Returns the clean URL for the path if one exists. Otherwise it
    *     returns the path unchanged.
    * @see ApplicationFramework::AddCleanUrl()
    */
    public function GetCleanUrlForPath($Path)
    {
        # the search patterns and callbacks require a specific format
        $Format = "href=\"".str_replace("&", "&amp;", $Path)."\"";
        $Search = $Format;

        # perform any regular expression replacements on the search string
        $Search = preg_replace($this->OutputModificationPatterns,
                $this->OutputModificationReplacements, $Search);

        # only run the callbacks if a replacement hasn't already been performed
        if ($Search == $Format)
        {
            # perform any callback replacements on the search string
            foreach ($this->OutputModificationCallbacks as $Info)
            {
                # make the information available to the callback
                $this->OutputModificationCallbackInfo = $Info;

                # execute the callback
                $Search = preg_replace_callback($Info["SearchPattern"],
                        array($this, "OutputModificationCallbackShell"),
                        $Search);
            }
        }

        # return the path untouched if no replacements were performed
        if ($Search == $Format)
        {
            return $Path;
        }

        # remove the bits added to the search string to get it recognized by
        # the replacement expressions and callbacks
        $Result = substr($Search, 6, -1);

        return $Result;
    }

    /**
    * Get the unclean URL for mapped for a path.
    * @param string $Path Clean path, e.g., r123/resource-title
    * @return string Returns the unclean URL for the path if one exists.
    *       Otherwise it returns the path unchanged.
    */
    public function GetUncleanUrlForPath($Path)
    {
        # for each clean URL mapping
        foreach ($this->CleanUrlMappings as $Info)
        {
            # if current path matches the clean URL pattern
            if (preg_match($Info["Pattern"], $Path, $Matches))
            {
                # the GET parameters for the URL, starting with the page name
                $GetVars = array("P" => $Info["Page"]);

                # if additional $_GET variables specified for clean URL
                if ($Info["GetVars"] !== NULL)
                {
                    # for each $_GET variable specified for clean URL
                    foreach ($Info["GetVars"] as $VarName => $VarTemplate)
                    {
                        # start with template for variable value
                        $Value = $VarTemplate;

                        # for each subpattern matched in current URL
                        foreach ($Matches as $Index => $Match)
                        {
                            # if not first (whole) match
                            if ($Index > 0)
                            {
                                # make any substitutions in template
                                $Value = str_replace("$".$Index, $Match, $Value);
                            }
                        }

                        # add the GET variable
                        $GetVars[$VarName] = $Value;
                    }
                }

                # return the unclean URL
                return "index.php?" . http_build_query($GetVars);
            }
        }

        # return the path unchanged
        return $Path;
    }

    /**
    * Get the clean URL for the current page if one is available. Otherwise,
    * the unclean URL will be returned.
    * @return Returns the clean URL for the current page if possible.
    */
    public function GetCleanUrl()
    {
        return $this->GetCleanUrlForPath($this->GetUncleanUrl());
    }

    /**
    * Get the unclean URL for the current page.
    * @return Returns the unclean URL for the current page.
    */
    public function GetUncleanUrl()
    {
        $GetVars = array("P" => $this->GetPageName()) + $_GET;
        return "index.php?" . http_build_query($GetVars);
    }

    /**
    * Add an alternate domain for the site which should map to a
    * path tree under the main site URL.  In addition to CleanURL
    * support via htaccess, this functionality also requires that a
    * RootUrlOverride be configured specifying the primary URL of the
    * site.
    * @param string $Domain Domain that should be served from a prefix.
    * @param string $Prefix Prefix (a URL path component) from whence
    *     the domain should be served.
    * @see ApplicationFramework::RootUrlOverride()
    */
    public function AddPrefixForAlternateDomain($Domain, $Prefix)
    {
        $this->AlternateDomainPrefixes[$Domain] = $Prefix;
    }

    /*@)*/ /* Clean URL Support */

    # ---- Server Environment ------------------------------------------------

    /** @name Server Environment */ /*@(*/

    /**
    * Get/set session timeout in seconds.
    * @param int $NewValue New session timeout value.  (OPTIONAL)
    * @return Current session timeout value in seconds.
    */
    public static function SessionLifetime($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            self::$SessionLifetime = $NewValue;
        }
        return self::$SessionLifetime;
    }

    /**
    * Determine if .htaccess files are enabled.  This method depends on the
    * environment variable HTACCESS_SUPPORT being set in .htaccess.
    * @return TRUE if .htaccess files are enabled or FALSE otherwise
    */
    public static function HtaccessSupport()
    {
        return isset($_SERVER["HTACCESS_SUPPORT"])
                || isset($_SERVER["REDIRECT_HTACCESS_SUPPORT"]);
    }

    /**
    * Determine if rewrite support for URL fingerprinting is available.  This
    * method depends on the environment variable URL_FINGERPRINTING_SUPPORT
    * being set in .htaccess.
    * @return TRUE if URL fingerprinting support is available or FALSE otherwise
    */
    public static function UrlFingerprintingRewriteSupport()
    {
        return isset($_SERVER["URL_FINGERPRINTING_SUPPORT"])
                || isset($_SERVER["REDIRECT_URL_FINGERPRINTING_SUPPORT"]);
    }

    /**
    * Determine if SCSS rewrite support is available.  This method
    * depends on the environment variable SCSS_REWRITE_SUPPORT being
    * set in .htaccess.
    * @return TRUE if SCSS rewrite support is available or FALSE otherwise
    */
    public static function ScssRewriteSupport()
    {
        return isset($_SERVER["SCSS_REWRITE_SUPPORT"])
                || isset($_SERVER["REDIRECT_SCSS_REWRITE_SUPPORT"]);
    }

    /**
    * Determine if rewrite support for JavaScript minification is available.
    * This method depends on the environment variable JSMIN_REWRITE_SUPPORT
    * being set in .htaccess.
    * @return TRUE if URL fingerprinting is available or FALSE otherwise
    */
    public static function JsMinRewriteSupport()
    {
        return isset($_SERVER["JSMIN_REWRITE_SUPPORT"])
                || isset($_SERVER["REDIRECT_JSMIN_REWRITE_SUPPORT"]);
    }

    /**
    * Get portion of current URL through host name, with no trailing
    * slash (e.g. http://foobar.com).
    * @return URL portion.
    * @see ApplicationFramework::PreferHttpHost()
    * @see ApplicationFramework::RootUrlOverride()
    */
    public static function RootUrl()
    {
        # return override value if one is set
        if (self::$RootUrlOverride !== NULL)
        {
            return self::$RootUrlOverride;
        }

        # determine scheme name
        $Protocol = (isset($_SERVER["HTTPS"]) ? "https" : "http");

        # if HTTP_HOST is preferred or SERVER_NAME points to localhost
        #       and HTTP_HOST is set
        if ((self::$PreferHttpHost || ($_SERVER["SERVER_NAME"] == "127.0.0.1"))
                && isset($_SERVER["HTTP_HOST"]))
        {
            # use HTTP_HOST for domain name
            $DomainName = $_SERVER["HTTP_HOST"];
        }
        else
        {
            # use SERVER_NAME for domain name
            $DomainName = $_SERVER["HTTP_HOST"];
        }

        # build URL root and return to caller
        return $Protocol."://".$DomainName;
    }

    /**
    * Get/set root URL override.  (The "root URL" is the portion of the URL
    * through the host name.)  Any trailing slash will be removed.  Pass in
    * NULL to clear any existing override.  This setting primarily affects
    * the values returned by the URL retrieval methods and the attempted
    * insertion of clean URLs in outgoing HTML.
    * @param string $NewValue New root URL override.  (OPTIONAL)
    * @return string Current root URL override, or NULL if root URL
    *       is not currently overridden.
    * @see ApplicationFramework::RootUrl()
    * @see ApplicationFramework::BaseUrl()
    * @see ApplicationFramework::FullUrl()
    * @see ApplicationFramework::GetPageUrl()
    */
    public static function RootUrlOverride($NewValue = self::NOVALUE)
    {
        if ($NewValue !== self::NOVALUE)
        {
            self::$RootUrlOverride = strlen(trim($NewValue)) ? $NewValue : NULL;
        }
        return self::$RootUrlOverride;
    }

    /**
    * Get current base URL (the part before index.php) (e.g. http://foobar.com/path/).
    * The base URL is determined using the ultimate executing URL, after
    * any clean URL remapping has been applied, so any extra "directory"
    * segments that are really just part of a clean URL will not be included.
    * @return Base URL string with trailing slash.
    * @see ApplicationFramework::PreferHttpHost()
    * @see ApplicationFramework::RootUrlOverride()
    */
    public static function BaseUrl()
    {
        $BaseUrl = self::RootUrl().dirname($_SERVER["SCRIPT_NAME"]);
        if (substr($BaseUrl, -1) != "/") {  $BaseUrl .= "/";  }
        return $BaseUrl;
    }

    /**
    * Get current full URL, before any clean URL remapping and with any query
    * string (e.g. http://foobar.com/path/index.php?A=123&B=456).
    * @return string Full URL.
    * @see ApplicationFramework::PreferHttpHost()
    * @see ApplicationFramework::RootUrlOverride()
    */
    public static function FullUrl()
    {
        return self::RootUrl().$_SERVER["REQUEST_URI"];
    }

    /**
    * Get/set whether to prefer $_SERVER["HTTP_HOST"] (if available) over
    * $_SERVER["SERVER_NAME"] when determining the current URL.  The default
    * is FALSE.
    * @param bool $NewValue TRUE to prefer HTTP_HOST, or FALSE to prefer SERVER_NAME.
    * @return bool TRUE if HTTP_HOST is currently preferred, otherwise FALSE.
    * @see ApplicationFramework::RootUrl()
    * @see ApplicationFramework::BaseUrl()
    * @see ApplicationFramework::FullUrl()
    */
    public static function PreferHttpHost($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            self::$PreferHttpHost = ($NewValue ? TRUE : FALSE);
        }
        return self::$PreferHttpHost;
    }

    /**
    * Get current base path (usually the part after the host name).
    * @return Base path string with trailing slash.
    */
    public static function BasePath()
    {
        $BasePath = dirname($_SERVER["SCRIPT_NAME"]);

        if (substr($BasePath, -1) != "/")
        {
            $BasePath .= "/";
        }

        return $BasePath;
    }

    /**
    * Retrieve SCRIPT_URL server value, pulling it from elsewhere if that
    * variable isn't set.
    * @return SCRIPT_URL value or NULL if unable to determine value.
    */
    public static function GetScriptUrl()
    {
        if (array_key_exists("SCRIPT_URL", $_SERVER))
        {
            return $_SERVER["SCRIPT_URL"];
        }
        elseif (array_key_exists("REQUEST_URI", $_SERVER))
        {
            $Pieces = parse_url($_SERVER["REQUEST_URI"]);
            return isset($Pieces["path"]) ? $Pieces["path"] : NULL;
        }
        elseif (array_key_exists("REDIRECT_URL", $_SERVER))
        {
            return $_SERVER["REDIRECT_URL"];
        }
        else
        {
            return NULL;
        }
    }

    /**
    * Determine if the URL was rewritten, i.e., the script is being accessed
    * through a URL that isn't directly accessing the file the script is in.
    * This is not equivalent to determining whether a clean URL is set up for
    * the URL.
    * @param string $ScriptName The file name of the running script.
    * @return Returns TRUE if the URL was rewritten and FALSE if not.
    */
    public static function WasUrlRewritten($ScriptName="index.php")
    {
        # needed to get the path of the URL minus the query and fragment pieces
        $Components = parse_url(self::GetScriptUrl());

        # if parsing was successful and a path is set
        if (is_array($Components) && isset($Components["path"]))
        {
            $BasePath = self::BasePath();
            $Path = $Components["path"];

            # the URL was rewritten if the path isn't the base path, i.e., the
            # home page, and the file in the URL isn't the script generating the
            # page
            if ($BasePath != $Path && basename($Path) != $ScriptName)
            {
                return TRUE;
            }
        }

        # the URL wasn't rewritten
        return FALSE;
    }

    /**
    * Get current amount of free memory.  The value returned is a "best
    * guess" based on reported memory usage.
    * @return Number of bytes.
    */
    public static function GetFreeMemory()
    {
        return self::GetPhpMemoryLimit() - memory_get_usage();
    }

    /**
    * Get PHP memory limit in bytes.  This is necessary because the PHP
    * configuration setting can be in "shorthand" (e.g. "16M").
    * @return int PHP memory limit in bytes.
    */
    public static function GetPhpMemoryLimit()
    {
        $Str = strtoupper(ini_get("memory_limit"));
        if (substr($Str, -1) == "B") {  $Str = substr($Str, 0, strlen($Str) - 1);  }
        switch (substr($Str, -1))
        {
            case "K":
                $MemoryLimit = (int)$Str * 1024;
                break;

            case "M":
                $MemoryLimit = (int)$Str * 1048576;
                break;

            case "G":
                $MemoryLimit = (int)$Str * 1073741824;
                break;

            default:
                $MemoryLimit = (int)$Str;
                break;
        }
        return $MemoryLimit;
    }

    /**
    * Get/set maximum PHP execution time.  Setting a new value is not possible
    * if PHP is running in safe mode.  Note that this method returns the actual
    * maximum execution time as currently understood by PHP, which could be
    * different from the saved ApplicationFramework setting.
    * @param int $NewValue New setting for max execution time in seconds.  (OPTIONAL,
    *       but minimum value is 5 if specified)
    * @param bool $Persistent If TRUE the new value will be saved (i.e.
    *       persistent across page loads), otherwise the value will apply to
    *       just the current page load.  (OPTIONAL, defaults to FALSE)
    * @return Current max execution time in seconds.
    */
    public function MaxExecutionTime($NewValue = DB_NOVALUE, $Persistent = FALSE)
    {
        if ($NewValue !== DB_NOVALUE)
        {
            $NewValue = max($NewValue, 5);
            ini_set("max_execution_time", $NewValue);
            set_time_limit($NewValue - $this->GetElapsedExecutionTime());
            $this->UpdateSetting("MaxExecTime", $NewValue, $Persistent);
        }
        return ini_get("max_execution_time");
    }

    /*@)*/ /* Server Environment */


    # ---- Utility -----------------------------------------------------------

    /** @name Utility */ /*@(*/

    /**
    * Send specified file for download by user.  This method takes care of
    * setting up the headers and suppressing further output, and is normally
    * called from within the page file.
    * @param string $FilePath Full path to file.
    * @param string $FileName Name of file.  If not supplied, the name will
    *       be taken from the file path.  (OPTIONAL)
    * @param string $MimeType MIME type of file.  If not supplied, an attempt
    *       will be made to determine the MIME type.  (OPTIONAL)
    * @return bool TRUE if no issues were detected, otherwise FALSE.
    */
    public function DownloadFile($FilePath, $FileName = NULL, $MimeType = NULL)
    {
        # check that file is readable
        if (!is_readable($FilePath))
        {
            return FALSE;
        }

        # if file name was not supplied
        if ($FileName === NULL)
        {
            # extract file name from path
            $FileName = basename($FilePath);
        }

        # if MIME type was not supplied
        if ($MimeType === NULL)
        {
            # attempt to determine MIME type
            $FInfoHandle = finfo_open(FILEINFO_MIME);
            if ($FInfoHandle)
            {
                $FInfoMime = finfo_file($FInfoHandle, $FilePath);
                finfo_close($FInfoHandle);
                if ($FInfoMime)
                {
                    $MimeType = $FInfoMime;
                }
            }

            # use default if unable to determine MIME type
            if ($MimeType === NULL)
            {
                $MimeType = "application/octet-stream";
            }
        }

        # set headers to download file
        header("Content-Type: ".$MimeType);
        header("Content-Length: ".filesize($FilePath));
        if ($this->CleanUrlRewritePerformed)
        {
            header('Content-Disposition: attachment; filename="'.$FileName.'"');
        }

        # make sure that apache does not attempt to compress file
        apache_setenv('no-gzip', '1');

        # send file to user, but unbuffered to avoid memory issues
        $this->AddUnbufferedCallback(function ($File)
        {
            $BlockSize = 512000;
            $Handle = @fopen($File, "rb");
            if ($Handle === FALSE)
            {
                return;
            }
            while (!feof($Handle))
            {
                print fread($Handle, $BlockSize);
                flush();
            }
            fclose($Handle);
        }, array($FilePath));

        # prevent HTML output that might interfere with download
        $this->SuppressHTMLOutput();

        # set flag to indicate not to log a slow page load in case client
        #       connection delays PHP execution because of header
        $this->DoNotLogSlowPageLoad = TRUE;

        # report no errors found to caller
        return TRUE;
    }

    /**
    * Get an exclusive ("write") lock on the specified name.  If the
    * maximum PHP execution time is being modified in proximity to
    * obtaining a lock (e.g. because a task will take longer than typical),
    * that should be done before calling GetLock().
    * @param string $LockName Name of lock.
    * @param bool $Wait If TRUE, method will not return until a lock has
    *       been obtained.  (optional, defaults to TRUE)
    * @return bool TRUE if lock was obtained, otherwise FALSE.
    * @see ApplicationFramework::ReleaseLock()
    * @see ApplicationFramework::MaxExecutionTime()
    */
    public function GetLock($LockName, $Wait = TRUE)
    {
        # assume we will not get a lock
        $GotLock = FALSE;

        # clear out any stale locks
        static $CleanupHasBeenDone = FALSE;
        if (!$CleanupHasBeenDone)
        {
            # (margin for clearing stale locks is twice the known
            #       maximum PHP execution time, because the max time
            #       techinically does not include external operations
            #       like database queries)
            $ClearLocksObtainedBefore = date(StdLib::SQL_DATE_FORMAT,
                    (time() - ($this->MaxExecutionTime() * 2)));
            $this->DB->Query("DELETE FROM AF_Locks WHERE"
                    ." ObtainedAt < '".$ClearLocksObtainedBefore."' AND"
                    ." LockName = '".addslashes($LockName)."'");
        }

        do
        {
            # lock database table so nobody else can try to get a lock
            $this->DB->Query("LOCK TABLES AF_Locks WRITE");

            # look for lock with specified name
            $FoundCount = $this->DB->Query("SELECT COUNT(*) AS FoundCount"
                    ." FROM AF_Locks WHERE LockName = '"
                    .addslashes($LockName)."'", "FoundCount");
            $LockFound = ($FoundCount > 0) ? TRUE : FALSE;

            # if lock found
            if ($LockFound)
            {
                # unlock database tables
                $this->DB->Query("UNLOCK TABLES");

                # if blocking was requested
                if ($Wait)
                {
                    # wait to give someone else a chance to release lock
                    sleep(2);
                }
            }
        // @codingStandardsIgnoreStart
        // (because phpcs does not correctly handle do-while loops)
        # while lock was found and blocking was requested
        } while ($LockFound && $Wait);
        // @codingStandardsIgnoreEnd

        # if lock was not found
        if (!$LockFound)
        {
            # get our lock
            $this->DB->Query("INSERT INTO AF_Locks (LockName) VALUES ('"
                    .addslashes($LockName)."')");
            $GotLock = TRUE;

            # unlock database tables
            $this->DB->Query("UNLOCK TABLES");
        }

        # report to caller whether lock was obtained
        return $GotLock;
    }

    /**
    * Release lock with specified name.
    * @param string $LockName Name of lock.
    * @return bool TRUE if an existing lock was released, or FALSE if no lock
    *       with specified name was found.
    * @see ApplicationFramework::GetLock()
    */
    public function ReleaseLock($LockName)
    {
        # release any existing locks
        $this->DB->Query("DELETE FROM AF_Locks WHERE LockName = '"
                .addslashes($LockName)."'");

        # report to caller whether existing lock was released
        return $this->DB->NumRowsAffected() ? TRUE : FALSE;
    }

    /*@)*/ /* Utility */


    # ---- Backward Compatibility --------------------------------------------

    /** @name Backward Compatibility */ /*@(*/

    /**
    * Preserved for backward compatibility for use with code written prior
    * to October 2012.
    * @param string $BaseName Base name of template.
    * @return string Result of search.
    */
    public function FindCommonTemplate($BaseName)
    {
        return $this->FindFile(
                $this->IncludeDirList, $BaseName, array("tpl", "html"));
    }

    /*@)*/ /* Backward Compatibility */


    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $AdditionalRequiredUIFiles = array();
    private $BackgroundTaskMemLeakLogThreshold = 10;    # percentage of max mem
    private $BackgroundTaskMinFreeMemPercent = 25;
    private $BrowserDetectFunc;
    private $CacheCurrentPage = TRUE;
    private $AlternateDomainPrefixes = array();
    private $CleanUrlMappings = array();
    private $CleanUrlRewritePerformed = FALSE;
    private $CssUrlFingerprintPath;
    private $DB;
    private $DefaultPage = "Home";
    private $DoNotMinimizeList = array();
    private $DoNotLogSlowPageLoad = FALSE;
    private $EnvIncludes = array();
    private $ExecutionStartTime;
    private $FoundUIFiles = array();
    private $HtmlCharset = "UTF-8";
    private $JSMinimizerJavaScriptPackerAvailable = FALSE;
    private $JSMinimizerJShrinkAvailable = TRUE;
    private $JumpToPage = NULL;
    private $JumpToPageDelay = 0;
    private $LogFileName = "local/logs/site.log";
    private $MaxRunningTasksToTrack = 250;
    private $OutputModificationCallbackInfo;
    private $OutputModificationCallbacks = array();
    private $OutputModificationPatterns = array();
    private $OutputModificationReplacements = array();
    private $PageCacheTags = array();
    private $PageName;
    private $PostProcessingFuncs = array();
    private $RunningInBackground = FALSE;
    private $RunningTask;
    private $SavedContext;
    private $SaveTemplateLocationCache = FALSE;
    private $SessionStorage;
    private $SessionGcProbability;
    private $Settings;
    private $SuppressHTML = FALSE;
    private $TemplateLocationCache;
    private $TemplateLocationCacheInterval = 60;        # in minutes
    private $TemplateLocationCacheExpiration;
    private $UnbufferedCallbacks = array();
    private $UrlFingerprintBlacklist = array();
    private $UseBaseTag = FALSE;

    private static $ActiveUI = "default";
    private static $AppName = "ScoutAF";
    private static $DefaultUI = "default";
    private static $JSMinCacheDir = "local/data/caches/JSMin";
    private static $ObjectDirectories = array();
    private static $ObjectLocationCache;
    private static $ObjectLocationCacheInterval = 60;
    private static $ObjectLocationCacheExpiration;
    private static $PreferHttpHost = FALSE;
    private static $RootUrlOverride = NULL;
    private static $SaveObjectLocationCache = FALSE;
    private static $ScssCacheDir = "local/data/caches/SCSS";
    private static $SessionLifetime = 1440;             # in seconds

    # offset used to generate page cache tag IDs from numeric tags
    const PAGECACHETAGIDOFFSET = 100000;

    # minimum expired session garbage collection probability
    const MIN_GC_PROBABILITY = 0.01;

    /**
    * Set to TRUE to not close browser connection before running
    *       background tasks (useful when debugging)
    */
    private $NoTSR = FALSE;

    private $KnownPeriodicEvents = array();
    private $PeriodicEvents = array(
                "EVENT_HOURLY" => self::EVENTTYPE_DEFAULT,
                "EVENT_DAILY" => self::EVENTTYPE_DEFAULT,
                "EVENT_WEEKLY" => self::EVENTTYPE_DEFAULT,
                "EVENT_MONTHLY" => self::EVENTTYPE_DEFAULT,
                "EVENT_PERIODIC" => self::EVENTTYPE_NAMED,
                );
    private $EventPeriods = array(
                "EVENT_HOURLY" => 3600,
                "EVENT_DAILY" => 86400,
                "EVENT_WEEKLY" => 604800,
                "EVENT_MONTHLY" => 2592000,
                "EVENT_PERIODIC" => 0,
                );
    private $UIEvents = array(
                "EVENT_PAGE_LOAD" => self::EVENTTYPE_DEFAULT,
                "EVENT_PHP_FILE_LOAD" => self::EVENTTYPE_CHAIN,
                "EVENT_PHP_FILE_LOAD_COMPLETE" => self::EVENTTYPE_DEFAULT,
                "EVENT_HTML_FILE_LOAD" => self::EVENTTYPE_CHAIN,
                "EVENT_HTML_FILE_LOAD_COMPLETE" => self::EVENTTYPE_DEFAULT,
                "EVENT_PAGE_OUTPUT_FILTER" => self::EVENTTYPE_CHAIN,
                );

    /**
    * Load our settings from database, initializing them if needed.
    * @throws Exception If unable to load settings.
    */
    private function LoadSettings()
    {
        # read settings in from database
        $this->DB->Query("SELECT * FROM ApplicationFrameworkSettings");
        $this->Settings = $this->DB->FetchRow();

        # if settings were not previously initialized
        if ($this->Settings === FALSE)
        {
            # initialize settings in database
            $this->DB->Query("INSERT INTO ApplicationFrameworkSettings"
                    ." (LastTaskRunAt) VALUES ('2000-01-02 03:04:05')");

            # read new settings in from database
            $this->DB->Query("SELECT * FROM ApplicationFrameworkSettings");
            $this->Settings = $this->DB->FetchRow();

            # bail out if reloading new settings failed
            if ($this->Settings === FALSE)
            {
                throw new Exception(
                        "Unable to load application framework settings.");
            }
        }

        # if base path was not previously set or we appear to have moved
        if (!array_key_exists("BasePath", $this->Settings)
                || (!strlen($this->Settings["BasePath"]))
                || (!array_key_exists("BasePathCheck", $this->Settings))
                || (__FILE__ != $this->Settings["BasePathCheck"]))
        {
            # attempt to extract base path from Apache .htaccess file
            if (is_readable(".htaccess"))
            {
                $Lines = file(".htaccess");
                foreach ($Lines as $Line)
                {
                    if (preg_match("/\\s*RewriteBase\\s+/", $Line))
                    {
                        $Pieces = preg_split(
                                "/\\s+/", $Line, NULL, PREG_SPLIT_NO_EMPTY);
                        $BasePath = $Pieces[1];
                    }
                }
            }

            # if base path was found
            if (isset($BasePath))
            {
                # save base path locally
                $this->Settings["BasePath"] = $BasePath;

                # save base path to database
                $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                        ." SET BasePath = '".addslashes($BasePath)."'"
                        .", BasePathCheck = '".addslashes(__FILE__)."'");
            }
        }

        # retrieve template location cache
        $this->TemplateLocationCache = unserialize(
                $this->Settings["TemplateLocationCache"]);
        $this->TemplateLocationCacheInterval =
                $this->Settings["TemplateLocationCacheInterval"];
        $this->TemplateLocationCacheExpiration =
                strtotime($this->Settings["TemplateLocationCacheExpiration"]);

        # if template location cache looks invalid or has expired
        $CurrentTime = time();
        if (!count($this->TemplateLocationCache)
                || ($CurrentTime >= $this->TemplateLocationCacheExpiration))
        {
            # clear cache and reset cache expiration
            $this->TemplateLocationCache = array();
            $this->TemplateLocationCacheExpiration =
                    $CurrentTime + ($this->TemplateLocationCacheInterval * 60);
            $this->SaveTemplateLocationCache = TRUE;
        }

        # retrieve object location cache
        self::$ObjectLocationCache =
                unserialize($this->Settings["ObjectLocationCache"]);
        self::$ObjectLocationCacheInterval =
                $this->Settings["ObjectLocationCacheInterval"];
        self::$ObjectLocationCacheExpiration =
                strtotime($this->Settings["ObjectLocationCacheExpiration"]);

        # if object location cache looks invalid or has expired
        if (!count(self::$ObjectLocationCache)
                || ($CurrentTime >= self::$ObjectLocationCacheExpiration))
        {
            # clear cache and reset cache expiration
            self::$ObjectLocationCache = array();
            self::$ObjectLocationCacheExpiration =
                    $CurrentTime + (self::$ObjectLocationCacheInterval * 60);
            self::$SaveObjectLocationCache = TRUE;
        }
    }

    /**
    * Perform any page redirects or $_GET value settings resulting from
    * clean URL mappings.
    * @param string $PageName Starting page name.
    * @return string Page name after any clean URL mappings.
    */
    private function RewriteCleanUrls($PageName)
    {
        # if URL rewriting is supported by the server
        if ($this->HtaccessSupport())
        {
            # retrieve current URL and remove base path if present
            $Url = $this->GetPageLocation();

            # for each clean URL mapping
            foreach ($this->CleanUrlMappings as $Info)
            {
                # if current URL matches clean URL pattern
                if (preg_match($Info["Pattern"], $Url, $Matches))
                {
                    # set new page
                    $PageName = $Info["Page"];

                    # if $_GET variables specified for clean URL
                    if ($Info["GetVars"] !== NULL)
                    {
                        # for each $_GET variable specified for clean URL
                        foreach ($Info["GetVars"] as $VarName => $VarTemplate)
                        {
                            # start with template for variable value
                            $Value = $VarTemplate;

                            # for each subpattern matched in current URL
                            foreach ($Matches as $Index => $Match)
                            {
                                # if not first (whole) match
                                if ($Index > 0)
                                {
                                    # make any substitutions in template
                                    $Value = str_replace("$".$Index, $Match, $Value);
                                }
                            }

                            # set $_GET variable
                            $_GET[$VarName] = $Value;
                        }
                    }

                    # set flag indicating clean URL mapped
                    $this->CleanUrlRewritePerformed = TRUE;

                    # stop looking for a mapping
                    break;
                }
            }
        }

        # return (possibly) updated page name to caller
        return $PageName;
    }

    /**
    * Check if alternate domains are configured and if this page was
    * accessed via an alternate domain, rewriting URLs in the page
    * output to refer to the correct domain.  Any URLs that live within
    * the path subtree configured for our alternate domain will be
    * converted to absolute URLs under that domain.  Any URLs that live
    * outside the path subtree for our alternate domain (e.g., css
    * files) will be converted to absolute URLs under our primary
    * domain.
    * @param string $Html Input HTML.
    * @return possibly modified HTML.
    */
    private function RewriteAlternateDomainUrls($Html)
    {
        if ($this->HtaccessSupport() &&
            self::$RootUrlOverride !== NULL)
        {
            $VHost = $_SERVER["SERVER_NAME"];
            if (isset($this->AlternateDomainPrefixes[$VHost]))
            {
                $ThisPrefix = $this->AlternateDomainPrefixes[$VHost];

                # get the URL for the primary domain
                $RootUrl = $this->RootUrl()."/";

                # and figure out what protcol we were using
                $Protocol = (isset($_SERVER["HTTPS"]) ? "https" : "http");

                # convert all relative URLs absolute URLs within our
                # primary domain, then substitute in the alternate domain
                # for paths inside our configured prefix
                $RelativePathPatterns = array(
                    "%src=\"(?!http://|https://)%i",
                    "%src='(?!http://|https://)%i",
                    "%href=\"(?!http://|https://)%i",
                    "%href='(?!http://|https://)%i",
                    "%action=\"(?!http://|https://)%i",
                    "%action='(?!http://|https://)%i",
                    "%@import\s+url\(\"(?!http://|https://)%i",
                    "%@import\s+url\('(?!http://|https://)%i",
                    "%src:\s+url\(\"(?!http://|https://)%i",
                    "%src:\s+url\('(?!http://|https://)%i",
                    "%@import\s+\"(?!http://|https://)%i",
                    "%@import\s+'(?!http://|https://)%i",
                    "%".preg_quote($RootUrl.$ThisPrefix."/", "%")."%",
                    );
                $RelativePathReplacements = array(
                    "src=\"".$RootUrl,
                    "src='".$RootUrl,
                    "href=\"".$RootUrl,
                    "href='".$RootUrl,
                    "action=\"".$RootUrl,
                    "action='".$RootUrl,
                    "@import url(\"".$RootUrl,
                    "@import url('".$RootUrl,
                    "src: url(\"".$RootUrl,
                    "src: url('".$RootUrl,
                    "@import \"".$RootUrl,
                    "@import '".$RootUrl,
                    $Protocol."://".$VHost."/",
                    );

                $NewHtml = preg_replace(
                    $RelativePathPatterns,
                    $RelativePathReplacements,
                    $Html);

                # check to make sure relative path fixes didn't fail
                $Html = $this->CheckOutputModification(
                        $Html, $NewHtml,
                        "alternate domain substitutions");
            }
        }

        return $Html;
    }

    /**
    * Look for template file in supplied list of possible locations,
    * including the currently active UI in the location path where
    * indicated.  Locations are read from a cache, which is discarded
    * when the cache expiration time is reached.  If updated, the cache
    * is saved to the database in __destruct().
    * @param array $DirectoryList Array of directories (or array of arrays
    *       of directories) to search.  Directories must include a
    *       trailing slash.
    * @param string $BaseName File name or file name base.
    * @param array $PossibleSuffixes Array with possible suffixes for file
    *       name, if no suffix evident.  (Suffixes should not include
    *       a leading period.)  (OPTIONAL)
    * @param array $PossiblePrefixes Array with possible prefixes for file to
    *       check.  (OPTIONAL)
    * @return string File name with leading relative path or NULL if no
    *       matching file found.
    */
    private function FindFile($DirectoryList, $BaseName,
            $PossibleSuffixes = NULL, $PossiblePrefixes = NULL)
    {
        # generate template cache index for this page
        $CacheIndex = md5(serialize($DirectoryList))
                .":".self::$ActiveUI.":".$BaseName;

        # if caching is enabled and we have cached location
        if (($this->TemplateLocationCacheInterval > 0)
                && array_key_exists($CacheIndex,
                        $this->TemplateLocationCache))
        {
            # use template location from cache
            $FoundFileName = $this->TemplateLocationCache[$CacheIndex];
        }
        else
        {
            # if suffixes specified and base name does not include suffix
            if (count($PossibleSuffixes)
                    && !preg_match("/\.[a-zA-Z0-9]+$/", $BaseName))
            {
                # add versions of file names with suffixes to file name list
                $FileNames = array();
                foreach ($PossibleSuffixes as $Suffix)
                {
                    $FileNames[] = $BaseName.".".$Suffix;
                }
            }
            else
            {
                # use base name as file name
                $FileNames = array($BaseName);
            }

            # if prefixes specified
            if (count($PossiblePrefixes))
            {
                # add versions of file names with prefixes to file name list
                $NewFileNames = array();
                foreach ($FileNames as $FileName)
                {
                    foreach ($PossiblePrefixes as $Prefix)
                    {
                        $NewFileNames[] = $Prefix.$FileName;
                    }
                }
                $FileNames = $NewFileNames;
            }

            # for each possible location
            $FoundFileName = NULL;
            foreach ($DirectoryList as $Dir)
            {
                # substitute active or default UI name into path
                $Dir = str_replace(array("%ACTIVEUI%", "%DEFAULTUI%"),
                        array(self::$ActiveUI, self::$DefaultUI), $Dir);

                # for each possible file name
                foreach ($FileNames as $File)
                {
                    # if template is found at location
                    if (file_exists($Dir.$File))
                    {
                        # save full template file name and stop looking
                        $FoundFileName = $Dir.$File;
                        break 2;
                    }
                }
            }

            # save location in cache
            $this->TemplateLocationCache[$CacheIndex]
                    = $FoundFileName;

            # set flag indicating that cache should be saved
            $this->SaveTemplateLocationCache = TRUE;
        }

        # return full template file name to caller
        return $FoundFileName;
    }

    /**
    * Compile SCSS file (if updated) to cache directory and return path to
    * resulting CSS file to caller.
    * @param string $SrcFile SCSS file name with leading path.
    * @return string Compiled CSS file with leading path or NULL if compile
    *       failed or compiled CSS file could not be written.
    * @see ApplicationFramework::CssUrlFingerprintInsertion()
    */
    private function CompileScssFile($SrcFile)
    {
        # build path to CSS file
        $DstFile = self::$ScssCacheDir."/".dirname($SrcFile)
                ."/".basename($SrcFile);
        $DstFile = substr_replace($DstFile, "css", -4);

        # if SCSS file is newer than CSS file
        if (!file_exists($DstFile)
                || (filemtime($SrcFile) > filemtime($DstFile)))
        {
            # attempt to create CSS cache subdirectory if not present
            if (!is_dir(dirname($DstFile)))
            {
                @mkdir(dirname($DstFile), 0777, TRUE);
            }

            # if CSS cache directory and CSS file path appear writable
            static $CacheDirIsWritable;
            if (!isset($CacheDirIsWritable))
                    {  $CacheDirIsWritable = is_writable(self::$ScssCacheDir);  }
            if (is_writable($DstFile)
                    || (!file_exists($DstFile) && $CacheDirIsWritable))
            {
                # load SCSS and compile to CSS
                $ScssCode = file_get_contents($SrcFile);
                $ScssCompiler = new scssc();
                $ScssCompiler->setFormatter($this->GenerateCompactCss()
                        ? "scss_formatter_compressed" : "scss_formatter");
                try
                {
                    $CssCode = $ScssCompiler->compile($ScssCode);

                    # add fingerprinting for URLs in CSS
                    $this->CssUrlFingerprintPath = dirname($SrcFile);
                    $CssCode = preg_replace_callback(
                            "/url\((['\"]*)(.+)\.([a-z]+)(['\"]*)\)/",
                            array($this, "CssUrlFingerprintInsertion"),
                            $CssCode);

                    # strip out comments from CSS (if requested)
                    if ($this->GenerateCompactCss())
                    {
                        $CssCode = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!',
                                '', $CssCode);
                    }

                    # write out CSS file
                    file_put_contents($DstFile, $CssCode);
                }
                catch (Exception $Ex)
                {
                    $this->LogError(self::LOGLVL_ERROR,
                            "Error compiling SCSS file ".$SrcFile.": "
                                    .$Ex->getMessage());
                    $DstFile = NULL;
                }
            }
            else
            {
                # log error and set CSS file path to indicate failure
                $this->LogError(self::LOGLVL_ERROR,
                        "Unable to write out CSS file (compiled from SCSS) to "
                        .$DstFile);
                $DstFile = NULL;
            }
        }

        # return CSS file path to caller
        return $DstFile;
    }

    /**
    * Minimize JavaScript file (if updated) to cache directory and return
    * path to resulting minimized file to caller.
    * @param string $SrcFile JavaScript file name with leading path.
    * @return string Minimized JavaScript file with leading path or NULL if
    *       minimization failed or minimized file could not be written.
    */
    private function MinimizeJavascriptFile($SrcFile)
    {
        # bail out if file is on exclusion list
        foreach ($this->DoNotMinimizeList as $DNMFile)
        {
            if (($SrcFile == $DNMFile) || (basename($SrcFile) == $DNMFile))
            {
                return NULL;
            }
        }

        # build path to minimized file
        $DstFile = self::$JSMinCacheDir."/".dirname($SrcFile)
                ."/".basename($SrcFile);
        $DstFile = substr_replace($DstFile, ".min", -3, 0);

        # if original file is newer than minimized file
        if (!file_exists($DstFile)
                || (filemtime($SrcFile) > filemtime($DstFile)))
        {
            # attempt to create cache subdirectory if not present
            if (!is_dir(dirname($DstFile)))
            {
                @mkdir(dirname($DstFile), 0777, TRUE);
            }

            # if cache directory and minimized file path appear writable
            static $CacheDirIsWritable;
            if (!isset($CacheDirIsWritable))
                    {  $CacheDirIsWritable = is_writable(self::$JSMinCacheDir);  }
            if (is_writable($DstFile)
                    || (!file_exists($DstFile) && $CacheDirIsWritable))
            {
                # load JavaScript code
                $Code = file_get_contents($SrcFile);

                # decide which minimizer to use
                if ($this->JSMinimizerJavaScriptPackerAvailable
                        && $this->JSMinimizerJShrinkAvailable)
                {
                    $Minimizer = (strlen($Code) < 5000)
                            ? "JShrink" : "JavaScriptPacker";
                }
                elseif ($this->JSMinimizerJShrinkAvailable)
                {
                    $Minimizer = "JShrink";
                }
                else
                {
                    $Minimizer = "NONE";
                }

                # minimize code
                switch ($Minimizer)
                {
                    case "JavaScriptMinimizer":
                        $Packer = new JavaScriptPacker($Code, "Normal");
                        $MinimizedCode = $Packer->pack();
                        break;

                    case "JShrink":
                        try
                        {
                            $MinimizedCode = \JShrink\Minifier::minify($Code);
                        }
                        catch (Exception $Exception)
                        {
                            unset($MinimizedCode);
                            $MinimizeError = $Exception->getMessage();
                        }
                        break;
                }

                # if minimization succeeded
                if (isset($MinimizedCode))
                {
                    # write out minimized file
                    file_put_contents($DstFile, $MinimizedCode);
                }
                else
                {
                    # log error and set destination file path to indicate failure
                    $ErrMsg = "Unable to minimize JavaScript file ".$SrcFile;
                    if (isset($MinimizeError))
                    {
                        $ErrMsg .= " (".$MinimizeError.")";
                    }
                    $this->LogError(self::LOGLVL_ERROR, $ErrMsg);
                    $DstFile = NULL;
                }
            }
            else
            {
                # log error and set destination file path to indicate failure
                $this->LogError(self::LOGLVL_ERROR,
                        "Unable to write out minimized JavaScript to file ".$DstFile);
                $DstFile = NULL;
            }
        }

        # return CSS file path to caller
        return $DstFile;
    }

    /**
    * Insert fingerprint string in file name in URL within CSS.  This is
    * intended to be called via preg_replace_callback().
    * @param array $Matches Array of strings matching patterns.
    * @return string URL string with fingerprint inserted.
    * @see ApplicationFramework::CompileScssFile()
    */
    private function CssUrlFingerprintInsertion($Matches)
    {
        # generate fingerprint string from CSS file modification time
        $FileName = realpath($this->CssUrlFingerprintPath."/".
                $Matches[2].".".$Matches[3]);
        $MTime = filemtime($FileName);
        $Fingerprint = sprintf("%06X", ($MTime % 0xFFFFFF));

        # build URL string with fingerprint and return it to caller
        return "url(".$Matches[1].$Matches[2].".".$Fingerprint
                .".".$Matches[3].$Matches[4].")";
    }

    /**
    * Figure out which required UI files have not yet been loaded for specified
    * page content file.
    * @param string $PageContentFile Page content file.
    * @return array Array of names of required files (without paths).
    */
    private function GetRequiredFilesNotYetLoaded($PageContentFile)
    {
        # start out assuming no files required
        $RequiredFiles = array();

        # if page content file supplied
        if ($PageContentFile)
        {
            # if file containing list of required files is available
            $Path = dirname($PageContentFile);
            $RequireListFile = $Path."/REQUIRES";
            if (file_exists($RequireListFile))
            {
                # read in list of required files
                $RequestedFiles = file($RequireListFile);

                # for each line in required file list
                foreach ($RequestedFiles as $Line)
                {
                    # if line is not a comment
                    $Line = trim($Line);
                    if (!preg_match("/^#/", $Line))
                    {
                        # if file has not already been loaded
                        if (!in_array($Line, $this->FoundUIFiles))
                        {
                            # add to list of required files
                            $RequiredFiles[] = $Line;
                        }
                    }
                }
            }
        }

        # add in additional required files if any
        if (count($this->AdditionalRequiredUIFiles))
        {
            # make sure there are no duplicates
            $AdditionalRequiredUIFiles = array_unique(
                $this->AdditionalRequiredUIFiles);

            $RequiredFiles = array_merge(
                    $RequiredFiles, $AdditionalRequiredUIFiles);
        }

        # return list of required files to caller
        return $RequiredFiles;
    }

    /**
    * Get HTML tag for loading specified CSS or JavaScript file.  If the
    * type of the specified file is unknown or unsupported, an empty string
    * is returned.
    * @param string $FileName UI file name, including leading path.
    * @param string $AdditionalAttributes Any additional attributes that
    *       should be included in HTML tag.  (OPTIONAL)
    * @return string Tag to load file, or empty string if file type was unknown
    *       or unsupported.
    */
    private function GetUIFileLoadingTag($FileName, $AdditionalAttributes = NULL)
    {
        # pad additional attributes if supplied
        $AddAttribs = $AdditionalAttributes ? " ".$AdditionalAttributes : "";

        # retrieve type of UI file
        $FileType = $this->GetFileType($FileName);

        # construct tag based on file type
        switch ($FileType)
        {
            case self::FT_CSS:
                $Tag = "<link rel=\"stylesheet\" type=\"text/css\""
                        ." media=\"all\" href=\"".$FileName."\""
                        .$AddAttribs." />";
                break;

            case self::FT_JAVASCRIPT:
                $Tag = "<script type=\"text/javascript\""
                        ." src=\"".$FileName."\""
                        .$AddAttribs."></script>";
                break;

            default:
                $Tag = "";
                break;
        }

        # return constructed tag to caller
        return $Tag;
    }

    /**
    * Load object file for specified class.
    * @param string $ClassName Name of class.
    */
    private function AutoloadObjects($ClassName)
    {
        # if caching is not turned off
        #       and we have a cached location for class
        #       and file at cached location is readable
        if ((self::$ObjectLocationCacheInterval > 0)
                && array_key_exists($ClassName,
                        self::$ObjectLocationCache)
                && is_readable(self::$ObjectLocationCache[$ClassName]))
        {
            # use object location from cache
            require_once(self::$ObjectLocationCache[$ClassName]);
        }
        else
        {
            # convert any namespace separators in class name
            $ClassName = str_replace("\\", "-", $ClassName);

            # for each possible object file directory
            static $FileLists;
            foreach (self::$ObjectDirectories as $Location => $Info)
            {
                # make any needed replacements in directory path
                $Location = str_replace(array("%ACTIVEUI%", "%DEFAULTUI%"),
                        array(self::$ActiveUI, self::$DefaultUI), $Location);

                # if directory looks valid
                if (is_dir($Location))
                {
                    # build class file name
                    $NewClassName = ($Info["ClassPattern"] && $Info["ClassReplacement"])
                            ? preg_replace($Info["ClassPattern"],
                                    $Info["ClassReplacement"], $ClassName)
                            : $ClassName;

                    # read in directory contents if not already retrieved
                    if (!isset($FileLists[$Location]))
                    {
                        $FileLists[$Location] = self::ReadDirectoryTree(
                                $Location, '/^.+\.php$/i');
                    }

                    # for each file in target directory
                    $FileNames = $FileLists[$Location];
                    $TargetName = strtolower($Info["Prefix"].$NewClassName.".php");
                    foreach ($FileNames as $FileName)
                    {
                        # if file matches our target object file name
                        if (strtolower($FileName) == $TargetName)
                        {
                            # include object file
                            require_once($Location.$FileName);

                            # save location to cache
                            self::$ObjectLocationCache[$ClassName]
                                    = $Location.$FileName;

                            # set flag indicating that cache should be saved
                            self::$SaveObjectLocationCache = TRUE;

                            # stop looking
                            break 2;
                        }
                    }
                }
            }
        }
    }

    /**
    * Recursively read in list of file names matching pattern beginning at
    * specified directory.
    * @param string $Directory Directory at top of tree to search.
    * @param string $Pattern Regular expression pattern to match.
    * @return array Array containing names of matching files with relative paths.
    */
    private static function ReadDirectoryTree($Directory, $Pattern)
    {
        $CurrentDir = getcwd();
        chdir($Directory);
        $DirIter = new RecursiveDirectoryIterator(".");
        $IterIter = new RecursiveIteratorIterator($DirIter);
        $RegexResults = new RegexIterator($IterIter, $Pattern,
                RecursiveRegexIterator::GET_MATCH);
        $FileList = array();
        foreach ($RegexResults as $Result)
        {
            $FileList[] = substr($Result[0], 2);
        }
        chdir($CurrentDir);
        return $FileList;
    }

    /**
    * Turn off and undo PHP "magic quotes" as much as practical.
    */
    private function UndoMagicQuotes()
    {
        # if this PHP version has magic quotes support
        if (version_compare(PHP_VERSION, "5.4.0", "<"))
        {
            # turn off runtime magic quotes if on
            if (get_magic_quotes_runtime())
            {
                // @codingStandardsIgnoreStart
                set_magic_quotes_runtime(FALSE);
                // @codingStandardsIgnoreEnd
            }

            # if magic quotes GPC is on
            if (get_magic_quotes_gpc())
            {
                # strip added slashes from incoming variables
                $GPC = array(&$_GET, &$_POST, &$_COOKIE, &$_REQUEST);
                array_walk_recursive($GPC,
                        array($this, "UndoMagicQuotes_StripCallback"));
            }
        }
    }
    /**
    * Callback for use in removing PHP "magic quotes".
    * @param string $Value Value to strip.
    */
    private function UndoMagicQuotes_StripCallback(&$Value)
    {
        $Value = stripslashes($Value);
    }

    /**
    * Load any user interface functions available in interface include
    * directories (in F-FuncName.html or F-FuncName.php files).
    */
    private function LoadUIFunctions()
    {
        $Dirs = array(
                "local/interface/%ACTIVEUI%/include",
                "interface/%ACTIVEUI%/include",
                "local/interface/%DEFAULTUI%/include",
                "interface/%DEFAULTUI%/include",
                );
        foreach ($Dirs as $Dir)
        {
            $Dir = str_replace(array("%ACTIVEUI%", "%DEFAULTUI%"),
                    array(self::$ActiveUI, self::$DefaultUI), $Dir);
            if (is_dir($Dir))
            {
                $FileNames = scandir($Dir);
                foreach ($FileNames as $FileName)
                {
                    if (preg_match("/^F-([A-Za-z0-9_]+)\.php/",
                                    $FileName, $Matches)
                            || preg_match("/^F-([A-Za-z0-9_]+)\.html/",
                                    $FileName, $Matches))
                    {
                        if (!function_exists($Matches[1]))
                        {
                            include_once($Dir."/".$FileName);
                        }
                    }
                }
            }
        }
    }

    /**
    * Run periodic event if appropriate.
    * @param string $EventName Name of event.
    * @param callback $Callback Event callback.
    */
    private function ProcessPeriodicEvent($EventName, $Callback)
    {
        # retrieve last execution time for event if available
        $Signature = self::GetCallbackSignature($Callback);
        $LastRun = $this->DB->Query("SELECT LastRunAt FROM PeriodicEvents"
                ." WHERE Signature = '".addslashes($Signature)."'", "LastRunAt");

        # determine whether enough time has passed for event to execute
        $ShouldExecute = (($LastRun === NULL)
                || (time() > (strtotime($LastRun) + $this->EventPeriods[$EventName])))
                ? TRUE : FALSE;

        # if event should run
        if ($ShouldExecute)
        {
            # add event to task queue
            $WrapperCallback = array("ApplicationFramework", "PeriodicEventWrapper");
            $WrapperParameters = array(
                    $EventName, $Callback, array("LastRunAt" => $LastRun));
            $this->QueueUniqueTask($WrapperCallback, $WrapperParameters);
        }

        # add event to list of periodic events
        $this->KnownPeriodicEvents[$Signature] = array(
                "Period" => $EventName,
                "Callback" => $Callback,
                "Queued" => $ShouldExecute);
    }

    /**
    * Wrapper to run periodic events and then save info needed to know when to
    * run them again.
    * @param string $EventName Name of event.
    * @param callback $Callback Event callback.
    * @param array $Parameters Array of parameters to pass to event.
    */
    private static function PeriodicEventWrapper($EventName, $Callback, $Parameters)
    {
        static $DB;
        if (!isset($DB)) {  $DB = new Database();  }

        # run event
        $ReturnVal = call_user_func_array($Callback, $Parameters);

        # if event is already in database
        $Signature = self::GetCallbackSignature($Callback);
        if ($DB->Query("SELECT COUNT(*) AS EventCount FROM PeriodicEvents"
                ." WHERE Signature = '".addslashes($Signature)."'", "EventCount"))
        {
            # update last run time for event
            $DB->Query("UPDATE PeriodicEvents SET LastRunAt = "
                    .(($EventName == "EVENT_PERIODIC")
                            ? "'".date("Y-m-d H:i:s", time() + ($ReturnVal * 60))."'"
                            : "NOW()")
                    ." WHERE Signature = '".addslashes($Signature)."'");
        }
        else
        {
            # add last run time for event to database
            $DB->Query("INSERT INTO PeriodicEvents (Signature, LastRunAt) VALUES "
                    ."('".addslashes($Signature)."', "
                    .(($EventName == "EVENT_PERIODIC")
                            ? "'".date("Y-m-d H:i:s", time() + ($ReturnVal * 60))."'"
                            : "NOW()").")");
        }
    }

    /**
    * Generate and return signature for specified callback.
    * @param callback $Callback Callback to generate signature for.
    * @return string Signature string.
    */
    private static function GetCallbackSignature($Callback)
    {
        return !is_array($Callback) ? $Callback
                : (is_object($Callback[0]) ? md5(serialize($Callback[0])) : $Callback[0])
                        ."::".$Callback[1];
    }

    /**
    * Prepare environment for eventual background task execution.
    * @return bool TRUE if there are tasks to be run, otherwise FALSE.
    */
    private function PrepForTSR()
    {
        # if HTML has been output and it's time to launch another task
        # (only TSR if HTML has been output because otherwise browsers
        #       may misbehave after connection is closed)
        if ((PHP_SAPI != "cli")
                && ($this->JumpToPage || !$this->SuppressHTML)
                && (time() > (strtotime($this->Settings["LastTaskRunAt"])
                        + ($this->MaxExecutionTime()
                                / $this->Settings["MaxTasksRunning"]) + 5))
                && $this->GetTaskQueueSize()
                && $this->Settings["TaskExecutionEnabled"])
        {
            # begin buffering output for TSR
            ob_start();

            # let caller know it is time to launch another task
            return TRUE;
        }
        else
        {
            # let caller know it is not time to launch another task
            return FALSE;
        }
    }

    /**
    * Attempt to close out page loading with the browser and then execute
    * background tasks.
    */
    private function LaunchTSR()
    {
        # set headers to close out connection to browser
        if (!$this->NoTSR)
        {
            ignore_user_abort(TRUE);
            header("Connection: close");
            header("Content-Length: ".ob_get_length());
        }

        # output buffered content
        while (ob_get_level()) {  ob_end_flush();  }
        flush();

        # write out any outstanding data and end HTTP session
        session_write_close();

        # set flag indicating that we are now running in background
        $this->RunningInBackground = TRUE;

        # handle garbage collection for session data
        if (isset($this->SessionStorage) &&
            (rand()/getrandmax()) <= $this->SessionGcProbability)
        {
            # determine when sessions will expire
            $ExpiredTime = strtotime("-". self::$SessionLifetime." seconds");

            # iterate over files in the session directory with a DirectoryIterator
            # NB: we cannot use scandir() here because it reads the
            # entire list of files into memory and may exceed the memory
            # limit for directories with very many files
            $DI = new DirectoryIterator($this->SessionStorage);
            while ($DI->valid())
            {
                if ((strpos($DI->getFilename(), "sess_") === 0) &&
                   $DI->isFile() &&
                   $DI->getCTime() < $ExpiredTime)
                {
                    unlink($DI->getPathname());
                }
                $DI->next();
            }
        }

        # if there is still a task in the queue
        if ($this->GetTaskQueueSize())
        {
            # garbage collect to give as much memory as possible for tasks
            if (function_exists("gc_collect_cycles")) {  gc_collect_cycles();  }

            # turn on output buffering to (hopefully) record any crash output
            ob_start();

            # lock tables and grab last task run time to double check
            $this->DB->Query("LOCK TABLES ApplicationFrameworkSettings WRITE");
            $this->LoadSettings();

            # if still time to launch another task
            if (time() > (strtotime($this->Settings["LastTaskRunAt"])
                        + ($this->MaxExecutionTime()
                                / $this->Settings["MaxTasksRunning"]) + 5))
            {
                # update the "last run" time and release tables
                $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                        ." SET LastTaskRunAt = '".date("Y-m-d H:i:s")."'");
                $this->DB->Query("UNLOCK TABLES");

                # run tasks while there is a task in the queue
                #       and enough time and memory left
                do
                {
                    # run the next task
                    $this->RunNextTask();

                    # calculate percentage of memory still available
                    $PercentFreeMem = (self::GetFreeMemory()
                            / self::GetPhpMemoryLimit()) * 100;
                }
                while ($this->GetTaskQueueSize()
                        && ($this->GetSecondsBeforeTimeout() > 65)
                        && ($PercentFreeMem > $this->BackgroundTaskMinFreeMemPercent));
            }
            else
            {
                # release tables
                $this->DB->Query("UNLOCK TABLES");
            }
        }
    }

    /**
    * Retrieve list of tasks with specified query.
    * @param string $DBQuery Database query.
    * @param int $Count Number to retrieve.
    * @param int $Offset Offset into queue to start retrieval.
    * @return array Array with task IDs for index and task info for values.
    *       Task info is stored as associative array with "Callback" and
    *       "Parameter" indices.
    */
    private function GetTaskList($DBQuery, $Count, $Offset)
    {
        $this->DB->Query($DBQuery." LIMIT ".intval($Offset).",".intval($Count));
        $Tasks = array();
        while ($Row = $this->DB->FetchRow())
        {
            $Tasks[$Row["TaskId"]] = $Row;
            if ($Row["Callback"] ==
                    serialize(array("ApplicationFramework", "PeriodicEventWrapper")))
            {
                $WrappedCallback = unserialize($Row["Parameters"]);
                $Tasks[$Row["TaskId"]]["Callback"] = $WrappedCallback[1];
                $Tasks[$Row["TaskId"]]["Parameters"] = NULL;
            }
            else
            {
                $Tasks[$Row["TaskId"]]["Callback"] = unserialize($Row["Callback"]);
                $Tasks[$Row["TaskId"]]["Parameters"] = unserialize($Row["Parameters"]);
            }
        }
        return $Tasks;
    }

    /**
    * Run the next task in the queue.
    */
    private function RunNextTask()
    {
        # lock tables to prevent same task from being run by multiple sessions
        $this->DB->Query("LOCK TABLES TaskQueue WRITE, RunningTasks WRITE");

        # look for task at head of queue
        $this->DB->Query("SELECT * FROM TaskQueue ORDER BY Priority, TaskId LIMIT 1");
        $Task = $this->DB->FetchRow();

        # if there was a task available
        if ($Task)
        {
            # move task from queue to running tasks list
            $this->DB->Query("INSERT INTO RunningTasks "
                             ."(TaskId,Callback,Parameters,Priority,Description) "
                             ."SELECT * FROM TaskQueue WHERE TaskId = "
                                    .intval($Task["TaskId"]));
            $this->DB->Query("DELETE FROM TaskQueue WHERE TaskId = "
                    .intval($Task["TaskId"]));

            # release table locks to again allow other sessions to run tasks
            $this->DB->Query("UNLOCK TABLES");

            # unpack stored task info
            $Callback = unserialize($Task["Callback"]);
            $Parameters = unserialize($Task["Parameters"]);

            # attempt to load task callback if not already available
            $this->LoadFunction($Callback);

            # save amount of free memory for later comparison
            $BeforeFreeMem = self::GetFreeMemory();

            # run task
            $this->RunningTask = $Task;
            if ($Parameters)
            {
                call_user_func_array($Callback, $Parameters);
            }
            else
            {
                call_user_func($Callback);
            }
            unset($this->RunningTask);

            # log if task leaked significant memory
            if (function_exists("gc_collect_cycles")) {  gc_collect_cycles();  }
            $AfterFreeMem = self::GetFreeMemory();
            $LeakThreshold = self::GetPhpMemoryLimit()
                    * ($this->BackgroundTaskMemLeakLogThreshold / 100);
            if (($BeforeFreeMem - $AfterFreeMem) > $LeakThreshold)
            {
                $this->LogError(self::LOGLVL_DEBUG, "Task "
                        .self::GetTaskCallbackSynopsis(
                                $this->GetTask($Task["TaskId"]))." leaked "
                        .number_format($BeforeFreeMem - $AfterFreeMem)." bytes.");
            }

            # remove task from running tasks list
            $this->DB->Query("DELETE FROM RunningTasks"
                    ." WHERE TaskId = ".intval($Task["TaskId"]));

            # prune running tasks list if necessary
            $RunningTasksCount = $this->DB->Query(
                    "SELECT COUNT(*) AS TaskCount FROM RunningTasks", "TaskCount");
            if ($RunningTasksCount > $this->MaxRunningTasksToTrack)
            {
                $this->DB->Query("DELETE FROM RunningTasks ORDER BY StartedAt"
                        ." LIMIT ".($RunningTasksCount - $this->MaxRunningTasksToTrack));
            }
        }
        else
        {
            # release table locks to again allow other sessions to run tasks
            $this->DB->Query("UNLOCK TABLES");
        }
    }

    /**
    * Called automatically at program termination to ensure output is written out.
    * (Not intended to be called directly, could not be made private to class because
    * of automatic execution method.)
    */
    public function OnCrash()
    {
        # attempt to remove any memory limits
        $FreeMemory = $this->GetFreeMemory();
        ini_set("memory_limit", -1);

        # if there is a background task currently running
        if (isset($this->RunningTask))
        {
            # add info about current page load
            $CrashInfo["ElapsedTime"] = $this->GetElapsedExecutionTime();
            $CrashInfo["FreeMemory"] = $FreeMemory;
            $CrashInfo["REMOTE_ADDR"] = $_SERVER["REMOTE_ADDR"];
            $CrashInfo["REQUEST_URI"] = $_SERVER["REQUEST_URI"];
            if (isset($_SERVER["REQUEST_TIME"]))
            {
                $CrashInfo["REQUEST_TIME"] = $_SERVER["REQUEST_TIME"];
            }
            if (isset($_SERVER["REMOTE_HOST"]))
            {
                $CrashInfo["REMOTE_HOST"] = $_SERVER["REMOTE_HOST"];
            }

            # add info about error that caused crash (if available)
            if (function_exists("error_get_last"))
            {
                $CrashInfo["LastError"] = error_get_last();
            }

            # add info about current output buffer contents (if available)
            if (ob_get_length() !== FALSE)
            {
                $CrashInfo["OutputBuffer"] = ob_get_contents();
            }

            # if backtrace info is available for the crash
            $Backtrace = debug_backtrace();
            if (count($Backtrace) > 1)
            {
                # discard the current context from the backtrace
                array_shift($Backtrace);

                # add the backtrace to the crash info
                $CrashInfo["Backtrace"] = $Backtrace;
            }
            # else if saved backtrace info is available
            elseif (isset($this->SavedContext))
            {
                # add the saved backtrace to the crash info
                $CrashInfo["Backtrace"] = $this->SavedContext;
            }

            # save crash info for currently running task
            $DB = new Database();
            $DB->Query("UPDATE RunningTasks SET CrashInfo = '"
                    .addslashes(serialize($CrashInfo))
                    ."' WHERE TaskId = ".intval($this->RunningTask["TaskId"]));
        }

        print("\n");
        return;
    }

    /**
    * Add additional directory(s) to be searched for files.  Specified
    * directory(s) will be searched, in order, before the default directories
    * or any other directories previously specified.  If a directory is already
    * present in the list, it will be moved to front to be searched first (or
    * to the end to be searched last, if SearchLast is set).  SearchLast only
    * affects whether added directories are searched before or after those
    * currently in the list;  when multiple directories are added, they are
    * always searched in the order they appear in the array.
    * @param array $DirList Current directory list.
    * @param string $Dir String with directory or array with directories to be searched.
    * @param bool $SearchLast If TRUE, the directory(s) are searched after the entries
    *       current in the list, instead of before.
    * @param bool $SkipSlashCheck If TRUE, check for trailing slash will be skipped.
    * @return array Modified directory list.
    */
    private function AddToDirList($DirList, $Dir, $SearchLast, $SkipSlashCheck)
    {
        # convert incoming directory to array of directories (if needed)
        $Dirs = is_array($Dir) ? $Dir : array($Dir);

        # reverse array so directories are searched in specified order
        $Dirs = array_reverse($Dirs);

        # for each directory
        foreach ($Dirs as $Location)
        {
            # make sure directory includes trailing slash
            if (!$SkipSlashCheck)
            {
                $Location = $Location
                        .((substr($Location, -1) != "/") ? "/" : "");
            }

            # remove directory from list if already present
            if (in_array($Location, $DirList))
            {
                $DirList = array_diff(
                        $DirList, array($Location));
            }

            # add directory to list of directories
            if ($SearchLast)
            {
                array_push($DirList, $Location);
            }
            else
            {
                array_unshift($DirList, $Location);
            }
        }

        # return updated directory list to caller
        return $DirList;
    }

    /**
    * Return all possible permutations of a given array.
    * @param array $Items Array to permutate.
    * @param array $Perms Current set of permutations, used internally for
    *       recursive calls.  (DO NOT USE)
    * @return array Array of arrays of permutations.
    */
    private function ArrayPermutations($Items, $Perms = array())
    {
        if (empty($Items))
        {
            $Result = array($Perms);
        }
        else
        {
            $Result = array();
            for ($Index = count($Items) - 1;  $Index >= 0;  --$Index)
            {
                $NewItems = $Items;
                $NewPerms = $Perms;
                list($Segment) = array_splice($NewItems, $Index, 1);
                array_unshift($NewPerms, $Segment);
                $Result = array_merge($Result,
                        $this->ArrayPermutations($NewItems, $NewPerms));
            }
        }
        return $Result;
    }

    /**
    * Callback function for output modifications that require a callback to
    * an external function.  This method is set up to be called
    * @param array $Matches Array of matched elements.
    * @return string Replacement string.
    */
    private function OutputModificationCallbackShell($Matches)
    {
        # call previously-stored external function
        return call_user_func($this->OutputModificationCallbackInfo["Callback"],
                $Matches,
                $this->OutputModificationCallbackInfo["Pattern"],
                $this->OutputModificationCallbackInfo["Page"],
                $this->OutputModificationCallbackInfo["SearchPattern"]);
    }

    /**
    * Check to make sure output modification (via preg function) didn't fail
    * in a detectable fashion.
    * @param string $Original Original output.
    * @param string $Modified Modified output.
    * @param string $ErrorInfo Text to include in any logged error message.
    * @return string Version of output to use.
    */
    private function CheckOutputModification($Original, $Modified, $ErrorInfo)
    {
        # if error was reported by regex engine
        if (preg_last_error() !== PREG_NO_ERROR)
        {
            # log error
            $this->LogError(self::LOGLVL_ERROR,
                    "Error reported by regex engine when modifying output."
                    ." (".$ErrorInfo.")");

            # use unmodified version of output
            $OutputToUse = $Original;
        }
        # else if modification reduced output by more than threshold
        elseif ((strlen(trim($Modified)) / strlen(trim($Original)))
                < self::OUTPUT_MODIFICATION_THRESHOLD)
        {
            # log error
            $this->LogError(self::LOGLVL_WARNING,
                    "Content reduced below acceptable threshold while modifying output."
                    ." (".$ErrorInfo.")");

            # use unmodified version of output
            $OutputToUse = $Original;
        }
        else
        {
            # use modified version of output
            $OutputToUse = $Modified;
        }

        # return output to use to caller
        return $OutputToUse;
    }

    /** Threshold below which page output modifications are considered to have failed. */
    const OUTPUT_MODIFICATION_THRESHOLD = 0.10;

    /**
    * Convenience function for getting/setting our settings.
    * @param string $FieldName Name of database field used to store setting.
    * @param mixed $NewValue New value for setting.  (OPTIONAL)
    * @param bool $Persistent If TRUE the new value will be saved (i.e.
    *       persistent across page loads), otherwise the value will apply to
    *       just the current page load.  (OPTIONAL, defaults to TRUE)
    * @return mixed Current value for setting.
    */
    private function UpdateSetting(
            $FieldName, $NewValue = DB_NOVALUE, $Persistent = TRUE)
    {
        static $LocalSettings;
        if ($NewValue !== DB_NOVALUE)
        {
            if ($Persistent)
            {
                $LocalSettings[$FieldName] = $this->DB->UpdateValue(
                        "ApplicationFrameworkSettings",
                        $FieldName, $NewValue, NULL, $this->Settings);
            }
            else
            {
                $LocalSettings[$FieldName] = $NewValue;
            }
        }
        elseif (!isset($LocalSettings[$FieldName]))
        {
            $LocalSettings[$FieldName] = $this->DB->UpdateValue(
                    "ApplicationFrameworkSettings",
                    $FieldName, $NewValue, NULL, $this->Settings);
        }
        return $LocalSettings[$FieldName];
    }

    /** Default list of directories to search for user interface (HTML/TPL) files. */
    private $InterfaceDirList = array(
            "local/interface/%ACTIVEUI%/",
            "interface/%ACTIVEUI%/",
            "local/interface/%DEFAULTUI%/",
            "interface/%DEFAULTUI%/",
            );
    /**
    * Default list of directories to search for UI include (CSS, JavaScript,
    * common HTML, common PHP, /etc) files.
    */
    private $IncludeDirList = array(
            "local/interface/%ACTIVEUI%/include/",
            "interface/%ACTIVEUI%/include/",
            "interface/%ACTIVEUI%/objects/",
            "local/interface/%DEFAULTUI%/include/",
            "interface/%DEFAULTUI%/include/",
            "interface/%DEFAULTUI%/objects/",
            );
    /** Default list of directories to search for image files. */
    private $ImageDirList = array(
            "local/interface/%ACTIVEUI%/images/",
            "interface/%ACTIVEUI%/images/",
            "local/interface/%DEFAULTUI%/images/",
            "interface/%DEFAULTUI%/images/",
            );
    /** Default list of directories to search for files containing PHP functions. */
    private $FunctionDirList = array(
            "local/interface/%ACTIVEUI%/include/",
            "interface/%ACTIVEUI%/include/",
            "local/interface/%DEFAULTUI%/include/",
            "interface/%DEFAULTUI%/include/",
            "local/include/",
            "include/",
            );

    const NOVALUE = ".-+-.NO VALUE PASSED IN FOR ARGUMENT.-+-.";


    # ---- Page Caching (Internal Methods) -----------------------------------

    /**
    * Check for and return cached page if one is available.
    * @param string $PageName Base name of current page.
    * @return string Cached page content or NULL if no cached page available.
    */
    private function CheckForCachedPage($PageName)
    {
        # assume no cached page will be found
        $CachedPage = NULL;

        # if returning a cached page is allowed
        if ($this->CacheCurrentPage)
        {
            # get fingerprint for requested page
            $PageFingerprint = $this->GetPageFingerprint($PageName);

            # look for matching page in cache in database
            $this->DB->Query("SELECT * FROM AF_CachedPages"
                    ." WHERE Fingerprint = '".addslashes($PageFingerprint)."'");

            # if matching page found
            if ($this->DB->NumRowsSelected())
            {
                # if cached page has expired
                $Row = $this->DB->FetchRow();
                $ExpirationTime = strtotime(
                        "-".$this->PageCacheExpirationPeriod()." seconds");
                if (strtotime($Row["CachedAt"]) < $ExpirationTime)
                {
                    # clear expired pages from cache
                    $ExpirationTimestamp = date("Y-m-d H:i:s", $ExpirationTime);
                    $this->DB->Query("DELETE CP, CPTI FROM AF_CachedPages CP,"
                                    ." AF_CachedPageTagInts CPTI"
                            ." WHERE CP.CachedAt < '".$ExpirationTimestamp."'"
                            ." AND CPTI.CacheId = CP.CacheId");
                    $this->DB->Query("DELETE FROM AF_CachedPages "
                            ." WHERE CachedAt < '".$ExpirationTimestamp."'");
                }
                else
                {
                    # display cached page and exit
                    $CachedPage = $Row["PageContent"];
                }
            }
        }

        # return any cached page found to caller
        return $CachedPage;
    }

    /**
    * Update the page cache for the current page.
    * @param string $PageName Name of page.
    * @param string $PageContent Full content of page.
    */
    private function UpdatePageCache($PageName, $PageContent)
    {
        # if page caching is enabled and current page should be cached
        if ($this->PageCacheEnabled()
                && $this->CacheCurrentPage
                && ($PageName != "404"))
        {
            # if page content looks invalid
            if (strlen(trim(strip_tags($PageContent))) == 0)
            {
                # log error
                $LogMsg = "Page not cached because content was empty."
                        ." (PAGE: ".$PageName.", URL: ".$this->FullUrl().")";
                $this->LogError(self::LOGLVL_ERROR, $LogMsg);
            }
            else
            {
                # save page to cache
                $PageFingerprint = $this->GetPageFingerprint($PageName);
                $this->DB->Query("INSERT INTO AF_CachedPages"
                        ." (Fingerprint, PageContent) VALUES"
                        ." ('".$this->DB->EscapeString($PageFingerprint)."', '"
                        .$this->DB->EscapeString($PageContent)."')");
                $CacheId = $this->DB->LastInsertId();

                # for each page cache tag that was added
                foreach ($this->PageCacheTags as $Tag => $Pages)
                {
                    # if current page is in list for tag
                    if (in_array("CURRENT", $Pages) || in_array($PageName, $Pages))
                    {
                        # look up tag ID
                        $TagId = $this->GetPageCacheTagId($Tag);

                        # mark current page as associated with tag
                        $this->DB->Query("INSERT INTO AF_CachedPageTagInts"
                                ." (TagId, CacheId) VALUES "
                                ." (".intval($TagId).", ".intval($CacheId).")");
                    }
                }
            }
        }
    }

    /**
    * Get ID for specified page cache tag.
    * @param string $Tag Page cache tag string.
    * @return int Page cache tag ID.
    */
    private function GetPageCacheTagId($Tag)
    {
        # if tag is a non-negative integer
        if (is_numeric($Tag) && ($Tag > 0) && (intval($Tag) == $Tag))
        {
            # generate ID
            $Id = self::PAGECACHETAGIDOFFSET + $Tag;
        }
        else
        {
            # look up ID in database
            $Id = $this->DB->Query("SELECT TagId FROM AF_CachedPageTags"
                    ." WHERE Tag = '".addslashes($Tag)."'", "TagId");

            # if ID was not found
            if ($Id === NULL)
            {
                # add tag to database
                $this->DB->Query("INSERT INTO AF_CachedPageTags"
                        ." SET Tag = '".addslashes($Tag)."'");
                $Id = $this->DB->LastInsertId();
            }
        }

        # return tag ID to caller
        return $Id;
    }

    /**
    * Get fingerprint string for current page.
    * @param string $PageName Name of current page.
    * @return string Fingerprint string.
    */
    private function GetPageFingerprint($PageName)
    {
        # only get the environmental fingerprint once so that it is consistent
        #       between page construction start and end
        static $EnvFingerprint;
        if (!isset($EnvFingerprint))
        {
            $EnvData = json_encode($_GET).json_encode($_POST);

            # if alternate domain support is enabled
            if ($this->HtaccessSupport() && self::$RootUrlOverride !== NULL)
            {
                # and if we were accessed via an alternate domain
                $VHost = $_SERVER["SERVER_NAME"];
                if (isset($this->AlternateDomainPrefixes[$VHost]))
                {
                    # then add the alternate domain that was used to our
                    # environment data
                    $EnvData .= $VHost;
                }
            }

            $EnvFingerprint = md5($EnvData);

        }

        # build page fingerprint and return it to caller
        return $PageName."-".$EnvFingerprint;
    }
}
