<?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.
*/
abstract class ItemFactory {

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

    /**
    * Class constructor.
    * @param string $ItemClassName Class of items to be manipulated by factory.
    * @param string $ItemTableName Name of database table used to store info
    *       about items.
    * @param string $ItemIdFieldName Name of field in database table used to
    *       store item IDs.
    * @param string $ItemNameFieldName Name of field in database table used to
    *       store item names.  (OPTIONAL)
    * @param bool $OrderOpsAllowed If TRUE, ordering operations are allowed
    *       with items, and the database table must contain "Previous" and
    *       "Next" fields as described by the PersistentDoublyLinkedList class.
    * @param string $SqlCondition SQL condition clause (without "WHERE") to
    *       include when retrieving items from database.  (OPTIONAL)
    */
    function ItemFactory($ItemClassName, $ItemTableName, $ItemIdFieldName,
            $ItemNameFieldName = NULL, $OrderOpsAllowed = FALSE, $SqlCondition = NULL)
    {
        # save item access names
        $this->ItemClassName = $ItemClassName;
        $this->ItemTableName = $ItemTableName;
        $this->ItemIdFieldName = $ItemIdFieldName;
        $this->ItemNameFieldName = $ItemNameFieldName;

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

        # save database operation conditional
        $this->SqlCondition = $SqlCondition;

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

    /**
    * Get class name of items manipulated by factory.
    * @return string Class name.
    */
    function GetItemClassName()
    {
        return $this->ItemClassName;
    }

    /**
    * Get ID of currently edited item.
    * @return int Item ID.
    */
    function GetCurrentEditedItemId()
    {
        # if ID available in session variable
        $SessionIndex = $this->ItemClassName."EditedIds";
        if (isset($_SESSION[$SessionIndex]))
        {
            # look up value in session variable
            $ItemId = $_SESSION[$SessionIndex][0];
        }
        else
        {
            # attempt to look up last temp item ID
            $ItemId = $this->GetLastTempItemId();

            # store it in session variable
            $_SESSION[$SessionIndex] = array($ItemId);
        }

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

    /**
    * Set ID of currently edited item.
    * @param int $NewId Item ID.
    */
    function SetCurrentEditedItemId($NewId)
    {
        # if edited ID array already stored for session
        $SessionIndex = $this->ItemClassName."EditedIds";
        if (isset($_SESSION[$SessionIndex]))
        {
            # prepend new value to array
            array_unshift($_SESSION[$SessionIndex], $NewId);
        }
        else
        {
            # start with fresh array
            $_SESSION[$SessionIndex] = array($NewId);
        }
    }

    /**
    * Clear currently edited item ID.
    */
    function ClearCurrentEditedItemId()
    {
        # if edited item IDs available in a session variable
        $SessionIndex = $this->ItemClassName."EditedIds";
        if (isset($_SESSION[$SessionIndex]))
        {
            # remove current item from edited item ID array
            array_shift($_SESSION[$SessionIndex]);

            # if no further edited items
            if (count($_SESSION[$SessionIndex]) < 1)
            {
                # destroy session variable
                unset($_SESSION[$SessionIndex]);
            }
        }
    }

    /**
    * Delete currently edited item and clear currently edited item ID.
    * If a Delete() method is available for the item class, then that is
    * called, otherwise rows from the item table containing the appropriate
    * item ID are just deleted from the database.
    */
    function ClearCurrentEditedItem()
    {
        # if current edited item is temp item
        $CurrentEditedItemId = $this->GetCurrentEditedItemId();
        if ($CurrentEditedItemId < 0)
        {
            # delete temp item from DB
            if (method_exists($this->ItemClassName, "Delete"))
            {
                $Item = new $this->ItemClassName($CurrentEditedItemId);
                $Item->Delete();
            }
            else
            {
                $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 int $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)"
                   .($this->SqlCondition ? " AND ".$this->SqlCondition : ""));
        $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 for currently-logged-in user.
    * @return int Temp item ID, or NULL if no temp item found for the
    *       current user.
    */
    function GetLastTempItemId()
    {
        # retrieve ID of most recently modified temp item for this user
        $ItemId = $this->DB->Query("SELECT ".$this->ItemIdFieldName
                ." FROM ".$this->ItemTableName
                ." WHERE LastModifiedById = '"
                        .$GLOBALS["User"]->Get("UserId")."'"
                ." AND ".$this->ItemIdFieldName." < 0"
                .($this->SqlCondition ? " AND ".$this->SqlCondition : "")
                ." ORDER BY ".$this->ItemIdFieldName." ASC"
                ." LIMIT 1",
                $this->ItemIdFieldName);

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

    /**
    * Retrieve next available (non-temp) item ID.  If there are currently no
    * items, an ID of 1 will be returned.
    * @return int Item ID.
    */
    function GetNextItemId()
    {
        # if no highest item ID found
        $HighestItemId = $this->GetHighestItemId(TRUE);
        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;
    }

    /**
    * Retrieve highest item ID in use.
    * @param bool $IgnoreSqlCondition If TRUE, any SQL condition set via the
    *       constructor is ignored.  (OPTIONAL, defaults to FALSE)
    * @return int Item ID.
    * @see ItemFactory::ItemFactory()
    */
    function GetHighestItemId($IgnoreSqlCondition = FALSE)
    {
        # use class-wide condition if set
        $ConditionString = ($this->SqlCondition && !$IgnoreSqlCondition)
                ? " WHERE ".$this->SqlCondition : "";

        # 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 available temporary item ID.
    * @return int Temporary 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;
    }

    /**
    * Get count of items.
    * @param string $Condition SQL condition to include in query to retrieve
    *       item count.  The condition should not include "WHERE".  (OPTIONAL)
    * @param bool $IncludeTempItems Whether to include temporary items in
    *       count.  (OPTIONAL, defaults to FALSE)
    * @return int Item count.
    */
    function GetItemCount($Condition = NULL, $IncludeTempItems = FALSE)
    {
        # use condition if supplied
        $ConditionString = ($Condition != NULL) ? " WHERE ".$Condition : "";

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

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            if (strlen($ConditionString))
            {
                $ConditionString .= " AND ".$this->SqlCondition;
            }
            else
            {
                $ConditionString = " WHERE ".$this->SqlCondition;
            }
        }

        # 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.
    * @param string $Condition SQL condition clause to restrict selection
    *       of items (should not include "WHERE").
    * @param bool $IncludeTempItems Whether to include temporary items
    *       in returned set.  (OPTIONAL, defaults to FALSE)
    * @param string $SortField Database column to use to sort results.
    *       (OPTIONAL)
    * @param bool $SortAscending If TRUE, sort items in ascending order,
    *       otherwise sort items in descending order.  (OPTIONAL, and
    *       only meaningful if a sort field is specified.)
    * @return array Array of item IDs.
    */
    function GetItemIds($Condition = NULL, $IncludeTempItems = FALSE,
            $SortField = NULL, $SortAscending = TRUE)
    {
        # 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);
        }

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            if (strlen($ConditionString))
            {
                $ConditionString .= " AND ".$this->SqlCondition;
            }
            else
            {
                $ConditionString = " WHERE ".$this->SqlCondition;
            }
        }

        # add sorting if specified
        if ($SortField !== NULL)
        {
            $ConditionString .= " ORDER BY `".addslashes($SortField)."` "
                    .($SortAscending ? "ASC" : "DESC");
        }

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

    /**
    * Get newest modification date (based on values in "DateLastModified"
    * column in database table).
    * @param string $Condition SQL condition clause to restrict selection
    *       of items (should not include "WHERE").
    * @return string Lastest modification date in SQL date/time format.
    */
    function GetLatestModificationDate($Condition = NULL)
    {
        # set up SQL condition if supplied
        $ConditionString = ($Condition == NULL) ? "" : " WHERE ".$Condition;

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            if (strlen($ConditionString))
            {
                $ConditionString .= " AND ".$this->SqlCondition;
            }
            else
            {
                $ConditionString = " WHERE ".$this->SqlCondition;
            }
        }

        # return modification date for item most recently changed
        return $this->DB->Query("SELECT MAX(DateLastModified) AS LastChangeDate"
                                    ." FROM ".$this->ItemTableName.$ConditionString,
                                "LastChangeDate");
    }

    /**
    * Retrieve item by item ID.  This method assumes that an item can be
    * loaded by passing an item ID to the appropriate class constructor.
    * @param int $ItemId Item ID.
    * @return object Item of appropriate class.
    */
    function GetItem($ItemId)
    {
        return new $this->ItemClassName($ItemId);
    }

    /**
    * Check that item exists with specified ID.
    * @param int $ItemId ID of item.
    * @param bool $IgnoreSqlCondition If TRUE, any SQL condition set in the
    *       constructor is ignored.  (OPTIONAL, defaults to FALSE)
    * @return bool TRUE if item exists with specified ID.
    */
    function ItemExists($ItemId, $IgnoreSqlCondition = FALSE)
    {
        $Condition = $IgnoreSqlCondition ? ""
                : ($this->SqlCondition ? " AND ".$this->SqlCondition : "");
        $ItemCount = $this->DB->Query("SELECT COUNT(*) AS ItemCount"
                ." FROM ".$this->ItemTableName
                ." WHERE ".$this->ItemIdFieldName." = ".intval($ItemId)
                .$Condition, "ItemCount");
        return ($ItemCount > 0) ? TRUE : FALSE;
    }

    /**
    * Retrieve item by name.
    * @param string $Name Name to match.
    * @param bool $IgnoreCase If TRUE, ignore case when attempting to match
    *       the item name.  (OPTIONAL, defaults to FALSE)
    * @return object Item of appropriate class or NULL if item not found.
    */
    function GetItemByName($Name, $IgnoreCase = FALSE)
    {
        # get item ID
        $ItemId = $this->GetItemIdByName($Name, $IgnoreCase);

        # if item not found
        if ($ItemId === NULL)
        {
            # report error to caller
            return NULL;
        }
        else
        {
            # load object and return to caller
            return $this->GetItem($ItemId);
        }
    }

    /**
    * Retrieve item ID by name.
    * @param string $Name Name to match.
    * @param bool $IgnoreCase If TRUE, ignore case when attempting to match
    *       the item name.  (OPTIONAL, defaults to FALSE)
    * @return int ID or NULL if name not found.
    */
    function GetItemIdByName($Name, $IgnoreCase = FALSE)
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            throw new Exception("Attempt to get item ID by name on item type"
                    ."(".$this->ItemClassName.") that has no name field specified.");
        }

        # 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
                                            .($this->SqlCondition
                                                    ? " AND ".$this->SqlCondition
                                                    : ""),
                                   $this->ItemIdFieldName);

