<?PHP
#
#   FILE:  MetadataSchema.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2012-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis
#

/**
* Metadata schema (in effect a Factory class for MetadataField).
*/
class MetadataSchema extends ItemFactory {

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

    # metadata field base types
    # (must parallel MetadataFields.FieldType declaration in install/CreateTables.sql
    #        and MetadataField::$FieldTypeDBEnums declaration below)
    const MDFTYPE_TEXT =            1;
    const MDFTYPE_PARAGRAPH =       2;
    const MDFTYPE_NUMBER =          4;
    const MDFTYPE_DATE =            8;
    const MDFTYPE_TIMESTAMP =       16;
    const MDFTYPE_FLAG =            32;
    const MDFTYPE_TREE =            64;
    const MDFTYPE_CONTROLLEDNAME =  128;
    const MDFTYPE_OPTION =          256;
    const MDFTYPE_USER =            512;
    const MDFTYPE_IMAGE =           1024;
    const MDFTYPE_FILE =            2048;
    const MDFTYPE_URL =             4096;
    const MDFTYPE_POINT =           8192;
    const MDFTYPE_REFERENCE =       16384;

    # types of field ordering
    const MDFORDER_DISPLAY =  1;
    const MDFORDER_EDITING =  2;
    const MDFORDER_ALPHABETICAL =  3;

    # error status codes
    const MDFSTAT_OK =                 1;
    const MDFSTAT_ERROR =              2;
    const MDFSTAT_DUPLICATENAME =      4;
    const MDFSTAT_DUPLICATEDBCOLUMN =  8;
    const MDFSTAT_FIELDDOESNOTEXIST =  16;
    const MDFSTAT_ILLEGALNAME =        32;
    const MDFSTAT_DUPLICATELABEL =     64;
    const MDFSTAT_ILLEGALLABEL =       128;

    # special schema IDs
    const SCHEMAID_DEFAULT = 0;
    const SCHEMAID_USER    = 1;

    # resource names
    const RESOURCENAME_DEFAULT = "Resource";
    const RESOURCENAME_USER = "User";

    # names used for display and edit orders
    const ORDER_DISPLAY_NAME = "Display";
    const ORDER_EDIT_NAME = "Edit";

    /**
    * Object constructor, used to load an existing schema.  (Use
    * MetadataSchema::Create() to create a new schema.)
    * @param mixed $SchemaId ID of schema.  Schema IDs are numerical, except for
    *       two special values SCHEMAID_DEFAULT and SCHEMAID_USER.  (OPTIONAL,
    *       defaults to SCHEMAID_DEFAULT)
    * @see MetadataSchema::Create()
    */
    function __construct($SchemaId = self::SCHEMAID_DEFAULT)
    {
        # set schema ID
        $this->Id = $SchemaId;

        # set up item factory base class
        $this->ItemFactory(
            "MetadataField", "MetadataFields", "FieldId", "FieldName", FALSE,
            "SchemaId = ".intval($this->Id()));

        # load schema info from database
        $this->DB->Query("SELECT * FROM MetadataSchemas"
                ." WHERE SchemaId = ".intval($SchemaId));
        if ($this->DB->NumRowsSelected() < 1)
        {
            throw new Exception("Attempt to load metadata schema with "
                    ." invalid ID (".$SchemaId.").");
        }
        $Info = $this->DB->FetchRow();
        $this->Name = $Info["Name"];
        $this->AuthoringPrivileges = new PrivilegeSet($Info["AuthoringPrivileges"]);
        $this->EditingPrivileges = new PrivilegeSet($Info["EditingPrivileges"]);
        $this->ViewingPrivileges = new PrivilegeSet($Info["ViewingPrivileges"]);
        $this->ViewPage = $Info["ViewPage"];

        # start with field info caching enabled
        $this->CachingOn = TRUE;
    }

    /**
    * Get name (string) for constant.  If there are multiple constants
    * defined with the same value, the first constant found with a name that
    * matches the prefix (if supplied) is returned.
    * @param enum $Value Constant value.
    * @param string $Prefix Prefix to look for at beginning of name.  Needed
    *       when there may be multiple constants with the same value.  (OPTIONAL)
    * @return string Constant name or NULL if no matching value found.
    */
    static function GetConstantName($Value, $Prefix = NULL)
    {
        # retrieve all constants for class
        $Reflect = new ReflectionClass(get_class());
        $Constants = $Reflect->getConstants();

        # for each constant
        foreach ($Constants as $CName => $CValue)
        {
            # if value matches and prefix (if supplied) matches
            if (($CValue == $Value)
                    && (($Prefix === NULL) || (strpos($CName, $Prefix) === 0)))
            {
                # return name to caller
                return $CName;
            }
        }

        # report to caller that no matching constant was found
        return NULL;
    }

    /**
    * Create new metadata schema.
    * @param string $Name Schema name.
    * @param object $AuthorPrivs PrivilegeSet required for authoring.
    *       (OPTIONAL, defaults to all users)
    * @param object $EditPrivs PrivilegeSet required for editing.  (OPTIONAL,
    *       defaults to all users)
    * @param object $ViewPrivs PrivilegeSet required for viewing.  (OPTIONAL,
    *       defaults to all users)
    * @param string $ViewPage The page used to view the full record for a
    *       resource. If "$ID" shows up in the parameter, it will be replaced by
    *       the resource ID when viewing the resource.  (OPTIONAL)
    * @return object MetadataSchema object.
    */
    static function Create($Name,
            PrivilegeSet $AuthorPrivs = NULL,
            PrivilegeSet $EditPrivs = NULL,
            PrivilegeSet $ViewPrivs = NULL,
            $ViewPage = "",
            $ResourceName = NULL)
    {
        # supply privilege settings if none provided
        if ($AuthorPrivs === NULL) {  $AuthorPrivs = new PrivilegeSet();  }
        if ($EditPrivs === NULL) {  $EditPrivs = new PrivilegeSet();  }
        if ($ViewPrivs === NULL) {  $ViewPrivs = new PrivilegeSet();  }

        # add schema to database
        $DB = new Database;
        if (strtoupper($Name) == "DEFAULT")
        {
            $Id = self::SCHEMAID_DEFAULT;
        }
        elseif (strtoupper($Name) == "USER")
        {
            $Id = self::SCHEMAID_USER;
        }
        else
        {
            $Id = $DB->Query("SELECT SchemaId FROM MetadataSchemas"
                ." ORDER BY SchemaId DESC LIMIT 1", "SchemaId") + 1;
        }
        $DB->Query("INSERT INTO MetadataSchemas"
                ." (SchemaId, Name, ViewPage,"
                        ." AuthoringPrivileges, EditingPrivileges, ViewingPrivileges)"
                ." VALUES (".intval($Id).","
                        ."'".addslashes($Name)."',"
                        ."'".mysql_escape_string($ViewPage)."',"
                        ."'".mysql_escape_string($AuthorPrivs->Data())."',"
                        ."'".mysql_escape_string($EditPrivs->Data())."',"
                        ."'".mysql_escape_string($ViewPrivs->Data())."')");

        # construct the new schema
        $Schema = new MetadataSchema($Id);

        # set schema name if none supplied
        if (!strlen($Name))
        {
            $Schema->Name("Metadata Schema ".$Id);
        }

        # set the resource name if one is supplied
        if (!is_null($ResourceName))
        {
            $Schema->ResourceName($ResourceName);
        }

        # Nudge the Edit and Display order so that they will exist
        $Schema->GetEditOrder();
        $Schema->GetDisplayOrder();

        # return the new schema
        return $Schema;
    }

