package de.deepamehta.webclient;
import de.deepamehta.core.Association;
import de.deepamehta.core.AssociationType;
import de.deepamehta.core.DeepaMehtaObject;
import de.deepamehta.core.DeepaMehtaType;
import de.deepamehta.core.RelatedTopic;
import de.deepamehta.core.Role;
import de.deepamehta.core.Topic;
import de.deepamehta.core.TopicType;
import de.deepamehta.core.ViewConfiguration;
import de.deepamehta.core.model.AssociationTypeModel;
import de.deepamehta.core.model.TopicModel;
import de.deepamehta.core.model.TopicTypeModel;
import de.deepamehta.core.model.TypeModel;
import de.deepamehta.core.model.ViewConfigurationModel;
import de.deepamehta.core.osgi.PluginActivator;
import de.deepamehta.core.service.Directive;
import de.deepamehta.core.service.Directives;
import de.deepamehta.core.service.event.AllPluginsActiveListener;
import de.deepamehta.core.service.event.IntroduceTopicTypeListener;
import de.deepamehta.core.service.event.IntroduceAssociationTypeListener;
import de.deepamehta.core.service.event.PostUpdateTopicListener;
import de.deepamehta.core.service.event.PreCreateTopicTypeListener;
import de.deepamehta.core.service.event.PreCreateAssociationTypeListener;
import de.deepamehta.core.service.Transactional;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import java.awt.Desktop;
import java.net.URI;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.logging.Logger;
@Path("/webclient")
@Consumes("application/json")
@Produces("application/json")
public class WebclientPlugin extends PluginActivator implements AllPluginsActiveListener,
IntroduceTopicTypeListener,
IntroduceAssociationTypeListener,
PreCreateTopicTypeListener,
PreCreateAssociationTypeListener,
PostUpdateTopicListener {
// ------------------------------------------------------------------------------------------------------- Constants
private static final String VIEW_CONFIG_LABEL = "View Configuration";
// ---------------------------------------------------------------------------------------------- Instance Variables
private boolean hasWebclientLaunched = false;
private Logger logger = Logger.getLogger(getClass().getName());
// -------------------------------------------------------------------------------------------------- Public Methods
// *************************
// *** Webclient Service ***
// *************************
// Note: the client service is provided as REST service only (OSGi service not required for the moment).
/**
* Performs a fulltext search and creates a search result topic.
*/
@GET
@Path("/search")
@Transactional
public Topic searchTopics(@QueryParam("search") String searchTerm, @QueryParam("field") String fieldUri) {
try {
logger.info("searchTerm=\"" + searchTerm + "\", fieldUri=\"" + fieldUri + "\"");
List<Topic> singleTopics = dm4.searchTopics(searchTerm, fieldUri);
Set<Topic> topics = findSearchableUnits(singleTopics);
logger.info(singleTopics.size() + " single topics found, " + topics.size() + " searchable units");
//
return createSearchTopic(searchTerm, topics);
} catch (Exception e) {
throw new RuntimeException("Searching topics failed", e);
}
}
/**
* Performs a by-type search and creates a search result topic.
* <p>
* Note: this resource method is actually part of the Type Search plugin.
* TODO: proper modularization. Either let the Type Search plugin provide its own REST service or make the
* Type Search plugin an integral part of the Webclient plugin.
*/
@GET
@Path("/search/by_type/{type_uri}")
@Transactional
public Topic getTopics(@PathParam("type_uri") String typeUri) {
try {
logger.info("typeUri=\"" + typeUri + "\"");
String searchTerm = dm4.getTopicType(typeUri).getSimpleValue() + "(s)";
List<Topic> topics = dm4.getTopicsByType(typeUri);
//
return createSearchTopic(searchTerm, topics);
} catch (Exception e) {
throw new RuntimeException("Searching topics of type \"" + typeUri + "\" failed", e);
}
}
// ---
@GET
@Path("/object/{id}/related_topics")
public List<RelatedTopic> getRelatedTopics(@PathParam("id") long objectId) {
DeepaMehtaObject object = dm4.getObject(objectId);
List<RelatedTopic> relTopics = object.getRelatedTopics(null); // assocTypeUri=null
Iterator<RelatedTopic> i = relTopics.iterator();
int removed = 0;
while (i.hasNext()) {
RelatedTopic relTopic = i.next();
if (isDirectModelledChildTopic(object, relTopic)) {
i.remove();
removed++;
}
}
logger.fine("### " + removed + " topics are removed from result set of object " + objectId);
return relTopics;
}
// ********************************
// *** Listener Implementations ***
// ********************************
@Override
public void allPluginsActive() {
String webclientUrl = getWebclientUrl();
//
if (hasWebclientLaunched == true) {
logger.info("### Launching webclient (url=\"" + webclientUrl + "\") SKIPPED -- already launched");
return;
}
//
try {
logger.info("### Launching webclient (url=\"" + webclientUrl + "\")");
Desktop.getDesktop().browse(new URI(webclientUrl));
hasWebclientLaunched = true;
} catch (Exception e) {
logger.warning("### Launching webclient failed (" + e + ")");
logger.warning("### To launch it manually: " + webclientUrl);
}
}
/**
* Add a default view config to the type in case no one is set.
* <p>
* Note: the default view config needs a workspace assignment. The default view config must be added *before* the
* assignment can take place. Workspace assignment for a type (including its components like the view config) is
* performed by the type-introduction hook of the Workspaces module. Here we use the pre-create-type hook (instead
* of type-introduction too) as the pre-create-type hook is guaranteed to be invoked *before* type-introduction.
* On the other hand the order of type-introduction invocations is not deterministic accross plugins.
*/
@Override
public void preCreateTopicType(TopicTypeModel model) {
addDefaultViewConfig(model);
}
/**
* Add a default view config to the type in case no one is set.
* <p>
* Note: the default view config needs a workspace assignment. The default view config must be added *before* the
* assignment can take place. Workspace assignment for a type (including its components like the view config) is
* performed by the type-introduction hook of the Workspaces module. Here we use the pre-create-type hook (instead
* of type-introduction too) as the pre-create-type hook is guaranteed to be invoked *before* type-introduction.
* On the other hand the order of type-introduction invocations is not deterministic accross plugins.
*/
@Override
public void preCreateAssociationType(AssociationTypeModel model) {
addDefaultViewConfig(model);
}
/**
* Once a view configuration is updated in the DB we must update the cached view configuration model.
*/
@Override
public void postUpdateTopic(Topic topic, TopicModel updateModel, TopicModel oldTopic) {
if (topic.getTypeUri().equals("dm4.webclient.view_config")) {
updateType(topic);
setConfigTopicLabel(topic);
}
}
// ---
@Override
public void introduceTopicType(TopicType topicType) {
setViewConfigLabel(topicType.getViewConfig());
}
@Override
public void introduceAssociationType(AssociationType assocType) {
setViewConfigLabel(assocType.getViewConfig());
}
// ------------------------------------------------------------------------------------------------- Private Methods
// === Search ===
// ### TODO: use Collection instead of Set
private Set<Topic> findSearchableUnits(List<? extends Topic> topics) {
Set<Topic> searchableUnits = new LinkedHashSet();
for (Topic topic : topics) {
if (searchableAsUnit(topic)) {
searchableUnits.add(topic);
} else {
List<RelatedTopic> parentTopics = topic.getRelatedTopics((String) null, "dm4.core.child",
"dm4.core.parent", null);
if (parentTopics.isEmpty()) {
searchableUnits.add(topic);
} else {
searchableUnits.addAll(findSearchableUnits(parentTopics));
}
}
}
return searchableUnits;
}
/**
* Creates a "Search" topic.
*/
private Topic createSearchTopic(final String searchTerm, final Collection<Topic> resultItems) {
try {
// We suppress standard workspace assignment here as a Search topic requires a special assignment.
// That is done by the Access Control module. ### TODO: refactoring. Do the assignment here.
return dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {
@Override
public Topic call() {
Topic searchTopic = dm4.createTopic(mf.newTopicModel("dm4.webclient.search",
mf.newChildTopicsModel().put("dm4.webclient.search_term", searchTerm)
));
// associate result items
for (Topic resultItem : resultItems) {
dm4.createAssociation(mf.newAssociationModel("dm4.webclient.search_result_item",
mf.newTopicRoleModel(searchTopic.getId(), "dm4.core.default"),
mf.newTopicRoleModel(resultItem.getId(), "dm4.core.default")
));
}
//
return searchTopic;
}
});
} catch (Exception e) {
throw new RuntimeException("Creating search topic for \"" + searchTerm + "\" failed", e);
}
}
// ---
private boolean searchableAsUnit(Topic topic) {
TopicType topicType = dm4.getTopicType(topic.getTypeUri());
Boolean searchableAsUnit = (Boolean) getViewConfigValue(topicType, "searchable_as_unit");
return searchableAsUnit != null ? searchableAsUnit.booleanValue() : false; // default is false
}
/**
* Convenience method to lookup a Webclient view config value.
* <p>
* Compare to client-side counterpart: function get_view_config() in webclient.js
*
* @param topicType The topic type whose view configuration is used for lookup.
* @param setting Last component of the child type URI whose value to lookup, e.g. "icon".
*
* @return The config value, or <code>null</code> if no value is set
*/
private Object getViewConfigValue(TopicType topicType, String setting) {
return topicType.getViewConfigValue("dm4.webclient.view_config", "dm4.webclient." + setting);
}
// === View Configuration ===
private void updateType(Topic viewConfig) {
Topic type = viewConfig.getRelatedTopic("dm4.core.aggregation", "dm4.core.view_config", "dm4.core.type", null);
if (type != null) {
String typeUri = type.getTypeUri();
if (typeUri.equals("dm4.core.topic_type") || typeUri.equals("dm4.core.meta_type")) {
updateTopicType(type, viewConfig);
} else if (typeUri.equals("dm4.core.assoc_type")) {
updateAssociationType(type, viewConfig);
} else {
throw new RuntimeException("View Configuration " + viewConfig.getId() + " is associated to an " +
"unexpected topic (type=" + type + "\nviewConfig=" + viewConfig + ")");
}
} else {
// ### FIXME: handle association definitions
}
}
// ---
private void updateTopicType(Topic type, Topic viewConfig) {
logger.info("### Updating view configuration of topic type \"" + type.getUri() + "\" (viewConfig=" +
viewConfig + ")");
TopicType topicType = dm4.getTopicType(type.getUri());
updateViewConfig(topicType, viewConfig);
Directives.get().add(Directive.UPDATE_TOPIC_TYPE, topicType); // ### TODO: should be implicit
}
private void updateAssociationType(Topic type, Topic viewConfig) {
logger.info("### Updating view configuration of association type \"" + type.getUri() + "\" (viewConfig=" +
viewConfig + ")");
AssociationType assocType = dm4.getAssociationType(type.getUri());
updateViewConfig(assocType, viewConfig);
Directives.get().add(Directive.UPDATE_ASSOCIATION_TYPE, assocType); // ### TODO: should be implicit
}
// ---
private void updateViewConfig(DeepaMehtaType type, Topic viewConfig) {
type.getModel().getViewConfig().updateConfigTopic(viewConfig.getModel());
}
// --- Label ---
private void setViewConfigLabel(ViewConfiguration viewConfig) {
for (Topic configTopic : viewConfig.getConfigTopics()) {
setConfigTopicLabel(configTopic);
}
}
private void setConfigTopicLabel(Topic viewConfig) {
viewConfig.setSimpleValue(VIEW_CONFIG_LABEL);
}
// --- Default Value ---
/**
* Add a default view config topic to the given type model in case no one is set already.
* <p>
* This ensures a programmatically created type (through a migration) will
* have a view config in any case, for being edited interactively afterwards.
*/
private void addDefaultViewConfig(TypeModel typeModel) {
ViewConfigurationModel viewConfig = typeModel.getViewConfig();
TopicModel configTopic = viewConfig.getConfigTopic("dm4.webclient.view_config");
if (configTopic == null) {
viewConfig.addConfigTopic(mf.newTopicModel("dm4.webclient.view_config"));
}
}
// === Webclient Start ===
private String getWebclientUrl() {
boolean isHttpsEnabled = Boolean.getBoolean("org.apache.felix.https.enable");
String protocol, port;
if (isHttpsEnabled) {
// Note: if both protocols are enabled HTTPS takes precedence
protocol = "https";
port = System.getProperty("org.osgi.service.http.port.secure");
} else {
protocol = "http";
port = System.getProperty("org.osgi.service.http.port");
}
return protocol + "://localhost:" + port + "/de.deepamehta.webclient/";
}
// === Misc ===
private boolean isDirectModelledChildTopic(DeepaMehtaObject parentObject, RelatedTopic childTopic) {
// association definition
if (hasAssocDef(parentObject, childTopic)) {
// role types
Association assoc = childTopic.getRelatingAssociation();
return assoc.matches("dm4.core.parent", parentObject.getId(), "dm4.core.child", childTopic.getId());
}
return false;
}
private boolean hasAssocDef(DeepaMehtaObject parentObject, RelatedTopic childTopic) {
// Note: the user might have no explicit READ permission for the type.
// DeepaMehtaObject's getType() has *implicit* READ permission.
DeepaMehtaType parentType = parentObject.getType();
//
String childTypeUri = childTopic.getTypeUri();
String assocTypeUri = childTopic.getRelatingAssociation().getTypeUri();
String assocDefUri = childTypeUri + "#" + assocTypeUri;
if (parentType.hasAssocDef(assocDefUri)) {
return true;
} else if (parentType.hasAssocDef(childTypeUri)) {
return parentType.getAssocDef(childTypeUri).getInstanceLevelAssocTypeUri().equals(assocTypeUri);
}
return false;
}
}