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 }