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