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

/**
* Adds the ability to insert a Google Maps window into a CWIS interface.
*/
class GoogleMaps extends Plugin
{
    # map style constants
    #   These constants can be used to generate a googlemaps.MapOptions configuration.
    #   Below, the effect of each constant is briefly described, and the MapOptions
    #   elements corresponding to the contant are listed.

    # See https://developers.google.com/maps/documentation/javascript/reference#MapOptions
    #   for additional detail on the MapOptions.

    ##
    # options that disable certain features

    # disable all UI elements, showing just the map
    const NO_DEFAULT_UI         =    1; # disableDefaultUI = true

    # disable 'double click to zoom'
    const NO_DOUBLE_CLICK_ZOOM  =    2; # disableDoubleClickZoom = true

    # disable dragging of the map
    const NO_DRAGGABLE          =    4; # draggable = false

    # disable keyboard shortcuts
    const NO_KEYBOARD_SHORTCUTS =    8; # keybardShortcuts = false

    # do not clear the map <div> before adding map tiles to it
    const NO_CLEAR              =   16; # noClear = true

    # disable the overview map control
    const NO_OVERVIEW           =   32; # overviewMapControl = false

    # disable the pan control
    const NO_PAN                =   64; # panControl = false

    # disable the map rotation control
    const NO_ROTATE             =  128; # rotateControl = false

    # disable scroll wheel zoom
    const NO_WHEEL_ZOOM         =  256; # scroolwheel = false

    # disable zooming entirely
    const NO_ZOOM               =  512; # zoomControl = false

    ##
    # options that set initial values for certain controls

    # set the map type control to be initially hidden
    const MAP_TYPE_START_OFF    = 1024; # mapTypeControl = false

    # set the map scale control to be initially hidden
    const SCALE_START_OFF       = 2048; # scaleControl = false

    # set the street view control (the "pegman") to be initially hidden
    const STREET_VIEW_START_OFF = 4096; # streetViewControl = false

    # use MapMaker tiles instead of regular tiles
    #   see http://www.google.com/mapmaker
    const USE_MAP_MAKER         = 8192; # mapMaker = true

    /**
    * Register information about this plugin.
    */
    public function Register()
    {
        $this->Name = "Google Maps";
        $this->Version = "1.2.9";
        $this->Description = "This plugin adds the ability to insert a Google"
                ." Maps window into a CWIS interface.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array(
            "CWISCore" => "2.1.2"
            );

        $this->CfgSetup["ExpTime"] = array(
            "Type" => "Number",
            "MaxVal" => 31556926,
            "Label" => "Geocode Cache Expiration Time",
            "Help" => "Specifies how long to cache geocode results, in seconds.");

        $this->CfgSetup["DefaultSqlQuery"] = array(
            "Type" => "Paragraph",
            "Label" => "SQL to generate map locations",
            "Help" =>
            "An SQL select statement which returns your geodata in columns named: ".
            "Latitude, Longitude, ResourceId, MarkerColor, MarkerLabel, LabelColor. ".
            "Column order is not significant, but column names are. ".
            "Latitude, Longitude, and ResourceId are required, the rest are optional. ".
            "NOTE: if you specify both an SQL query and a metadata field, the metadata ".
            "field will be used."
            );

        $this->CfgSetup["DefaultPointField"] = array(
            "Type" => "MetadataField",
            "FieldTypes" => MetadataSchema::MDFTYPE_PARAGRAPH
                    | MetadataSchema::MDFTYPE_TEXT
                    | MetadataSchema::MDFTYPE_POINT,
            "Label" => "Default field for map locations",
            "Help" =>
                    "Text or Paragraph field containing address data ".
                    "or a Point field containing a Lat/Lng. ".
                    "Must be a Text or Paragraph field to use autopopulation. ".
                    "If enabled, autopopulation will be based on this field. ".
                    "When GoogleMaps_EVENT_HTML_TAGS_SIMPLE is signaled, ".
                    "markers locations will come from this field."
            );

        $this->CfgSetup["AutoPopulateEnable"] = array(
            "Type" => "Flag",
            "Label" => "Enable autopopulation of metadata fields",
            "Help" => "This determines if GoogleMaps should update metadata fields ".
                "based on geocoded address information.  When enabled, the default ".
                "field for map locations must be a text or paragraph field.",
            "Default" => FALSE,
            "OnLabel" => "Yes",
            "OffLabel" => "No"
            );

        $this->CfgSetup["AutoPopulateInterval"] = array(
            "Type" => "Number",
            "Label" => "Auto-Popluate Interval",
            "Default" => 5,
            "Units" => "minutes",
            "Size" => 4,
            "Help" => "How often to check for fields that need autopopulation.",
            );

