<?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_RESOURCES = 0;
    const SCHEMAID_USER = 1;
    const SCHEMAID_USERS = 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()
    */
    public function __construct($SchemaId = self::SCHEMAID_DEFAULT)
    {
        # set schema ID
        $this->Id = $SchemaId;

        # set up item factory base class
        parent::__construct(
            "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.") at "
                    .StdLib::GetMyCaller().".");
        }
        $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"];

        if (!isset(self::$FieldMappings[$this->Id]))
        {
            self::$FieldMappings[$this->Id] = array();
        }

        # 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.
    */
    public 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)
    * @param string $ResourceName User-readable name for resources for which
    *       the schema will be used.  (OPTIONAL, defaults to singular version
    *       of schema name)
    * @return object MetadataSchema object.
    */
    public 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) == "RESOURCES")
        {
            $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)."',"
                        ."'".$DB->EscapeString($ViewPage)."',"
                        ."'".$DB->EscapeString($AuthorPrivs->Data())."',"
                        ."'".$DB->EscapeString($EditPrivs->Data())."',"
                        ."'".$DB->EscapeString($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 ($ResourceName === NULL)
        {
            $ResourceName = StdLib::Singularize($Name);
        }
        $Schema->ResourceName($ResourceName);

        # create display and edit orders
        MetadataFieldOrder::Create($Schema, self::ORDER_DISPLAY_NAME, array() );
        MetadataFieldOrder::Create($Schema, self::ORDER_EDIT_NAME, array() );

        # return the new schema
        return $Schema;
    }

    /**
    * Destroy metadata schema.  Schema may no longer be used after this
    * method is called.
    */
    public function Delete()
    {
        # delete resources associated with schema
        $RFactory = new ResourceFactory($this->Id);
        $ResourceIds = $RFactory->GetItemIds();
        foreach ($ResourceIds as $ResourceId)
        {
            $Resource = new Resource($ResourceId);
            $Resource->Delete();
        }

        # delete fields associated with schema
        $Fields = $this->GetFields(NULL, NULL, TRUE, TRUE);
        foreach ($Fields as $FieldId => $Field)
        {
            $this->DropField($FieldId);
        }

        # delete metadata field orders associated with schema
        foreach (MetadataFieldOrder::GetOrdersForSchema($this) as $Order)
        {
            $Order->Delete();
        }

        # remove schema info from database
        $this->DB->Query("DELETE FROM MetadataSchemas WHERE SchemaId = "
                .intval($this->Id));
    }

    /**
    * Check with schema exists with specified ID.
    * @param int $SchemaId ID to check.
    * @return bool TRUE if schema exists with specified ID, otherwise FALSE.
    */
    public 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.
    */
    public function Id()
    {
        # return value to caller
        return intval($this->Id);
    }

    /**
    * Get/set name of schema.
    * @param string $NewValue New name for schema.  (OPTIONAL)
    * @return string Current schema name.
    */
    public 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 abbreviated name of schema.
     * The abbreviated name is one letter long, usually used
     * by tag names.
     * @param string $NewValue New abbreviated name for schema. (OPTIONAL)
     * @return string Current schema abbreviated name.
     */
    public function AbbreviatedName($NewValue = NULL)
    {
        # set new abbreviated name if one supplied
        if ($NewValue !== NULL)
        {
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET AbbreviatedName = '".addslashes($NewValue)."'"
                    ." WHERE SchemaId = '".intval($this->Id)."'");
            $this->AbbreviatedName = $NewValue;
        }

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

        # most schema won't have this field set
        # fall back to the default value: the first letter of
        # the schema name
        if ($this->AbbreviatedName === NULL || $this->AbbreviatedName == "")
        {
            $this->AbbreviatedName = strtoupper(substr($this->Name(), 0, 1));
        }

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

    /**
    * Get/set name of resources using this schema.
    * @param string $NewValue New resource name for schema.  (OPTIONAL)
    * @return Returns the current resource name.
    */
    public 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.
    */
    public 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.
    */
    public function AuthoringPrivileges(PrivilegeSet $NewValue = NULL)
    {
        # if new privileges supplied
        if ($NewValue !== NULL)
        {
            # store new privileges in database
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET AuthoringPrivileges = '"
                            .$this->DB->EscapeString($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.
    */
    public function EditingPrivileges(PrivilegeSet $NewValue = NULL)
    {
        # if new privileges supplied
        if ($NewValue !== NULL)
        {
            # store new privileges in database
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET EditingPrivileges = '"
                            .$this->DB->EscapeString($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.
    */
    public function ViewingPrivileges(PrivilegeSet $NewValue = NULL)
    {
        # if new privileges supplied
        if ($NewValue !== NULL)
        {
            # store new privileges in database
            $this->DB->Query("UPDATE MetadataSchemas"
                    ." SET ViewingPrivileges = '"
                            .$this->DB->EscapeString($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 to check.
    * @return bool TRUE if the user can author resources and FALSE otherwise
    */
    public function UserCanAuthor($User)
    {
        # get authoring privilege set for schema
        $AuthorPrivs = $this->AuthoringPrivileges();

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

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

    /**
    * Determine if the given user can edit resources using this schema.
    * The result of this method can be modified via the
    * EVENT_RESOURCE_EDIT_PERMISSION_CHECK event.
    * @param User $User User to check.
    * @return bool TRUE if the user can edit resources and FALSE otherwise
    */
    public function UserCanEdit($User)
    {
        # get editing privilege set for schema
        $EditPrivs = $this->EditingPrivileges();

        # user can edit if privileges are greater than resource set
        $CanEdit = $EditPrivs->MeetsRequirements($User);

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

        # report back to caller whether user can edit field
        return $CanEdit;
    }

    /**
    * Determine if the given user can view resources using this schema.
    * The result of this method can be modified via the
    * EVENT_RESOURCE_VIEW_PERMISSION_CHECK event.
    * @param User $User User to check.
    * @return bool TRUE if the user can view resources and FALSE otherwise
    */
    public function UserCanView($User)
    {
        # get viewing privilege set for schema
        $ViewPrivs = $this->ViewingPrivileges();

        # user can view if privileges are greater than resource set
        $CanView = $ViewPrivs->MeetsRequirements($User);

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

        # report back to caller whether user can view field
        return $CanView;
    }

    /**
    * 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.
    */
    public 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.
    */
    public 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.
    */
    public 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.
    */
    public 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 string $Owner Owner to set for new fields.  (OPTIONAL, supply
    *       NULL to not set an owner)
    * @param bool $TestRun If TRUE, any new fields created are removed before
    *       the method returns.  (OPTIONAL, defaults to FALSE)
    * @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()
    */
    public function AddFieldsFromXmlFile($FileName, $Owner = NULL, $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();
            }
            else
            {
                # set owner for new fields (if supplied)
                if ($Owner !== NULL)
                {
                    foreach ($this->NewFields as $Field)
                    {
                        $Field->Owner($Owner);
                    }
                }
            }
        }

        # 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()
    */
    public 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()
    */
    public 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.
    */
    public 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.
    */
    public function DropField($FieldId)
    {
        $Field = $this->GetField($FieldId);
        if ($Field !== NULL)
        {
            global $AF;
            $AF->SignalEvent("EVENT_PRE_FIELD_DELETE",
                array("FieldId" => $Field->Id()) );

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

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

        # convert field name to ID if necessary
        if (!is_numeric($FieldId))
        {
            $FieldName = $FieldId;
            $FieldId = $this->GetFieldIdByName($FieldName);
            if ($FieldId === FALSE)
            {
                throw new InvalidArgumentException("Attempt to retrieve field"
                        ." with unknown name (".$FieldName.").");
            }
        }

        # 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 if it comes from our schema
        return ($Fields[$FieldId] !== NULL &&
                $this->Id() == $Fields[$FieldId]->SchemaId()) ?
                $Fields[$FieldId] : NULL;
    }

    /**
    * Retrieve metadata field by name.  This method is deprecated in favor
    * of GetField(), which has been updated to accept a field ID or 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.
    * @deprecated
    * @see MetadataSchema::GetField()
    */
    public function GetFieldByName($FieldName, $IgnoreCase = FALSE)
    {
        $FieldId = $this->GetFieldIdByName($FieldName, $IgnoreCase);
        return ($FieldId === FALSE) ? 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.
    */
    public 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.
    */
    public function GetFieldIdByName($FieldName, $IgnoreCase = FALSE)
    {
        return $this->GetItemIdByName($FieldName, $IgnoreCase);
    }

    /**
    * 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.
    */
    public 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 mixed $Field Name or ID of field.
    * @return TRUE if field with specified name exists, otherwise FALSE.
    */
    public function FieldExists($Field)
    {
        return is_numeric($Field)
                ? $this->ItemExists($Field)
                : $this->NameIsInUse($Field);
    }

    /**
    * 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.
    */
    public 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.
    *
    */
    public 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
    * @param bool $Disabled If TRUE, field will not be editable.
    * @return HTML for option list.
    */
    public function GetFieldsAsOptionList($OptionListName, $FieldTypes = NULL,
            $SelectedFieldId = NULL, $IncludeNullOption = TRUE,
            $AddEntries = NULL, $AllowMultiple = FALSE, $Disabled = FALSE)
    {
        # retrieve requested fields
        $FieldNames = $this->GetFieldNames($FieldTypes);

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

        # add in null entry if requested
        if ($IncludeNullOption)
        {
            $FieldNames = array("" => "--") + $FieldNames;
        }

        # add additional entries if supplied
        if ($AddEntries)
        {
            $FieldNames = $FieldNames + $AddEntries;
        }

        # construct option list
        $OptList = new HtmlOptionList($OptionListName, $FieldNames, $SelectedFieldId);
        $OptList->MultipleAllowed($AllowMultiple);
        $OptList->Disabled($Disabled);

        # return option list HTML to caller
        return $OptList->GetHtml();
    }

    /**
    * Retrieve array of field types.
    * @return Array with enumerated types for the indexes and field names
    *       (strings) for the values.
    */
    public 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.
    */
    public function GetAllowedFieldTypes()
    {
        return MetadataField::$FieldTypeDBAllowedEnums;
    }

    /**
    * Remove all metadata field associations for a given qualifier.
    * @param Qualifier $QualifierIdOrObject Qualifier object or ID.
    */
    public 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.
    */
    public 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.
    */
    public 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.
    */
    public function StdNameToFieldMapping($MappedName, $FieldId = NULL)
    {
        if ($FieldId !== NULL)
        {
            self::$FieldMappings[$this->Id][$MappedName] = $FieldId;
        }
        return isset(self::$FieldMappings[$this->Id][$MappedName])
                ? self::$FieldMappings[$this->Id][$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.
    */
    public function FieldToStdNameMapping($FieldId)
    {
        if ($FieldId != -1)
        {
            foreach (self::$FieldMappings[$this->Id] 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.
    */
    public function GetFieldByMappedName($MappedName)
    {
        return ($this->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.
    */
    public function GetFieldIdByMappedName($MappedName)
    {
        return $this->StdNameToFieldMapping($MappedName);
    }

    /**
    * Get fields that have an owner associated with them.
    * @return Array of fields that have an owner associated with them.
    */
    public 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;
    }

    /**
    * Determine if a Field exists in any schema.
    * @param mixed $Field Field name or FieldId to check
    * @return TRUE for fields that exist.
    */
    public static function FieldExistsInAnySchema($Field)
    {
        static $ValidFieldIds;

        # first time through, set up our lookup array
        if (!isset($ValidFieldIds))
        {
            $DB = new Database();
            $DB->Query("SELECT FieldId FROM MetadataFields");
            $ValidFieldIds = array_flip($DB->FetchColumn("FieldId"));
        }

        # if we were given a field id, check to see if it exists
        if (is_numeric($Field) &&
            array_key_exists($Field, $ValidFieldIds))
        {
            return TRUE;
        }

        # otherwise, try to look up this field
        try
        {
            $FieldId = self::GetCanonicalFieldIdentifier($Field);
            return array_key_exists($FieldId, $ValidFieldIds) ?
                TRUE : FALSE;
        }
        catch (Exception $e)
        {
            # if we can't find the field, then it doesn't exist
            return FALSE;
        }
    }

    /**
    * Retrieve canonical identifier for field.  If a name is passed in
    * and there are multiple fields with that name, the field belonging
    * to the schema with the lowest ID (usually the default schema) is
    * returned.  Names passed in are compared against field names, not
    * field labels.  This method should only be used in situations where
    * a static method is needed and there are no concerns about field
    * information changing during invocation.
    * @param mixed $Field Field object, ID, or name.
    * @return int Canonical field identifier.
    * @throws InvalidArgumentException If field argument supplied could
    *       not be interpreted.
    * @throws Exception If a field name is supplied that does not match
    *       any existing metadata field.
    */
    public static function GetCanonicalFieldIdentifier($Field)
    {
        # if field object was passed in
        if ($Field instanceof MetadataField)
        {
            # return identifier from field to caller
            return $Field->Id();
        }
        # else if field ID was passed in
        elseif (is_numeric($Field))
        {
            # return supplied field ID to caller
            return (int)$Field;
        }
        # else if field name was passed in
        elseif (is_string($Field))
        {
            # retrieve field names if not already loaded
            static $FieldNames;
            static $FieldNamesWithSchema;
            static $FieldSchemaIds;
            if (!isset($FieldNames))
            {
                $DB = new Database();

                $DB->Query("SELECT SchemaId, Name FROM MetadataSchemas");
                $SchemaNames = $DB->FetchColumn("Name", "SchemaId");

                $DB->Query("SELECT FieldId, FieldName, SchemaId"
                        ." FROM MetadataFields");
                while ($Row = $DB->FetchRow())
                {
                    if ($Row["SchemaId"] != self::SCHEMAID_DEFAULT)
                    {
                        $FieldNamesWithSchema[$Row["FieldId"]] =
                                $SchemaNames[$Row["SchemaId"]].": ".
                                $Row["FieldName"];
                    }
                    $FieldNames[$Row["FieldId"]] = $Row["FieldName"];
                    $FieldSchemaIds[$Row["FieldId"]] = $Row["SchemaId"];
                }
            }

            # look for the specified name with a schema prefix
            $Ids = array_keys($FieldNamesWithSchema, $Field);

            # if we find a unique schema-prefixed name, use that
            # (note that more than one schema-prefixed name shouldn't
            #  be possible because fields must be unique inside a schema)
            if (count($Ids) == 1)
            {
                return (int)$Ids[0];
            }
            elseif (count($Ids) > 1)
            {
                throw new Exception("Multiple schema-name prefixed "
                        ."metadata fields found, which should be impossible.");
            }

            # look for field with specified name
            $Ids = array_keys($FieldNames, $Field);

            # if one matching field was found
            $FieldsFound = count($Ids);
            if ($FieldsFound == 1)
            {
                # return ID for matching field
                return (int)$Ids[0];
            }
            # else if multiple matching fields were found
            elseif ($FieldsFound > 1)
            {
                # return ID of field with the lowest schema ID
                $LowestSchemaId = PHP_INT_MAX;
                foreach ($Ids as $Id)
                {
                    if ($FieldSchemaIds[$Id] < $LowestSchemaId)
                    {
                        $FieldId = $Id;
                        $LowestSchemaId = $FieldSchemaIds[$Id];
                    }
                }
                return $FieldId;
            }
            # else error out because no matching field found
            else
            {
                throw new Exception("No metadata field found with the"
                        ." name \"".$Field."\".");
            }
        }
        # else error out because we were given an illegal field argument
        else
        {
            throw new InvalidArgumentException(
                    "Illegal field argument supplied.");
        }
    }

    /**
    * Retrieve label for field.  If no label is available for the field,
    * the field name is returned instead.  Handling of the $Field argument
    * is the same as GetCanonicalFieldIdentifier().  This method should only
    * be used in situations where a static method is needed and there are no
    * concerns about field information changing during invocation.
    * @param mixed $Field Field object, ID, or name.
    * @return string Human-readable field name.
    * @throws InvalidArgumentException If field argument supplied could
    *       not be interpreted.
    * @throws Exception If a field name is supplied that does not match
    *       any existing metadata field.
    * @see MetadataSchema::GetCanonicalFieldIdentifier()
    */
    public static function GetPrintableFieldName($Field)
    {
        # retrieve field ID
        $Id = self::GetCanonicalFieldIdentifier($Field);

        # retrieve printable labels if not yet populated
        static $FieldNames;

        if (!isset($FieldNames))
        {
            $DB = new Database();
            $DB->Query("SELECT SchemaId, Name FROM MetadataSchemas");
            $SchemaNames = $DB->FetchColumn("Name", "SchemaId");

            $DB->Query("SELECT SchemaId, FieldId, FieldName, Label FROM MetadataFields");
            while ($Row = $DB->FetchRow())
            {
                $SchemaPrefix = ($Row["SchemaId"] != self::SCHEMAID_DEFAULT)
                             ? $SchemaNames[$Row["SchemaId"]].": " : "";

                $TrimmedLabel = trim($Row["Label"]);
                $FieldNames[$Row["FieldId"]] = $SchemaPrefix
                        .(strlen($TrimmedLabel) ? $TrimmedLabel : $Row["FieldName"]);
            }
        }

        return isset($FieldNames[$Id]) ? $FieldNames[$Id] : "" ;
    }

    /**
    * Translate search values from a legacy URL string to
    *  their modern equivalents.
    * @param int $FieldId FieldId to use for translation
    * @param mixed $Values Values to translate
    * @return array of translated values
    */
    public static function TranslateLegacySearchValues(
            $FieldId, $Values)
    {
        # start out assuming we won't find any values to translate
        $ReturnValues = array();

        # try to grab the specified field
        try
        {
            $Field = new MetadataField($FieldId);
        }
        catch (Exception $e)
        {
            # field no longer exists, so there are no values to translate
            return $ReturnValues;
        }

        # if incoming value is not an array
        if (!is_array($Values))
        {
            # convert incoming value to an array
            $Values = array($Values);
        }

        # for each incoming value
        foreach ($Values as $Value)
        {
            # look up value for index
            if ($Field->Type() == self::MDFTYPE_FLAG)
            {
                # (for flag fields the value index (0 or 1) is used in Database)
                if ($Value >= 0)
                {
                    $ReturnValues[] = "=".$Value;
                }
            }
            elseif ($Field->Type() == self::MDFTYPE_NUMBER)
            {
                # (for flag fields the value index (0 or 1) is used in Database)
                if ($Value >= 0)
                {
                    $ReturnValues[] = ">=".$Value;
                }
            }
            elseif ($Field->Type() == self::MDFTYPE_USER)
            {
                $User = new CWUser(intval($Value));
                if ($User)
                {
                    $ReturnValues[] = "=".$User->Get("UserName");
                }
            }
            elseif ($Field->Type() == self::MDFTYPE_OPTION)
            {
                if (!isset($PossibleFieldValues))
                {
                    $PossibleFieldValues = $Field->GetPossibleValues();
                }

                if (isset($PossibleFieldValues[$Value]))
                {
                    $ReturnValues[] = "=".$PossibleFieldValues[$Value];
                }
            }
            else
            {
                $NewValue = $Field->GetValueForId($Value);
                if ($NewValue !== NULL)
                {
                    $ReturnValues[] = "=".$NewValue;
                }
            }
        }

        # return array of translated values to caller
        return $ReturnValues;
    }

    /**
    * Get IDs for all existing metadata schemas.
    * @return Returns an array of MetadataSchema IDs.
    */
    public static function GetAllSchemaIds()
    {
        # fetch IDs of all metadata schemas
        $Database = new Database();
        $Database->Query("SELECT * FROM MetadataSchemas");
        $SchemaIds = $Database->FetchColumn("SchemaId");

        # return IDs to caller
        return $SchemaIds;
    }

    /**
    * Get all existing metadata schemas.
    * @return Returns an array of MetadataSchema objects with the schema
    *       IDs for the index.
    */
    public static function GetAllSchemas()
    {
        # fetch IDs of all metadata schemas
        $SchemaIds = self::GetAllSchemaIds();

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

        # return schemas to caller
        return $Schemas;
    }

    /**
    * Allow external dependencies, i.e., the current list of owners that are
    * available, to be injected.
    * @param callback $Callback Retrieval callback.
    */
    public 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.
    */
    public 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.
    */
    public function GetDisplayOrder()
    {
        return MetadataFieldOrder::GetOrderForSchema($this, self::ORDER_DISPLAY_NAME);
    }

    /**
    * Get the editing order for the schema.
    * @return Returns a MetadataFieldOrder object.
    */
    public function GetEditOrder()
    {
        return MetadataFieldOrder::GetOrderForSchema($this, self::ORDER_EDIT_NAME);
    }

    /**
    * 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 == self::MDFORDER_ALPHABETICAL)
        {
            return ($FieldA->GetDisplayName() < $FieldB->GetDisplayName()) ? -1 : 1;
        }

        if ($this->FieldCompareType == self::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;
    }


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

    private $Id;
    private $Name;
    private $AbbreviatedName;
    private $ResourceName;
    private $AuthoringPrivileges;
    private $EditingPrivileges;
    private $ViewingPrivileges;
    private $ViewPage;
    private $FieldCompareType;
    private $CachingOn;
    private $NewFields = array();
    private $ErrorMsgs = array();
    private static $FieldMappings = array();
    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->GetField($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
                        elseif (strtoupper($Value) == "TRUE")
                        {
                            $Value = TRUE;
                        }
                        elseif (strtoupper($Value) == "FALSE")
                        {
                            $Value = FALSE;
                        }
                        # convert privilege flag names if needed and appropriate
                        elseif (preg_match("/Privilege$/", $Tag))
                        {
                            static $Privileges;
                            if (!isset($Privileges))
                            {
                                $PFactory = new PrivilegeFactory();
                                $Privileges = $PFactory->GetPrivileges(TRUE, FALSE);
                            }
                            if (in_array($Value, $Privileges))
                            {
                                $Value = array_search($Value, $Privileges);
                            }
                        }

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