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}