001package systems.dmx.webclient; 002 003import systems.dmx.core.Association; 004import systems.dmx.core.AssociationType; 005import systems.dmx.core.DMXObject; 006import systems.dmx.core.DMXType; 007import systems.dmx.core.RelatedTopic; 008import systems.dmx.core.Role; 009import systems.dmx.core.Topic; 010import systems.dmx.core.TopicType; 011import systems.dmx.core.ViewConfiguration; 012import systems.dmx.core.model.AssociationTypeModel; 013import systems.dmx.core.model.TopicModel; 014import systems.dmx.core.model.TopicTypeModel; 015import systems.dmx.core.model.TypeModel; 016import systems.dmx.core.model.ViewConfigurationModel; 017import systems.dmx.core.osgi.PluginActivator; 018import systems.dmx.core.service.Directive; 019import systems.dmx.core.service.Directives; 020import systems.dmx.core.service.event.AllPluginsActiveListener; 021import systems.dmx.core.service.event.IntroduceTopicTypeListener; 022import systems.dmx.core.service.event.IntroduceAssociationTypeListener; 023import systems.dmx.core.service.event.PostUpdateTopicListener; 024import systems.dmx.core.service.event.PreCreateTopicTypeListener; 025import systems.dmx.core.service.event.PreCreateAssociationTypeListener; 026import systems.dmx.core.service.Transactional; 027 028import javax.ws.rs.Consumes; 029import javax.ws.rs.GET; 030import javax.ws.rs.Path; 031import javax.ws.rs.PathParam; 032import javax.ws.rs.Produces; 033import javax.ws.rs.QueryParam; 034 035import java.awt.Desktop; 036import java.net.URI; 037import java.util.Collection; 038import java.util.Iterator; 039import java.util.LinkedHashSet; 040import java.util.List; 041import java.util.Set; 042import java.util.concurrent.Callable; 043import java.util.logging.Logger; 044 045 046 047@Path("/webclient") 048@Consumes("application/json") 049@Produces("application/json") 050public class WebclientPlugin extends PluginActivator implements AllPluginsActiveListener, 051 IntroduceTopicTypeListener, 052 IntroduceAssociationTypeListener, 053 PreCreateTopicTypeListener, 054 PreCreateAssociationTypeListener, 055 PostUpdateTopicListener { 056 057 // ------------------------------------------------------------------------------------------------------- Constants 058 059 private static final String VIEW_CONFIG_LABEL = "View Configuration"; 060 061 // ---------------------------------------------------------------------------------------------- Instance Variables 062 063 private boolean hasWebclientLaunched = false; 064 065 private Logger logger = Logger.getLogger(getClass().getName()); 066 067 // -------------------------------------------------------------------------------------------------- Public Methods 068 069 070 071 // ************************* 072 // *** Webclient Service *** 073 // ************************* 074 075 // Note: the client service is provided as REST service only (OSGi service not required for the moment). 076 077 078 079 /** 080 * Performs a fulltext search and creates a search result topic. 081 */ 082 @GET 083 @Path("/search") 084 @Transactional 085 public Topic searchTopics(@QueryParam("search") String searchTerm, @QueryParam("field") String fieldUri) { 086 try { 087 logger.info("searchTerm=\"" + searchTerm + "\", fieldUri=\"" + fieldUri + "\""); 088 List<Topic> singleTopics = dmx.searchTopics(searchTerm, fieldUri); 089 Set<Topic> topics = findSearchableUnits(singleTopics); 090 logger.info(singleTopics.size() + " single topics found, " + topics.size() + " searchable units"); 091 // 092 return createSearchTopic(searchTerm, topics); 093 } catch (Exception e) { 094 throw new RuntimeException("Searching topics failed", e); 095 } 096 } 097 098 /** 099 * Performs a by-type search and creates a search result topic. 100 * <p> 101 * Note: this resource method is actually part of the Type Search plugin. 102 * TODO: proper modularization. Either let the Type Search plugin provide its own REST service or make the 103 * Type Search plugin an integral part of the Webclient plugin. 104 */ 105 @GET 106 @Path("/search/by_type/{type_uri}") 107 @Transactional 108 public Topic getTopics(@PathParam("type_uri") String typeUri) { 109 try { 110 logger.info("typeUri=\"" + typeUri + "\""); 111 String searchTerm = dmx.getTopicType(typeUri).getSimpleValue() + "(s)"; 112 List<Topic> topics = dmx.getTopicsByType(typeUri); 113 // 114 return createSearchTopic(searchTerm, topics); 115 } catch (Exception e) { 116 throw new RuntimeException("Searching topics of type \"" + typeUri + "\" failed", e); 117 } 118 } 119 120 // --- 121 122 @GET 123 @Path("/object/{id}/related_topics") 124 public List<RelatedTopic> getRelatedTopics(@PathParam("id") long objectId) { 125 DMXObject object = dmx.getObject(objectId); 126 List<RelatedTopic> relTopics = object.getRelatedTopics(null); // assocTypeUri=null 127 Iterator<RelatedTopic> i = relTopics.iterator(); 128 int removed = 0; 129 while (i.hasNext()) { 130 RelatedTopic relTopic = i.next(); 131 if (isDirectModelledChildTopic(object, relTopic)) { 132 i.remove(); 133 removed++; 134 } 135 } 136 logger.fine("### " + removed + " topics are removed from result set of object " + objectId); 137 return relTopics; 138 } 139 140 141 142 // ******************************** 143 // *** Listener Implementations *** 144 // ******************************** 145 146 147 148 @Override 149 public void allPluginsActive() { 150 String webclientUrl = getWebclientUrl(); 151 // 152 if (hasWebclientLaunched == true) { 153 logger.info("### Launching webclient (url=\"" + webclientUrl + "\") SKIPPED -- already launched"); 154 return; 155 } 156 // 157 try { 158 logger.info("### Launching webclient (url=\"" + webclientUrl + "\")"); 159 Desktop.getDesktop().browse(new URI(webclientUrl)); 160 hasWebclientLaunched = true; 161 } catch (Exception e) { 162 logger.warning("### Launching webclient failed (" + e + ")"); 163 logger.warning("### To launch it manually: " + webclientUrl); 164 } 165 } 166 167 /** 168 * Add a default view config to the type in case no one is set. 169 * <p> 170 * Note: the default view config needs a workspace assignment. The default view config must be added *before* the 171 * assignment can take place. Workspace assignment for a type (including its components like the view config) is 172 * performed by the type-introduction hook of the Workspaces module. Here we use the pre-create-type hook (instead 173 * of type-introduction too) as the pre-create-type hook is guaranteed to be invoked *before* type-introduction. 174 * On the other hand the order of type-introduction invocations is not deterministic accross plugins. 175 */ 176 @Override 177 public void preCreateTopicType(TopicTypeModel model) { 178 addDefaultViewConfig(model); 179 } 180 181 /** 182 * Add a default view config to the type in case no one is set. 183 * <p> 184 * Note: the default view config needs a workspace assignment. The default view config must be added *before* the 185 * assignment can take place. Workspace assignment for a type (including its components like the view config) is 186 * performed by the type-introduction hook of the Workspaces module. Here we use the pre-create-type hook (instead 187 * of type-introduction too) as the pre-create-type hook is guaranteed to be invoked *before* type-introduction. 188 * On the other hand the order of type-introduction invocations is not deterministic accross plugins. 189 */ 190 @Override 191 public void preCreateAssociationType(AssociationTypeModel model) { 192 addDefaultViewConfig(model); 193 } 194 195 /** 196 * Once a view configuration is updated in the DB we must update the cached view configuration model. 197 */ 198 @Override 199 public void postUpdateTopic(Topic topic, TopicModel updateModel, TopicModel oldTopic) { 200 if (topic.getTypeUri().equals("dmx.webclient.view_config")) { 201 updateType(topic); 202 setConfigTopicLabel(topic); 203 } 204 } 205 206 // --- 207 208 @Override 209 public void introduceTopicType(TopicType topicType) { 210 setViewConfigLabel(topicType.getViewConfig()); 211 } 212 213 @Override 214 public void introduceAssociationType(AssociationType assocType) { 215 setViewConfigLabel(assocType.getViewConfig()); 216 } 217 218 // ------------------------------------------------------------------------------------------------- Private Methods 219 220 221 222 // === Search === 223 224 // ### TODO: use Collection instead of Set 225 private Set<Topic> findSearchableUnits(List<? extends Topic> topics) { 226 Set<Topic> searchableUnits = new LinkedHashSet(); 227 for (Topic topic : topics) { 228 if (searchableAsUnit(topic)) { 229 searchableUnits.add(topic); 230 } else { 231 List<RelatedTopic> parentTopics = topic.getRelatedTopics((String) null, "dmx.core.child", 232 "dmx.core.parent", null); 233 if (parentTopics.isEmpty()) { 234 searchableUnits.add(topic); 235 } else { 236 searchableUnits.addAll(findSearchableUnits(parentTopics)); 237 } 238 } 239 } 240 return searchableUnits; 241 } 242 243 /** 244 * Creates a "Search" topic. 245 */ 246 private Topic createSearchTopic(final String searchTerm, final Collection<Topic> resultItems) { 247 try { 248 // We suppress standard workspace assignment here as a Search topic requires a special assignment. 249 // That is done by the Access Control module. ### TODO: refactoring. Do the assignment here. 250 return dmx.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() { 251 @Override 252 public Topic call() { 253 Topic searchTopic = dmx.createTopic(mf.newTopicModel("dmx.webclient.search", 254 mf.newChildTopicsModel().put("dmx.webclient.search_term", searchTerm) 255 )); 256 // associate result items 257 for (Topic resultItem : resultItems) { 258 dmx.createAssociation(mf.newAssociationModel("dmx.webclient.search_result_item", 259 mf.newTopicRoleModel(searchTopic.getId(), "dmx.core.default"), 260 mf.newTopicRoleModel(resultItem.getId(), "dmx.core.default") 261 )); 262 } 263 // 264 return searchTopic; 265 } 266 }); 267 } catch (Exception e) { 268 throw new RuntimeException("Creating search topic for \"" + searchTerm + "\" failed", e); 269 } 270 } 271 272 // --- 273 274 private boolean searchableAsUnit(Topic topic) { 275 TopicType topicType = dmx.getTopicType(topic.getTypeUri()); 276 Boolean searchableAsUnit = (Boolean) getViewConfigValue(topicType, "searchable_as_unit"); 277 return searchableAsUnit != null ? searchableAsUnit.booleanValue() : false; // default is false 278 } 279 280 /** 281 * Convenience method to lookup a Webclient view config value. 282 * <p> 283 * Compare to client-side counterpart: function get_view_config() in webclient.js 284 * 285 * @param topicType The topic type whose view configuration is used for lookup. 286 * @param setting Last component of the child type URI whose value to lookup, e.g. "icon". 287 * 288 * @return The config value, or <code>null</code> if no value is set 289 */ 290 private Object getViewConfigValue(TopicType topicType, String setting) { 291 return topicType.getViewConfigValue("dmx.webclient.view_config", "dmx.webclient." + setting); 292 } 293 294 295 296 // === View Configuration === 297 298 private void updateType(Topic viewConfig) { 299 Topic type = viewConfig.getRelatedTopic("dmx.core.aggregation", "dmx.core.view_config", "dmx.core.type", null); 300 if (type != null) { 301 String typeUri = type.getTypeUri(); 302 if (typeUri.equals("dmx.core.topic_type") || typeUri.equals("dmx.core.meta_type")) { 303 updateTopicType(type, viewConfig); 304 } else if (typeUri.equals("dmx.core.assoc_type")) { 305 updateAssociationType(type, viewConfig); 306 } else { 307 throw new RuntimeException("View Configuration " + viewConfig.getId() + " is associated to an " + 308 "unexpected topic (type=" + type + "\nviewConfig=" + viewConfig + ")"); 309 } 310 } else { 311 // ### FIXME: handle association definitions 312 } 313 } 314 315 // --- 316 317 private void updateTopicType(Topic type, Topic viewConfig) { 318 logger.info("### Updating view config of topic type \"" + type.getUri() + "\""); 319 TopicType topicType = dmx.getTopicType(type.getUri()); 320 updateViewConfig(topicType, viewConfig); 321 Directives.get().add(Directive.UPDATE_TOPIC_TYPE, topicType); // ### TODO: should be implicit 322 } 323 324 private void updateAssociationType(Topic type, Topic viewConfig) { 325 logger.info("### Updating view config of assoc type \"" + type.getUri() + "\""); 326 AssociationType assocType = dmx.getAssociationType(type.getUri()); 327 updateViewConfig(assocType, viewConfig); 328 Directives.get().add(Directive.UPDATE_ASSOCIATION_TYPE, assocType); // ### TODO: should be implicit 329 } 330 331 // --- 332 333 private void updateViewConfig(DMXType type, Topic viewConfig) { 334 type.getModel().getViewConfig().updateConfigTopic(viewConfig.getModel()); 335 } 336 337 // --- Label --- 338 339 private void setViewConfigLabel(ViewConfiguration viewConfig) { 340 for (Topic configTopic : viewConfig.getConfigTopics()) { 341 setConfigTopicLabel(configTopic); 342 } 343 } 344 345 private void setConfigTopicLabel(Topic viewConfig) { 346 viewConfig.setSimpleValue(VIEW_CONFIG_LABEL); 347 } 348 349 // --- Default Value --- 350 351 /** 352 * Add a default view config topic to the given type model in case no one is set already. 353 * <p> 354 * This ensures a programmatically created type (through a migration) will 355 * have a view config in any case, for being edited interactively afterwards. 356 */ 357 private void addDefaultViewConfig(TypeModel typeModel) { 358 // This would create View Config topics without any child topics. 359 // Now with the ValueIntegrator we can't create empty composites. 360 // See also WebclientPlugin Migration3.java 361 // ### TODO: rethink about this. 362 /* 363 ViewConfigurationModel viewConfig = typeModel.getViewConfig(); 364 TopicModel configTopic = viewConfig.getConfigTopic("dmx.webclient.view_config"); 365 if (configTopic == null) { 366 viewConfig.addConfigTopic(mf.newTopicModel("dmx.webclient.view_config")); 367 } 368 */ 369 } 370 371 372 373 // === Webclient Start === 374 375 private String getWebclientUrl() { 376 boolean isHttpsEnabled = Boolean.getBoolean("org.apache.felix.https.enable"); 377 String protocol, port; 378 if (isHttpsEnabled) { 379 // Note: if both protocols are enabled HTTPS takes precedence 380 protocol = "https"; 381 port = System.getProperty("org.osgi.service.http.port.secure"); 382 } else { 383 protocol = "http"; 384 port = System.getProperty("org.osgi.service.http.port"); 385 } 386 return protocol + "://localhost:" + port + "/systems.dmx.webclient/"; 387 } 388 389 390 391 // === Misc === 392 393 private boolean isDirectModelledChildTopic(DMXObject parentObject, RelatedTopic childTopic) { 394 // association definition 395 if (hasAssocDef(parentObject, childTopic)) { 396 // role types 397 Association assoc = childTopic.getRelatingAssociation(); 398 return assoc.matches("dmx.core.parent", parentObject.getId(), "dmx.core.child", childTopic.getId()); 399 } 400 return false; 401 } 402 403 private boolean hasAssocDef(DMXObject parentObject, RelatedTopic childTopic) { 404 // Note: the user might have no explicit READ permission for the type. 405 // DMXObject's getType() has *implicit* READ permission. 406 DMXType parentType = parentObject.getType(); 407 // 408 String childTypeUri = childTopic.getTypeUri(); 409 String assocTypeUri = childTopic.getRelatingAssociation().getTypeUri(); 410 String assocDefUri = childTypeUri + "#" + assocTypeUri; 411 if (parentType.hasAssocDef(assocDefUri)) { 412 return true; 413 } else if (parentType.hasAssocDef(childTypeUri)) { 414 return parentType.getAssocDef(childTypeUri).getInstanceLevelAssocTypeUri().equals(assocTypeUri); 415 } 416 return false; 417 } 418}