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