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