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