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