<?PHP

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

/**
* Common factory class for item manipulation.  Not intended to be used directly,
* but rather as a parent for factory classes for specific item types.  For a
* derived class to use the temp methods the item record in the database must
* include "DateLastModified" and "LastModifiedById" fields, and the item object
* must include a "Delete()" method.
*/
class ItemFactory {

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

    # object constructor
    function ItemFactory($ItemClassName, $ItemTableName, $ItemIdFieldName,
            $ItemNameFieldName = NULL, $FieldId = NULL, $OrderOpsAllowed = FALSE)
    {
        # save item access names
        $this->ItemClassName = $ItemClassName;
        $this->ItemTableName = $ItemTableName;
        $this->ItemIdFieldName = $ItemIdFieldName;
        $this->ItemNameFieldName = $ItemNameFieldName;

        # save field ID (if specified)
        if ($FieldId !== NULL) {  $this->FieldId = intval($FieldId);  }

        # save flag indicating whether item type allows ordering operations
        $this->OrderOpsAllowed = $OrderOpsAllowed;
        if ($OrderOpsAllowed)
        {
            $this->OrderList = new PersistentDoublyLinkedList(
                    $ItemTableName, $ItemIdFieldName);
            $this->SetOrderOpsCondition(NULL);
        }

        # grab our own database handle
        $this->DB = new Database();

        # assume everything will be okay
        $this->ErrorStatus = 0;
    }

    # return current error status
    function Status() {  return $this->ErrorStatus;  }

    # get ID of currently edited item
    function GetCurrentEditedItemId()
    {
        # if ID available in session variable
        global $Session;
        if ($EditedIds = $Session->Get($this->ItemClassName."EditedIds"))
        {
            # look up value in session variable
            $ItemId = $EditedIds[0];
        }
        else
        {
            # attempt to look up last temp item ID
            $ItemId = $this->GetLastTempItemId();

            # store it in session variable
            $EditedIds = array($ItemId);
            $Session->RegisterVariable($this->ItemClassName."EditedIds", $EditedIds);
        }

        # return ID (if any) to caller
        return $ItemId;
    }

    # set ID of currently edited item
    function SetCurrentEditedItemId($NewId)
    {
        # if edited ID array already stored for session
        global $Session;
        if ($EditedIds = $Session->Get($this->ItemClassName."EditedIds"))
        {
            # prepend new value to array
            array_unshift($EditedIds, $NewId);
        }
        else
        {
            # start with fresh array
            $EditedIds = array($NewId);
        }

        # save in session variable
        $Session->RegisterVariable($this->ItemClassName."EditedIds", $EditedIds);
    }

    # clear currently edited item ID
    function ClearCurrentEditedItemId()
    {
        # if edited item IDs available in a session variable
        global $Session;
        $SessionVarName = $this->ItemClassName."EditedIds";
        if ($EditedIds = $Session->Get($SessionVarName))
        {
            # remove current item from edited item ID array
            array_shift($EditedIds);

            # if no further edited items
            if (count($EditedIds) < 1)
            {
                # destroy session variable
                $Session->UnregisterVariable($SessionVarName);
            }
            else
            {
                # save new shorter edited item ID array to session variable
                $Session->RegisterVariable($SessionVarName, $EditedIds);
            }
        }
    }

    # clear currently edited item ID and item
    function ClearCurrentEditedItem()
    {
        # if current edited item is temp item
        $CurrentEditedItemId = $this->GetCurrentEditedItemId();
        if ($CurrentEditedItemId < 0)
        {
            # delete temp item from DB
            $this->DB->Query("DELETE FROM ".$this->ItemTableName
                             ." WHERE ".$this->ItemIdFieldName." = ".$CurrentEditedItemId);
        }

        # clear current edited item ID
        $this->ClearCurrentEditedItemId();
    }

