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