    /**
    * Check with schema exists with specified ID.
    * @param int $SchemaId ID to check.
    * @return bool TRUE if schema exists with specified ID, otherwise FALSE.
    */
    static function SchemaExistsWithId($SchemaId)
    {
        $DB = new Database();
        $DB->Query("SELECT * FROM MetadataSchemas"
                ." WHERE SchemaId = ".intval($SchemaId));
        return ($DB->NumRowsSelected() > 0) ? TRUE : FALSE;
    }

    /**
    * Get schema ID.  Schema IDs are numerical, with two special
    * values SCHEMAID_DEFAULT and SCHEMAID_USER.
    * @return Current schema ID.
    */
    function Id()
    {
        # return value to caller
        return $this->Id;
    }

    /**
    * Get/set name of schema.
    * @param string $NewValue New name for schema.  (OPTIONAL)
    * @return string Current schema name.
    */
    function Name($NewValue = NULL)
    {
        # set new name if one supplied
        if ($NewValue !== NULL)
        {
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET Name = '".addslashes($NewValue)."'"
                    ." WHERE SchemaId = '".intval($this->Id)."'");
            $this->Name = $NewValue;
        }

        # get the name if it hasn't been cached yet
        if (!isset($this->Name))
        {
            $this->Name = $this->DB->Query("SELECT * FROM MetadataSchemas"
                    ." WHERE SchemaId = '".intval($this->Id)."'", "Name");
        }