    /**
    * Clear out (call the Delete() method) for any temp items more than specified
    *       number of minutes old.
    * @param MinutesUntilStale Number of minutes before items are considered stale.
    *       (OPTIONAL - defaults to 7 days)
    * @return Number of stale items deleted.
    */
    function CleanOutStaleTempItems($MinutesUntilStale = 10080)
    {
        # load array of stale items
        $MinutesUntilStale = max($MinutesUntilStale, 1);
        $this->DB->Query("SELECT ".$this->ItemIdFieldName." FROM ".$this->ItemTableName
                   ." WHERE ".$this->ItemIdFieldName." < 0"
                   ." AND DateLastModified < DATE_SUB(NOW(), "
                            ." INTERVAL ".intval($MinutesUntilStale)." MINUTE)");
        $ItemIds = $this->DB->FetchColumn($this->ItemIdFieldName);

        # delete stale items
        foreach ($ItemIds as $ItemId)
        {
            $Item = new $this->ItemClassName($ItemId);
            $Item->Delete();
        }

        # report number of items deleted to caller
        return count($ItemIds);
    }

    # retrieve most recent temp item ID based on user ID
    # (returns NULL if no temp item found for that user ID)
    function GetLastTempItemId()
    {
        # retrieve ID of most recently modified temp item for this user
        global $User;
        $ItemId = $this->DB->Query("SELECT ".$this->ItemIdFieldName." FROM ".$this->ItemTableName
                                 ." WHERE LastModifiedById = '".$User->Get("UserId")."'"
                                 ." AND ".$this->ItemIdFieldName." < 0"
                                 ." ORDER BY ".$this->ItemIdFieldName." ASC"
                                 ." LIMIT 1",
                                 $this->ItemIdFieldName);

        # return item to caller (or NULL if none found)
        return $ItemId;
    }

    # return next item ID
    function GetNextItemId()
    {
        # if no highest item ID found
        $HighestItemId = $this->GetHighestItemId();
        if ($HighestItemId <= 0)
        {
            # start with item ID 1
            $ItemId = 1;
        }
        else
        {
            # else use next ID available after highest
            $ItemId = $HighestItemId + 1;
        }

        # return next ID to caller
        return $ItemId;
    }

