001package de.deepamehta.webclient;
002
003import de.deepamehta.core.Association;
004import de.deepamehta.core.AssociationType;
005import de.deepamehta.core.DeepaMehtaObject;
006import de.deepamehta.core.DeepaMehtaType;
007import de.deepamehta.core.RelatedTopic;
008import de.deepamehta.core.Role;
009import de.deepamehta.core.Topic;
010import de.deepamehta.core.TopicType;
011import de.deepamehta.core.ViewConfiguration;
012import de.deepamehta.core.model.AssociationTypeModel;
013import de.deepamehta.core.model.TopicModel;
014import de.deepamehta.core.model.TopicTypeModel;
015import de.deepamehta.core.model.TypeModel;
016import de.deepamehta.core.model.ViewConfigurationModel;
017import de.deepamehta.core.osgi.PluginActivator;
018import de.deepamehta.core.service.Directive;
019import de.deepamehta.core.service.Directives;
020import de.deepamehta.core.service.event.AllPluginsActiveListener;
021import de.deepamehta.core.service.event.IntroduceTopicTypeListener;
022import de.deepamehta.core.service.event.IntroduceAssociationTypeListener;
023import de.deepamehta.core.service.event.PostUpdateTopicListener;
024import de.deepamehta.core.service.event.PreCreateTopicTypeListener;
025import de.deepamehta.core.service.event.PreCreateAssociationTypeListener;
026import de.deepamehta.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 = dm4.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 = dm4.getTopicType(typeUri).getSimpleValue() + "(s)";
112            List<Topic> topics = dm4.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        DeepaMehtaObject object = dm4.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("dm4.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, "dm4.core.child",
232                    "dm4.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 dm4.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {
251                @Override
252                public Topic call() {
253                    Topic searchTopic = dm4.createTopic(mf.newTopicModel("dm4.webclient.search",
254                        mf.newChildTopicsModel().put("dm4.webclient.search_term", searchTerm)
255                    ));
256                    // associate result items
257                    for (Topic resultItem : resultItems) {
258                        dm4.createAssociation(mf.newAssociationModel("dm4.webclient.search_result_item",
259                            mf.newTopicRoleModel(searchTopic.getId(), "dm4.core.default"),
260                            mf.newTopicRoleModel(resultItem.getId(), "dm4.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 = dm4.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("dm4.webclient.view_config", "dm4.webclient." + setting);
292    }
293
294
295
296    // === View Configuration ===
297
298    private void updateType(Topic viewConfig) {
299        Topic type = viewConfig.getRelatedTopic("dm4.core.aggregation", "dm4.core.view_config", "dm4.core.type", null);
300        if (type != null) {
301            String typeUri = type.getTypeUri();
302            if (typeUri.equals("dm4.core.topic_type") || typeUri.equals("dm4.core.meta_type")) {
303                updateTopicType(type, viewConfig);
304            } else if (typeUri.equals("dm4.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 configuration of topic type \"" + type.getUri() + "\" (viewConfig=" +
319            viewConfig + ")");
320        TopicType topicType = dm4.getTopicType(type.getUri());
321        updateViewConfig(topicType, viewConfig);
322        Directives.get().add(Directive.UPDATE_TOPIC_TYPE, topicType);           // ### TODO: should be implicit
323    }
324
325    private void updateAssociationType(Topic type, Topic viewConfig) {
326        logger.info("### Updating view configuration of association type \"" + type.getUri() + "\" (viewConfig=" +
327            viewConfig + ")");
328        AssociationType assocType = dm4.getAssociationType(type.getUri());
329        updateViewConfig(assocType, viewConfig);
330        Directives.get().add(Directive.UPDATE_ASSOCIATION_TYPE, assocType);     // ### TODO: should be implicit
331    }
332
333    // ---
334
335    private void updateViewConfig(DeepaMehtaType type, Topic viewConfig) {
336        type.getModel().getViewConfig().updateConfigTopic(viewConfig.getModel());
337    }
338
339    // --- Label ---
340
341    private void setViewConfigLabel(ViewConfiguration viewConfig) {
342        for (Topic configTopic : viewConfig.getConfigTopics()) {
343            setConfigTopicLabel(configTopic);
344        }
345    }
346
347    private void setConfigTopicLabel(Topic viewConfig) {
348        viewConfig.setSimpleValue(VIEW_CONFIG_LABEL);
349    }
350
351    // --- Default Value ---
352
353    /**
354     * Add a default view config topic to the given type model in case no one is set already.
355     * <p>
356     * This ensures a programmatically created type (through a migration) will
357     * have a view config in any case, for being edited interactively afterwards.
358     */
359    private void addDefaultViewConfig(TypeModel typeModel) {
360        ViewConfigurationModel viewConfig = typeModel.getViewConfig();
361        TopicModel configTopic = viewConfig.getConfigTopic("dm4.webclient.view_config");
362        if (configTopic == null) {
363            viewConfig.addConfigTopic(mf.newTopicModel("dm4.webclient.view_config"));
364        }
365    }
366
367
368
369    // === Webclient Start ===
370
371    private String getWebclientUrl() {
372        boolean isHttpsEnabled = Boolean.getBoolean("org.apache.felix.https.enable");
373        String protocol, port;
374        if (isHttpsEnabled) {
375            // Note: if both protocols are enabled HTTPS takes precedence
376            protocol = "https";
377            port = System.getProperty("org.osgi.service.http.port.secure");
378        } else {
379            protocol = "http";
380            port = System.getProperty("org.osgi.service.http.port");
381        }
382        return protocol + "://localhost:" + port + "/de.deepamehta.webclient/";
383    }
384
385
386
387    // === Misc ===
388
389    private boolean isDirectModelledChildTopic(DeepaMehtaObject parentObject, RelatedTopic childTopic) {
390        // association definition
391        if (hasAssocDef(parentObject, childTopic)) {
392            // role types
393            Association assoc = childTopic.getRelatingAssociation();
394            return assoc.matches("dm4.core.parent", parentObject.getId(), "dm4.core.child", childTopic.getId());
395        }
396        return false;
397    }
398
399    private boolean hasAssocDef(DeepaMehtaObject parentObject, RelatedTopic childTopic) {
400        // Note: the user might have no explicit READ permission for the type.
401        // DeepaMehtaObject's getType() has *implicit* READ permission.
402        DeepaMehtaType parentType = parentObject.getType();
403        //
404        String childTypeUri = childTopic.getTypeUri();
405        String assocTypeUri = childTopic.getRelatingAssociation().getTypeUri();
406        String assocDefUri = childTypeUri + "#" + assocTypeUri;
407        if (parentType.hasAssocDef(assocDefUri)) {
408            return true;
409        } else if (parentType.hasAssocDef(childTypeUri)) {
410            return parentType.getAssocDef(childTypeUri).getInstanceLevelAssocTypeUri().equals(assocTypeUri);
411        }
412        return false;
413    }
414}