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