        # return ID or error indicator to caller
        return ($ItemId === FALSE) ? NULL : $ItemId;
    }

    /**
    * Retrieve item names.
    * @param string $SqlCondition SQL condition (w/o "WHERE") for name
    *      retrieval. (OPTIONAL)
    * @return array 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)
        {
            throw new Exception("Attempt to get array of item names"
                    ." on item type (".$this->ItemClassName.") that has no"
                    ." name field specified.");
        }

        # query database for item names
        $Condition = "";
        if ($SqlCondition)
        {
            $Condition = "WHERE ".$SqlCondition;
        }
        if ($this->SqlCondition)
        {
            if (strlen($Condition))
            {
                $Condition .= " AND ".$this->SqlCondition;
            }
            else
            {
                $Condition = " WHERE ".$this->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 string $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();
        $Ids = $this->GetItemIds($SqlCondition);
        foreach ($Ids as $Id)
        {
            $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 string $OptionListName Value of option list "name" attribute.
    * @param int $SelectedItemId ID of currently-selected item or array of IDs
    *       of currently-selected items.  (OPTIONAL)
    * @param string $SqlCondition SQL condition (w/o "WHERE") for item retrieval.
    *       (OPTIONAL, defaults to NULL)
    * @param int $DisplaySize Display length of option list.  (OPTIONAL,
    *       defaults to 1)
    * @param bool $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 string $Name Name to check.
    * @param bool $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)."'";
        if ($this->SqlCondition)
        {
            $Condition .= " AND ".$this->SqlCondition;
        }
        $NameCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount FROM "
                .$this->ItemTableName." WHERE ".$Condition, "RecordCount");
        return ($NameCount > 0) ? TRUE : FALSE;
    }

    /**
    * Retrieve items with names matching search string.
    * @param string $SearchString String to search for.
    * @param int $NumberOfResults Number of results to return.  (OPTIONAL,
    *       defaults to 100)
    * @param bool $IncludeVariants (NOT YET IMPLEMENTED)  (OPTIONAL)
    * @param bool $UseBooleanMode If TRUE, perform search using MySQL
    *       "Boolean Mode", which among other things supports inclusion and
    *       exclusion operators on terms in the search string.  (OPTIONAL,
    *       defaults to TRUE)
    * @param int $Offset Beginning offset into results.  (OPTIONAL, defaults
    *       to 0, which is the first element)
    * @param array $IdExclusions List of IDs of items to exclude.
    * @param array $ValueExclusions List of names of items to exclude.
    * @return array List of item names, with item IDs for index.
    */
    function SearchForItemNames($SearchString, $NumberOfResults = 100,
            $IncludeVariants = FALSE, $UseBooleanMode = TRUE, $Offset=0,
            $IdExclusions = array(), $ValueExclusions=array())
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            throw new Exception("Attempt to search for item names on item type"
                    ."(".$this->ItemClassName.") that has no name field specified.");
        }

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

        # add each ID exclusion
        foreach ($IdExclusions as $IdExclusion)
        {
            $QueryString .= " AND ".$this->ItemIdFieldName." != '"
                .addslashes($IdExclusion)."' ";
        }

        # add each value exclusion
        foreach ($ValueExclusions as $ValueExclusion)
        {
            $QueryString .= " AND ".$this->ItemNameFieldName." != '"
                .addslashes($ValueExclusion)."' ";
        }

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            $QueryString .= " AND ".$this->SqlCondition;
        }

        # limit response set
        $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 count of items with names matching search string.
    * @param string $SearchString String to search for.
    * @param bool $IncludeVariants (NOT YET IMPLEMENTED)  (OPTIONAL)
    * @param bool $UseBooleanMode If TRUE, perform search using MySQL
    *       "Boolean Mode", which among other things supports inclusion and
    *       exclusion operators on terms in the search string.  (OPTIONAL,
    *       defaults to TRUE)
    * @param array $IdExclusions List of IDs of items to exclude.
    * @param array $ValueExclusions List of names of items to exclude.
    * @return int Count of matching items.
    */
    function GetCountForItemNames($SearchString, $IncludeVariants = FALSE,
            $UseBooleanMode = TRUE, $IdExclusions = array(), $ValueExclusions=array())
    {
        # 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 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))."')";
        }

        # add each ID exclusion
        foreach ($IdExclusions as $IdExclusion)
        {
            $QueryString .= " AND ".$this->ItemIdFieldName." != '"
                .addslashes($IdExclusion)."' ";
        }

        # add each value exclusion
        foreach ($ValueExclusions as $ValueExclusion)
        {
            $QueryString .= " AND ".$this->ItemNameFieldName." != '"
                .addslashes($ValueExclusion)."' ";
        }

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            $QueryString .= " AND ".$this->SqlCondition;
        }

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

    /**
    * Add new item.
    * @param string $ItemName Value to store in name field for new item.
    * @param array $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 int $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 operations.  NULL may be passed in to clear any existing
    * condition.
    * @param string $Condition SQL condition (should not include "WHERE").
    */
    function SetOrderOpsCondition($Condition)
    {
        # condition is non-negative IDs (non-temp items) plus supplied condition
        $NewCondition = $this->ItemIdFieldName." >= 0"
                   .($Condition ? " AND ".$Condition : "")
                   .($this->SqlCondition ? " AND ".$this->SqlCondition : "");
        $this->OrderList->SqlCondition($NewCondition);
    }

    /**
    * Insert item into order before specified item.  If the item is already
    * present in the order, it is moved to the new location.  If the target
    * item is not found, the new item is added to the beginning of the order.
    * @param mixed $TargetItem Item (object or ID) to insert before.
    * @param mixed $NewItem Item (object or ID) to insert.
    */
    function InsertBefore($TargetItem, $NewItem)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

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

    /**
    * Insert item into order after specified item.  If the item is already
    * present in the order, it is moved to the new location.  If the target
    * item is not found, the new item is added to the end of the order.
    * @param mixed $TargetItem Item (object or ID) to insert after.
    * @param mixed $NewItem Item to insert.
    */
    function InsertAfter($TargetItem, $NewItem)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

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

    /**
    * Add item to beginning of order.  If the item is already present in the order,
    * it is moved to the beginning.
    * @param mixed $Item Item (object or ID) to add.
    */
    function Prepend($Item)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

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

    /**
    * Add item to end of order.  If the item is already present in the order,
    * it is moved to the end.
    * @param mixed $Item Item (object or ID) to add.
    */
    function Append($Item)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

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

    /**
    * Retrieve list of item IDs in order.
    * @return array List of item IDs.
    */
    function GetItemIdsInOrder()
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

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

    /**
    * Remove item from existing order.  If the item is not currently in the
    * existing order, then the call has no effect.  This does not delete or
    * otherwise remove the item from the database.
    * @param int $ItemId ID of item to be removed from order.
    */
    function RemoveItemFromOrder($ItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

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


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

    protected $DB;

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