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}