001package systems.dmx.geomaps;
002
003import systems.dmx.geomaps.model.GeoCoordinate;
004import systems.dmx.geomaps.model.Geomap;
005import systems.dmx.topicmaps.TopicmapsService;
006import systems.dmx.facets.FacetsService;
007
008import systems.dmx.core.Association;
009import systems.dmx.core.AssociationDefinition;
010import systems.dmx.core.ChildTopics;
011import systems.dmx.core.RelatedTopic;
012import systems.dmx.core.Topic;
013import systems.dmx.core.TopicType;
014import systems.dmx.core.model.AssociationModel;
015import systems.dmx.core.model.ChildTopicsModel;
016import systems.dmx.core.model.TopicModel;
017import systems.dmx.core.osgi.PluginActivator;
018import systems.dmx.core.service.Cookies;
019import systems.dmx.core.service.Inject;
020import systems.dmx.core.service.Transactional;
021import systems.dmx.core.service.event.PostCreateTopicListener;
022import systems.dmx.core.service.event.PostUpdateTopicListener;
023import systems.dmx.core.service.event.PreSendTopicListener;
024import systems.dmx.core.util.ContextTracker;
025import systems.dmx.core.util.JavaUtils;
026
027import org.codehaus.jettison.json.JSONObject;
028
029import javax.ws.rs.GET;
030import javax.ws.rs.PUT;
031import javax.ws.rs.HeaderParam;
032import javax.ws.rs.Path;
033import javax.ws.rs.PathParam;
034import javax.ws.rs.QueryParam;
035import javax.ws.rs.Produces;
036import javax.ws.rs.Consumes;
037
038import java.net.URL;
039import java.util.List;
040import java.util.concurrent.Callable;
041import java.util.logging.Level;
042import java.util.logging.Logger;
043
044
045
046@Path("/geomap")
047@Consumes("application/json")
048@Produces("application/json")
049public class GeomapsPlugin extends PluginActivator implements GeomapsService, PostCreateTopicListener,
050                                                                              PostUpdateTopicListener,
051                                                                              PreSendTopicListener {
052
053    private static final String GEOCODER_URL = "http://maps.googleapis.com/maps/api/geocode/json?" +
054        "address=%s&sensor=false";
055
056    private static final String COOKIE_NO_GEOCODING = "dmx_no_geocoding";
057
058    private static final double EARTH_RADIUS_KM = 6371.009;
059
060    // ---------------------------------------------------------------------------------------------- Instance Variables
061
062    @Inject private TopicmapsService topicmapsService;
063    @Inject private FacetsService facetsService;
064
065    // used for geocoding suppression
066    private ContextTracker contextTracker = new ContextTracker();
067
068    private Logger logger = Logger.getLogger(getClass().getName());
069
070    // -------------------------------------------------------------------------------------------------- Public Methods
071
072
073
074    // *************************************
075    // *** GeomapsService Implementation ***
076    // *************************************
077
078
079
080    @GET
081    @Path("/{id}")
082    @Override
083    public Geomap getGeomap(@PathParam("id") long geomapId) {
084        return new Geomap(geomapId, dmx);
085    }
086
087    // Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
088    @GET
089    @Path("/topic/{id}")
090    @Override
091    public Topic getDomainTopic(@PathParam("id") long geoCoordId) {
092        try {
093            Topic topic = dmx.getTopic(geoCoordId);
094            RelatedTopic parentTopic;
095            while ((parentTopic = topic.getRelatedTopic(null, "dmx.core.child", "dmx.core.parent", null)) != null) {
096                topic = parentTopic;
097            }
098            return topic;
099        } catch (Exception e) {
100            throw new RuntimeException("Finding the geo coordinate's domain topic failed (geoCoordId=" +
101                geoCoordId + ")", e);
102        }
103    }
104
105    @Override
106    public GeoCoordinate getGeoCoordinate(Topic geoTopic) {
107        try {
108            Topic geoCoordTopic = getGeoCoordinateTopic(geoTopic);
109            if (geoCoordTopic != null) {
110                return geoCoordinate(geoCoordTopic);
111            } else {
112                return null;
113            }
114        } catch (Exception e) {
115            throw new RuntimeException("Getting the geo coordinate failed (geoTopic=" + geoTopic + ")", e);
116        }
117    }
118
119    @Override
120    public GeoCoordinate geoCoordinate(Topic geoCoordTopic) {
121        ChildTopics childTopics = geoCoordTopic.getChildTopics();
122        return new GeoCoordinate(
123            childTopics.getDouble("dmx.geomaps.longitude"),
124            childTopics.getDouble("dmx.geomaps.latitude")
125        );
126    }
127
128    @PUT
129    @Path("/{id}/topic/{geo_coord_id}")
130    @Transactional
131    @Override
132    public void addCoordinateToGeomap(@PathParam("id") long geomapId, @PathParam("geo_coord_id") long geoCoordId) {
133        logger.info("### Adding geo coordinate topic " + geoCoordId + " to geomap " + geomapId);
134        AssociationModel model = mf.newAssociationModel("dmx.geomaps.geotopic_mapcontext",
135            mf.newTopicRoleModel(geomapId,   "dmx.core.default"),
136            mf.newTopicRoleModel(geoCoordId, "dmx.topicmaps.topicmap_topic")
137        );
138        dmx.createAssociation(model);
139    }
140
141    @PUT
142    @Path("/{id}/center/{lon}/{lat}/zoom/{zoom}")
143    @Transactional
144    @Override
145    public void setGeomapState(@PathParam("id") long geomapId, @PathParam("lon") double lon,
146                               @PathParam("lat") double lat, @PathParam("zoom") int zoom) {
147        ChildTopicsModel geomapState = mf.newChildTopicsModel().put(
148            "dmx.topicmaps.state", mf.newChildTopicsModel().put(
149                "dmx.topicmaps.translation", mf.newChildTopicsModel().put(
150                    "dmx.topicmaps.translation_x", lon).put(
151                    "dmx.topicmaps.translation_y", lat)).put(
152                "dmx.topicmaps.zoom_level", zoom)
153        );
154        dmx.updateTopic(mf.newTopicModel(geomapId, geomapState));
155    }
156
157    @GET
158    @Path("/distance")
159    @Override
160    public double getDistance(@QueryParam("coord1") GeoCoordinate coord1,
161                              @QueryParam("coord2") GeoCoordinate coord2) {
162        // calculate distance by the flat-surface formula for a "Spherical Earth projected to a plane"
163        // http://en.wikipedia.org/wiki/Geographical_distance#Flat-surface_formulae
164        double lonDiff = Math.toRadians(coord2.lon - coord1.lon);
165        double latDiff = Math.toRadians(coord2.lat - coord1.lat);
166        double latMean = Math.toRadians((coord1.lat + coord2.lat) / 2);
167        return EARTH_RADIUS_KM * Math.sqrt(Math.pow(latDiff, 2) + Math.pow(Math.cos(latMean) * lonDiff, 2));
168    }
169
170    // ---
171
172    @Override
173    public <V> V runWithoutGeocoding(Callable<V> callable) throws Exception {
174        return contextTracker.run(callable);
175    }
176
177
178
179    // ****************************
180    // *** Hook Implementations ***
181    // ****************************
182
183
184
185    @Override
186    public void init() {
187        topicmapsService.registerTopicmapRenderer(new GeomapRenderer());
188    }
189
190
191
192    // ********************************
193    // *** Listener Implementations ***
194    // ********************************
195
196
197
198    @Override
199    public void postCreateTopic(Topic topic) {
200        if (topic.getTypeUri().equals("dmx.contacts.address")) {
201            if (!abortGeocoding(topic)) {
202                //
203                facetsService.addFacetTypeToTopic(topic.getId(), "dmx.geomaps.geo_coordinate_facet");
204                //
205                Address address = new Address(topic.getChildTopics().getModel());
206                if (!address.isEmpty()) {
207                    logger.info("### New " + address);
208                    geocodeAndStoreFacet(address, topic);
209                } else {
210                    logger.info("New empty address");
211                }
212            }
213        }
214    }
215
216    @Override
217    public void postUpdateTopic(Topic topic, TopicModel updateModel, TopicModel oldTopic) {
218        if (topic.getTypeUri().equals("dmx.contacts.address")) {
219            if (!abortGeocoding(topic)) {
220                Address address    = new Address(topic.getChildTopics().getModel());
221                Address oldAddress = new Address(oldTopic.getChildTopicsModel());
222                if (!address.equals(oldAddress)) {
223                    logger.info("### Address changed:" + address.changeReport(oldAddress));
224                    geocodeAndStoreFacet(address, topic);
225                } else {
226                    logger.info("Address not changed");
227                }
228            }
229        }
230    }
231
232    // ---
233
234    /**
235     * Enriches an Address topic with its geo coordinate.
236     */
237    @Override
238    public void preSendTopic(Topic topic) {
239        Topic address = topic.findChildTopic("dmx.contacts.address");
240        if (address != null) {
241            String operation = "Enriching address " + address.getId() + " with its geo coordinate";
242            Topic geoCoordTopic = getGeoCoordinateTopic(address);
243            if (geoCoordTopic != null) {
244                logger.info(operation);
245                address.getChildTopics().getModel().put("dmx.geomaps.geo_coordinate", geoCoordTopic.getModel());
246            } else {
247                logger.info(operation + " SKIPPED -- no geo coordinate in DB");
248            }
249        }
250    }
251
252
253
254    // ------------------------------------------------------------------------------------------------- Private Methods
255
256    /**
257     * Returns the Geo Coordinate topic (including its child topics) of a geo-facetted topic (e.g. an Address),
258     * or <code>null</code> if no geo coordinate is stored.
259     */
260    private Topic getGeoCoordinateTopic(Topic geoTopic) {
261        Topic geoCoordTopic = facetsService.getFacet(geoTopic, "dmx.geomaps.geo_coordinate_facet");
262        return geoCoordTopic != null ? geoCoordTopic.loadChildTopics() : null;
263    }
264
265    // ---
266
267    /**
268     * Geocodes the given address and stores the resulting coordinate as a facet value of the given Address topic.
269     * If geocoding (or storing the coordinate) fails a warning is logged; no exception is thrown.
270     *
271     * @param   topic   the Address topic to be facetted.
272     */
273    private void geocodeAndStoreFacet(Address address, Topic topic) {
274        try {
275            GeoCoordinate geoCoord = address.geocode();
276            storeGeoCoordinate(topic, geoCoord);
277        } catch (Exception e) {
278            // ### TODO: show to the user?
279            logger.log(Level.WARNING, "Adding geo coordinate to " + address + " failed", e);
280        }
281    }
282
283    /**
284     * Stores a geo coordinate for an address topic in the DB.
285     */
286    private void storeGeoCoordinate(Topic address, GeoCoordinate geoCoord) {
287        try {
288            logger.info("Storing geo coordinate (" + geoCoord + ") of address topic " + address.getId());
289            facetsService.updateFacet(address, "dmx.geomaps.geo_coordinate_facet",
290                mf.newFacetValueModel("dmx.geomaps.geo_coordinate")
291                .put(mf.newChildTopicsModel()
292                    .put("dmx.geomaps.longitude", geoCoord.lon)
293                    .put("dmx.geomaps.latitude",  geoCoord.lat)
294                )
295            );
296        } catch (Exception e) {
297            throw new RuntimeException("Storing geo coordinate of address " + address.getId() + " failed", e);
298        }
299    }
300
301    // ---
302
303    private boolean abortGeocoding(Topic address) {
304        return abortGeocodingByCookie(address) || abortGeocodingByExcecutionContext(address);
305    }
306
307    private boolean abortGeocodingByCookie(Topic address) {
308        boolean abort = false;
309        Cookies cookies = Cookies.get();
310        if (cookies.has(COOKIE_NO_GEOCODING)) {
311            String value = cookies.get(COOKIE_NO_GEOCODING);
312            if (!value.equals("false") && !value.equals("true")) {
313                throw new RuntimeException("\"" + value + "\" is an unexpected value for the \"" + COOKIE_NO_GEOCODING +
314                    "\" cookie (expected are \"false\" or \"true\")");
315            }
316            abort = value.equals("true");
317            if (abort) {
318                logger.info("Geocoding for Address topic " + address.getId() + " SUPPRESSED -- \"" +
319                    COOKIE_NO_GEOCODING + "\" cookie detected");
320            }
321        }
322        return abort;
323    }
324
325    private boolean abortGeocodingByExcecutionContext(Topic address) {
326        boolean abort = contextTracker.runsInTrackedContext();
327        if (abort) {
328            logger.info("Geocoding for Address topic " + address.getId() + " SUPPRESSED -- runWithoutGeocoding() " +
329                "context detected");
330        }
331        return abort;
332    }
333
334    // ------------------------------------------------------------------------------------------------- Private Classes
335
336    private class Address {
337
338        String street, postalCode, city, country;
339
340        // ---
341
342        Address(ChildTopicsModel address) {
343            // Note: some Address child topics might be deleted (resp. do not exist), so we use ""
344            // as defaults here. Otherwise "Invalid access to ChildTopicsModel" would be thrown.
345            street     = address.getString("dmx.contacts.street", "");
346            postalCode = address.getString("dmx.contacts.postal_code", "");
347            city       = address.getString("dmx.contacts.city", "");
348            country    = address.getString("dmx.contacts.country", "");
349        }
350
351        // ---
352
353        GeoCoordinate geocode() {
354            URL url = null;
355            try {
356                // perform request
357                String address = street + ", " + postalCode + " " + city + ", " + country;
358                url = new URL(String.format(GEOCODER_URL, JavaUtils.encodeURIComponent(address)));
359                logger.info("Geocoding \"" + address + "\"\n    url=\"" + url + "\"");
360                JSONObject response = new JSONObject(JavaUtils.readTextURL(url));
361                // check response status
362                String status = response.getString("status");
363                if (!status.equals("OK")) {
364                    throw new RuntimeException(status);
365                }
366                // parse response
367                JSONObject location = response.getJSONArray("results").getJSONObject(0).getJSONObject("geometry")
368                    .getJSONObject("location");
369                double lng = location.getDouble("lng");
370                double lat = location.getDouble("lat");
371                // create result
372                GeoCoordinate geoCoord = new GeoCoordinate(lng, lat);
373                logger.info("=> " + geoCoord);
374                return geoCoord;
375            } catch (Exception e) {
376                throw new RuntimeException("Geocoding failed (url=\"" + url + "\")", e);
377            }
378        }
379
380        boolean isEmpty() {
381            return street.equals("") && postalCode.equals("") && city.equals("") && country.equals("");
382        }
383
384        String changeReport(Address oldAddr) {
385            StringBuilder report = new StringBuilder();
386            if (!street.equals(oldAddr.street)) {
387                report.append("\n    Street: \"" + oldAddr.street + "\" -> \"" + street + "\"");
388            }
389            if (!postalCode.equals(oldAddr.postalCode)) {
390                report.append("\n    Postal Code: \"" + oldAddr.postalCode + "\" -> \"" + postalCode + "\"");
391            }
392            if (!city.equals(oldAddr.city)) {
393                report.append("\n    City: \"" + oldAddr.city + "\" -> \"" + city + "\"");
394            }
395            if (!country.equals(oldAddr.country)) {
396                report.append("\n    Country: \"" + oldAddr.country + "\" -> \"" + country + "\"");
397            }
398            return report.toString();
399        }
400
401        // === Java API ===
402
403        @Override
404        public boolean equals(Object o) {
405            if (o instanceof Address) {
406                Address addr = (Address) o;
407                return street.equals(addr.street) && postalCode.equals(addr.postalCode) &&
408                    city.equals(addr.city) && country.equals(addr.country);
409            }
410            return false;
411        }
412
413        @Override
414        public int hashCode() {
415            return (street + postalCode + city + country).hashCode();
416        }
417
418        @Override
419        public String toString() {
420            return "address (street=\"" + street + "\", postalCode=\"" + postalCode +
421                "\", city=\"" + city + "\", country=\"" + country + "\")";
422        }
423    }
424}