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