1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 package info.magnolia.admincentral.findbar;
35
36 import static info.magnolia.admincentral.ViewportLayout.VIEW.*;
37 import static java.util.stream.Collectors.*;
38
39 import info.magnolia.admincentral.ViewportLayout;
40 import info.magnolia.admincentral.findbar.PeriscopeAppDetailCreator.AppDetail;
41 import info.magnolia.admincentral.findbar.layout.FilterLayout;
42 import info.magnolia.admincentral.findbar.search.ResultCollector;
43 import info.magnolia.admincentral.findbar.search.SupplierSorter;
44 import info.magnolia.cms.security.SecuritySupport;
45 import info.magnolia.cms.security.User;
46 import info.magnolia.config.registry.DefinitionProvider;
47 import info.magnolia.context.Context;
48 import info.magnolia.event.EventBus;
49 import info.magnolia.i18nsystem.SimpleTranslator;
50 import info.magnolia.icons.MagnoliaIcons;
51 import info.magnolia.jcr.util.NodeTypes;
52 import info.magnolia.periscope.Periscope;
53 import info.magnolia.periscope.SupplierAwareSearchResult;
54 import info.magnolia.periscope.ai.speech.SpeechRecognizer;
55 import info.magnolia.periscope.search.SearchQuery;
56 import info.magnolia.periscope.search.SearchResultSupplier;
57 import info.magnolia.periscope.search.jcr.JcrSearchResultSupplier;
58 import info.magnolia.periscope.search.jcr.JcrSearchResultSupplierDefinition;
59 import info.magnolia.periscope.tag.EmptyPeriscopeTagsProvider;
60 import info.magnolia.periscope.tag.PeriscopeTagsProvider;
61 import info.magnolia.ui.api.app.AppController;
62 import info.magnolia.ui.api.app.AppDescriptor;
63 import info.magnolia.ui.api.app.AppLifecycleEvent;
64 import info.magnolia.ui.api.app.AppLifecycleEventHandler;
65 import info.magnolia.ui.api.app.launcherlayout.AppLauncherGroupEntry;
66 import info.magnolia.ui.api.app.launcherlayout.AppLauncherLayout;
67 import info.magnolia.ui.api.app.launcherlayout.AppLauncherLayoutManager;
68 import info.magnolia.ui.api.app.registry.AppDescriptorRegistry;
69 import info.magnolia.ui.api.event.AdmincentralEventBus;
70 import info.magnolia.ui.api.location.DefaultLocation;
71 import info.magnolia.ui.api.location.Location;
72 import info.magnolia.ui.api.location.LocationController;
73 import info.magnolia.ui.api.view.View;
74 import info.magnolia.ui.theme.ResurfaceTheme;
75 import info.magnolia.ui.vaadin.applauncher.AppLauncher;
76
77 import java.time.ZoneId;
78 import java.util.ArrayList;
79 import java.util.Collection;
80 import java.util.Collections;
81 import java.util.HashSet;
82 import java.util.List;
83 import java.util.Objects;
84 import java.util.Optional;
85 import java.util.Set;
86 import java.util.concurrent.atomic.AtomicBoolean;
87
88 import javax.inject.Inject;
89 import javax.inject.Named;
90 import javax.inject.Provider;
91
92 import org.apache.commons.collections4.CollectionUtils;
93 import org.apache.commons.lang3.StringUtils;
94 import org.slf4j.Logger;
95 import org.slf4j.LoggerFactory;
96 import org.vaadin.addons.ComboBoxMultiselect;
97
98 import com.google.common.collect.Sets;
99 import com.vaadin.addon.daterangefield.DateRangeField;
100 import com.vaadin.event.ShortcutAction;
101 import com.vaadin.event.ShortcutListener;
102 import com.vaadin.ui.AbsoluteLayout;
103 import com.vaadin.ui.AbstractOrderedLayout;
104 import com.vaadin.ui.Button;
105 import com.vaadin.ui.Component;
106 import com.vaadin.ui.Label;
107 import com.vaadin.ui.Layout;
108 import com.vaadin.ui.TextField;
109 import com.vaadin.ui.UI;
110 import com.vaadin.ui.themes.ValoTheme;
111 import com.vaadin.v7.ui.AbstractField;
112
113
114
115
116
117
118
119 public class FindBar implements View {
120
121 private static final Logger log = LoggerFactory.getLogger(FindBar.class);
122
123 private static final int DEFAULT_POLL_INTERVAL = -1;
124
125 private final AbsoluteLayout findBarLayout = new AbsoluteLayout();
126
127 private TextField textField;
128 private ComboBoxMultiselect<String> supplierField;
129 private ComboBoxMultiselect<String> tagField;
130 private ComboBoxMultiselect<String> editorField;
131 private DateRangeField dateRangeField;
132 SearchResultsGrid resultsGrid;
133 private Set<SearchResultSupplier> availableResultSuppliers;
134
135 private final Periscope periscope;
136 private final PeriscopeAppDetailCreator appDetailCreator;
137 private final FindBarConfigurationProvider configProvider;
138 private final PeriscopeTagsProvider periscopeTagsProvider;
139 private final SecuritySupport securitySupport;
140 private final SimpleTranslator i18n;
141 private final EventBus eventBus;
142 private final User currentUser;
143
144 private final LocationController locationController;
145 private final AppLauncher appLauncher;
146 private final AppLauncherLayoutManager appLauncherLayoutManager;
147 private final AppController appController;
148 private final ResultCollector resultCollector;
149 private final AppDescriptorRegistry appDescriptorRegistry;
150 private final SupplierSorter sorter;
151 private final SpeechRecognizer speechRecognizer;
152
153 private SearchQuery latestQuery;
154
155 private final ViewportLayout viewportLayout;
156
157 @Inject
158 FindBar(Periscope periscope, SecuritySupport securitySupport, SpeechRecognizer speechRecognizer,
159 FindBarConfigurationProvider configProvider, PeriscopeAppDetailCreator appDetailCreator,
160 PeriscopeTagsProvider periscopeTagsProvider, @Named(AdmincentralEventBus.NAME) EventBus eventBus,
161 SimpleTranslator i18n, Provider<Context> context, LocationController locationController,
162 AppLauncherLayoutManager appLauncherLayoutManager, AppController appController, AppDescriptorRegistry appDescriptorRegistry,
163 ViewportLayout viewportLayout) {
164
165 this.periscope = periscope;
166 this.appDetailCreator = appDetailCreator;
167 this.configProvider = configProvider;
168 this.periscopeTagsProvider = periscopeTagsProvider;
169 this.securitySupport = securitySupport;
170 this.i18n = i18n;
171 this.eventBus = eventBus;
172 this.currentUser = context.get().getUser();
173 this.locationController = locationController;
174 this.appLauncherLayoutManager = appLauncherLayoutManager;
175 this.appController = appController;
176 this.appLauncher = new AppLauncher(this::openApp);
177 this.appDescriptorRegistry = appDescriptorRegistry;
178 this.resultCollector = new ResultCollector(periscope::search, this::updateResults);
179 this.speechRecognizer = speechRecognizer;
180
181 this.sorter = new SupplierSorter(Optional.ofNullable(configProvider.get().getSupplierOrder()).orElse(Collections.emptyList()));
182 this.viewportLayout = viewportLayout;
183 initUi();
184 }
185
186 private synchronized void updateResults(SearchQuery searchQuery, List<SupplierAwareSearchResult> results) {
187 findUi(this.asVaadinComponent()).access(() -> {
188 resultsGrid.setCount(StringUtils.isEmpty(searchQuery.getQuery()), results.size());
189 resultsGrid.setSearchResults(results);
190 });
191 }
192
193 private UI findUi(Component component) {
194 Component current = component;
195 while (!(current instanceof UI) && current != null) {
196 current = current.getParent();
197 }
198 if (current == null) {
199 throw new IllegalStateException("Top-level UI component could not be found");
200 }
201 return (UI) current;
202 }
203
204 void initUi() {
205 Set<String> configuredSuppliers = configProvider.get().getSuppliers();
206 availableResultSuppliers = CollectionUtils.isEmpty(configuredSuppliers) ? new HashSet<>(periscope.getResultSuppliers())
207 : Sets.filter(periscope.getResultSuppliers(), s -> configuredSuppliers.contains(s.getName()));
208
209 Set<SearchResultSupplier> generatedSearchResultSuppliers = initialiseJCRSearchResultSuppliers(availableResultSuppliers);
210 availableResultSuppliers.addAll(generatedSearchResultSuppliers);
211
212 AppLauncherLayout userLayout = this.appLauncherLayoutManager.getLayoutForUser(currentUser);
213 List<SearchResultSupplier> inaccessibleAppSuppliers = availableResultSuppliers.stream()
214 .filter(supplier -> isForbiddenApp(supplier, userLayout))
215 .collect(toList());
216 availableResultSuppliers.removeAll(inaccessibleAppSuppliers);
217
218 Set<String> availableSupplierNames = availableResultSuppliers.stream()
219 .map(SearchResultSupplier::getName)
220 .collect(toSet());
221
222 if (CollectionUtils.isNotEmpty(configuredSuppliers)) {
223 Sets.difference(configuredSuppliers, availableSupplierNames).forEach(supplier -> log.warn("Search result supplier enabled in config, but not found: {}", supplier));
224 }
225
226 this.textField = new TextField();
227 this.textField.setSizeFull();
228 this.textField.addStyleNames("findbar");
229 this.textField.setPlaceholder(i18n.translate("findbar.placeholder"));
230 this.textField.addAttachListener(event -> search());
231 this.textField.focus();
232
233 this.supplierField = new ComboBoxMultiselect<>(i18n.translate("findbar.type"));
234 this.supplierField.setItems(availableSupplierNames);
235
236 this.tagField = new ComboBoxMultiselect<>(i18n.translate("findbar.tags"));
237 this.tagField.setItems(periscopeTagsProvider.getAllTags());
238
239 this.editorField = new ComboBoxMultiselect<>(i18n.translate("findbar.lasteditor"));
240 this.editorField.setItems(resolveUsers());
241 this.dateRangeField = new DateRangeField(i18n.translate("findbar.lastedited"));
242
243 this.resultsGrid = new SearchResultsGrid(i18n);
244 this.resultsGrid.addSelectionCallback(searchResult -> this.periscope.resultPicked(buildSearchQuery(), searchResult));
245
246 FilterLayout filterLayout = periscopeTagsProvider instanceof EmptyPeriscopeTagsProvider ? new FilterLayout(dateRangeField, i18n, supplierField, editorField) : new FilterLayout(dateRangeField, i18n, supplierField, tagField, editorField);
247 Layout resultsLayout = viewportLayout.getFindBarResultsLayout();
248 resultsLayout.addComponents(filterLayout, resultsGrid);
249 resultsGrid.setSizeFull();
250 resultsLayout.addStyleName("filter-and-results");
251
252 userLayout.getGroups().forEach(group -> {
253 appLauncher.addAppGroup(group.getName(), group.getLabel(), group.getColor(), true, group.isClientGroup());
254 group.getApps().stream().map(AppLauncherGroupEntry::getAppDescriptor).forEach(descriptor -> {
255 appLauncher.addAppTile(descriptor.getName(), descriptor.getLabel(), descriptor.getIcon(), group.getName());
256 appLauncher.setAppActive(descriptor.getName(), appController.isAppStarted(descriptor.getName()));
257 });
258 });
259
260 final Label title = new Label("Apps");
261 title.addStyleName(ValoTheme.LABEL_H1);
262
263 AbstractOrderedLayout appLauncherLayout = viewportLayout.getAppLauncherLayout();
264 appLauncher.setSizeFull();
265 appLauncherLayout.setSizeFull();
266 appLauncherLayout.addComponents(title, appLauncher);
267 appLauncherLayout.setExpandRatio(appLauncher, 1f);
268 appLauncherLayout.addStyleName("v-app-launcher");
269
270 appController.setViewport(view -> {
271 Component content = view == null ? null : view.asVaadinComponent();
272 viewportLayout.getAppLayout().setContent(content);
273 });
274
275 this.supplierField.addValueChangeListener(event -> search(true));
276 this.tagField.addValueChangeListener(event -> search(true));
277 this.editorField.addValueChangeListener(event -> search(true));
278 this.dateRangeField.addValueChangeListener(event -> search(true));
279
280 Button findBarButton = new Button(MagnoliaIcons.SEARCH);
281 findBarButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "clear");
282 findBarButton.addClickListener(event -> {
283 viewportLayout.toggleFindBar();
284 if (viewportLayout.getActiveView() == FIND_BAR) {
285 findBarButton.addStyleName("active");
286 textField.focus();
287 } else {
288 findBarButton.removeStyleName("active");
289 }
290 });
291
292 Button clearTextButton = new Button(MagnoliaIcons.DELETE_SEARCH);
293 clearTextButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "clear");
294 clearTextButton.setVisible(false);
295 clearTextButton.addClickListener(event -> {
296 textField.clear();
297 textField.focus();
298 clearTextButton.setVisible(false);
299 });
300
301 Button voiceButton = new Button(MagnoliaIcons.MICROPHONE);
302 voiceButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "voice");
303
304
305 Button appLauncherButton = new Button(MagnoliaIcons.APP_LAUNCHER);
306 appLauncherButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "appLauncher");
307 appLauncherButton.addClickListener(event -> {
308 findBarButton.removeStyleName("active");
309 viewportLayout.toggleAppLauncher();
310 appLauncherButton.setStyleName("active",viewportLayout.getActiveView() == APP_LAUNCHER);
311 });
312 appLauncherButton.addBlurListener(event -> appLauncherButton.removeStyleName("active"));
313
314 AtomicBoolean textFieldHasFocus = new AtomicBoolean(false);
315 this.textField.addFocusListener(event -> {
316 viewportLayout.showFindBar();
317 textFieldHasFocus.set(true);
318 findBarButton.addStyleName("active");
319 appLauncherButton.removeStyleName("active");
320 });
321 this.textField.addBlurListener(event -> {
322 textFieldHasFocus.set(false);
323 findBarButton.removeStyleName("active");
324 });
325
326 final ShortcutListener enterListener = new ShortcutListener("Enter", ShortcutAction.KeyCode.ENTER, null) {
327 @Override
328 public void handleAction(Object sender, Object target) {
329 if (textFieldHasFocus.get()) {
330 resultsGrid.triggerSelected();
331 }
332 }
333 };
334 this.textField.addFocusListener((e) -> this.textField.addShortcutListener(enterListener));
335 this.textField.addBlurListener((e) -> this.textField.removeShortcutListener(enterListener));
336
337
338 this.textField.addShortcutListener(new AbstractField.FocusShortcut(textField,
339 ShortcutAction.KeyCode.F, ShortcutAction.ModifierKey.SHIFT, ShortcutAction.ModifierKey.ALT));
340
341
342 this.textField.setValueChangeTimeout(200);
343 this.textField.addValueChangeListener(event -> {
344 if (StringUtils.isBlank(event.getValue())) {
345 clearTextButton.setVisible(false);
346 } else {
347 clearTextButton.setVisible(true);
348 }
349 search(true);
350 });
351
352
353 findBarLayout.addComponent(textField);
354 findBarLayout.addComponent(findBarButton, "top: 4px; left: 8px;");
355 findBarLayout.addComponent(clearTextButton, "top: 6px; right: 40px;");
356 findBarLayout.addComponent(voiceButton, "top: 6px; right: 40px;");
357 findBarLayout.addComponent(appLauncherButton, "top: 6px; right: 8px;");
358 findBarLayout.addStyleNames("findbar");
359
360 initBrowserSpeechRecognition(voiceButton, () -> {
361 voiceButton.setVisible(true);
362
363 AbsoluteLayout.ComponentPosition clearPosition = findBarLayout.getPosition(clearTextButton);
364 clearPosition.setCSSString("top: 6px; right: 72px;");
365 });
366
367 eventBus.addHandler(AppLifecycleEvent.class, new AppLifecycleEventHandler.Adapter() {
368
369 @Override
370 public void onAppFocused(AppLifecycleEvent event) {
371 log.debug("#onAppFocused: {}", event.getAppDescriptor().getName());
372 updateFiltersForApp(event.getAppDescriptor().getName());
373 viewportLayout.showApp();
374 }
375
376 @Override
377 public void onAppStopped(AppLifecycleEvent event) {
378 log.debug("#onAppStopped: {}", event.getAppDescriptor().getName());
379 supplierField.deselectAll();
380 appLauncher.setAppActive(event.getAppDescriptor().getName(), false);
381 filterLayout.updateGlobalTokenLayout();
382
383 search();
384 viewportLayout.hideApp();
385 }
386
387 @Override
388 public void onAppStarted(AppLifecycleEvent event) {
389 appLauncher.setAppActive(event.getAppDescriptor().getName(), true);
390 log.debug("#onAppStarted: {}", event.getAppDescriptor().getName());
391 updateFiltersForApp(event.getAppDescriptor().getName());
392 viewportLayout.showApp();
393 }
394
395 private void updateFiltersForApp(String appName) {
396 if (supplierField.getValue().contains(appName)) {
397 return;
398 }
399
400 supplierField.deselectAll();
401 if (availableSupplierNames.contains(appName)) {
402 supplierField.select(appName);
403 textField.clear();
404 }
405 filterLayout.updateGlobalTokenLayout();
406 }
407 });
408 }
409
410 private void openApp(String appName) {
411 locationController.goTo(new DefaultLocation(Location.LOCATION_TYPE_APP, appName));
412 viewportLayout.showApp();
413 }
414
415 private void initBrowserSpeechRecognition(Button voiceButton, SpeechRecognizer.Listener supportedListener) {
416 speechRecognizer.attachTo(findBarLayout);
417
418
419 voiceButton.setVisible(false);
420 speechRecognizer.setSupportedListener(supportedListener);
421
422 final AtomicBoolean isRecording = new AtomicBoolean(false);
423
424 speechRecognizer.addSpeechResultListener(transcript -> {
425 textField.setValue(transcript);
426 voiceButton.removeStyleName("recording");
427 isRecording.set(false);
428 });
429
430 voiceButton.addClickListener((Button.ClickListener) event -> {
431 if (isRecording.get()) {
432 return;
433 }
434
435 voiceButton.addStyleName("recording");
436 isRecording.set(true);
437 speechRecognizer.record();
438 });
439 }
440
441
442
443
444
445
446 private Set<SearchResultSupplier> initialiseJCRSearchResultSuppliers(Set<SearchResultSupplier> definedResultSuppliers) {
447 Set<String> definedSupplierNames = definedResultSuppliers.stream()
448 .map(SearchResultSupplier::getName).collect(toSet());
449
450 return appDescriptorRegistry.getAllProviders().stream()
451 .filter(DefinitionProvider::isValid)
452 .filter(provider -> definedSupplierNames.stream().noneMatch(name -> name.equals(provider.get().getName())))
453 .map(definitionProvider -> toSearchResultSupplier(definitionProvider.get()))
454 .filter(Objects::nonNull)
455 .collect(toSet());
456 }
457
458 private SearchResultSupplier toSearchResultSupplier(AppDescriptor appDescriptor) {
459 JcrSearchResultSupplierDefinition definition = new JcrSearchResultSupplierDefinition();
460 Optional<AppDetail> appDetailOptional = appDetailCreator.createAppDetail(appDescriptor.getName());
461 if (!appDetailOptional.isPresent()) {
462 return null;
463 }
464
465 AppDetail appDetail = appDetailOptional.get();
466 if (StringUtils.isBlank(appDetail.getWorkspace())) {
467 return null;
468 }
469 String workspace = appDetail.getWorkspace();
470
471 definition.setAppName(appDescriptor.getName());
472 definition.setWorkspace(workspace);
473
474 if (CollectionUtils.isEmpty(appDetail.getNodeTypes())) {
475 definition.setNodeTypes(Sets.newHashSet(NodeTypes.Content.NAME));
476 } else {
477 definition.setNodeTypes(appDetail.getNodeTypes());
478 }
479 definition.setEnabled(appDescriptor.isEnabled());
480 definition.setIcon(appDescriptor.getIcon());
481
482 return definition.getStrategy().construct(appDescriptor.getName(), definition);
483 }
484
485 @Override
486 public Component asVaadinComponent() {
487 return findBarLayout;
488 }
489
490
491
492
493 private boolean isForbiddenApp(SearchResultSupplier supplier, AppLauncherLayout userLayout) {
494 if (!(supplier instanceof JcrSearchResultSupplier)) {
495 return false;
496 }
497
498 if (appDetailCreator == null) {
499 log.warn("No app detail creator provided - find bar might have problems handling JCR suppliers correctly");
500 return false;
501 }
502
503 JcrSearchResultSupplier jcrSupplier = (JcrSearchResultSupplier) supplier;
504 Optional<AppDetail> appDetail = appDetailCreator.createAppDetailForWorkspace(jcrSupplier.getWorkspace());
505 return appDetail.filter(appDetailConfig -> !userLayout.containsApp(appDetailConfig.getAppName()))
506 .isPresent();
507 }
508
509 protected synchronized void search() {
510 search(false);
511 }
512
513 protected synchronized void search(boolean forceSearch) {
514 resultsGrid.clear();
515
516 SearchQuery searchQuery = buildSearchQuery();
517
518 if (!forceSearch && searchQuery.equals(latestQuery)) {
519 return;
520 }
521 latestQuery = searchQuery;
522
523 FindBarConfiguration findBarConfiguration = configProvider.get();
524 List<String> configuredSuppliers = findBarConfiguration.getSupplierOrder();
525
526 searchQuery.setLimitPerSupplier(findBarConfiguration.getDefaultCountPerSupplier());
527
528
529 if (forceSearch && StringUtils.isNotBlank(searchQuery.getQuery())) {
530 boolean succeeded = periscope.sniffQuery(searchQuery.getQuery());
531 if (succeeded) {
532 return;
533 }
534 } else {
535
536
537 searchQuery.setLimitPerSupplier(findBarConfiguration.getSuggestionCountPerSupplier());
538 }
539
540 Set<String> selectedSuppliers = this.supplierField.getValue();
541 Set<SearchResultSupplier> suppliers = CollectionUtils.isEmpty(selectedSuppliers) ? availableResultSuppliers :
542 Sets.filter(availableResultSuppliers, e -> selectedSuppliers.contains(e.getName()));
543
544 if (StringUtils.isBlank(searchQuery.getQuery()) && CollectionUtils.isNotEmpty(configuredSuppliers)) {
545 suppliers = suppliers.stream()
546 .filter(supplier -> configuredSuppliers.contains(supplier.getName()))
547 .collect(toSet());
548 }
549
550
551 final UI currentUi = UI.getCurrent();
552 if (suppliers.size() > 0) {
553 currentUi.setPollInterval(100);
554 resultsGrid.addStyleName("loading");
555 }
556
557 final List<SearchResultSupplier> sortedSuppliers = new ArrayList<>(suppliers);
558 sorter.sort(sortedSuppliers);
559
560 resultCollector.search(searchQuery, sortedSuppliers, () -> {
561
562 if (latestQuery == searchQuery) {
563 currentUi.access(() -> {
564 viewportLayout.disableAnimations();
565 currentUi.setPollInterval(DEFAULT_POLL_INTERVAL);
566 resultsGrid.removeStyleName("loading");
567 });
568 }
569 });
570 }
571
572 private SearchQuery buildSearchQuery() {
573 SearchQuery.SearchQueryBuilder searchQueryBuilder = SearchQuery.builder()
574 .query(this.textField.getValue())
575 .tags(this.tagField.getValue())
576 .editors(this.editorField.getValue())
577 .currentUser(this.currentUser);
578
579
580 if (this.dateRangeField.getBeginDateField().getValue() != null) {
581 searchQueryBuilder.startDate(this.dateRangeField.getBeginDateField().getValue().atStartOfDay(ZoneId.systemDefault()));
582 }
583 if (this.dateRangeField.getEndDateField().getValue() != null) {
584 searchQueryBuilder.endDate(this.dateRangeField.getEndDateField().getValue().atStartOfDay(ZoneId.systemDefault()));
585 }
586
587 return searchQueryBuilder.build();
588 }
589
590 private List<String> resolveUsers() {
591 Collection<User> allUsers = securitySupport.getUserManager().getAllUsers();
592 return allUsers.stream()
593 .map(User::getName)
594 .collect(toList());
595 }
596
597
598
599
600 TextField getTextField() {
601 return textField;
602 }
603
604
605
606
607 ComboBoxMultiselect<String> getSupplierField() {
608 return supplierField;
609 }
610 }