        # set up field mappings
        foreach ($this->AutoPopulateData as $Key => $Data)
        {
            $this->CfgSetup["FieldMapping-".$Key] = array(
                "Type" => "MetadataField",
                "FieldTypes" => $Data["FieldType"],
                "Label" => "Autopopulate ".$Data["Display"],
                "Help" => "Field to auto-populate with "
                .$Data["Display"]." based on geocoding of of the "
                ."selected map location field"
                );
        }

    }

    /**
    * Initialize default settings.
    */
    public function Install()
    {
        $DB = new Database();

        $DB->Query("CREATE TABLE IF NOT EXISTS GoogleMaps_Callbacks ("
                   ."Id VARCHAR(32), "
                   ."Payload TEXT, "
                   ."LastUsed TIMESTAMP, "
                   ."Params TEXT, "
                   ."UNIQUE UIndex_I (Id) )");

        $DB->Query("CREATE TABLE IF NOT EXISTS GoogleMaps_Geocodes ("
                   ."Id VARCHAR(32), "
                   ."Lat DOUBLE, "
                   ."Lng DOUBLE, "
                   ."LastUpdate TIMESTAMP, "
                   ."AddrData MEDIUMBLOB, "
                   ."UNIQUE UIndex_I (Id))");
    }

    /**
    * Uninstall the plugin.
    * @return Returns  NULL if successful or an error message otherwise.
    */
    public function Uninstall()
    {
        $Database = new Database();

        # callbacks table
        if (FALSE === $Database->Query("DROP TABLE GoogleMaps_Callbacks;"))
        { return "Could not remove the callbacks table"; }

        # geocodes table
        if (FALSE === $Database->Query("DROP TABLE GoogleMaps_Geocodes;"))
        { return "Could not remove the geocodes table"; }

        # remove the cache directory
        if (!RemoveFromFilesystem($this->GetCachePath()))
        {
            return "Could not delete the cache directory.";
        }
    }

    /**
    * Upgrade from a previous version.
    * @param string $PreviousVersion Previous version number
    */
    public function Upgrade($PreviousVersion)
    {
        $DB = new Database();

        switch ($PreviousVersion)
        {
            case "1.0.1":
                $DB->Query("CREATE TABLE GoogleMapsCallbacks ("
                           ."Id VARCHAR(32) UNIQUE, "
                           ."Payload TEXT,"
                           ."LastUsed TIMESTAMP,"
                           ." INDEX (Id))");
            case "1.0.2":
                $DB->Query("CREATE TABLE GoogleMapsGeocodes ("
                           ."Id VARCHAR(32) UNIQUE, "
                           ."Lat DOUBLE, "
                           ."Lng DOUBLE, "
                           ."LastUpdate TIMESTAMP, "
                           ."INDEX (Id))");
                $this->ConfigSetting("ExpTime", 604800);
            case "1.0.3":
                $this->ConfigSetting("DefaultGridSize", 10);
                $this->ConfigSetting("DesiredPointCount", 100);
                $this->ConfigSetting("MaxIterationCount", 10);
            case "1.0.4":
                $DB->Query("ALTER TABLE GoogleMapsCallbacks "
                           ."ADD COLUMN Params TEXT");
            case "1.1.0":
                $this->CheckCacheDirectory();
            case "1.2.1":
            case "1.2.2":
                $DB->Query("ALTER TABLE GoogleMapsCallbacks "
                           ."RENAME TO GoogleMaps_Callbacks");
                $DB->Query("ALTER TABLE GoogleMapsGeocodes "
                           ."RENAME TO GoogleMaps_Geocodes");
            case "1.2.7":
                $this->ConfigSetting("ResourcesLastModified", time());
            case "1.2.8":
                $DB->Query("ALTER TABLE GoogleMaps_Geocodes "
                           ."ADD COLUMN AddrData MEDIUMBLOB");
                $DB->Query("DELETE FROM GoogleMaps_Geocodes");
            default:
        }
    }

    /**
    * Declare the events this plugin provides to the application framework.
    * @return Returns an array of the events this plugin provides to the
    *      application framework.
    */
    public function DeclareEvents()
    {
        return array(
            "GoogleMaps_EVENT_HTML_TAGS"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_HTML_TAGS_SIMPLE"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_STATIC_MAP"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_CHANGE_POINT_PROVIDER"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_GEOCODE"
            => ApplicationFramework::EVENTTYPE_FIRST,
            "GoogleMaps_EVENT_DISTANCE"
            => ApplicationFramework::EVENTTYPE_FIRST,
            "GoogleMaps_EVENT_BEARING"
            => ApplicationFramework::EVENTTYPE_FIRST,
            "GoogleMaps_EVENT_GET_KML"
            => ApplicationFramework::EVENTTYPE_FIRST);
    }

    /**
    * Hook the events into the application framework.
    * @return Returns an array of events to be hooked into the application
    *      framework.
    */
    public function HookEvents()
    {
        $Events = array(
            "EVENT_DAILY" => "CleanCaches",
            "EVENT_RESOURCE_ADD" => "UpdateResourceTimestamp",
            "EVENT_RESOURCE_DELETE" => "UpdateResourceTimestamp",
            "GoogleMaps_EVENT_HTML_TAGS" => "GenerateHtmlTags",
            "GoogleMaps_EVENT_HTML_TAGS_SIMPLE" => "GenerateHtmlTagsSimple",
            "GoogleMaps_EVENT_STATIC_MAP"   => "StaticMap",
            "GoogleMaps_EVENT_CHANGE_POINT_PROVIDER" => "ChangePointProvider",
            "GoogleMaps_EVENT_GEOCODE" => "Geocode",
            "GoogleMaps_EVENT_DISTANCE" => "ComputeDistance",
            "GoogleMaps_EVENT_BEARING" => "ComputeBearing",
            "GoogleMaps_EVENT_GET_KML" => "GetKml");

        if ($this->ConfigSetting("AutoPopulateEnable"))
        {
            $Events["EVENT_RESOURCE_MODIFY"] = array(
                "UpdateResourceTimestamp",
                "BlankAutoPopulatedFields");
            $Events["EVENT_PERIODIC"] = "UpdateAutoPopulatedFields";
        }
        else
        {
            $Events["EVENT_RESOURCE_MODIFY"] = "UpdateResourceTimestamp";
        }

        return $Events;
    }

    /**
    * Startup initialization for the plugin.
    * @return NULL on success, otherwise an error string.
    */
    public function Initialize()
    {
        if ($this->ConfigSetting("AutoPopulateEnable"))
        {
            $SrcFieldId = $this->ConfigSetting("DefaultPointField");
            $SrcField = new MetadataField($SrcFieldId);

            if ($SrcField->Type() == MetadataSchema::MDFTYPE_POINT)
            {
                return "Autopopulation cannot be used with a Point field. "
                    ."Please select a different field or disable Autopopulation.";
            }
        }

        $GLOBALS["AF"]->AddIncludeDirectories(
            array(
                "plugins/GoogleMaps/interface/default/include/",
                "plugins/GoogleMaps/interface/%ACTIVEUI%/include/",
                "local/plugins/GoogleMaps/interface/default/include/",
                "local/plugins/GoogleMaps/interface/%ACTIVEUI%/include/"));
    }

    /**
    * Periodically check for resources that need their location
    * information set.
    * @return int The minimum number of minutes to wait before calling
    *     this method again.
    */
    public function UpdateAutoPopulatedFields()
    {
        $Schema = new MetadataSchema();

        $SrcFieldId = $this->ConfigSetting("DefaultPointField");

        # if no field configured, come back later
        if ($SrcFieldId == NULL)
        {
            return $this->ConfigSetting("AutoPopulateInterval");
        }

        # pull out the list of configured fields to autopopulate
        $AutoPopulateFields = array();
        foreach ($this->AutoPopulateData as $Key => $Data)
        {
            $TgtField = $this->ConfigSetting("FieldMapping-".$Key);

            # if this field isn't mapped, check the next one
            if ($TgtField == NULL)
            {
                continue;
            }

            $AutoPopulateFields[$Key] = $Data;
            $AutoPopulateFields[$Key]["FieldId"] = $TgtField;
        }

        # assemble a list of resources that should be autopopulated, but are not
        $ValuesToMatch = array();
        foreach ($AutoPopulateFields as $Key => $Data)
        {
            $ValuesToMatch[$Data["FieldId"]] = "NULL";
        }

        # get resources that want an autopopulation
        $RFactory = new ResourceFactory();
        $Resources = $RFactory->GetMatchingResources(
            $ValuesToMatch, FALSE, FALSE);

        # get the resources that have no address
        $ExcludeResources = $RFactory->GetMatchingResources(
            array($SrcFieldId => "NULL"), TRUE, FALSE);

        # remove those from the list we'll consider
        $Resources = array_diff($Resources, $ExcludeResources);

        foreach ($Resources as $ResourceId)
        {
            $Resource = new Resource($ResourceId);

            # pull out the address
            $Address = $Resource->GetByFieldId($SrcFieldId);

            # geocode it
            $GeoData = $this->Geocode($Address, TRUE);

            # if there was no geodata available for this address,
            if ($GeoData === NULL)
            {
                continue;
            }

            foreach ($AutoPopulateFields as $Key => $Data)
            {
                if ($Key == "LatLng")
                {
                    $Resource->SetByFieldId(
                        $Data["FieldId"],
                        array("X" => $GeoData["Lat"],
                              "Y" => $GeoData["Lng"]) );
                }
                else
                {
                    if (array_key_exists($Data["GoogleElement"], $GeoData["AddrData"]))
                    {
                        $Resource->SetByFieldId(
                            $Data["FieldId"],
                            current( $GeoData["AddrData"][$Data["GoogleElement"]] ) );
                    }
                }
            }

            # make sure there's time for another query
            #   forground queries take ca 60s, so make sure we've got
            #   1.5x that in case of a particularly slow one
            if ($GLOBALS["AF"]->GetSecondsBeforeTimeout() < 90)
            {
                break;
            }
        }

        return $this->ConfigSetting("AutoPopulateInterval");
    }

    /**
    * Generates the HTML tags to make the Google Maps widget.
    *
    * Takes two parameters, a PointProvider and a DetailProvider.
    * Both are the PHP callbacks, the former takes a user-provided
    * array, and is expected to return all of the points with GPS
    * coordinates. The latter should take an id number (usually a
    * ResourceId) and a user-provided array and print a fragment of
    * HTML to display in the info window that pops up over the map
    * marker for that resource.  Anything that can be a php callback
    * is fair game.
    *
    * If you're using functions, they need to be part of the
    * environment when the helper pages for the plugin are loaded.
    * To accomplish this, put them in files called
    * F-(FUNCTION_NAME).html or F-(FUNCTION_NAME).php in your
    * 'interface', 'local/pages' or inside your interface directory.
    *
    * If you're using object methods, the objects will need to be
    * somewhere that the ApplicationFramework's object loading will
    * look, 'local/objects' is likely best.
    *
    * @param callback $PointProvider Callback that provides point information
    * @param array $PointProviderParams Parameters passed to the point
    *      provider when it's called
    * @param callback $DetailProvider Callback that provides detailed
    *      information about a point
    * @param array $DetailProviderParams Parameters passed to the detail
    *      provider when it's called
    * @param int $DefaultLat Latitude of initial center of the map
    * @param int $DefaultLon Longitude of the initial center of the map
    * @param int $DefaultZoom Zoom level of the initial map
    * @param string $InfoDisplayEvent Event that should trigger showing detailed
    *      point information.
    * @param string $KmlPage Page that generates KML for Google
    * @param int $MapsOptions Either a bitmask of style constants from this object, or
    *    an array specifying additional options for this map based on
    *    https://developers.google.com/maps/documentation/javascript/reference#MapOptions
    * @see GenerateHTMLTagsSimple()
    */
    public function GenerateHTMLTags($PointProvider, $PointProviderParams,
                              $DetailProvider, $DetailProviderParams,
                              $DefaultLat, $DefaultLon, $DefaultZoom,
                              $InfoDisplayEvent, $KmlPage="P_GoogleMaps_GetKML",
                              $MapsOptions=array())
    {
        $UseSsl = isset($_SERVER["HTTPS"]);
        $Protocol = $UseSsl ? "https://" : "http://";

        # Spit out the html tags required for the map
        print('<div class="GoogleMap"><br/><br/><br/><br/><center>'
                .'<span style="color: #DDDDDD;">[JavaScript Required]'
                .'</span></center></div>');

        $ApiUrl = ($UseSsl) ?
            'https://maps-api-ssl.google.com/maps/api/js?sensor=false' :
            'http://maps.google.com/maps/api/js?sensor=false' ;
        print('<script type="text/javascript" src="'.$ApiUrl.'"></script>');

        foreach (array("jquery.cookie.js", "GoogleMapsExtensions.js")
                 as $Tgt)
        {
            print('<script type="text/javascript" src="'.
                  $GLOBALS["AF"]->GUIFile($Tgt).'"></script>');
        }

        # make sure the parameters are serialized the same, regardless of the
        # order in which they're added
        ksort($PointProviderParams);
        ksort($DetailProviderParams);

        $PPSerial = serialize($PointProvider);
        $PPParamsSerial = serialize($PointProviderParams);
        $PPHash = md5($PPSerial.$PPParamsSerial);

        $DPSerial = serialize($DetailProvider);
        $DPParamsSerial = serialize($DetailProviderParams);
        $DPHash = md5($DPSerial.$DPParamsSerial);

        $DB = new Database();

        $DB->Query(
            "INSERT IGNORE INTO GoogleMaps_Callbacks "
            ."(Id, Payload, Params, LastUsed) VALUES "
            ."('".$PPHash."','".addslashes($PPSerial)."','"
                .addslashes($PPParamsSerial)."',NOW()),"
            ."('".$DPHash."','".addslashes($DPSerial)."','"
                .addslashes($DPParamsSerial)."',NOW())");

        $MyLocation = $Protocol.$_SERVER['SERVER_NAME'].
            ( strpos($_SERVER['REQUEST_URI'], '.php') ?
              dirname($_SERVER['REQUEST_URI']).'/' :
              $_SERVER['REQUEST_URI']);

        # process options provided by the user
        if (is_numeric($MapsOptions))
        {
            $OptionsArray = array();

            if ($MapsOptions & self::NO_DEFAULT_UI)
            {
                $OptionsArray["disableDefaultUI"] = TRUE;
            }
            if ($MapsOptions & self::NO_DOUBLE_CLICK_ZOOM)
            {
                $OptionsArray["disableDoubleClickZoom"] = TRUE;
            }
            if ($MapsOptions & self::NO_DRAGGABLE)
            {
                $OptionsArray["draggable"] = FALSE;
            }
            if ($MapsOptions & self::NO_KEYBOARD_SHORTCUTS)
            {
                $OptionsArray["keyboardShortcusts"] = FALSE;
            }
            if ($MapsOptions & self::NO_CLEAR)
            {
                $OptionsArray["noClear"] = TRUE;
            }
            if ($MapsOptions & self::NO_OVERVIEW)
            {
                $OptionsArray["overviewMapControl"] = FALSE;
            }
            if ($MapsOptions & self::NO_PAN)
            {
                $OptionsArray["panControl"] = FALSE;
            }
            if ($MapsOptions & self::NO_ROTATE)
            {
                $OptionsArray["rotateControl"] = FALSE;
            }
            if ($MapsOptions & self::NO_WHEEL_ZOOM)
            {
                $OptionsArray["scrollwheel"] = FALSE;
            }
            if ($MapsOptions & self::NO_ZOOM)
            {
                $OptionsArray["zoomControl"] = FALSE;
            }

            if ($MapsOptions & self::MAP_TYPE_START_OFF)
            {
                $OptionsArray["mapTypeControl"] = FALSE;
            }
            if ($MapsOptions & self::SCALE_START_OFF)
            {
                $OptionsArray["scaleControl"] = FALSE;
            }
            if ($MapsOptions & self::STREET_VIEW_START_OFF)
            {
                $OptionsArray["streetViewControl"] = FALSE;
            }

            if ($MapsOptions & self::USE_MAP_MAKER)
            {
                $OptionsArray["mapMaker"] = TRUE;
            }

            $MapsOptions = $OptionsArray;
        }

        $MapApplication = str_replace(
            array("X-POINT-PROVIDER-X", "X-DETAIL-PROVIDER-X",
                  "X-DEFAULT-LAT-X","X-DEFAULT-LON-X","X-DEFAULT-ZOOM-X",
                  "X-DESIRED-POINT-COUNT-X","X-INFO-DISPLAY-EVENT-X",
                  "X-BASE-URL-X", "X-KML-PAGE-X", "X-MAP-OPTIONS-X" ),
            array($PPHash, $DPHash, $DefaultLat, $DefaultLon, $DefaultZoom,
                  $this->ConfigSetting("DesiredPointCount"),
                  $InfoDisplayEvent, $MyLocation, $KmlPage,
                  json_encode( $MapsOptions)),
            file_get_contents(
                "./plugins/GoogleMaps/GoogleMapsDisplay.js"));

        print '<style type="text/css">';
        print file_get_contents("plugins/GoogleMaps/GoogleMapsDisplay.css");
        print '</style>';

        print('<script type="text/javascript">'.$MapApplication.'</script>');
    }

    /**
    * Generates the HTML tags to make the Google Maps widget using the default
    * point and detail provider.
    * @param int $DefaultLat Latitude of initial center of the map.
    * @param int $DefaultLon Longitude of the initial center of the map.
    * @param int $DefaultZoom Zoom level to use initially.
    * @param string $InfoDisplayEvent Event that should trigger showing detailed
    *      point information.
    * @see GenerateHTMLTags()
    */
    public function GenerateHTMLTagsSimple($DefaultLat, $DefaultLon,
            $DefaultZoom, $InfoDisplayEvent)
    {
        $PointProvider = array("GoogleMaps", "DefaultPointProvider");
        $PointProviderParams = array();
        $DetailProvider = array("GoogleMaps", "DefaultDetailProvider");
        $DetailProviderParams = array();

        $this->GenerateHTMLTags($PointProvider, $PointProviderParams,
                                $DetailProvider, $DetailProviderParams,
                                $DefaultLat, $DefaultLon, $DefaultZoom,
                                $InfoDisplayEvent);
    }

    /**
    * Generates JavaScript code that can be used to change a point provider. Can
    * be useful with a jQuery .click() to avoid reloading maps.
    * @param callback $PointProvider Callback used to provide points.
    * @param array $Params Parameters to pass to the point provider callback.
    * @see DefaultPointProvider()
    */
    public function ChangePointProvider($PointProvider, $Params)
    {
        # make sure the parameters are serialized the same, regardless of the
        # order in which they're added
        ksort($Params);

        $PPSerial = serialize($PointProvider);
        $PPParamsSerial = serialize($Params);
        $PPHash = md5($PPSerial.$PPParamsSerial);

        $DB = new Database();

        $DB->Query(
            "INSERT IGNORE INTO GoogleMaps_Callbacks "
            ."(Id, Payload, Params, LastUsed) VALUES "
            ."('".$PPHash."','".addslashes($PPSerial)."','"
            .addslashes($PPParamsSerial)."',NOW())");

        print("change_point_provider('".$PPHash."');");
    }

    /**
    * Generates and prints a static map image. This makes use of the Google
    * Static Maps API.
    *
    * Google's docs are here:
    * http://code.google.com/apis/maps/documentation/staticmaps/
    *
    * @param int $Lat Latitude of the center of the map image.
    * @param int $Long Longitude of the center of the map image.
    * @param int $Width Width of the map image.
    * @param int $Height Height of the map image.
    * @param int $Zoom Zoom level of the maps image.
    */
    public function StaticMap($Lat, $Long, $Width, $Height, $Zoom=14)
    {
        $UseSsl = isset($_SERVER["HTTPS"]);
        $Host = $UseSsl ? "https://maps.googleapis.com" : "http://maps.google.com";

        $Url= $Host."/maps/api/staticmap?maptype=roadmap"
            ."&size=".$Width."x".$Height
            ."&zoom=".$Zoom
            ."&markers=".$Lat.",".$Long
            ."&sensor=false";

        print('<img src="'.$Url.'" alt="Google Map">');
    }

    /**
    * Given an address, get the latitude and longitude of its coordinates.
    *
    * Details on Google's Geocoding API are here:
    * http://code.google.com/apis/maps/documentation/geocoding/
    *
    * NB: Geocoding is rate and quantity limited (see the "Limits"
    * section in Google's docs). As of this writing, they allow only
    * 2500 requests per day. Geocode requests sent from servers
    * (rather than via Firefox or IE) appear to be answered slowly,
    * taking about one minute per reply.  Furthermore, google
    * explicitly states that they will block access to their service
    * which is "abusive".
    *
    * To avoid potentials with the rate/quantity issue, this geocoder
    * caches results for up to a week.  If an address is requested
    * which is not in the cache, NULL will be returned and the
    * geocoding request will take place in the background.
    *
    * @param string $Address Address of a location.
    * @param bool $Foreground TRUE to wait for an address, rather than
    *   spawning a background task to fetch it.
    */
    public function Geocode($Address, $Foreground=FALSE)
    {
        $DB = new Database();

        # Then look for the desired address
        $DB->Query("SELECT Lat,Lng,AddrData FROM GoogleMaps_Geocodes "
                   ."WHERE Id='".md5($Address)."'");

        # If we couldn't find the desired address:
        if ($DB->NumRowsSelected()==0)
        {
            # If we're running in the foreground, fetch it now,
            #  otherwise, use a background task to fetch it.
            if ($Foreground)
            {
                $this->GeocodeRequest($Address);
                $DB->Query("SELECT Lat,Lng,AddrData FROM GoogleMaps_Geocodes "
                           ."WHERE Id='".md5($Address)."'");
                $Row = $DB->FetchRow();
            }
            else
            {
                # If we can't find it, set up a Geocoding request and return NULL;
                global $AF;
                $AF->QueueUniqueTask(
                    array($this,'GeocodeRequest'),
                    array($Address),
                    ApplicationFramework::PRIORITY_HIGH
                    );
                return NULL;
            }
        }
        else
        {
            $Row = $DB->FetchRow();
        }

        # if there was no value, return null
        #  otherwise, unpack the serialized array and return the result
        if ($Row["Lat"] == NULL)
        {
            return NULL;
        }
        else
        {
            $Row["AddrData"] = unserialize($Row["AddrData"]);
            return $Row;
        }
    }

    /**
    * Perform the request necessary to geocode an address.
    * @param string $Address Address of a location.
    */
    public function GeocodeRequest($Address)
    {
        $Data = file_get_contents(
            "http://maps.google.com/maps/api/geocode/xml?sensor=false&address="
            .urlencode($Address), FALSE,
            stream_context_create(
                array( 'http' => array(
                           'method' => "GET",
                           'header'=>"User-Agent: GoogleMaps/".$this->Version
                           ." CWIS/".CWIS_VERSION." PHP/".PHP_VERSION."\r\n"))
                )
            );

        $DB = new Database();
        $ParsedData = simplexml_load_string($Data);
        if ($ParsedData->status == "OK")
        {
            # extract lat and lng
            $Lat = floatval($ParsedData->result->geometry->location->lat);
            $Lng = floatval($ParsedData->result->geometry->location->lng);

            # extract more detailed address data
            $AddrData = array();
            foreach ($ParsedData->result->address_component as $Item)
            {
                foreach ($Item->type as $ItemType)
                {
                    # skip the 'political' elements, we don't want those
                    if ((string)$ItemType == "political")
                    {
                        continue;
                    }

                    # snag the value
                    $AddrData[(string)$ItemType]["Name"] = (string)$Item->long_name;

                    # if there was a distinct appreviation, get that as well
                    if ( (string)$Item->long_name != (string)$Item->short_name)
                    {
                        $AddrData[(string)$ItemType]["ShortName"] =
                            (string)$Item->short_name;
                    }
                }
            }

            $DB->Query(
                "INSERT INTO GoogleMaps_Geocodes "
                ."(Id,Lat,Lng,AddrData,LastUpdate) VALUES ("
                ."'".md5($Address)."',"
                .$Lat.","
                .$Lng.","
                ."'".$DB->EscapeString( serialize($AddrData) )."',"
                ."NOW()"
                .")");
        }
        else
        {
            $DB->Query(
                "INSERT INTO GoogleMaps_Geocodes "
                ."(Id,Lat,Lng,AddrData,LastUpdate) VALUES "
                ."('".md5($Address)."',NULL,NULL,NULL,NOW())");
        }
    }

    /**
    * Computes the distance in kilometers between two points, assuming a
    * spherical earth.
    * @param int $LatSrc Latitude of the source coordinate.
    * @param int $LonSrc Longitude of the source coordinate.
    * @param int $LatDst Latitude of the destination coordinate.
    * @param int $LonDst Longitude of the destination coordinate.
    * @return distance in miles between the two points.
    */
    public function ComputeDistance($LatSrc, $LonSrc,
                             $LatDst, $LonDst)
    {
        return StdLib::ComputeGreatCircleDistane(
            $LatSrc, $LonSrc,
            $LatDst, $LonDst);
    }

    /**
    * Computes the initial angle on a course connecting two points, assuming a
    * spherical earth.
    * @param int $LatSrc Latitude of the source coordinate.
    * @param int $LonSrc Longitude of the source coordinate.
    * @param int $LatDst Latitude of the destination coordinate.
    * @param int $LonDst Longitude of the destination coordinate.
    * @return initial angle on a course connecting two points.
    */
    public function ComputeBearing($LatSrc, $LonSrc,
                            $LatDst, $LonDst)
    {
        return StdLIb::ComputeBearing(
            $LatSrc, $LonSrc,
            $LatDst, $LonDst);
    }

    /**
    * Periodic function to clean old data from DB cache tables.
    */
    public function CleanCaches()
    {
        $DB = new Database();

        # Clean the cache of entries older than the configured expiration time
        $DB->Query("DELETE FROM GoogleMaps_Geocodes WHERE "
                   ."NOW() - LastUpdate >".$this->ConfigSetting("ExpTime"));

        $DB->Query("DELETE FROM GoogleMaps_Callbacks WHERE "
                   ."NOW() - LastUsed > 86400");
    }

    /**
    * Update the timestamp storing the last change to any resource.
    */
    public function UpdateResourceTimestamp()
    {
        $this->ConfigSetting("ResourcesLastModified", time());
    }

    /**
    * When a resource is modified, clear the autopopulated fields so
    * that they can be updated.
    * @param Resource $Resource Resource to blank.
    */
    public function BlankAutoPopulatedFields($Resource)
    {
        # pull out the list of configured fields to autopopulate
        foreach ($this->AutoPopulateData as $Key => $Data)
        {
            $TgtField = $this->ConfigSetting("FieldMapping-".$Key);

            # if this field isn't mapped, check the next one
            if ($TgtField == NULL)
            {
                continue;
            }

            $Resource->ClearByFieldId( $TgtField );
        }
    }

    /**
    * Get the file path to the KML file named by the given point and detail
    * provider hashes. This will get the cached KML file if it exists or will
    * generate it first if it does not exist.
    * @param string $PointProviderHash Point provider hash.
    * @param string $DetailProviderHash Detail provider hash.
    * @return Returns the path to the cached KML file.
    */
    public function GetKml($PointProviderHash, $DetailProviderHash)
    {
        global $AF;

        $Kml = $this->GetKmlFilePath($PointProviderHash, $DetailProviderHash);

        # the file hasn't been generated yet
        if (!file_exists($Kml))
        {
            $this->GenerateKml($PointProviderHash, $DetailProviderHash);
        }

        # the file has been already generated so don't generate it now, but
        # update it in the background
        else
        {
            if (filemtime($Kml) <
                    $this->ConfigSetting("ResourcesLastModified") )
            {
                $AF->QueueUniqueTask(
                        array($this, "GenerateKml"),
                        array($PointProviderHash, $DetailProviderHash),
                        ApplicationFramework::PRIORITY_BACKGROUND,
                        "Update a KML cache file.");
            }
        }

        return $Kml;
    }

    /**
    * Generate the cached KML file named by the given point and detail provider
    * hashes.
    * @param string $PointProviderHash Point provider hash.
    * @param string $DetailProviderHash Detail provider hash.
    * @see GetKmlFilePath()
    */
    public function GenerateKml($PointProviderHash, $DetailProviderHash)
    {
        global $AF;

        # make sure the cache directories exist and are usable before attempting
        # to generate the KML
        if (strlen($this->CheckCacheDirectory()))
        {
            return;
        }

        $DB = new Database();
        $Path = $this->GetKmlFilePath($PointProviderHash, $DetailProviderHash);

        # Pull out the point provider callback, user spec'd parameters, and try
        # to load the callback
        $Row = $DB->Query("
            SELECT Payload,Params FROM GoogleMaps_Callbacks
            WHERE Id='".addslashes($PointProviderHash)."'");
        $Row = $DB->FetchRow();
        $PPCallback = unserialize($Row["Payload"]);
        $PPParams   = unserialize($Row["Params"]);

        # Pull out the detail provider callback, user spec'd parameters, and try
        # to load the callback
        $DB->Query("
            SELECT Payload, Params FROM GoogleMaps_Callbacks
            WHERE Id='".addslashes($DetailProviderHash)."'");
        $Row = $DB->FetchRow();
        $DPCallback = unserialize($Row["Payload"]);
        $DPParams   = unserialize($Row["Params"]);

        # initialize the KML file
        $Kml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
        $Kml .= '<kml xmlns="http://www.opengis.net/kml/2.2">'."\n";
        $Kml .= "<Document>\n";

        if ($AF->LoadFunction($PPCallback) && $AF->LoadFunction($DPCallback))
        {
            # Call the supplied detail provider, expecting an Array.
            $Points = call_user_func_array($PPCallback, array($PPParams));

            # add log message if no points where found
            if (count($Points) < 1)
            {
                $Message = "[GoogleMaps] No points found for parameters: ";
                $Message .= var_export(array(
                    "Parameters" => $PPParams),
                    TRUE);

                $AF->LogMessage(ApplicationFramework::LOGLVL_INFO, $Message);
            }

            # Enumerate the different marker types that we're using:
            $MarkerTypes = array();
            foreach ($Points as $Point)
            {
                $BgColor = str_replace('#', '', $Point[3]);
                $Label   = defaulthtmlentities($Point[4]);
                $FgColor = str_replace('#', '', $Point[5]);

                $MarkerTypes[$Label.$BgColor.$FgColor] =
                    "http://chart.apis.google.com/chart"
                    ."?chst=d_map_pin_letter_withshadow"
                    ."&chld=".urlencode($Label."|".$BgColor."|".$FgColor);
            }

            # Style elements to define markers:
            foreach ($MarkerTypes as $Key => $Value)
            {
                $Kml .= '<Style id="_'.defaulthtmlentities($Key).'">';
                $Kml .= '<IconStyle>';
                $Kml .= '<Icon><href>'.defaulthtmlentities($Value).'</href></Icon>';

                # the offset (starts from lower left) for the anchor of the
                # marker image from the Google Chart API is 11 px and 2px, but
                # the markers are 40x37 and get resized by Google Maps to the
                # maximum of 32x32, so the actual offset is 9px and 2px
                $Kml .= '<hotSpot x="9" y="2" xunits="pixels" yunits="pixels" />';

                $Kml .= '</IconStyle>';
                $Kml .= '</Style>';
            }


            # Keep track of how many markers are placed at each point:
            $MarkersAtPoint = array();

            # Point elements:
            foreach ($Points as $Point)
            {
                $Lat = $Point[0];
                $Lon = $Point[1];
                $Id  = $Point[2];
                $BgColor = $Point[3];
                $Label   = $Point[4];
                $FgColor = $Point[5];

                $ix =  "X-".$Lat.$Lon."-X";
                if ( !isset($MarkersAtPoint[$ix]) )
                {
                    $MarkersAtPoint[$ix] = 1;
                }
                else
                {

                    $Lat += 0.005 * $MarkersAtPoint[$ix];
                    $Lon += 0.005 * $MarkersAtPoint[$ix];

                    $MarkersAtPoint[$ix]++;
                }

                $Kml .= '<Placemark id="_'.defaulthtmlentities($Id).'">';
                $Kml .= '<name></name>';
                $Kml .= '<description><![CDATA[';

                # add the description text/HTML
                ob_start();
                call_user_func_array(
                    $DPCallback,
                    array("Id" => $Id, "Params" => $DPParams));
                $Kml .= ob_get_contents();
                ob_end_clean();

                $Kml .= ']]></description>';

                $Kml .= '<styleUrl>#_'.$Label.$BgColor.$FgColor.'</styleUrl>';
                $Kml .= '<Point><coordinates>'.$Lat.','.$Lon.'</coordinates></Point>';
                $Kml .= '</Placemark>';
            }
        }

        else
        {
            $Message = "[GoogleMaps] Could not load callbacks: ";
            $Message .= var_export(array(
                "Point Provider Callback" => $PPCallback,
                "Detail Provider Callback" => $DPCallback),
                TRUE);

            $AF->LogMessage(ApplicationFramework::LOGLVL_ERROR, $Message);
        }

        # complete the KML document
        $Kml .= "</Document></kml>";
        file_put_contents($Path, $Kml);
    }

    /**
    * Provides a default point provider that retrieves all points from the
    * default point field.
    * @param array $Params Parameters to the point provider. The default point
    *      provider doesn't use them.
    * @return Returns an array of points and associated data.
    */
    public static function DefaultPointProvider($Params)
    {
        global $DB;

        $rc = array();

        $MyPlugin = $GLOBALS["G_PluginManager"]->GetPlugin("GoogleMaps");

        $MetadataField = $MyPlugin->ConfigSetting("DefaultPointField");

        if ($MetadataField != "" )
        {
            $Schema = new MetadataSchema();
            $Field = $Schema->GetField($MetadataField);
            $DBFieldName = $Field->DBFieldName();

            if ($Field->Type() == MetadataSchema::MDFTYPE_POINT)
            {
                # Data is lat/long encoded in a point field
                $DB->Query(
                    "SELECT ResourceId, "
                    ."`".$DBFieldName."X` AS Y,"
                    ."`".$DBFieldName."Y` AS X FROM Resources "
                    ."WHERE "
                    ."`".$DBFieldName."Y` IS NOT NULL AND "
                    ."`".$DBFieldName."X` IS NOT NULL");
                $Points = $DB->FetchRows();

                foreach ($Points as $Point)
                {
                    $rc[]= array(
                        $Point["X"],
                        $Point["Y"],
                        $Point["ResourceId"],
                        "8F00FF", "", "000000"
                        );
                }
            }
            else
            {
                # Data is an address which needs to be geocoded:
                $DB->Query(
                    "SELECT ResourceId, `".$DBFieldName."` AS Addr FROM Resources "
                    ."WHERE `".$DBFieldName."` IS NOT NULL");
                $Addresses = $DB->FetchRows();

                foreach ($Addresses as $Address)
                {
                    $GeoData = $MyPlugin->Geocode($Address["Addr"]);
                    if ($GeoData !== NULL)
                    {
                        $rc[]= array(
                            $GeoData["Lng"],
                            $GeoData["Lat"],
                            $Address["ResourceId"],
                            "8F00FF","","000000"
                            );
                    }
                }
            }
        }
        else
        {
            $SqlQuery =  $MyPlugin->ConfigSetting("DefaultSqlQuery");

            if ($SqlQuery != NULL && !ContainsDangerousSQL($SqlQuery) )
            {
                $DB->Query($SqlQuery);
                while ($Row = $DB->FetchRow())
                {
                    $rc[]= array(
                        $Row["Longitude"],
                        $Row["Latitude"],
                        $Row["ResourceId"],
                        isset($Row["MarkerColor"])? $Row["MarkerColor"] : "8F00FF" ,
                        isset($Row["MarkerLabel"])? $Row["MarkerLabel"] : "",
                        isset($Row["LabelColor"]) ? $Row["LabelColor"]  : "000000"
                        );
                }
            }
        }

        return $rc;
    }

    /**
    * Provides a default detail provider containing the title, description, and
    * a link to the full record of a resource.
    * @param int $ResourceId ID of the resource for which to provide details.
    */
    public static function DefaultDetailProvider($ResourceId)
    {
        if (!Resource::ItemExists($ResourceId))
        {
            print("ERROR: Invalid ResourceId\n");
        }
        else
        {
            $Resource = new Resource($ResourceId);
            print(
                '<h1>'.$Resource->GetMapped("Title").'</h1>'
                .'<p>'.$Resource->GetMapped("Description").'</p>'
                .'<p><a href="index.php?P=FullRecord&amp;ID='.$Resource->Id().'">'
                .'(more information)</a></p>'
                );
        }
    }

    /**
    * Generate the file path string for the cached KML file given the point and
    * detail provider hashes.
    * @param string $PointProviderHash Point provider hash.
    * @param string $DetailProviderHash Detail provider hash
    * @return Path to the cached KML file.
    */
    protected function GetKmlFilePath($PointProviderHash, $DetailProviderHash)
    {
        $CachePath = $this->GetKmlCachePath();
        $FileName = $PointProviderHash . "_" .$DetailProviderHash . ".xml";
        $FilePath = $CachePath . "/" . $FileName;

        return $FilePath;
    }

    /**
    * Get the path of the KML cache directory.
    * @return Returns the path of the KML cache directory.
    */
    protected function GetKmlCachePath()
    {
        return $this->GetCachePath() . "/kml";
    }

    /**
    * Get the path of the cache directory.
    * @return Returns the path of the cache directory.
    */
    protected function GetCachePath()
    {
        return getcwd() . "/local/data/caches/GoogleMaps";
    }

    /**
    * Make sure the cache directories exist and are usable, creating them if
    * necessary.
    * @return Returns a string if there's an error and NULL otherwise.
    */
    protected function CheckCacheDirectory()
    {
        $CachePath = $this->GetCachePath();

        # the cache directory doesn't exist, try to create it
        if (!file_exists($CachePath))
        {
            $Result = @mkdir($CachePath, 0777, TRUE);

            if (FALSE === $Result)
            {
                return "The cache directory could not be created.";
            }
        }

        # exists, but is not a directory
        if (!is_dir($CachePath))
        {
            return "(".$CachePath.") is not a directory.";
        }

        # exists and is a directory, but is not writeable
        if (!is_writeable($CachePath))
        {
            return "The cache directory is not writeable.";
        }

        $KmlPath = $this->GetKmlCachePath();

        # the KML cache directory doesn't exist, try to create it
        if (!file_exists($KmlPath))
        {
            $Result = @mkdir($KmlPath);

            if (FALSE === $Result)
            {
                return "The KML cache directory could not be created.";
            }
        }

        # exists, but is not a directory
        if (!is_dir($KmlPath))
        {
            return "(".$KmlPath.") is not a directory.";
        }

        # exists and is a directory, but is not writeable
        if (!is_writeable($KmlPath))
        {
            return "The KML cache directory is not writeable.";
        }

        return NULL;
    }

    # array defining which of Google's elements we autopopulate
    # data from
    private $AutoPopulateData = array(
        "City" => array(
            "Display" => "City",
            "FieldType" => MetadataSchema::MDFTYPE_TEXT,
            "GoogleElement" => "locality"),
        "State" => array(
            "Display" => "State/Province",
            "FieldType" => MetadataSchema::MDFTYPE_TEXT,
            "GoogleElement" => "administrative_area_level_1"),
        "Country" => array(
            "Display" => "Country",
            "FieldType" => MetadataSchema::MDFTYPE_TEXT,
            "GoogleElement" => "country"),
        "PostCode" => array(
            "Display" => "Postal Code",
            "FieldType" => MetadataSchema::MDFTYPE_TEXT,
            "GoogleElement" => "postal_code"),
        "LatLng" => array(
            "Display" => "Latitude/Longitude",
            "FieldType" => MetadataSchema::MDFTYPE_POINT,
            "GoogleElement" => NULL)
        );
}
