<?PHP

class UrlChecker extends Plugin
{

    /**
     * Constructor: initialize objects and settings.
     */
    public function __construct()
    {
        # default constant values
        $this->DefaultNumToCheck = 10;
        $this->ValidHttpCodes = array(-1, 200);
        $this->FailureThreshold = 3;

        # flags
        $this->DoingIntervalChecks = FALSE;

        # objects
        $this->DB = new Database();
    }

    /**
     * Register the information about this plugin.
     */
    public function Register()
    {
        $this->Name = "URL Checker";
        $this->Version = "1.0.2";
        $this->Description = 'CWIS plugin for periodically validating resource URL fields.';
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array("CWISCore" => "2.0.0");
        $this->EnabledByDefault = FALSE;
    }

    /**
     * Create the database tables necessary to use this plugin.
     * @return NULL if everything went ok or an error message otherwise
     */
    public function Install()
    {
        # create the table to store history data
        $Result1 = $this->DB->Query("
            CREATE TABLE IF NOT EXISTS UrlChecker_History (
                DateChecked    TIMESTAMP,
                ResourceId     INT,
                INDEX          (ResourceId)
            );");

        # create the table to store failure data
        $Result2 = $this->DB->Query("
            CREATE TABLE IF NOT EXISTS UrlChecker_Failures (
                DateChecked    TIMESTAMP,
                TimesFailed    INT,
                ResourceId     INT,
                FieldId        INT,
                StatusNo       SMALLINT,
                StatusText     TEXT,
                Url            TEXT,
                DataOne        TEXT,
                DataTwo        TEXT,
                INDEX          (ResourceId, FieldId)
            );");

        return ($Result1 === FALSE || $Result2 === FALSE)
            ? "Database setup failed." : NULL;
    }

    /**
     * Declare the events this plugin provides to the application framework.
     * @return an array of the events this plugin provides
     */
    public function DeclareEvents()
    {
        $Events = array(
            "URLCHECKER_REMOVE_STALE_DATA"
              => ApplicationFramework::EVENTTYPE_DEFAULT,
            "URLCHECKER_GET_STATS"
              => ApplicationFramework::EVENTTYPE_FIRST,
            "URLCHECKER_GET_FAILURES"
              => ApplicationFramework::EVENTTYPE_FIRST,
            "URLCHECKER_GET_UNRELEASED_FAILURES"
              => ApplicationFramework::EVENTTYPE_FIRST,
            "URLCHECKER_GET_RELEASED_FAILURES"
              => ApplicationFramework::EVENTTYPE_FIRST
            );

        # only declare these events if we have url fields to check
        if ($this->Enabled())
        {
            $Events["URLCHECKER_CHECK_RESOURCE_URLS"] = ApplicationFramework::EVENTTYPE_DEFAULT;
            $Events["URLCHECKER_CHECK_RESOURCE_URLS_BY_ID"] = ApplicationFramework::EVENTTYPE_DEFAULT;
        }

        return $Events;
    }

    /**
     * Hook the events into the application framework.
     * @return an array of events to be hooked into the application framework
     */
    public function HookEvents()
    {
        $Events = array(
                "EVENT_COLLECTION_ADMINISTRATION_MENU" => "DeclareColAdminPages",
                "URLCHECKER_REMOVE_STALE_DATA" => "RemoveStaleData",
                "URLCHECKER_GET_STATS" => "GetStats",
                "URLCHECKER_GET_FAILURES" => "GetFailures",
                "URLCHECKER_GET_RELEASED_FAILURES" => "GetReleasedFailures",
                "URLCHECKER_GET_UNRELEASED_FAILURES" => "GetUnreleasedFailures"
                );

        # only hook these events if we have url fields to check
        if ($this->Enabled())
        {
            $Events["EVENT_PERIODIC"] = "DoUrlChecks";
            $Events["URLCHECKER_CHECK_RESOURCE_URLS"] = "CheckResourceUrls";
            $Events["URLCHECKER_CHECK_RESOURCE_URLS_BY_ID"] = "CheckResourceUrlsById";
        }

        return $Events;
    }

    /**
     * Declare the collection administration pages this plugin provides to the
     * application framework.
     * @return an array of the pages this plugin provides
     */
    public function DeclareColAdminPages()
    {
        return array("Results" => "URL Checker Results");
    }

    /**
     * Do the interval URL checks.
     * @return time in minutes until this should be executed again
     */
    public function DoUrlChecks()
    {
        # temporarily change the timeout to prevent long hangs
        $Timeout = ini_get("default_socket_timeout");
        ini_set("default_socket_timeout", 9.0);

        # signal that the interval checks are being done
        $this->DoingIntervalChecks = TRUE;

        # validate a subset of the resources ($Speed is passed by reference)
        $Speed = 0;
        foreach ($this->GetResourcesToCheck($this->DefaultNumToCheck, $Speed) as $Resource)
        {
             $this->CheckResourceUrls($Resource);
        }

        # signal that the interval checks are done
        $this->DoingIntervalChecks = FALSE;

        # restore the original timeout setting
        ini_set("default_socket_timeout", $Timeout);

        # just more failures to check: 5 minutes
        if ($Speed == 1) {  return 5;  }

        # more new resources or (more failures and more new resources to check)
        else if ($Speed > 1 && $Speed < 4) {  return 2;  }

        # default interval time: 60 minutes
        return 60;
    }

    /**
     * Remove stale history and failure data.
     */
    public function RemoveStaleData()
    {
        # remove data from deleted resources
        $this->DB->Query("DELETE UH"
                ." FROM UrlChecker_History UH"
                ." LEFT JOIN Resources R"
                ." ON UH.ResourceId = R.ResourceId"
                ." WHERE R.ResourceId IS NULL");

        # remove data from deleted resources
        $this->DB->Query("DELETE UF"
                ." FROM UrlChecker_Failures UF"
                ." LEFT JOIN Resources R"
                ." ON UF.ResourceId = R.ResourceId"
                ." WHERE R.ResourceId IS NULL");

        # remove failure data that corresponds to a changed URL
        foreach ($this->GetUrlFields() as $Field)
        {
            $this->DB->Query("DELETE UF"
                    ." FROM UrlChecker_Failures UF"
                    ." LEFT JOIN Resources R"
                    ." ON UF.ResourceId = R.ResourceId"
                    ." WHERE R.".$Field->DBFieldName()." != UF.Url");
        }
    }

    /**
     * Check the URL fields of the resource with the given resource id.
     * @param $ResourceId resource id of the resource to check the urls of
     */
    public function CheckResourceUrlsById($ResourceId)
    {
        $this->CheckResourceUrls(new Resource($ResourceId));
    }

    /**
     * Check the URL fields of the given resource.
     * @param $Resource resource to check
     */
    public function CheckResourceUrls(Resource $Resource)
    {
        # determine whether or not we should actually check this resource
        if ($Resource->Status() == 1
            && ($this->DoingIntervalChecks
                || $this->ShouldCheckResourceUrls($Resource)))
        {
            # loop through each URL metadata field
            foreach ($this->GetUrlFields() as $Field)
            {
                # get the url's http status
                $Status = $this->GetHttpStatus($Resource->Get($Field));

                # remove old failure data, if any, if the url is ok or is a 301
                # that redirects to a 200 and the URLs are "similar"
                if (in_array($Status[0], $this->ValidHttpCodes)
                    || ($Status[0] == 301 && $Status[2] == 200
                        && $this->TrivialChange($Resource->Get($Field), $Status[4])))
                {
                    $this->RemoveFailures($Resource, $Field);
                }

                # record a failure since there was a problem
                else
                {
                    $this->RecordFailure($Resource, $Field, $Status[0],
                        $Status[1], $Status[2], $Status[4]);
                }
            }

            # record that the resource was checked
            $this->RecordHistory($Resource);
        }
    }

    /**
     * Get statistics about the URL checker.
     * @array array of statistics about the URL checker
     */
    public function GetStats()
    {
        $Stats = array();

        # get whether or not the plugin is enabled (if there are any url fields)
        $Stats["Enabled"] = $this->Enabled();

        # get the number of resources checked
        $this->DB->Query("SELECT COUNT(*) as NumChecked FROM UrlChecker_History");
        $Stats["NumResourcesChecked"] = intval($this->DB->FetchField("NumChecked"));

        # get the number of resources that haven't been checked
        $this->DB->Query("SELECT COUNT(*) as NumResources FROM Resources");
        $Stats["NumResourcesUnchecked"] = intval($this->DB->FetchField("NumResources"))
            - $Stats["NumResourcesChecked"];

        # get the number of the failures past the threshold
        $this->DB->Query("SELECT COUNT(*) as NumFailures"
                ." FROM UrlChecker_Failures"
                ." WHERE TimesFailed > ".intval($this->FailureThreshold));
        $Stats["NumFailures"] = intval($this->DB->FetchField("NumFailures"));

        # get the number of all invalid urls
        $this->DB->Query("SELECT COUNT(*) as NumFailures FROM UrlChecker_Failures");
        $Stats["NumPossibleFailures"] = intval($this->DB->FetchField("NumFailures"))
            - $Stats["NumFailures"];

        # get the number of released (release flag > 0) failures
        $this->DB->Query("SELECT COUNT(*) as NumFailures"
                ." FROM UrlChecker_Failures UF"
                ." LEFT JOIN Resources R"
                ." ON UF.ResourceId = R.ResourceId"
                ." WHERE UF.TimesFailed > ".intval($this->FailureThreshold)
                ." AND R.ReleaseFlag > 0");
        $Stats["NumReleasedFailures"] = intval($this->DB->FetchField("NumFailures"));

        # get the number of unreleased (release flag < 1) failures
        $this->DB->Query("SELECT COUNT(*) as NumFailures"
                ." FROM UrlChecker_Failures UF"
                ." LEFT JOIN Resources R"
                ." ON UF.ResourceId = R.ResourceId"
                ." WHERE UF.TimesFailed > ".intval($this->FailureThreshold)
                ." AND R.ReleaseFlag < 1");
        $Stats["NumUnreleasedFailures"] = intval($this->DB->FetchField("NumFailures"));

        # get the last time a check was done
        $this->DB->Query("SELECT * FROM UrlChecker_History ORDER BY DateChecked DESC LIMIT 1");
        $Stats["DateLastResourceChecked"] = $this->DB->FetchField("DateChecked");

        # get the next time a check will be performed (hacky!)
        $Signature = md5(serialize($this))."::DoUrlChecks"; # app framework func
        $this->DB->Query("SELECT * FROM PeriodicEvents"
            ." WHERE Signature = '".addslashes($Signature)."'");
        $Stats["DateNextCheck"] = $this->DB->FetchField("LastRunAt");

        return $Stats;
    }

    /**
     * Get a subset of the tuples of failed URLs.
     * @param $Limit max number of failures to return
     * @param $Offset offset the failures returned by this number
     * @return the subset of failed URLs found
     */
    public function GetFailures($Limit = 15, $Offset = 0)
    {
        # get the failures sorted by how many failures there are for each
        # resource/field pair
        $this->DB->Query("SELECT *"
                ." FROM UrlChecker_Failures"
                ." WHERE TimesFailed > ".intval($this->FailureThreshold)
                ." ORDER BY TimesFailed DESC, ResourceId ASC"
                ." LIMIT ".intval($Offset).",".intval($Limit));

        # return the failures found
        return $this->DB->FetchRows();
    }

    /**
     * Get a subset of the tuples of failed URLs for released resources only.
     * @param $Limit max number of failures to return
     * @param $Offset offset the failures returned by this number
     * @return the subset of failed URLs found
     */
    public function GetReleasedFailures($Limit = 15, $Offset = 0)
    {
        # get the failures sorted by how many failures there are for each
        # resource/field pair
        $this->DB->Query("SELECT *"
                ." FROM UrlChecker_Failures UF"
                ." LEFT JOIN Resources R"
                ." ON UF.ResourceId = R.ResourceId"
                ." WHERE UF.TimesFailed > ".intval($this->FailureThreshold)
                ." AND R.ReleaseFlag > 0"
                ." ORDER BY UF.TimesFailed DESC, UF.ResourceId ASC"
                ." LIMIT ".intval($Offset).",".intval($Limit));

        # return the failures found
        return $this->DB->FetchRows();
    }

    /**
     * Get a subset of the tuples of failed URLs for unreleased resources only.
     * @param $Limit max number of failures to return
     * @param $Offset offset the failures returned by this number
     * @return the subset of failed URLs found
     */
    public function GetUnreleasedFailures($Limit = 15, $Offset = 0)
    {
        # get the failures sorted by how many failures there are for each
        # resource/field pair
        $this->DB->Query("SELECT *"
                ." FROM UrlChecker_Failures UF"
                ." LEFT JOIN Resources R"
                ." ON UF.ResourceId = R.ResourceId"
                ." WHERE UF.TimesFailed > ".intval($this->FailureThreshold)
                ." AND R.ReleaseFlag = 0"
                ." ORDER BY UF.TimesFailed DESC, UF.ResourceId ASC"
                ." LIMIT ".intval($Offset).",".intval($Limit));

        # return the failures found
        return $this->DB->FetchRows();
    }

    /**
     * Determine whether or not the plugin is enabled or not. Basically checks
     * if there are any Url-type fields.
     * @return TRUE if the plugin is enabled, FALSE if disabled
     */
    private function Enabled()
    {
        return (bool) $this->GetUrlFields();
    }

    /**
     * Choose a subset of the resources to check for valid URLs.
     * @param $Limit the max number of resources to return
     * @param &$Speed the recommended speed (passed by reference)
     * @return an array of resources to check
     */
    private function GetResourcesToCheck($Limit = 15, &$Speed = 0)
    {
        # remove stale data so we don't check deleted resources and so we do
        # check changed resources
        $this->RemoveStaleData();

        # assume that no resources will be found
        $Resources = array();

        # ensure that these are integers
        $Limit = intval($Limit);
        $Speed = intval($Speed);

        # resources that failed before, sorted by the last time they were checked
        # (limit to about half of the limit)
        $this->DB->Query("SELECT *"
                ." FROM UrlChecker_Failures"
                ." WHERE (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(DateChecked)) >= 86400"
                ." ORDER BY DateChecked ASC, ResourceId ASC"
                ." LIMIT ".floor($Limit/2));
        $ResourceIds = $this->DB->FetchColumn("ResourceId");

        # add each resource found
        foreach ($ResourceIds as $ResourceId)
        {
            $Resources[$ResourceId] = new Resource($ResourceId);
        }

        # increment the speed if there are more failed resources to check
        $Speed += (count($ResourceIds) == floor($Limit/2)) ? 1 : 0;

        # update limit to not include resources that failed
        $Limit -= count($ResourceIds);

        # resources that have never been checked or have been updated
        $this->DB->Query("SELECT R.ResourceId"
                ." FROM Resources R LEFT JOIN UrlChecker_History UH"
                ." ON R.ResourceId = UH.ResourceId"
                ." WHERE UH.ResourceId IS NULL"
                ." LIMIT ".$Limit);
        $ResourceIds = $this->DB->FetchColumn("ResourceId");

        # add each resource found
        foreach ($ResourceIds as $ResourceId)
        {
            $Resources[$ResourceId] = new Resource($ResourceId);
        }

        # increment the speed if there are more new resources to check
        $Speed += (count($ResourceIds) == $Limit) ? 2 : 0;

        # update limit to not include resources that have never been checked
        $Limit -= count($ResourceIds);

        # if we still have fewer resources than the limit, and we should if
        # there are no new resources to check
        if ($Limit > 0)
        {
            # resources that haven't been checked in at least one day, sorted
            # by the last time they were checked.
            $this->DB->Query("SELECT *"
                    ." FROM UrlChecker_History"
                    ." WHERE (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(DateChecked)) >= 86400"
                    ." ORDER BY DateChecked ASC"
                    ." LIMIT ".intval($Limit));

            # add each resource found
            foreach ($this->DB->FetchColumn("ResourceId") as $ResourceId)
            {
                $Resources[$ResourceId] = new Resource($ResourceId);
            }
        }

        # return resources to caller
        return $Resources;
    }

    /**
     * Get a tuple with the following schema: (first HTTP status code, first
     * HTTP status message, last HTTP status code or none if there is only one,
     * last HTTP status message or none if there is only one, the last location
     * header value, if available).
     * If blank, returns (-1, "", "", "", ""). On error, returns
     * (0, "", "", "", "").
     * @param $Url the URL to get the status of
     * @return tuple with various info about the HTTP status of the URL
     */
    private function GetHttpStatus($Url)
    {
        # blank url
        if (!strlen(trim($Url)))
        {
            return array(-1, "", "", "", "");
        }

        # assume we'll fail to find these
        $Code = 0;
        $Message = "";
        $LastCode = "";
        $LastMessage = "";
        $LastLocation = "";

        # if we can get the headers
        if (($Headers = @get_headers($Url, 1)))
        {
            # http status header (special since it doesn't have a name)
            for ($i = 0; isset($Headers[$i]); $i++)
            {
                # parse the line
                if (preg_match('/(HTTP[^\s]+)\s([0-9]+)\s+([^\r\n]+)/', $Headers[$i], $Matches))
                {
                    # update if this is the first http status line seen
                    if ($Code == 0)
                    {
                        $Code = intval($Matches[2]);
                        $Message = $Matches[3];
                    }

                    # otherwise update the last code
                    else
                    {
                        $LastCode = intval($Matches[2]);
                        $LastMessage = $Matches[3];
                    }
                }
            }

            # try to get the location header(s)
            if (isset($Headers["Location"]))
            {
                $Locations = $Headers["Location"];

                # if it's a string, make it an array so we only need one algo
                if (!is_array($Locations))
                {
                    $Locations = array($Locations);
                }

                foreach ($Locations as $Value)
                {
                    # the location header value
                    $Location = $Value;

                    # if the location value is absolute or relative
                    if (substr($Location, 0, 4) != "http")
                    {
                        # might need to parse the last location since we might
                        # have jumped to a different server by this point
                        $Parsed = (strlen($LastLocation)) ?
                            @parse_url($LastLocation) : @parse_url($Url);

                        # make sure the url could be parsed
                        if ($Parsed)
                        {
                            $Scheme = (isset($Parsed["scheme"])) ? $Parsed["scheme"] : "";
                            $Host = (isset($Parsed["host"])) ? $Parsed["host"] : "";

                            # relative url, so we also need to get the path
                            if (isset($Location{0}) && $Location{0} != "/")
                            {
                                $Path = (isset($Parsed["path"])) ? $Parsed["path"] : "/";
                                $Path .= ($Path{strlen($Path)-1} != "/") ? "/" : "";
                                $Location = $Path.$Location;
                            }

                            $Location = $Scheme."://".$Host.$Location;
                        }
                    }

                    # update the last location
                    $LastLocation = $Location;
                }
            }
        }

        return array($Code, $Message, $LastCode, $LastMessage, $LastLocation);
    }

    /**
     * Determine whether or not the change between the first URL and the second
     * is trivial, i.e., if the second only adds a "www." to the front or if the
     * second adds a "/" at the end.
     * @param $Start what the URL was
     * @param $End what the URL was changed to
     * @return TRUE if the change was trivial (as described above), FALSE otherwise
     */
    private function TrivialChange($Start, $End)
    {
        # parse the given urls (parse_url() emits a warning on error)
        $Url1 = @parse_url($Start);
        $Url2 = @parse_url($End);

        # if the URLs were successfully parsed
        if ($Url1 && $Url2)
        {
            # we need to get the host and path of the URLs to compare
            $Host1 = (isset($Url1["host"])) ? $Url1["host"] : "";
            $Host2 = (isset($Url2["host"])) ? $Url2["host"] : "";
            $Path1 = (isset($Url1["path"])) ? $Url1["path"] : "";
            $Path2 = (isset($Url2["path"])) ? $Url2["path"] : "";
            $Query1 = (isset($Url1["query"])) ? $Url1["query"] : "";
            $Query2 = (isset($Url2["query"])) ? $Url2["query"] : "";
            $Fragment1 = (isset($Url1["fragment"])) ? $Url1["fragment"] : "";
            $Fragment2 = (isset($Url2["fragment"])) ? $Url2["fragment"] : "";

            # trailing slash is insignificant so remove it
            $Path1 = (strlen($Path1) && $Path1{strlen($Path1)-1} == "/")
                ? substr($Path1, 0, strlen($Path1)-1): $Path1;
            $Path2 = (strlen($Path2) && $Path2{strlen($Path2)-1} == "/")
                ? substr($Path2, 0, strlen($Path2)-1): $Path2;

            # the first differs by "www." only
            if ($Path1 == $Path2 && $Query1 == $Query2 && $Fragment1 == $Fragment2
                && ($Host1 == $Host2
                    || ".".$Host1 == substr($Host2, intval(strpos($Host2, ".")))))
            {
                return TRUE;
            }
        }

        return FALSE;
    }

    /**
     * Get an array of all the Url fields.
     * @return array of the Url fields
     */
    private function GetUrlFields()
    {
        # get all Url-type fields
        $Schema = new MetadataSchema();
        return $Schema->GetFields(MetadataSchema::MDFTYPE_URL);
    }

    /**
     * Determine whether or not the URLs of the resource should be checked.
     * @param $Resource the resource to check
     * @return TRUE if the resource should be checked, FALSE otherwise
     */
    private function ShouldCheckResourceUrls(Resource $Resource)
    {
        # remove stale data only if we're not doing interval URL checks, since
        # this would be done already
        if (!$this->DoingIntervalChecks)
        {
            $this->RemoveStaleData();
        }

        # 0 if non-existent or if it's been over a day since it was last checked
        $this->DB->Query("SELECT UH.*"
                ." FROM UrlChecker_History UH LEFT JOIN Resources R"
                ." ON UH.ResourceId = R.ResourceId"
                ." WHERE UH.ResourceId = '".intval($Resource->Id())."'"
                ." AND (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(UH.DateChecked)) < 86400");

        return ($this->DB->NumRowsSelected() == 0);
    }

    /**
     * Record that the given resource was checked for validity.
     * @param $Resource resource to record
     */
    private function RecordHistory(Resource $Resource)
    {
        # lock table
        $this->DB->Query("LOCK TABLES UrlChecker_History WRITE");

        # delete/insert record (fragmentation? mysql: prob. not, pgsql: no)
        $this->DB->Query("DELETE FROM UrlChecker_History"
                ." WHERE ResourceId = '".intval($Resource->Id())."'");
        $this->DB->Query("INSERT INTO UrlChecker_History"
                ." SET ResourceId = '".intval($Resource->Id())."'");

        # unlock table
        $this->DB->Query("UNLOCK TABLES");
    }

    /**
     * Record an instance of a failure in the database.
     * @param $Resource resource to record
     * @param $Field field to record
     * @param $StatusNo HTTP status number to record
     * @param $Message HTTP status message to record
     * @param $LastStatusNo last status number, or blank if only one exists
     * @param $LastLocaton last location value, or blank if none exist
     */
    private function RecordFailure(Resource $Resource, MetadataField $Field,
        $StatusNo, $Message, $LastStatusNo, $LastLocation)
    {
        # assume a default TimesFailed value
        $TimesFailed = 1;

        # lock table
        $this->DB->Query("LOCK TABLES UrlChecker_Failures WRITE");

        # get current failure info
        $this->DB->Query("SELECT * FROM UrlChecker_Failures"
                ." WHERE ResourceId = '".intval($Resource->Id())."'"
                ." AND FieldId = '".intval($Field->Id())."'");

        # at least one row exists
        if (intval($this->DB->NumRowsSelected()))
        {
            # want to use the old TimesFailed value if we can
            $Row = $this->DB->FetchRow();
            if ($Row["StatusNo"] == $StatusNo && $Row["Url"] == $Resource->Get($Field)
                && $Row["DataOne"] == $LastStatusNo && $Row["DataTwo"] == $LastLocation)
            {
                $TimesFailed = intval($Row["TimesFailed"]) + 1;
            }

            # remove all failures (for duplicates if existing)
            $this->RemoveFailures($Resource, $Field);
        }

        # insert the record
        $this->DB->Query("INSERT INTO UrlChecker_Failures"
                ." SET TimesFailed = '".$TimesFailed."',"
                ." ResourceId = '".intval($Resource->Id())."',"
                ." FieldId = '".intval($Field->Id())."',"
                ." StatusNo = '".intval($StatusNo)."',"
                ." StatusText = '".addslashes($Message)."',"
                ." Url = '".addslashes($Resource->Get($Field))."',"
                ." DataOne = '".addslashes($LastStatusNo)."',"
                ." DataTwo = '".addslashes($LastLocation)."'");

        # unlock table
        $this->DB->Query("UNLOCK TABLES");
    }

    /**
     * Remove failures that are associated with the given resource and field.
     * @param $Resource resource associated with failures to be removed
     * @param $Field field associated with the failures to be removed
     */
    private function RemoveFailures(Resource $Resource, MetadataField $Field)
    {
        $this->DB->Query("DELETE FROM UrlChecker_Failures"
                ." WHERE ResourceId = '".intval($Resource->Id())."'"
                ." AND FieldId = '".intval($Field->Id())."'");
    }

    /**
     * @var $DB database instance
     * @var $DefaultNumToCheck the default number of resources to check
     * @var $ValidHttpCodes HTTP codes considered to be valid
     * @var $FailureThreshold times a resource should fail before being considered a failure
     * @var $DoingIntervalChecks TRUE when doing interval checks, FALSE otherwise
     */
    private $DB;
    private $DefaultNumToCheck;
    private $ValidHttpCodes;
    private $FailureThreshold;
    private $DoingIntervalChecks;

}
