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