<?PHP
#
#   FILE:  ApplicationFramework.php
#
#   Part of the ScoutLib application support library
#   Copyright 2009-2013 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.
    **/
    function __construct()
    {
        # save execution start time
        $this->ExecutionStartTime = microtime(TRUE);

        # begin/restore PHP session
        $SessionDomain = isset($_SERVER["SERVER_NAME"]) ? $_SERVER["SERVER_NAME"]
                : isset($_SERVER["HTTP_HOST"]) ? $_SERVER["HTTP_HOST"]
                : php_uname("n");
        if (is_writable(session_save_path()))
        {
            $SessionStorage = session_save_path()
                    ."/".self::$AppName."_".md5($SessionDomain.dirname(__FILE__));
            if (!is_dir($SessionStorage)) {  mkdir($SessionStorage, 0700 );  }
            if (is_writable($SessionStorage)) {  session_save_path($SessionStorage);  }
        }
        ini_set("session.gc_maxlifetime", self::$SessionLifetime);
        session_set_cookie_params(
                self::$SessionLifetime, "/", $SessionDomain);
        session_start();

        # set up object file autoloader
        $this->SetUpObjectAutoloading();

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

        # 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
        $this->MaxExecutionTime($this->Settings["MaxExecTime"]);

        # register events we handle internally
        $this->RegisterEvent($this->PeriodicEvents);
        $this->RegisterEvent($this->UIEvents);
    }
    /** @endcond */

    /** @cond */
    /**
    * Object destructor.
    **/
    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->Settings["TemplateLocationCache"]))."',"
                    ." TemplateLocationCacheExpiration = "
                            ." NOW() + INTERVAL "
                                    .$this->Settings["TemplateLocationCacheInterval"]
                                    ." MINUTE");
        }
    }
    /** @endcond */

    /** @cond */
    /**
    * Default top-level exception handler.
    **/
    function GlobalExceptionHandler($Exception)
    {
        # display exception info
        $Location = $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 $Exception->getTraceAsString();
                    ?></pre></blockquote>
        </div>
        </td></tr></table><?PHP

        # log exception if possible
        $LogMsg = "Uncaught exception (".$Exception->getMessage().").";
        $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.
    * @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)
    */
    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 = array_merge(
                array($Dir => array(
                        "Prefix" => $Prefix,
                        "ClassPattern" => $ClassPattern,
                        "ClassReplacement" => $ClassReplacement,
                        )),
                self::$ObjectDirectories);
    }

    /**
    * 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()
    */
    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()
    */
    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()
    */
    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()
    */
    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.
    */
    function SetBrowserDetectionFunc($DetectionFunc)
    {
        $this->BrowserDetectFunc = $DetectionFunc;
    }

    /**
    * Add a callback that will not be executed after buffered content has
    * been output and that won't have its output buffered.
    * @param $Callback callback
    * @param $Parameters optional callback parameters in an array
    */
    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.
    */
    function TemplateLocationCacheExpirationInterval($NewInterval = -1)
    {
        if ($NewInterval >= 0)
        {
            $this->Settings["TemplateLocationCacheInterval"] = $NewInterval;
            $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                    ." SET TemplateLocationCacheInterval = '"
                            .intval($NewInterval)."'");
        }
        return $this->Settings["TemplateLocationCacheInterval"];
    }

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

    /**
    * 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.
    */
    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.
    */
    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.
    * @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.
    */
    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 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.
    */
    function GetCleanUrl()
    {
        return $this->GetCleanUrlForPath($this->GetUncleanUrl());
    }

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

    /**
    * Load page PHP and HTML/TPL files.
    * @param string $PageName Name of page to be loaded (e.g. "BrowseResources").
    */
    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;

        # 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)
        {
            if (!strlen(trim($PageOutput)))
            {
                ?><html>
                <head>
                    <meta http-equiv="refresh" content="0; URL=<?PHP
                            print($this->JumpToPage);  ?>">
                </head>
                <body bgcolor="white">
                </body>
                </html><?PHP
            }
        }
        # 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.</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();

            # 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;
            }

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

                # if file was found
                if ($FilePath)
                {
                    # determine file type
                    $NamePieces = explode(".", $File);
                    $FileSuffix = strtolower(array_pop($NamePieces));

                    # add file to HTML output based on file type
                    $FilePath = htmlspecialchars($FilePath);
                    switch ($FileSuffix)
                    {
                        case "js":
                            $Tag = '<script type="text/javascript" src="'
                                    .$FilePath.'"></script>';
                            $PageEndOutput = preg_replace(
                                    "#</body>#i", $Tag."\n</body>", $PageEndOutput, 1);
                            break;

                        case "css":
                            $Tag = '<link rel="stylesheet" type="text/css"'
                                    .' media="all" href="'.$FilePath.'">';
                            $PageStartOutput = preg_replace(
                                    "#</head>#i", $Tag."\n</head>", $PageStartOutput, 1);
                            break;
                    }
                }
            }

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

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

            # perform any callback replacements in output
            foreach ($this->OutputModificationCallbacks as $Info)
            {
                $this->OutputModificationCallbackInfo = $Info;
                $FullPageOutput = preg_replace_callback($Info["SearchPattern"],
                        array($this, "OutputModificationCallbackShell"),
                        $FullPageOutput);
            }

            # 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 = preg_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
                    $FullPageOutput = preg_replace(
                        array("%href=\"(#[^:\" ]+)\"%i", "%href='(#[^:' ]+)'%i"),
                        array("href=\"".$FullUrl."$1\"", "href='".$FullUrl."$1'"),
                        $FullPageOutput);
                }
                else
                {
                    # try to fix any relative paths throughout code
                    $FullPageOutput = preg_replace(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",
                            "%@import\s+\"([^:\" ]+)\"\s*%i",
                            "%@import\s+'([^:\" ]+)'\s*%i",
                            ),
                            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')",
                            "@import \"".$BaseUrl."$1\"",
                            "@import '".$BaseUrl."$1'",
                            ),
                            $FullPageOutput);
                }
            }

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

            # 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;
        }

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

        # 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.
    */
    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
    */
    function GetPageLocation()
    {
        # retrieve current URL
        $Url = $this->GetScriptUrl();

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

        return $Url;
    }

    /**
    * Get the full URL to the page.
    * @return The full URL to the page.
    */
    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 bool $IsLiteral If TRUE, do not attempt to prepend "index.php?P=" to page.
    *       (OPTIONAL, defaults to FALSE)
    */
    function SetJumpToPage($Page, $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;
        }
    }

    /**
    * Report whether a page to autoload has been set.
    * @return TRUE if page is set to autoload, otherwise FALSE.
    */
    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.
    */
    function HtmlCharset($NewSetting = NULL)
    {
        if ($NewSetting !== NULL) {  $this->HtmlCharset = $NewSetting;  }
        return $this->HtmlCharset;
    }

    /**
    * Get/set whether or not to check for and use minimized JavaScript files
    * when getting a JavaScript UI file. The default value is FALSE.
    * @param bool $NewSetting New value to turn the setting on or off.
    * @return Current value.
    */
    public function UseMinimizedJavascript($NewSetting = NULL)
    {
        if ($NewSetting !== NULL) {  $this->UseMinimizedJavascript = $NewSetting;  }
        return $this->UseMinimizedJavascript;
    }

    /**
    * 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..
    */
    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)
    */
    function SuppressHTMLOutput($NewSetting = TRUE)
    {
        $this->SuppressHTML = $NewSetting;
    }

    /**
    * 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.
    */
    function ActiveUserInterface($UIName = NULL)
    {
        if ($UIName !== NULL)
        {
            $this->ActiveUI = preg_replace("/^SPTUI--/", "", $UIName);
        }
        return $this->ActiveUI;
    }

    /**
    * Get the list of available user interfaces. The result contains a map of
    * interface paths to interface labels.
    * @return array list of users interfaces (interface path => label)
    */
    function GetUserInterfaces()
    {
        # possible UI directories
        $InterfaceDirs = array(
            "interface",
            "local/interface");

        # start out with an empty list
        $Interfaces = 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 that doesn't have a name in the required format
                if (!preg_match('/^[a-zA-Z]+$/', $DirEntry))
                {
                    continue;
                }

                # skip anything that isn't a directory
                if (!is_dir($InterfacePath))
                {
                    continue;
                }

                # read the UI name (if available)
                $UIName = @file_get_contents($InterfacePath."/NAME");

                # use the directory name if the UI name isn't available
                if ($UIName === FALSE || !strlen($UIName))
                {
                    $UIName = $DirEntry;
                }

                $Interfaces[$InterfacePath] = $UIName;
            }

            $Dir->close();
        }

        # return list to caller
        return $Interfaces;
    }

    /**
    * 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)
    */
    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.
    */
    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.
    */
    function GUIFile($FileName)
    {
        # determine if the file is an image or JavaScript file
        $FileIsImage = preg_match("/\.(gif|jpg|png)$/", $FileName);
        $FileIsJavascript = preg_match("/\.js$/", $FileName);

        # determine which location to search based on file suffix
        $DirList = $FileIsImage ? $this->ImageDirList : $this->IncludeDirList;

        # if directed to get a minimized JavaScript file
        if ($FileIsJavascript && $this->UseMinimizedJavascript)
        {
            # first try to find the minimized JavaScript file
            $MinimizedFileName = substr_replace($FileName, ".min", -3, 0);
            $FoundFileName = $this->FindFile($DirList, $MinimizedFileName);

            # search for the regular file if a minimized file wasn't found
            if (is_null($FoundFileName))
            {
                $FoundFileName = $this->FindFile($DirList, $FileName);
            }
        }

        # 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 (!$FileIsImage) {  $this->FoundUIFiles[] = basename($FoundFileName);  }

        # return file name to caller
        return $FoundFileName;
    }

    /**
    * Search UI directories for specified image or CSS file and print name
    * of correct file.  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
    * it is in.
    * @param string $FileName Base file name.
    */
    function PUIFile($FileName)
    {
        $FullFileName = $this->GUIFile($FileName);
        if ($FullFileName) {  print($FullFileName);  }
    }

    /**
    * 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.
    */
    function RequireUIFile($FileName)
    {
        $this->AdditionalRequiredUIFiles[] = $FileName;
    }

    /**
    * 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.
    */
    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().");
            }
        }

        # 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).
    */
    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).
    */
    function GetSecondsBeforeTimeout()
    {
        return ini_get("max_execution_time") - $this->GetElapsedExecutionTime();
    }

    /**
    * Get current amount of free memory.  The value returned is a "best
    * guess" based on reported memory usage.
    * @return Number of bytes.
    */
    function GetFreeMemory()
    {
        $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 - memory_get_usage();
    }

    /**
    * Determine if .htaccess files are enabled.
    * @return TRUE if .htaccess files are enabled or FALSE otherwise
    */
    function HtaccessSupport()
    {
        # HTACCESS_SUPPORT is set in the .htaccess file
        return isset($_SERVER["HTACCESS_SUPPORT"]);
    }

    /**
    * Get current base URL (usually the part before index.php).
    * @return Base URL string with trailing slash.
    */
    static function BaseUrl()
    {
        $Protocol = (isset($_SERVER["HTTPS"]) ? "https" : "http");
        $BaseUrl = $Protocol."://"
                .(($_SERVER["SERVER_NAME"] != "127.0.0.1")
                        ? $_SERVER["SERVER_NAME"]
                        : $_SERVER["HTTP_HOST"])
                .dirname($_SERVER["SCRIPT_NAME"]);
        if (substr($BaseUrl, -1) != "/") {  $BaseUrl .= "/";  }
        return $BaseUrl;
    }

    /**
    * Get current base path (usually the part after the host name).
    * @return Base path string with trailing slash.
    */
    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.
    */
    static function GetScriptUrl()
    {
        if (array_key_exists("SCRIPT_URL", $_SERVER))
        {
            return $_SERVER["SCRIPT_URL"];
        }
        elseif (array_key_exists("REDIRECT_URL", $_SERVER))
        {
            return $_SERVER["REDIRECT_URL"];
        }
        elseif (array_key_exists("REQUEST_URI", $_SERVER))
        {
            $Pieces = parse_url($_SERVER["REQUEST_URI"]);
            return $Pieces["path"];
        }
        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.
    */
    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;
    }

    /**
    * 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()
    */
    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.").");
                }
            }

            # 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()
    */
    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",
                        );
                $LogEntry = date("Y-m-d H:i:s")
                        ." ".($this->RunningInBackground ? "B" : "F")
                        ." ".$ErrorAbbrevs[$Level]
                        ." ".$Msg;

                # write entry to log
                $Success = fputs($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()
    */
    function LoggingLevel($NewValue = NULL)
    {
        # if new logging level was specified
        if ($NewValue !== NULL)
        {
            # constrain new level to within legal bounds and store locally
            $this->Settings["LoggingLevel"] = max(min($NewValue, 6), 1);

            # save new logging level in database
            $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                    ." SET LoggingLevel = "
                    .intval($this->Settings["LoggingLevel"]));
        }

        # report current logging level to caller
        return $this->Settings["LoggingLevel"];
    }

    /**
    * 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.
    */
    function LogFile($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  $this->LogFileName = $NewValue;  }
        return $this->LogFileName;
    }

    /**
    * 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 leve.  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;

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

    # ---- 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)
    */
    function RegisterEvent($EventsOrEventName, $EventType = NULL)
    {
        # convert parameters to array if not already in that form
        $Events = is_array($EventsOrEventName) ? $EventsOrEventName
                : array($EventsOrEventName => $Type);

        # 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()
    */
    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()
    */
    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.
    */
    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"],
                                array("ApplicationFramework", "HookEvent_OrderCompare"));
                    }
                }
                else
                {
                    $Success = FALSE;
                }
            }
            else
            {
                $Success = FALSE;
            }
        }

        # report to caller whether all callbacks were hooked
        return $Success;
    }
    /** Order comparison function for use with usort:  http://php.net/usort  */
    private static function HookEvent_OrderCompare($A, $B)
    {
        if ($A["Order"] == $B["Order"]) {  return 0;  }
        return ($A["Order"] < $B["Order"]) ? -1 : 1;
    }

    /**
    * 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
    *       parameter names as indexes.  (OPTIONAL)
    * @return Appropriate return value for event type.  Returns NULL if no event
    *       with specified name was registered and for EVENTTYPE_DEFAULT events.
    */
    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 signaled (".$EventName.").");
        }

        # 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.
    */
    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.
    */
    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.
    */
    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)
    */
    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()
    */
    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.
    */
    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 of tasks.  (OPTIONAL, defaults to all priorities)
    * @return Number of tasks currently in queue.
    */
    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.
    */
    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.
    */
    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.
    */
    function GetRunningTaskList($Count = 100, $Offset = 0)
    {
        return $this->GetTaskList("SELECT * FROM RunningTasks"
                ." WHERE StartedAt >= '".date("Y-m-d H:i:s",
                        (time() - ini_get("max_execution_time")))."'"
                ." 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.
    */
    function GetOrphanedTaskList($Count = 100, $Offset = 0)
    {
        return $this->GetTaskList("SELECT * FROM RunningTasks"
                ." WHERE StartedAt < '".date("Y-m-d H:i:s",
                        (time() - ini_get("max_execution_time")))."'"
                ." ORDER BY StartedAt", $Count, $Offset);
    }

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

    /**
    * Move orphaned task back into queue.
    * @param int $TaskId Task ID.
    * @param int $NewPriority New priority for task being requeued.  (OPTIONAL)
    */
    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("TaskQueue");
            $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.
    */
    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.
    */
    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.
    * @return Returns TRUE if automatic task execution is enabled or
    *       otherwise FALSE.
    */
    function TaskExecutionEnabled($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            $this->Settings["TaskExecutionEnabled"] = $NewValue ? 1 : 0;
            $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                    ." SET TaskExecutionEnabled = "
                    .$this->Settings["TaskExecutionEnabled"]);
        }
        return $this->Settings["TaskExecutionEnabled"];
    }

    /**
    * 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.
    */
    function MaxTasks($NewValue = NULL)
    {
        if (func_num_args() && ($NewValue >= 1))
        {
            $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                    ." SET MaxTasksRunning = ".intval($NewValue));
            $this->Settings["MaxTasksRunning"] = intval($NewValue);
        }
        return $this->Settings["MaxTasksRunning"];
    }

    /**
    * Get/set maximum PHP execution time.  Setting a new value is not possible if
    *       PHP is running in safe mode.
    * @param int $NewValue New setting for max execution time in seconds.  (OPTIONAL,
    *       but minimum value is 5 if specified)
    * @return Current max execution time in seconds.
    */
    function MaxExecutionTime($NewValue = NULL)
    {
        if (func_num_args() && !ini_get("safe_mode"))
        {
            if ($NewValue != $this->Settings["MaxExecTime"])
            {
                $this->Settings["MaxExecTime"] = max($NewValue, 5);
                $this->DB->Query("UPDATE ApplicationFrameworkSettings"
                        ." SET MaxExecTime = '"
                                .intval($this->Settings["MaxExecTime"])."'");
            }
            ini_set("max_execution_time", $this->Settings["MaxExecTime"]);
            set_time_limit($this->Settings["MaxExecTime"]);
        }
        return ini_get("max_execution_time");
    }

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

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

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

    /**
    * Preserved for backward compatibility for use with code written prior
    * to October 2012.
    */
    function FindCommonTemplate($BaseName)
    {
        return $this->FindFile(
                $this->IncludeDirList, $BaseName, array("tpl", "html"));
    }

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


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

    private $ActiveUI = "default";
    private $BrowserDetectFunc;
    private $DB;
    private $DefaultPage = "Home";
    private $EnvIncludes = array();
    private $ExecutionStartTime;
    private $FoundUIFiles = array();
    private $AdditionalRequiredUIFiles = array();
    private $HtmlCharset = "UTF-8";
    private $UseMinimizedJavascript = FALSE;
    private $JumpToPage = NULL;
    private $PageName;
    private $LogFileName = "local/logs/site.log";
    private $MaxRunningTasksToTrack = 250;
    private $PostProcessingFuncs = array();
    private $RunningInBackground = FALSE;
    private $RunningTask;
    private $Settings;
    private $SuppressHTML = FALSE;
    private $SaveTemplateLocationCache = FALSE;
    private $UnbufferedCallbacks = array();
    private $UseBaseTag = FALSE;
    private $CleanUrlMappings = array();
    private $CleanUrlRewritePerformed = FALSE;
    private $OutputModificationPatterns = array();
    private $OutputModificationReplacements = array();
    private $OutputModificationCallbacks = array();
    private $OutputModificationCallbackInfo;

    private static $AppName = "ScoutAF";
    private static $ObjectDirectories = array();
    private static $SessionLifetime = 1440;

    /**
    * 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.
    */
    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)
        {
            # 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();
        }

        # 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__)."'");
            }
        }

        # if template location cache has been saved to database
        if (isset($this->Settings["TemplateLocationCache"]))
        {
            # unserialize cache values into array and use if valid
            $Cache = unserialize($this->Settings["TemplateLocationCache"]);
            $this->Settings["TemplateLocationCache"] =
                    count($Cache) ? $Cache : array();
        }
        else
        {
            # start with empty cache
            $this->Settings["TemplateLocationCache"] = array();
        }
    }

    /**
    * Perform any page redirects or $_GET value settings resulting from
    * clean URL mappings.
    * @param PageName Starting page name.
    * @return 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;
    }

    /**
    * 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 DirectoryList Array of directories (or array of arrays
    *       of directories) to search.  Directories must include a
    *       trailing slash.
    * @param BaseName File name or file name base.
    * @param PossibleSuffixes Array with possible suffixes for file
    *       name, if no suffix evident.  (Suffixes should not include
    *       a leading period.)  (OPTIONAL)
    * @param PossiblePrefixes Array with possible prefixes for file to
    *       check.  (OPTIONAL)
    */
    private function FindFile($DirectoryList, $BaseName,
            $PossibleSuffixes = NULL, $PossiblePrefixes = NULL)
    {
        # generate template cache index for this page
        $CacheIndex = md5(serialize($DirectoryList))
                .":".$this->ActiveUI.":".$BaseName;

        # if we have cached location and cache expiration time has not elapsed
        if (($this->Settings["TemplateLocationCacheInterval"] > 0)
                && count($this->Settings["TemplateLocationCache"])
                && array_key_exists($CacheIndex,
                        $this->Settings["TemplateLocationCache"])
                && (time() < strtotime(
                        $this->Settings["TemplateLocationCacheExpiration"])))
        {
            # use template location from cache
            $FoundFileName = $this->Settings[
                    "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 UI name into path
                $Dir = str_replace("%ACTIVEUI%", $this->ActiveUI, $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->Settings["TemplateLocationCache"][$CacheIndex]
                    = $FoundFileName;

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

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

    /**
    * Figure out which required UI files have not yet been loaded for specified
    * page content file.
    * @param PageContentFile Page content file.
    * @return 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;
    }

    /**
    * Set up autoloading of object files.
    */
    private function SetUpObjectAutoloading()
    {
        /** PHP class autoloading function:  http://php.net/autoload */
        function __autoload($ClassName)
        {
            ApplicationFramework::AutoloadObjects($ClassName);
        }
    }

    /** @cond */
    /**
    * Load object file for specified class.
    * @param string $ClassName Name of class.
    */
    static function AutoloadObjects($ClassName)
    {
        static $FileLists;
        foreach (self::$ObjectDirectories as $Location => $Info)
        {
            if (is_dir($Location))
            {
                $NewClassName = ($Info["ClassPattern"] && $Info["ClassReplacement"])
                        ? preg_replace($Info["ClassPattern"],
                                $Info["ClassReplacement"], $ClassName)
                        : $ClassName;
                if (!isset($FileLists[$Location]))
                {
                    $FileLists[$Location] = self::ReadDirectoryTree(
                            $Location, '/^.+\.php$/i');
                }
                $FileNames = $FileLists[$Location];
                $TargetName = strtolower($Info["Prefix"].$NewClassName.".php");
                foreach ($FileNames as $FileName)
                {
                    if (strtolower($FileName) == $TargetName)
                    {
                        require_once($Location.$FileName);
                        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;
    }
    /** @endcond */

    /**
    * 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())
            {
                set_magic_quotes_runtime(FALSE);
            }

            # 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"));
            }
        }
    }
    private function UndoMagicQuotes_StripCallback(&$Value, $Key)
    {
        $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/default/include",
                "interface/default/include",
                );
        foreach ($Dirs as $Dir)
        {
            $Dir = str_replace("%ACTIVEUI%", $this->ActiveUI, $Dir);
            if (is_dir($Dir))
            {
                $FileNames = scandir($Dir);
                foreach ($FileNames as $FileName)
                {
                    if (preg_match("/^F-([A-Za-z_]+)\.php/", $FileName, $Matches)
                            || preg_match("/^F-([A-Za-z_]+)\.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 (($this->JumpToPage || !$this->SuppressHTML)
                && (time() > (strtotime($this->Settings["LastTaskRunAt"])
                        + (ini_get("max_execution_time")
                                / $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;

        # if there is still a task in the queue
        if ($this->GetTaskQueueSize())
        {
            # 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"])
                        + (ini_get("max_execution_time")
                                / $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 left
                do
                {
                    # run the next task
                    $this->RunNextTask();
                }
                while ($this->GetTaskQueueSize()
                        && ($this->GetSecondsBeforeTimeout() > 65));
            }
            else
            {
                # release tables
                $this->DB->Query("UNLOCK TABLES");
            }
        }
    }

    /**
    * Retrieve list of tasks with specified query.
    * @param Count Number to retrieve.  (OPTIONAL, defaults to 100)
    * @param 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.
    */
    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);

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

            # 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.)
    */
    function OnCrash()
    {
        if (isset($this->RunningTask))
        {
            if (function_exists("error_get_last"))
            {
                $CrashInfo["LastError"] = error_get_last();
            }
            if (ob_get_length() !== FALSE)
            {
                $CrashInfo["OutputBuffer"] = ob_get_contents();
            }
            if (isset($CrashInfo))
            {
                $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 DirList Current directory list.
    * @param Dir String with directory or array with directories to be searched.
    * @param SearchLast If TRUE, the directory(s) are searched after the entries
    *       current in the list, instead of before.
    * @param SkipSlashCheck If TRUE, check for trailing slash will be skipped.
    * @return 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"]);
    }

    /** Default list of directories to search for user interface (HTML/TPL) files. */
    private $InterfaceDirList = array(
            "local/interface/%ACTIVEUI%/",
            "interface/%ACTIVEUI%/",
            "local/interface/default/",
            "interface/default/",
            );
    /**
    * 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/",
            "local/interface/default/include/",
            "interface/default/include/",
            );
    /** Default list of directories to search for image files. */
    private $ImageDirList = array(
            "local/interface/%ACTIVEUI%/images/",
            "interface/%ACTIVEUI%/images/",
            "local/interface/default/images/",
            "interface/default/images/",
            );
    /** Default list of directories to search for files containing PHP functions. */
    private $FunctionDirList = array(
            "local/interface/%ACTIVEUI%/include/",
            "interface/%ACTIVEUI%/include/",
            "local/interface/default/include/",
            "interface/default/include/",
            "local/include/",
            "include/",
            );

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