    # return highest item ID ($Condition should not include "WHERE")
    function GetHighestItemId($Condition = NULL, $IncludeTempItems = FALSE)
    {
        # if temp items are supposed to be included
        if ($IncludeTempItems)
        {
            # condition is only as supplied
            $ConditionString = ($Condition == NULL) ? "" : " WHERE ".$Condition;
        }
        else
        {
            # condition is non-negative IDs plus supplied condition
            $ConditionString = " WHERE ".$this->ItemIdFieldName." >= 0"
                       .(($Condition == NULL) ? "" : " AND ".$Condition);
        }

        # return highest item ID to caller
        return $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                    ." FROM ".$this->ItemTableName
                                    .$ConditionString
                                    ." ORDER BY ".$this->ItemIdFieldName
                                    ." DESC LIMIT 1",
                                $this->ItemIdFieldName);
    }

    # return next temp item ID
    function GetNextTempItemId()
    {
        $LowestItemId = $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                         ." FROM ".$this->ItemTableName
                                         ." ORDER BY ".$this->ItemIdFieldName
                                         ." ASC LIMIT 1",
                                         $this->ItemIdFieldName);
        if ($LowestItemId > 0)
        {
            $ItemId = -1;
        }
        else
        {
            $ItemId = $LowestItemId - 1;
        }
        return $ItemId;
    }

    # return count of items
    function GetItemCount($Condition = NULL, $IncludeTempItems = FALSE)
    {
        # if condition was supplied
        if ($Condition != NULL)
        {
            # use condition
            $ConditionString = " WHERE ".$Condition;
        }
        else
        {
            # if field ID is available
            if (isset($this->FieldId))
            {
                # use condition for matching field ID
                $ConditionString = " WHERE FieldId = ".intval($this->FieldId);
            }
            else
            {
                # use no condition
                $ConditionString = "";
            }
        }

        # if temp items are to be excluded
        if (!$IncludeTempItems)
        {
            # if a condition was previously set
            if (strlen($ConditionString))
            {
                # add in condition to exclude temp items
                $ConditionString .= " AND (".$this->ItemIdFieldName." >= 0)";
            }
            else
            {
                # use condition to exclude temp items
                $ConditionString = " WHERE ".$this->ItemIdFieldName." >= 0";
            }
        }

        # retrieve item count
        $Count = $this->DB->Query("SELECT COUNT(*) AS RecordCount"
                                      ." FROM ".$this->ItemTableName
                                      .$ConditionString,
                                  "RecordCount");

        # return count to caller
        return $Count;
    }

    # return array of item IDs ($Condition should not include "WHERE")
    function GetItemIds($Condition = NULL, $IncludeTempItems = FALSE)
    {
        # if temp items are supposed to be included
        if ($IncludeTempItems)
        {
            # condition is only as supplied
            $ConditionString = ($Condition == NULL) ? "" : " WHERE ".$Condition;
        }
        else
        {
            # condition is non-negative IDs plus supplied condition
            $ConditionString = " WHERE ".$this->ItemIdFieldName." >= 0"
                       .(($Condition == NULL) ? "" : " AND ".$Condition);
        }

        # get item IDs
        $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                      ." FROM ".$this->ItemTableName
                                      .$ConditionString);
        $ItemIds = $this->DB->FetchColumn($this->ItemIdFieldName);

        # return IDs to caller
        return $ItemIds;
    }

    # return latest modification date ($Condition should not include "WHERE")
    function GetLatestModificationDate($Condition = NULL)
    {
        # return modification date for item most recently changed
        $ConditionString = ($Condition == NULL) ? "" : " WHERE ".$Condition;
        return $this->DB->Query("SELECT MAX(DateLastModified) AS LastChangeDate"
                                    ." FROM ".$this->ItemTableName.$ConditionString,
                                "LastChangeDate");
    }

    # retrieve item by item ID
    function GetItem($ItemId)
    {
        return new $this->ItemClassName($ItemId);
    }

    /**
    * Check that item exists with specified ID.
    * @param ItemId ID of item.
    */
    function ItemExists($ItemId)
    {
        return $this->DB->Query("SELECT COUNT(*) AS ItemCount"
                ." FROM ".$this->ItemTableName
                ." WHERE ".$this->ItemIdFieldName." = ".intval($ItemId), "ItemCount")
                > 0 ? TRUE : FALSE;
    }

    # retrieve item by name
    function GetItemByName($Name, $IgnoreCase = FALSE)
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            exit("<br>ERROR: attempt to get item by name on item type"
                    ."(".$this->ItemClassName.") that has no name field specified<br>\n");
        }

        # query database for item ID
        $Comparison = $IgnoreCase
                ? "LOWER(".$this->ItemNameFieldName.") = '"
                        .addslashes(strtolower($Name))."'"
                : $this->ItemNameFieldName." = '" .addslashes($Name)."'";
        $ItemId = $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                      ." FROM ".$this->ItemTableName
                                      ." WHERE ".$Comparison
                                            .(isset($this->FieldId)
                                                    ? " AND FieldId = ".$this->FieldId
                                                    : ""),
                                   $this->ItemIdFieldName);

        # if item ID was not found
        if ($ItemId === NULL)
        {
            # return NULL to caller
            $Item = NULL;
        }
        else
        {
            # generate new item object
            $Item = $this->GetItem($ItemId);
        }

        # return new object to caller
        return $Item;
    }

    /**
    * Retrieve item names.
    * @param SqlCondition SQL condition (w/o "WHERE") for name retrieval. (OPTIONAL)
    * @return Array with item names as values and item IDs as indexes.
    */
    function GetItemNames($SqlCondition = NULL)
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            exit("<br>ERROR: attempt to get array of item names on item type"
                    ."(".$this->ItemClassName.") that has no name field specified<br>\n");
        }

        # query database for item names
        $Condition = "";
        if ($this->FieldId || $SqlCondition)
        {
            $Condition = "WHERE ";
            if ($this->FieldId)
                $Condition .= "FieldId = ".intval($this->FieldId);
            if ($this->FieldId && $SqlCondition)
                $Condition .= " AND ";
            if ($SqlCondition)
                $Condition .= $SqlCondition;
        }
        $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                            .", ".$this->ItemNameFieldName
                                        ." FROM ".$this->ItemTableName." "
                                        .$Condition
                                        ." ORDER BY ".$this->ItemNameFieldName);
        $Names = $this->DB->FetchColumn(
                $this->ItemNameFieldName, $this->ItemIdFieldName);

        # return item names to caller
        return $Names;
    }

    /**
    * Retrieve items.
    * @param SqlCondition SQL condition (w/o "WHERE") for name retrieval. (OPTIONAL)
    * @return Array with item objects as values and item IDs as indexes.
    */
    function GetItems($SqlCondition = NULL)
    {
        $Items = array();
        $Names = $this->GetItemNames($SqlCondition);
        foreach ($Names as $Id => $Name)
        {
            $Items[$Id] = $this->GetItem($Id);
        }
        return $Items;
    }

    /**
    * Retrieve items of specified type as HTML option list with item names
    * as labels and item IDs as value attributes.  The first element on the list
    * will have a label of "--" and an ID of -1 to indicate no item selected.
    * @param OptionListName Value of option list "name" attribute.
    * @param SelectedItemId ID of currently-selected item or array of IDs
    *       of currently-selected items.  (OPTIONAL)
    * @param SqlCondition SQL condition (w/o "WHERE") for item retrieval.  (OPTIONAL,
    *       defaults to NULL)
    * @param DisplaySize Display length of option list.  (OPTIONAL, defaults to 1)
    * @param SubmitOnChange Whether to submit form when option list changes.
    *       (OPTIONAL, defaults to FALSE)
    * @return HTML for option list.
    */
    function GetItemsAsOptionList($OptionListName, $SelectedItemId = NULL,
            $SqlCondition = NULL, $DisplaySize = 1, $SubmitOnChange = FALSE)
    {
        # retrieve requested fields
        $ItemNames = $this->GetItemNames($SqlCondition);

        # if multiple selections are allowed
        if ($DisplaySize > 1)
        {
            # begin multi-selection HTML option list
            $Html = "<select name=\"".htmlspecialchars($OptionListName)."[]\""
                    .($SubmitOnChange ? " onChange=\"submit()\"" : "")
                    ." multiple=\"multiple\" size=\"".$DisplaySize."\">\n";
        }
        else
        {
            # begin single-selection HTML option list
            $Html = "<select name=\"".htmlspecialchars($OptionListName)."\""
                    .($SubmitOnChange ? " onChange=\"submit()\"" : "")
                    ." size=\"1\">\n";
            $Html .= "<option value=\"-1\">--</option>\n";
        }

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

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

        # return constructed HTML to caller
        return $Html;
    }
    /**
    * Check whether item name is currently in use.
    * @param Name Name to check.
    * @param IgnoreCase If TRUE, ignore case when checking.  (Defaults to FALSE)
    * @return TRUE if name is in use, otherwise FALSE.
    */
    function NameIsInUse($Name, $IgnoreCase = FALSE)
    {
        $Condition = $IgnoreCase
                ? "LOWER(".$this->ItemNameFieldName.")"
                        ." = '".addslashes(strtolower($Name))."'"
                : $this->ItemNameFieldName." = '".addslashes($Name)."'";
        $NameCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount FROM "
                .$this->ItemTableName." WHERE ".$Condition, "RecordCount");
        return ($NameCount > 0) ? TRUE : FALSE;
    }

    # retrieve names of items matching search string (array index is IDs)
    # (NOTE:  IncludeVariants parameter is NOT YET SUPPORTED!)
    function SearchForItemNames($SearchString, $NumberOfResults = 100,
            $IncludeVariants = FALSE, $UseBooleanMode = TRUE, $Offset=0)
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            exit("<br>ERROR: attempt to search for item names on item type"
                    ."(".$this->ItemClassName.") that has no name field specified<br>\n");
        }

        # return no results if empty search string passed in
        if (!strlen(trim($SearchString))) {  return array();  }

        # construct SQL query
        $DB = new Database();
        $QueryString = "SELECT ".$this->ItemIdFieldName.",".$this->ItemNameFieldName
                ." FROM ".$this->ItemTableName." WHERE";
        if ($this->FieldId)
        {
            $QueryString .= " FieldId = ".$this->FieldId." AND";
        }

        # If the search string is valid but shorter than the minimum word length
        # indexed by the FTS, just do a normal equality test instead of using
        # the index. Otherwise, FTS away.
        $MinWordLen = $DB->Query(
            "SHOW VARIABLES WHERE variable_name='ft_min_word_len'", "Value");
        if (strlen($SearchString) < $MinWordLen)
        {
            $QueryString .= " ".$this->ItemNameFieldName."='".addslashes($SearchString)."'";
        }
        else if ($UseBooleanMode)
        {
            $SearchString = preg_replace("/[)\(><]+/", "", $SearchString);
            $Words = preg_split("/[\s]+/", trim($SearchString));
            $NewSearchString = "";
            $InQuotedString = FALSE;
            $SqlVarObj = new MysqlSystemVariables($DB);
            $StopWordList = $SqlVarObj->GetStopWords();
            $MinWordLen = $SqlVarObj->Get("ft_min_word_len");
            foreach ($Words as $Word)
            {
                # remove any query-specific terms, punctuation, etc.
                $JustTheWord = preg_replace("/[^a-zA-Z-]/", "", $Word);

                # require (boolean AND) certain words
                if ($InQuotedString == FALSE
                    && !in_array($JustTheWord, $StopWordList)
                    && strlen($JustTheWord) >= $MinWordLen
                    && $Word{0} != "+"
                    && $Word{0} != "-")
                {
                    $NewSearchString .= "+";
                }

                if (preg_match("/^\"/", $Word)) {  $InQuotedString = TRUE;  }
                if (preg_match("/\"$/", $Word)) {  $InQuotedString = FALSE;  }
                $NewSearchString .= $Word." ";
            }

            $QueryString .= " MATCH (".$this->ItemNameFieldName.")"
                    ." AGAINST ('".addslashes(trim($NewSearchString))."'"
                    ." IN BOOLEAN MODE)";
        }
        else
        {
            $QueryString .= " MATCH (".$this->ItemNameFieldName.")"
                    ." AGAINST ('".addslashes(trim($SearchString))."')";
        }
        $QueryString .= " LIMIT ".intval($NumberOfResults)." OFFSET "
            .intval($Offset);

        # perform query and retrieve names and IDs of items found by query
        $DB->Query($QueryString);
        $Names = $DB->FetchColumn($this->ItemNameFieldName, $this->ItemIdFieldName);

        # if boolean mode matching was performed
        if (strlen($SearchString) >= $MinWordLen && $UseBooleanMode)
        {
            foreach ($Words as $Word)
            {
                $TgtWord = preg_replace("/[^a-zA-Z]/", "", $Word);
                if ($Word{0} == "-" && strlen($TgtWord) < $MinWordLen)
                {
                    $NewNames = array();
                    foreach ($Names as $Id => $Name)
                    {
                        if (! preg_match('/\b'.$TgtWord.'/i', $Name))
                        {
                            $NewNames[$Id] = $Name;
                        }
                    }
                    $Names = $NewNames;
                }
            }
        }

        # return names to caller
        return $Names;
    }

    # retrieve the count of names of items matching search string (array index
    # is IDs) (NOTE:  IncludeVariants parameter is NOT YET SUPPORTED!)
    function GetCountForItemNames($SearchString, $IncludeVariants = FALSE,
        $UseBooleanMode = TRUE)
    {
        # return no results if empty search string passed in
        if (!strlen(trim($SearchString))) {  return 0;  }

        # construct SQL query
        $DB = new Database();
        $QueryString = "SELECT COUNT(*) as ItemCount FROM "
            .$this->ItemTableName." WHERE";
        if ($this->FieldId)
        {
            $QueryString .= " FieldId = ".$this->FieldId." AND";
        }

        # If the search string is valid but shorter than the minimum word length
        # indexed by the FTS, just do a normal equality test instead of using
        # the index. Otherwise, FTS away.
        $MinWordLen = $DB->Query(
            "SHOW VARIABLES WHERE variable_name='ft_min_word_len'", "Value");
        if (strlen($SearchString) < $MinWordLen)
        {
            $QueryString .= " ".$this->ItemNameFieldName."='".addslashes($SearchString)."'";
        }
        else if ($UseBooleanMode)
        {
            $SearchString = preg_replace("/[)\(><]+/", "", $SearchString);
            $Words = preg_split("/[\s]+/", trim($SearchString));
            $NewSearchString = "";
            $InQuotedString = FALSE;
            $SqlVarObj = new MysqlSystemVariables($DB);
            $StopWordList = $SqlVarObj->GetStopWords();
            $MinWordLen = $SqlVarObj->Get("ft_min_word_len");
            foreach ($Words as $Word)
            {
                # remove any query-specific terms, punctuation, etc.
                $JustTheWord = preg_replace("/[^a-zA-Z-]/", "", $Word);

                # require (boolean AND) certain words
                if ($InQuotedString == FALSE
                    && !in_array($JustTheWord, $StopWordList)
                    && strlen($JustTheWord) >= $MinWordLen
                    && $Word{0} != "+"
                    && $Word{0} != "-")
                {
                    $NewSearchString .= "+";
                }

                if (preg_match("/^\"/", $Word)) {  $InQuotedString = TRUE;  }
                if (preg_match("/\"$/", $Word)) {  $InQuotedString = FALSE;  }
                $NewSearchString .= $Word." ";
            }

            $QueryString .= " MATCH (".$this->ItemNameFieldName.")"
                    ." AGAINST ('".addslashes(trim($NewSearchString))."'"
                    ." IN BOOLEAN MODE)";
        }
        else
        {
            $QueryString .= " MATCH (".$this->ItemNameFieldName.")"
                    ." AGAINST ('".addslashes(trim($SearchString))."')";
        }

        # perform query and retrieve names and IDs of items found by query
        $DB->Query($QueryString);
        return intval($DB->FetchField("ItemCount"));
    }

    /**
    * add items with specified names
    * @param ItemNames Array of item names.  Leading or trailing whitespace is automatically trimmed off of the names.
    * @param Qualifier Qualifier object to associate with items being added.
    * @return Number of items added.
    * @note Only items with new names will be added.
    * @note This method only works for item types where a new item can be created by calling the constructor with NULL, an item name, and a field ID (in that order) as parameters.
    */
    function AddItems($ItemNames, $Qualifier = NULL)
    {
        # for each supplied item name
        $ItemCount = 0;
        foreach ($ItemNames as $Name)
        {
            # if item does not exist with this name
            $Name = trim($Name);
            if ($this->GetItemByName($Name) === NULL)
            {
                # add item
                $NewItem = new $this->ItemClassName(NULL, $Name, $this->FieldId);
                $ItemCount++;

                # assign qualifier to item if supplied
                if ($Qualifier !== NULL)
                {
                    $NewItem->Qualifier($Qualifier);
                }
            }
        }

        # return count of items added to caller
        return $ItemCount;
    }

    /**
    * Add new item.
    * @param ItemName Value to store in name field for new item.
    * @param AdditionalValues Associative array of additional values to set
    *       in the new item, with DB field names for the array index and values
    *       to set them to for the array values.  (OPTIONAL)
    * @return ID of new item.
    */
    function AddItem($ItemName, $AdditionalValues = NULL)
    {
        # build initial database query for adding item
        $Query = "INSERT INTO ".$this->ItemTableName." SET `"
                .$this->ItemNameFieldName."` = '".addslashes($ItemName)."'";

        # add any additional values to query
        if ($AdditionalValues)
        {
            foreach ($AdditionalValues as $FieldName => $Value)
            {
                $Query .= ", `".$FieldName."` = '".addslashes($Value)."'";
            }
        }

        # add item to database
        $this->DB->Query($Query);

        # retrieve ID of new item
        $Id = $this->DB->LastInsertId($this->ItemTableName);

        # return ID to caller
        return $Id;
    }

    /**
    * Delete item.
    * @param ItemId ID of item to delete.
    */
    function DeleteItem($ItemId)
    {
        # delete item from database
        $this->DB->Query("DELETE FROM ".$this->ItemTableName
                ." WHERE ".$this->ItemIdFieldName." = '".addslashes($ItemId)."'");
    }


    # ---- order operations --------------------------------------------------

    # set SQL condition (added to WHERE clause) used to select items for ordering ops
    # (use NULL to clear any previous condition)
    function SetOrderOpsCondition($Condition)
    {
        # condition is non-negative IDs (non-temp items) plus supplied condition
        $NewCondition = $this->ItemIdFieldName." >= 0"
                   .(($Condition) ? " AND ".$Condition : "");
        $this->OrderList->SqlCondition($NewCondition);
    }

    # insert/move item to before specified item
    function InsertBefore($SourceItemOrItemId, $TargetItemOrItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            exit("<br>ERROR: attempt to perform ordering operation"
                    ." (InsertBefore()) on item type"
                    ."(".$this->ItemClassName.") that does not support ordering<br>\n");
        }

        # insert/move item
        $this->OrderList->InsertBefore($SourceItemOrItemId, $TargetItemOrItemId);
    }

    # insert/move item to after specified item
    function InsertAfter($SourceItemOrItemId, $TargetItemOrItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            exit("<br>ERROR: attempt to perform ordering operation"
                    ." (InsertAfter()) on item type"
                    ."(".$this->ItemClassName.") that does not support ordering<br>\n");
        }

        # insert/move item
        $this->OrderList->InsertAfter($SourceItemOrItemId, $TargetItemOrItemId);
    }

    # add/move item to beginning of list
    function Prepend($ItemOrItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            exit("<br>ERROR: attempt to perform ordering operation"
                    ." (Prepend()) on item type"
                    ."(".$this->ItemClassName.") that does not support ordering<br>\n");
        }

        # prepend item
        $this->OrderList->Prepend($ItemOrItemId);
    }

    # add/move item to end of list
    function Append($ItemOrItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            exit("<br>ERROR: attempt to perform ordering operation"
                    ." (Append()) on item type"
                    ."(".$this->ItemClassName.") that does not support ordering<br>\n");
        }

        # add/move item
        $this->OrderList->Append($ItemOrItemId);
    }

    # retrieve list of item IDs in order
    function GetItemIdsInOrder($AddStrayItemsToOrder = TRUE)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            exit("<br>ERROR: attempt to perform ordering operation"
                    ." (GetItemIdsInOrder()) on item type"
                    ."(".$this->ItemClassName.") that does not support ordering<br>\n");
        }

        # retrieve list of IDs
        return $this->OrderList->GetIds($AddStrayItemsToOrder);
    }

    # remove item from existing order
    function RemoveItemFromOrder($ItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            exit("<br>ERROR: attempt to perform ordering operation"
                    ." (RemoveItemFromOrder()) on item type"
                    ."(".$this->ItemClassName.") that does not support ordering<br>\n");
        }

        # remove item
        $this->OrderList->Remove($ItemId);
    }


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

    protected $DB;
    protected $FieldId;

    private $ItemClassName;
    private $ItemTableName;
    private $ItemIdFieldName;
    private $ItemNameFieldName;
    private $ErrorStatus;
    private $OrderOpsAllowed;
    private $OrderList;
}