        # return current value to caller
        return $this->Name;
    }

    /**
    * Get/set name of resources using this schema.
    * @param string $NewValue New resource name for schema.  (OPTIONAL)
    * @return Returns the current resource name.
    */
    function ResourceName($NewValue = NULL)
    {
        # set new resource name if one supplied
        if ($NewValue !== NULL)
        {
            $this->DB->Query("
                UPDATE MetadataSchemas
                SET ResourceName = '".addslashes($NewValue)."'
                WHERE SchemaId = '".intval($this->Id)."'");
            $this->ResourceName = $NewValue;
        }

        # get the name if it hasn't been cached yet
        if (!isset($this->ResourceName))
        {
            $this->ResourceName = $this->DB->Query("
                SELECT * FROM MetadataSchemas
                WHERE SchemaId = '".intval($this->Id)."'",
                "ResourceName");

            # use the default resource name if one isn't set
            if (!strlen(trim($this->ResourceName)))
            {
                $this->ResourceName = self::RESOURCENAME_DEFAULT;
            }
        }

        # return current value to caller
        return $this->ResourceName;
    }

    /**
    * Get/set name of page to go to for viewing resources using this schema.
    * @param string $NewValue New name for schema.  (OPTIONAL)
    * @return string Current schema name.
    */
    function ViewPage($NewValue = NULL)
    {
        # set new viewing page if one supplied
        if ($NewValue !== NULL)
        {
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET ViewPage = '".addslashes($NewValue)."'"
                    ." WHERE SchemaId = '".intval($this->Id)."'");
            $this->ViewPage = $NewValue;
        }

        # get the view page if it hasn't been cached yet
        if (!isset($this->ViewPage))
        {
            $this->ViewPage = $this->DB->Query("SELECT * FROM MetadataSchemas"
                    ." WHERE SchemaId = '".intval($this->Id)."'", "ViewPage");
        }

        # return current value to caller
        return $this->ViewPage;
    }

    /**
    * Get/set privileges that allowing authoring resources with this schema.
    * @param object $NewValue New PrivilegeSet value.  (OPTIONAL)
    * @return object PrivilegeSet that allows authoring.
    */
    function AuthoringPrivileges(PrivilegeSet $NewValue = NULL)
    {
        # if new privileges supplied
        if ($NewValue !== NULL)
        {
            # store new privileges in database
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET AuthoringPrivileges = '"
                            .mysql_escape_string($NewValue->Data())."'"
                    ." WHERE SchemaId = ".intval($this->Id));
            $this->AuthoringPrivileges = $NewValue;
        }

        # return current value to caller
        return $this->AuthoringPrivileges;
    }

    /**
    * Get/set privileges that allowing editing resources with this schema.
    * @param object $NewValue New PrivilegeSet value.  (OPTIONAL)
    * @return object PrivilegeSet that allows editing.
    */
    function EditingPrivileges(PrivilegeSet $NewValue = NULL)
    {
        # if new privileges supplied
        if ($NewValue !== NULL)
        {
            # store new privileges in database
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET EditingPrivileges = '"
                            .mysql_escape_string($NewValue->Data())."'"
                    ." WHERE SchemaId = ".intval($this->Id));
            $this->EditingPrivileges = $NewValue;
        }

        # return current value to caller
        return $this->EditingPrivileges;
    }

    /**
    * Get/set privileges that allowing viewing resources with this schema.
    * @param object $NewValue New PrivilegeSet value.  (OPTIONAL)
    * @return object PrivilegeSet that allows viewing.
    */
    function ViewingPrivileges(PrivilegeSet $NewValue = NULL)
    {
        # if new privileges supplied
        if ($NewValue !== NULL)
        {
            # store new privileges in database
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET ViewingPrivileges = '"
                            .mysql_escape_string($NewValue->Data())."'"
                    ." WHERE SchemaId = ".intval($this->Id));
            $this->ViewingPrivileges = $NewValue;
        }

        # return current value to caller
        return $this->ViewingPrivileges;
    }

    /**
    * Determine if the given user can author resources using this schema.
    * The result of this method can be modified via the
    * EVENT_RESOURCE_AUTHOR_PERMISSION_CHECK event.
    * @param User $User user
    * @return bool TRUE if the user can edit the resource and FALSE otherwise
    */
    function UserCanAuthor($User)
    {
        # get authoring privilege set for schema
        $AuthorPrivs = $this->AuthoringPrivileges();

        # get privilege set for user
        $UserPrivs = $User->Privileges();

        # user can author if privileges are greater than resource set
        $CanAuthor = $UserPrivs->IsGreaterThan($AuthorPrivs);

        # allow plugins to modify result of permission check
        $SignalResult = $GLOBALS["AF"]->SignalEvent(
                "EVENT_RESOURCE_AUTHOR_PERMISSION_CHECK", array(
                    "Schema" => $this,
                    "User" => $User,
                    "CanAuthor" => $CanAuthor));
        $CanAuthor = $SignalResult["CanAuthor"];

        # report back to caller whether user can author field
        return $CanAuthor;
    }

    /**
    * Get the resource ID GET parameter for the view page for the schema.
    * @return Returns the resource ID GET parameter for the view page for the
    *     schema.
    */
    function GetViewPageIdParameter()
    {
        # get the query/GET parameters for the view page
        $Query = parse_url($this->ViewPage(), PHP_URL_QUERY);

        # the URL couldn't be parsed
        if (!is_string($Query))
        {
            return NULL;
        }

        # parse the GET parameters out of the query string
        $GetVars = ParseQueryString($Query);

        # search for the ID parameter
        $Result = array_search("\$ID", $GetVars);

        return $Result !== FALSE ? $Result : NULL;
    }

    /**
    * Determine if a path matches the view page path for the schema. For the two
    * to match, the path GET parameters must contain at least the GET parameters
    * in the view page's GET parameters, and all of the required GET parameters
    * must match the ones in the view page, unless the parameter is a variable
    * in the view page path. The path's GET parameters may contain more
    * parameters.
    * @param string $Path Path to match against, e.g.,
    *     index.php?P=FullRecord&ID=123.
    * @return Returns TRUE if the path matches the view page path for the
    *     schema.
    */
    function PathMatchesViewPage($Path)
    {
        # get the query/GET parameters for the view page
        $Query = parse_url($this->ViewPage(), PHP_URL_QUERY);

        # can't perform matching if the URL couldn't be parsed
        if (!is_string($Query))
        {
            return FALSE;
        }

        # parse the GET parameters out of the query string
        $GetVars = ParseQueryString($Query);

        # now, get the query/GET parameters from the path given
        $PathQuery = parse_url($Path, PHP_URL_QUERY);

        # can't perform matching if the URL couldn't be parsed
        if (!is_string($PathQuery))
        {
            return FALSE;
        }

        # parse the GET parameters out of the path's query string
        $PathGetVars = ParseQueryString($PathQuery);

        # make sure the given path GET parameters contain at least the GET
        # parameters from the view page and that all non-variable parameters are
        # equal. the path GET parameters may contain more, which is okay
        foreach ($GetVars as $GetVarName => $GetVarValue)
        {
            # there's a required parameter that is not included in the path GET
            # parameters
            if (!array_key_exists($GetVarName, $PathGetVars))
            {
                return FALSE;
            }

            # require the path's value to be equal to the view page's value if
            # the view page's value is not a variable,
            if ($PathGetVars[$GetVarName] != $GetVarValue
                && (!strlen($GetVarValue) || $GetVarValue{0} != "$"))
            {
                return FALSE;
            }
        }

        # the path matches the view page path
        return TRUE;
    }

    /**
    * Enable/disable caching of metadata field info.
    * @param boo $NewValue TRUE to enable caching or FALSE to disable caching.
    */
    function CacheData($NewValue)
    {
        $this->CachingOn = $NewValue;
    }

    /**
    * Add new metadata field.
    * @param string $FieldName Name of new field.
    * @param mixed $FieldType Type of new field.
    * @param bool $Optional Whether setting a value for new field is optional when
    *       creating new records that use the field.  (OPTIONAL, defaults to TRUE)
    * @param mixed $DefaultValue Initial default value for field.  (OPTIONAL)
    * @return MetadataField object or NULL if field addition failed.
    */
    function AddField($FieldName, $FieldType, $Optional = TRUE, $DefaultValue = NULL)
    {
        # clear any existing error messages
        if (array_key_exists(__METHOD__, $this->ErrorMsgs))
                {  unset($this->ErrorMsgs[__METHOD__]);  }

        # create new field
        try
        {
            $Field = MetadataField::Create($this->Id(), $FieldType,
                    $FieldName, $Optional, $DefaultValue);
        }
        catch (Exception $Exception)
        {
            $this->ErrorMsgs[__METHOD__][] = $Exception->getMessage();
            $Field = NULL;
        }

        # return new field to caller
        return $Field;
    }

    /**
    * Add new metadata fields from XML file.  NewFields() can be used to
    * determine how many (or whether) new fields were added, and ErrorMsgs()
    * can be used to determine what errors were * encountered.
    * @param string $FileName Name of XML file.
    * @param bool $TestRun If TRUE, any new fields created are removed before
    *       the method returns.
    * @return bool TRUE if no errors were encountered in loading or
    *       parsing the XML file or adding fields, otherwise FALSE.
    * @see MetadataSchema::NewFields()
    * @see MetadataSchema::ErrorMessages()
    */
    function AddFieldsFromXmlFile($FileName, $TestRun = FALSE)
    {
        # clear loading status
        $this->NewFields = array();
        if (array_key_exists(__METHOD__, $this->ErrorMsgs))
                {  unset($this->ErrorMsgs[__METHOD__]);  }

        # check that file exists and is readable
        if (!file_exists($FileName))
        {
            $this->ErrorMsgs[__METHOD__][] = "Could not find XML file '"
                    .$FileName."'.";
            return FALSE;
        }
        elseif (!is_readable($FileName))
        {
            $this->ErrorMsgs[__METHOD__][] = "Could not read from XML file '"
                    .$FileName."'.";
            return FALSE;
        }

        # load XML from file
        libxml_use_internal_errors(TRUE);
        $XmlData = simplexml_load_file($FileName);
        $Errors = libxml_get_errors();
        libxml_use_internal_errors(FALSE);

        # if XML load failed
        if ($XmlData === FALSE)
        {
            # retrieve XML error messages
            foreach ($Errors as $Err)
            {
                $ErrType = ($Err->level == LIBXML_ERR_WARNING) ? "Warning"
                        : (($Err->level == LIBXML_ERR_WARNING) ? "Error"
                        : "Fatal Error");
                $this->ErrorMsgs[__METHOD__][] = "XML ".$ErrType.": ".$Err->message
                        ." (".$Err->file.":".$Err->line.",".$Err->column.")";
            }
        }
        # else if no metadata fields found record error message
        elseif (!count($XmlData->MetadataField))
        {
            $this->ErrorMsgs[__METHOD__][] = "No metadata fields found.";
        }
        # else process metadata fields
        else
        {
            # for each metadata field entry found
            $FieldsAdded = 0;
            $FieldIndex = 0;
            foreach ($XmlData->MetadataField as $FieldXml)
            {
                $FieldIndex++;

                # pull out field type if present
                if (isset($FieldXml->Type))
                {
                    $FieldType = "MetadataSchema::".$FieldXml->Type;
                    if (!defined($FieldType))
                    {
                        $FieldType = "MetadataSchema::MDFTYPE_"
                                .strtoupper(preg_replace("/\\s+/", "",
                                        $FieldXml->Type));
                    }
                }

                # if required values are missing
                if (!isset($FieldXml->Name) || !isset($FieldXml->Type)
                        || !defined($FieldType))
                {
                    # add error message about required value missing
                    if (!isset($FieldXml->Name))
                    {
                        $this->ErrorMsgs[__METHOD__][] =
                                "Field name not found (MetadataField #"
                                .$FieldIndex.").";
                    }
                    else
                    {
                        $this->ErrorMsgs[__METHOD__][] =
                                "Valid type not found for field '"
                                .$FieldXml->Name."' (MetadataField #"
                                .$FieldIndex.").";
                    }
                }
                # else if there is not already a field with this name
                elseif (!$this->NameIsInUse(trim($FieldXml->Name)))
                {
                    # create new field
                    $Field = $this->AddField($FieldXml->Name, constant($FieldType));

                    # if field creation failed
                    if ($Field === NULL)
                    {
                        # add any error message to our error list
                        $ErrorMsgs = $this->ErrorMessages("AddField");
                        foreach ($ErrorMsgs as $Msg)
                        {
                            $this->ErrorMsgs[__METHOD__][] =
                                    $Msg." (AddField)";
                        }
                    }
                    else
                    {
                        # add field to list of created fields
                        $this->NewFields[$Field->Id()] = $Field;

                        # for other field attributes
                        foreach ($FieldXml as $MethodName => $Value)
                        {
                            # if tags look valid and have not already been set
                            if (method_exists($Field, $MethodName)
                                    && ($MethodName != "Name")
                                    && ($MethodName != "Type"))
                            {
                                # if tag indicates privilege set
                                if (preg_match("/^[a-z]+Privileges\$/i",
                                        $MethodName))
                                {
                                    # save element for later processing
                                    $PrivilegesToSet[$Field->Id()][$MethodName] = $Value;
                                }
                                else
                                {
                                    # condense down any extraneous whitespace
                                    $Value = preg_replace("/\s+/", " ", trim($Value));

                                    # set value for field
                                    $Field->$MethodName($Value);
                                }
                            }
                        }

                        # save the temp ID so that any privileges to set can be
                        # mapped to the actual ID when the field is made
                        # permanent
                        $TempId = $Field->Id();

                        # make new field permanent
                        $Field->IsTempItem(FALSE);

                        # map privileges to set to the permanent field ID
                        if (isset($PrivilegesToSet) &&
                            isset($PrivilegesToSet[$TempId]) )
                        {
                            # copy the privileges over
                            $PrivilegesToSet[$Field->Id()] =
                                $PrivilegesToSet[$TempId];

                            # remove the values for the temp ID
                            unset($PrivilegesToSet[$TempId]);
                        }
                    }
                }
            }

            # if we have privileges to set
            if (isset($PrivilegesToSet))
            {
                # for each field with privileges
                foreach ($PrivilegesToSet as $FieldId => $Privileges)
                {
                    # load the field for which to set the privileges
                    $Field = new MetadataField($FieldId);

                    # for each set of privileges for field
                    foreach ($Privileges as $MethodName => $Value)
                    {
                        # convert privilege value
                        $Value = $this->ConvertXmlToPrivilegeSet($Value);

                        # if conversion failed
                        if ($Value === NULL)
                        {
                            # add resulting error messages to our list
                            $ErrorMsgs = $this->ErrorMessages(
                                    "ConvertXmlToPrivilegeSet");
                            foreach ($ErrorMsgs as $Msg)
                            {
                                $this->ErrorMsgs[__METHOD__][] =
                                        $Msg." (ConvertXmlToPrivilegeSet)";
                            }
                        }
                        else
                        {
                            # set value for field
                            $Field->$MethodName($Value);
                        }
                    }
                }
            }

            # if errors were found during creation
            if (array_key_exists(__METHOD__, $this->ErrorMsgs) || $TestRun)
            {
                # remove any fields that were created
                foreach ($this->NewFields as $Field)
                {
                    $Field->Drop();
                }
                $this->NewFields = array();
            }
        }

        # report success or failure based on whether errors were recorded
        return (array_key_exists(__METHOD__, $this->ErrorMsgs)) ? FALSE : TRUE;
    }

    /**
    * Get new fields recently added (if any) via XML file.
    * @return array Array of fields recently added (MetadataField objects).
    * @see MetadataSchema::AddFieldsFromXmlFile()
    */
    function NewFields()
    {
        return $this->NewFields;
    }

    /**
    * Get error messages (if any) from recent calls.  If no method name is
    * specified, then an array is returned with method names for the index
    * and arrays of error messages for the values.
    * @param string $Method Name of method.  (OPTIONAL)
    * @return array Array of arrays of error message strings.
    * @see MetadataSchema::AddField()
    * @see MetadataSchema::AddFieldsFromXmlFile()
    */
    function ErrorMessages($Method = NULL)
    {
        if ($Method === NULL)
        {
            return $this->ErrorMsgs;
        }
        else
        {
            if (!method_exists($this, $Method))
            {
                throw new Exception("Error messages requested for non-existent"
                        ." method (".$Method.").");
            }
            return array_key_exists(__CLASS__."::".$Method, $this->ErrorMsgs)
                    ? $this->ErrorMsgs[__CLASS__."::".$Method] : array();
        }
    }

    /**
    * Add new metadata field based on supplied XML.  The XML elements are method
    * names from the MetadataField object, with the values being passed in as the
    * parameter to that method.  The <i>FieldName</i> and <i>FieldType</i>
    * elements are required.  Values for elements/methods that would normally be
    * called with constants in PHP can be constant names.
    * @param string $Xml Block of XML containing field description.
    * @return New MetadataField object or NULL if addition failed.
    */
    function AddFieldFromXml($Xml)
    {
        # assume field addition will fail
        $Field = self::MDFSTAT_ERROR;

        # add XML prefixes if needed
        $Xml = trim($Xml);
        if (!preg_match("/^<\?xml/i", $Xml))
        {
            if (!preg_match("/^<document>/i", $Xml))
            {
                $Xml = "<document>".$Xml."</document>";
            }
            $Xml = "<?xml version='1.0'?".">".$Xml;
        }

        # parse XML
        $XmlData = simplexml_load_string($Xml);

         # if required values are present
        if (is_object($XmlData)
                && isset($XmlData->Name)
                && isset($XmlData->Type)
                && constant("MetadataSchema::".$XmlData->Type))
        {
            # create the metadata field
            $Field = $this->AddField(
                $XmlData->Name,
                constant("MetadataSchema::".$XmlData->Type));

            # if field creation succeeded
            if ($Field != NULL)
            {
                # for other field attributes
                foreach ($XmlData as $MethodName => $Value)
                {
                    # if they look valid and have not already been set
                    if (method_exists($Field, $MethodName)
                            && ($MethodName != "Name")
                            && ($MethodName != "Type"))
                    {
                        # if tag indicates privilege set
                        if (preg_match("/^[a-z]+Privileges\$/i",
                                       $MethodName))
                        {
                            # save element for later processing
                            $PrivilegesToSet[$MethodName] = $Value;
                        }
                        else
                        {
                            # condense down any extraneous whitespace
                            $Value = preg_replace("/\s+/", " ", trim($Value));

                            # set value for field
                            $Field->$MethodName($Value);
                        }
                    }
                }

                # make new field permanent
                $Field->IsTempItem(FALSE);

                # if we have privileges to set
                if (isset($PrivilegesToSet))
                {
                    # for each set of privileges for field
                    foreach ($PrivilegesToSet as $MethodName => $Value)
                    {
                        # convert privilege value
                        $Value = $this->ConvertXmlToPrivilegeSet($Value);

                        # if conversion failed
                        if ($Value === NULL)
                        {
                            # add resulting error messages to our list
                            $ErrorMsgs = $this->ErrorMessages(
                                "ConvertXmlToPrivilegeSet");
                            foreach ($ErrorMsgs as $Msg)
                            {
                                $this->ErrorMsgs[__METHOD__][] =
                                    $Msg." (ConvertXmlToPrivilegeSet)";
                            }
                        }
                        else
                        {
                            # set value for field
                            $Field->$MethodName($Value);
                        }
                    }
                }
            }
        }

        # return new field (if any) to caller
        return $Field;
    }

    /**
    * Delete metadata field and all associated data.
    * @param int $FieldId ID of field to be deleted.
    * @return TRUE if delete succeeded, otherwise FALSE.
    */
    function DropField($FieldId)
    {
        $Field = $this->GetField($FieldId);
        if ($Field !== NULL)
        {
            global $AF;
            $AF->SignalEvent("EVENT_PRE_FIELD_DELETE",
                array("FieldId" => $FieldId) );

            $Field->Drop();
            return TRUE;
        }
        else
        {
            return FALSE;
        }
    }

    /**
    * Retrieve metadata field by ID.
    * @param int $FieldId ID of field.
    * @return object MetadataField object or NULL if no field found with specified name.
    */
    function GetField($FieldId)
    {
        static $Fields;

        # if caching is off or field is not already loaded
        if (($this->CachingOn != TRUE) || !isset($Fields[$FieldId]))
        {
            # retrieve field
            try
            {
                $Fields[$FieldId] = new MetadataField($FieldId);
            }
            catch (Exception $Exception)
            {
                $Fields[$FieldId] = NULL;
            }
        }

        # return field to caller
        return $Fields[$FieldId];
    }

    /**
    * Retrieve metadata field by name.
    * @param string $FieldName Field name.
    * @param bool $IgnoreCase If TRUE, case is ignore when matching field names.
    * @return Requested MetadataField or NULL if no field found with specified name.
    */
    function GetFieldByName($FieldName, $IgnoreCase = FALSE)
    {
        $FieldId = $this->GetFieldIdByName($FieldName, $IgnoreCase);
        return ($FieldId === NULL) ? NULL : $this->GetField($FieldId);
    }

    /**
    * Retrieve metadata field by label.
    * @param string $FieldLabel Field label.
    * @param bool $IgnoreCase If TRUE, case is ignore when matching field labels.
    * @return Requested MetadataField or NULL if no field found with specified label.
    */
    function GetFieldByLabel($FieldLabel, $IgnoreCase = FALSE)
    {
        $FieldId = $this->GetFieldIdByLabel($FieldLabel, $IgnoreCase);
        return ($FieldId === NULL) ? NULL : $this->GetField($FieldId);
    }

    /**
    * Retrieve metadata field ID by name.
    * @param string $FieldName Field name.
    * @param bool $IgnoreCase If TRUE, case is ignore when matching field names.
    * @return ID of requested MetadataField or FALSE if no field found with
    *       specified name.
    */
    function GetFieldIdByName($FieldName, $IgnoreCase = FALSE)
    {
        static $FieldIdsByName;

        # if caching is off or field ID is already loaded
        if (($this->CachingOn != TRUE) || !isset($FieldIdsByName[$this->Id][$FieldName]))
        {
            # retrieve field ID from DB
            $Condition = $IgnoreCase
                    ? "WHERE LOWER(FieldName) = '".addslashes(strtolower($FieldName))."'"
                    : "WHERE FieldName = '".addslashes($FieldName)."'";
            $Condition .= " AND SchemaId = ".intval($this->Id);
            $FieldIdsByName[$this->Id][$FieldName] = $this->DB->Query(
                    "SELECT FieldId FROM MetadataFields ".$Condition, "FieldId");
        }

        return $FieldIdsByName[$this->Id][$FieldName];
    }

    /**
    * Retrieve metadata field ID by label.
    * @param string $FieldLabel Field label.
    * @param bool $IgnoreCase If TRUE, case is ignore when matching field labels.
    * @return ID of requested MetadataField or FALSE if no field found with
    *       specified label.
    */
    function GetFieldIdByLabel($FieldLabel, $IgnoreCase = FALSE)
    {
        static $FieldIdsByLabel;

        # if caching is off or field ID is already loaded
        if (($this->CachingOn != TRUE) || !isset($FieldIdsByLabel[$FieldLabel]))
        {
            # retrieve field ID from DB
            $Condition = $IgnoreCase
                    ? "WHERE LOWER(Label) = '".addslashes(strtolower($FieldLabel))."'"
                    : "WHERE Label = '".addslashes($FieldLabel)."'";
            $Condition .= " AND SchemaId = ".intval($this->Id);
            $FieldIdsByLabel[$FieldLabel] = $this->DB->Query(
                    "SELECT FieldId FROM MetadataFields ".$Condition, "FieldId");
        }

        return $FieldIdsByLabel[$FieldLabel];
    }

    /**
    * Check whether field with specified name exists.
    * @param string $FieldName Name of field.
    * @return TRUE if field with specified name exists, otherwise FALSE.
    */
    function FieldExists($FieldName) {  return $this->NameIsInUse($FieldName);  }

    /**
    * Retrieve array of fields.
    * @param int $FieldTypes MetadataField types (MDFTYPE_ values) to retrieve, ORed
    *       together, or NULL to return all types of fields.  (OPTIONAL, defaults
    *       to NULL)
    * @param int $OrderType Order in which to return fields (MDFORDER_ value).
    *       (OPTIONAL, defaults to NULL which indicates no particular order)
    * @param bool $IncludeDisabledFields TRUE to include disabled fields.  (OPTIONAL,
    *       defaults to FALSE)
    * @param bool $IncludeTempFields TRUE to include temporary fields (in the process
    *       of being created/edited).  (OPTIONAL, defaults to FALSE)
    * @return Array of MetadataField objects, with field IDs for array index.
    */
    function GetFields($FieldTypes = NULL, $OrderType = NULL,
            $IncludeDisabledFields = FALSE, $IncludeTempFields = FALSE)
    {
        # create empty array to pass back
        $Fields = array();

        # for each field type in database
        $this->DB->Query("SELECT FieldId, FieldType FROM MetadataFields"
                    ." WHERE SchemaId = ".intval($this->Id)
                    .(!$IncludeDisabledFields ? " AND Enabled != 0" : "")
                    .(!$IncludeTempFields ? " AND FieldId >= 0" : ""));
        while ($Record = $this->DB->FetchRow())
        {
            # if field type is known
            if (array_key_exists($Record["FieldType"], MetadataField::$FieldTypePHPEnums))
            {
                # if no specific type requested or if field is of requested type
                if (($FieldTypes == NULL)
                        || (MetadataField::$FieldTypePHPEnums[$Record["FieldType"]]
                                & $FieldTypes))
                {
                    # create field object and add to array to be passed back
                    $Fields[$Record["FieldId"]] = $this->GetField($Record["FieldId"]);
                }
            }
        }

        # if field sorting requested
        if ($OrderType !== NULL)
        {
            # update field comparison ordering if not set yet
            if (!$this->FieldCompareOrdersSet())
            {
                $this->UpdateFieldCompareOrders();
            }

            $this->FieldCompareType = $OrderType;

            # sort field array by requested order type
            uasort($Fields, array($this, "CompareFieldOrder"));
        }

        # return array of field objects to caller
        return $Fields;
    }

    /**
    * Retrieve field names.
    * @param int $FieldTypes MetadataField types (MDFTYPE_ values) to retrieve, ORed
    *       together, or NULL to return all types of fields.  (OPTIONAL, defaults
    *       to NULL)
    * @param int $OrderType Order in which to return fields (MDFORDER_ value).
    *       (OPTIONAL, defaults to NULL which indicates no particular order)
    * @param bool $IncludeDisabledFields TRUE to include disabled fields.  (OPTIONAL,
    *       defaults to FALSE)
    * @param bool $IncludeTempFields TRUE to include temporary fields (in the process
    *       of being created/edited).  (OPTIONAL, defaults to FALSE)
    * @return Array of field names, with field IDs for array index.
    *
    */
    function GetFieldNames($FieldTypes = NULL, $OrderType = NULL,
            $IncludeDisabledFields = FALSE, $IncludeTempFields = FALSE)
    {
        $Fields = $this->GetFields($FieldTypes, $OrderType,
                $IncludeDisabledFields, $IncludeTempFields);

        $FieldNames = array();
        foreach($Fields as $Field)
        {
            $FieldNames[$Field->Id()] = $Field->Name();
        }

        return $FieldNames;
    }

    /**
    * Retrieve fields of specified type as HTML option list with field names
    * as labels and field IDs as value attributes.  The first element on the list
    * will have a label of "--" and an ID of -1 to indicate no field selected.
    * @param string $OptionListName Value of option list "name" and "id" attributes.
    * @param int $FieldTypes Types of fields to return.  (OPTIONAL - use NULL for all types)
    * @param int $SelectedFieldId ID or array of IDs of the currently-selected
    *       field(s).  (OPTIONAL)
    * @param bool $IncludeNullOption Whether to include "no selection" (-1) option.
    *       (OPTIONAL - defaults to TRUE)
    * @param array $AddEntries An array of additional entries to include at the end of
    *       the option list, with option list values for the indexes and option list
    *       labels for the values.  (OPTIONAL)
    * @param bool $AllowMultiple TRUE to allow multiple field selections
    * @return HTML for option list.
    */
    function GetFieldsAsOptionList($OptionListName, $FieldTypes = NULL,
            $SelectedFieldId = NULL, $IncludeNullOption = TRUE,
            $AddEntries = NULL, $AllowMultiple = FALSE)
    {
        # retrieve requested fields
        $FieldNames = $this->GetFieldNames($FieldTypes);

        # transform field names to labels
        foreach ($FieldNames as $FieldId => $FieldName)
        {
            $FieldNames[$FieldId] = $this->GetField($FieldId)->GetDisplayName();
        }

        # begin HTML option list
        $Html = "<select id=\"".$OptionListName."\" name=\"".$OptionListName."\"";

        # if multiple selections should be allowed
        if ($AllowMultiple)
        {
            $Html .= " multiple=\"multiple\"";
        }

        $Html .= ">\n";

        if ($IncludeNullOption)
        {
            $Html .= "<option value=\"\">--</option>\n";
        }

        # make checking for IDs simpler
        if (!is_array($SelectedFieldId))
        {
            $SelectedFieldId = array($SelectedFieldId);
        }

        # for each metadata field
        foreach ($FieldNames as $Id => $Name)
        {
            # add entry for field to option list
            $Html .= "<option value=\"".$Id."\"";
            if (in_array($Id, $SelectedFieldId)) {  $Html .= " selected";  }
            $Html .= ">".htmlspecialchars($Name)."</option>\n";
        }

        # if additional entries were requested
        if ($AddEntries)
        {
            foreach ($AddEntries as $Value => $Label)
            {
                $Html .= "<option value=\"".$Value."\"";
                if (in_array($Value, $SelectedFieldId))
                {
                    $Html .= " selected";
                }
                $Html .= ">".htmlspecialchars($Label)."</option>\n";
            }
        }

        # end HTML option list
        $Html .= "</select>\n";

        # return constructed HTML to caller
        return $Html;
    }

    /**
    * Retrieve array of field types.
    * @return Array with enumerated types for the indexes and field names
    *       (strings) for the values.
    */
    function GetFieldTypes()
    {
        return MetadataField::$FieldTypeDBEnums;
    }

    /**
    * Retrieve array of field types that user can create.
    * @return Array with enumerated types for the indexes and field names
    *       (strings) for the values.
    */
    function GetAllowedFieldTypes()
    {
        return MetadataField::$FieldTypeDBAllowedEnums;
    }

    /**
    * Remove all metadata field associations for a given qualifier.
    * @param Qualifier $QualifierIdOrObject Qualifier object or ID.
    */
    function RemoveQualifierAssociations($QualifierIdOrObject)
    {
        # sanitize qualifier ID or grab it from object
        $QualifierIdOrObject = is_object($QualifierIdOrObject)
                ? $QualifierIdOrObject->Id() : intval($QualifierIdOrObject);

        # delete intersection records from database
        $this->DB->Query("DELETE FROM FieldQualifierInts"
                ." WHERE QualifierId = ".$QualifierIdOrObject);
    }

    /**
    * Check whether qualifier is in use by any metadata field (in any schema).
    * @param Qualifier $QualifierIdOrObject Qualifier ID or Qualifier object.
    * @return TRUE if qualifier is in use, otherwise FALSE.
    */
    function QualifierIsInUse($QualifierIdOrObject)
    {
        # sanitize qualifier ID or grab it from object
        $QualifierIdOrObject = is_object($QualifierIdOrObject)
                ? $QualifierIdOrObject->Id() : intval($QualifierIdOrObject);

        # determine whether any fields use qualifier as default
        $DefaultCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount"
                ." FROM MetadataFields"
                ." WHERE DefaultQualifier = ".$QualifierIdOrObject,
                "RecordCount");

        # determine whether any fields are associated with qualifier
        $AssociationCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount"
                ." FROM FieldQualifierInts"
                ." WHERE QualifierId = ".$QualifierIdOrObject,
                "RecordCount");

        # report whether qualifier is in use based on defaults and associations
        return (($DefaultCount + $AssociationCount) > 0) ? TRUE : FALSE;
    }

    /**
    * Get highest field ID currently in use.
    * @return MetadataField ID value.
    */
    function GetHighestFieldId() {  return $this->GetHighestItemId();  }

    /**
    * Get/set mapping of standard field name to specific field.
    * @param string $MappedName Standard field name.
    * @param int $FieldId ID of field to map to. (OPTIONAL)
    * @return ID of field to which standard field name is mapped or NULL if
    *       specified standard field name is not currently mapped.
    */
    static function StdNameToFieldMapping($MappedName, $FieldId = NULL)
    {
        if ($FieldId !== NULL)
        {
            self::$FieldMappings[$MappedName] = $FieldId;
        }
        return isset(self::$FieldMappings[$MappedName])
                ? self::$FieldMappings[$MappedName] : NULL;
    }

    /**
    * Get mapping of field ID to standard field name.
    * @param int $FieldId Field ID.
    * @return Standard field name to which specified field is mapped, or
    *       NULL if field is not currently mapped.
    */
    static function FieldToStdNameMapping($FieldId)
    {
        if ($FieldId != -1)
        {
            foreach (self::$FieldMappings as $MappedName => $MappedFieldId)
            {
                if ($MappedFieldId == $FieldId)
                {
                    return $MappedName;
                }
            }
        }
        return NULL;
    }

    /**
    * Get field by standard field name.
    * @param string $MappedName Standard field name.
    * @return MetadataField to which standard field name is mapped or NULL
    *       if specified standard field name is not currently mapped or mapped
    *       field does not exist.
    */
    function GetFieldByMappedName($MappedName)
    {
        return (self::StdNameToFieldMapping($MappedName) == NULL) ? NULL
                : $this->GetField($this->StdNameToFieldMapping($MappedName));
    }

    /**
    * Get field ID by standard field name.
    * @param string $MappedName Standard field name.
    * @return ID for MetadataField to which standard field name is mapped or NULL
    *       if specified standard field name is not currently mapped or mapped
    *       field does not exist.
    */
    function GetFieldIdByMappedName($MappedName)
    {
        return self::StdNameToFieldMapping($MappedName);
    }

    /**
    * Get fields that have an owner associated with them.
    * @return Array of fields that have an owner associated with them.
    */
    function GetOwnedFields()
    {
        $Fields = array();

        $this->DB->Query("SELECT * FROM MetadataFields"
                ." WHERE Owner IS NOT NULL AND LENGTH(Owner) > 0"
                ." AND SchemaId = ".intval($this->Id));

        while (FALSE !== ($Row = $this->DB->FetchRow()))
        {
            $FieldId = $Row["FieldId"];
            $Fields[$FieldId] = $this->GetField($FieldId);
        }

        return $Fields;
    }

    /**
    * Get all existing metadata schemas.
    * @return Returns an array of MetadataSchema objects with the schema
    *       IDs for the index.
    */
    static function GetAllSchemas()
    {
        $Database = new Database();
        $Schemas = array();

        # fetch the IDs all of the metadata schemas
        $Database->Query("SELECT * FROM MetadataSchemas");
        $SchemaIds = $Database->FetchColumn("SchemaId");

        # construct objects from the IDs
        foreach ($SchemaIds as $SchemaId)
        {
            $Schemas[$SchemaId] = new MetadataSchema($SchemaId);
        }

        return $Schemas;
    }

    /**
    * Allow external dependencies, i.e., the current list of owners that are
    * available, to be injected.
    * @param $Callback retrieval callback
    */
    static function SetOwnerListRetrievalFunction($Callback)
    {
        if (is_callable($Callback))
        {
            self::$OwnerListRetrievalFunction = $Callback;
        }
    }

    /**
    * Disable owned fields that have an owner that is unavailable and
    * re-enable fields if an owner has returned and the field was flagged to
    * be re-enabled.
    */
    static function NormalizeOwnedFields()
    {
        # if an owner list retrieval function and default schema exists
        if (self::$OwnerListRetrievalFunction
                && self::SchemaExistsWithId(self::SCHEMAID_DEFAULT))
        {
            # retrieve the list of owners that currently exist
            $OwnerList = call_user_func(self::$OwnerListRetrievalFunction);

            # an array is expected
            if (is_array($OwnerList))
            {
                $Schema = new MetadataSchema(self::SCHEMAID_DEFAULT);

                # get each metadata field that is owned by a plugin
                $OwnedFields = $Schema->GetOwnedFields();

                # loop through each owned field
                foreach ($OwnedFields as $OwnedField)
                {
                    # the owner of the current field
                    $Owner = $OwnedField->Owner();

                    # if the owner of the field is in the list of owners that
                    # currently exist, i.e., available plugins
                    if (in_array($Owner, $OwnerList))
                    {
                        # enable the field and reset its "enable on owner return"
                        # flag if the "enable on owner return" flag is currently
                        # set to true. in other words, re-enable the field since
                        # the owner has returned to the list of existing owners
                        if ($OwnedField->EnableOnOwnerReturn())
                        {
                            $OwnedField->Enabled(TRUE);
                            $OwnedField->EnableOnOwnerReturn(FALSE);
                        }
                    }

                    # if the owner of the field is *not* in the list of owners
                    # that currently exist, i.e., available plugins
                    else
                    {
                        # first, see if the field is currently enabled since it
                        # will determine whether the field is re-enabled when
                        # the owner becomes available again
                        $Enabled = $OwnedField->Enabled();

                        # if the field is enabled, set its "enable on owner
                        # return" flag to true and disable the field. nothing
                        # needs to be done if the field is already disabled
                        if ($Enabled)
                        {
                            $OwnedField->EnableOnOwnerReturn($Enabled);
                            $OwnedField->Enabled(FALSE);
                        }
                    }
                }
            }
        }
    }

    /**
    * Update the field comparison ordering cache that is used for sorting
    * fields.
    */
    protected function UpdateFieldCompareOrders()
    {
        $Index = 0;

        foreach ($this->GetDisplayOrder()->GetFields() as $Field)
        {
            $this->FieldCompareDisplayOrder[$Field->Id()] = $Index++;
        }

        $Index = 0;

        foreach ($this->GetEditOrder()->GetFields() as $Field)
        {
            $this->FieldCompareEditOrder[$Field->Id()] = $Index++;
        }
    }

    /**
    * Get the display order for the schema.
    * @return Returns a MetadataFieldOrder object.
    */
    function GetDisplayOrder()
    {
        # try to fetch an existing display order
        $DisplayOrder = MetadataFieldOrder::GetOrderForSchema(
            $this,
            self::ORDER_DISPLAY_NAME);

        # if the order doesn't exist
        if (is_null($DisplayOrder))
        {
            $OldId = $GLOBALS["SysConfig"]->FieldDisplayFolder();

            # if the older version of MetadataFieldOrder was in use
            if ($OldId && $this->Id() == self::SCHEMAID_DEFAULT)
            {
                # add an entry for the existing folder
                $this->DB->Query("
                    INSERT INTO MetadataFieldOrders
                    SET SchemaId = '".addslashes($this->Id())."',
                    OrderId = '".addslashes($OldId)."',
                    OrderName = '".addslashes(self::ORDER_DISPLAY_NAME)."'");

                # use that folder
                $DisplayOrder = new MetadataFieldOrder($OldId);
            }

            # otherwise, just create a new order
            else
            {
                $DisplayOrder = MetadataFieldOrder::Create(
                    $this,
                    self::ORDER_DISPLAY_NAME,
                    self::GetOrderForUpgrade($this, self::ORDER_DISPLAY_NAME));
            }
        }

        return $DisplayOrder;
    }

    /**
    * Get the editing order for the schema.
    * @return Returns a MetadataFieldOrder object.
    */
    function GetEditOrder()
    {
        # try to fetch an existing edit order
        $EditOrder = MetadataFieldOrder::GetOrderForSchema(
            $this,
            self::ORDER_EDIT_NAME);

        # if the order doesn't exist
        if (is_null($EditOrder))
        {
            $OldId = $GLOBALS["SysConfig"]->FieldEditFolder();

            # if the older version of MetadataFieldOrder was in use
            if ($OldId && $this->Id() == self::SCHEMAID_DEFAULT)
            {
                # add an entry for the existing folder
                $this->DB->Query("
                    INSERT INTO MetadataFieldOrders
                    SET SchemaId = '".addslashes($this->Id())."',
                    OrderId = '".addslashes($OldId)."',
                    OrderName = '".addslashes(self::ORDER_EDIT_NAME)."'");

                # use that folder
                $EditOrder = new MetadataFieldOrder($OldId);
            }

            # otherwise, just create a new order
            else
            {
                $EditOrder = MetadataFieldOrder::Create(
                    $this,
                    self::ORDER_EDIT_NAME,
                    self::GetOrderForUpgrade($this, self::ORDER_EDIT_NAME));
            }
        }

        return $EditOrder;
    }

    /**
    * Determine whether the field comparison ordering caches are set.
    * @return bool TRUE if the caches are set or FALSE otherwise
    */
    protected function FieldCompareOrdersSet()
    {
        return $this->FieldCompareDisplayOrder && $this->FieldCompareEditOrder;
    }

    /**
    * Field sorting callback.
    * @param MetadataField $FieldA first comparision field
    * @param MetadataFIeld $FieldB second comparison field
    * @return bool -1, 0, or 1, depending on the order desired
    * @see usort()
    */
    protected function CompareFieldOrder($FieldA, $FieldB)
    {
        if ($this->FieldCompareType == MetadataSchema::MDFORDER_ALPHABETICAL)
        {
            return ($FieldA->GetDisplayName() < $FieldB->GetDisplayName()) ? -1 : 1;
        }

        if ($this->FieldCompareType == MetadataSchema::MDFORDER_EDITING)
        {
            $Order = $this->FieldCompareEditOrder;
        }

        else
        {
            $Order = $this->FieldCompareDisplayOrder;
        }

        $PositionA = GetArrayValue($Order, $FieldA->Id(), 0);
        $PositionB = GetArrayValue($Order, $FieldB->Id(), 0);

        return $PositionA < $PositionB ? -1 : 1;
    }

    /**
    * Get the metadata field order for the default metadata schema. This will
    * use the MetadataFields table If upgrading or the defaults included in this
    * class.
    * @param MetadataSchema $Schema Schema for which to get the field order.
    * @param string $Name The order name.
    * @return Returns an array of the field order to use.
    */
    protected static function GetOrderForUpgrade(MetadataSchema $Schema, $Name)
    {
        # don't do an upgrade for non-default schemas
        if ($Schema->Id() !== self::SCHEMAID_DEFAULT)
        {
            return array();
        }

        # get the default display order
        if ($Name == self::ORDER_DISPLAY_NAME)
        {
            # try to get the order from the database and, failing that, use the
            # defaults in the class
            $Rows = self::GetRowsForUpgrade(self::MDFORDER_DISPLAY);
            return count($Rows) ? $Rows: self::$DefaultDisplayOrder;
        }

        # get the default edit order
        if ($Name == self::ORDER_EDIT_NAME)
        {
            # try to get the order from the database and, failing that, use the
            # defaults in the class
            $Rows = self::GetRowsForUpgrade(self::MDFORDER_EDITING);
            return count($Rows) ? $Rows: self::$DefaultEditOrder;
        }

        # otherwise make no assumptions about the order
        return array();
    }

    /**
    * Get rows for upgrading purposes. This should only be run if an upgrade
    * should be performed.
    * @param int $Type ordering type from MetadataSchema::MDFORDER_...
    * @return array rows of field IDs in the correct order for the type
    * @see MetadataFieldOrdering::ShouldPerformOrderingUpgrade()
    */
    protected static function GetRowsForUpgrade($Type)
    {
        $Database = new Database();

        # temporarily suppress errors
        $Setting = Database::DisplayQueryErrors();
        Database::DisplayQueryErrors(FALSE);

        # see if the old columns exist
        $Handle = $Database->Query("
            SELECT EditingOrderPosition
            FROM MetadataFields
            LIMIT 1");

        # the columns do not exist so an upgrade cannot be performed
        if ($Handle === FALSE)
        {
            return array();
        }

        # determine which column to use for ordering
        $Column = $Type == MetadataSchema::MDFORDER_EDITING
            ? "DisplayOrderPosition" : "EditingOrderPosition";

        # query for the fields in their proper order
        $Database->Query("
            SELECT FieldId
            FROM MetadataFields
            WHERE FieldId > 0
            ORDER BY ".$Column." ASC");

        # restore the earlier error setting
        Database::DisplayQueryErrors($Setting);

        # return the resulting field IDs
        return $Database->FetchColumn("FieldId");
    }

    /**
    * The default display order for metadata fields. The key of each item is the
    * position and the value is the metadata field ID.
    */
    protected static $DefaultDisplayOrder = array(
        0 => 42,
        1 => 41,
        2 => 43,
        3 => 44,
        4 => 46,
        5 => 45,
        6 => 40,
        7 => 39,
        8 => 34,
        9 => 33,
        10 => 37,
        11 => 36,
        12 => 38,
        13 => 35,
        14 => 47,
        15 => 48,
        16 => 57,
        17 => 56,
        18 => 58,
        19 => 59,
        20 => 61,
        21 => 60,
        22 => 55,
        23 => 54,
        24 => 50,
        25 => 49,
        26 => 51,
        27 => 52,
        28 => 53,
        29 => 32,
        30 => 31,
        31 => 1,
        32 => 2,
        33 => 4,
        34 => 20,
        35 => 21,
        36 => 19,
        37 => 3,
        38 => 27,
        39 => 11,
        41 => 23,
        42 => 26,
        43 => 25,
        44 => 24,
        45 => 9,
        46 => 8,
        47 => 7,
        48 => 6,
        49 => 22,
        50 => 10,
        51 => 28,
        52 => 5,
        53 => 12,
        54 => 62,
        55 => 13,
        56 => 14,
        57 => 16,
        58 => 17,
        59 => 30,
        60 => 29,
        61 => 18,
        62 => 63,
        63 => 64,
        64 => 65,
        65 => 66);

    /**
    * The default editing order for metadata fields. The key of each item is the
    * position and the value is the metadata field ID.
    */
    protected static $DefaultEditOrder = array(
        0 => 42,
        1 => 41,
        2 => 43,
        3 => 44,
        4 => 46,
        5 => 45,
        6 => 40,
        7 => 39,
        8 => 34,
        9 => 33,
        10 => 37,
        11 => 36,
        12 => 38,
        13 => 35,
        14 => 47,
        15 => 48,
        16 => 57,
        17 => 56,
        18 => 58,
        19 => 59,
        20 => 61,
        21 => 60,
        22 => 55,
        23 => 54,
        24 => 50,
        25 => 49,
        26 => 51,
        27 => 52,
        28 => 53,
        29 => 32,
        30 => 31,
        31 => 1,
        32 => 2,
        33 => 4,
        34 => 20,
        36 => 21,
        37 => 19,
        38 => 3,
        39 => 27,
        40 => 11,
        41 => 23,
        42 => 26,
        43 => 25,
        44 => 24,
        45 => 9,
        46 => 8,
        47 => 30,
        48 => 7,
        49 => 6,
        50 => 29,
        51 => 22,
        52 => 10,
        53 => 28,
        54 => 5,
        55 => 12,
        56 => 62,
        57 => 13,
        58 => 18,
        59 => 14,
        60 => 16,
        61 => 17,
        62 => 63,
        63 => 64,
        64 => 65,
        65 => 66);

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

    private $Id;
    private $Name;
    private $ResourceName;
    private $AuthoringPrivileges;
    private $EditingPrivileges;
    private $ViewingPrivileges;
    private $ViewPage;
    private $FieldCompareType;
    private $CachingOn;
    private $NewFields = array();
    private $ErrorMsgs = array();
    private static $FieldMappings;
    protected static $OwnerListRetrievalFunction;

    /**
    * The cache for metadata field display ordering.
    */
    protected $FieldCompareDisplayOrder = array();

    /**
    * The cache for metadata field edit ordering.
    */
    protected $FieldCompareEditOrder = array();

    /**
    * Convert SimpleXmlElement to PrivilegeSet.  Any error messages resulting
    * from failed conversion can be retrieved with ErrorMessages().
    * @param object $Xml Element containing privilege XML.
    * @return object Resulting PrivilegeSet object or NULL if conversion failed.
    * @see MetadataSchema::ErrorMessages()
    */
    private function ConvertXmlToPrivilegeSet($Xml)
    {
        # clear any existing errors
        if (array_key_exists(__METHOD__, $this->ErrorMsgs))
                {  unset($this->ErrorMsgs[__METHOD__]);  }

        # create new privilege set
        $PrivSet = new PrivilegeSet();

        # for each XML child
        foreach ($Xml as $Tag => $Value)
        {
            # take action based on element name
            switch ($Tag)
            {
                case "PrivilegeSet":
                    # convert child data to new set
                    $NewSet = $this->ConvertXmlToPrivilegeSet($Value);

                    # add new set to our privilege set
                    $PrivSet->AddSet($NewSet);
                    break;

                case "AddCondition":
                    # start with default values for optional parameters
                    unset($ConditionField);
                    $ConditionValue = NULL;
                    $ConditionOperator = "==";

                    # pull out parameters
                    foreach ($Value as $ParamName => $ParamValue)
                    {
                        $ParamValue = trim($ParamValue);
                        switch ($ParamName)
                        {
                            case "Field":
                                $ConditionField = $this->GetFieldByName(
                                        (string)$ParamValue, TRUE);
                                if ($ConditionField === NULL)
                                {
                                    # record error about unknown field
                                    $this->ErrorMsgs[__METHOD__][] =
                                            "Unknown metadata field name found"
                                            ." in AddCondition (".$ParamValue.").";

                                    # bail out
                                    return NULL;
                                }
                                break;

                            case "Value":
                                $ConditionValue = ($ParamValue == "NULL")
                                        ? NULL : (string)$ParamValue;
                                break;

                            case "Operator":
                                $ConditionOperator = (string)$ParamValue;
                                break;

                            default:
                                # record error about unknown parameter name
                                $this->ErrorMsgs[__METHOD__][] =
                                        "Unknown tag found in AddCondition ("
                                        .$ParamName.").";

                                # bail out
                                return NULL;
                                break;
                        }
                    }

                    # if no field value
                    if (!isset($ConditionField))
                    {
                        # record error about no field value
                        $this->ErrorMsgs[__METHOD__][] =
                                "No metadata field specified in AddCondition.";

                        # bail out
                        return NULL;
                    }

                    # add conditional to privilege set
                    $PrivSet->AddCondition($ConditionField,
                            $ConditionValue, $ConditionOperator);
                    break;

                default:
                    # strip any excess whitespace off of value
                    $Value = trim($Value);

                    # if child looks like valid method name
                    if (method_exists("PrivilegeSet", $Tag))
                    {
                        # convert constants if needed
                        if (defined($Value)) {  $Value = constant($Value);  }

                        # convert booleans if needed
                        if (strtoupper($Value) == "TRUE") {  $Value = TRUE;  }
                        elseif (strtoupper($Value) == "FALSE") {  $Value = FALSE;  }

                        # set value using child data
                        $PrivSet->$Tag((string)$Value);
                    }
                    else
                    {
                        # record error about bad tag
                        $this->ErrorMsgs[__METHOD__][] =
                                "Unknown tag encountered (".$Tag.").";

                        # bail out
                        return NULL;
                    }
                    break;
            }
        }

        # return new privilege set to caller
        return $PrivSet;
    }
}
