001/*
002 * (C) Copyright 2011-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Anahide Tchertchian
018 */
019package org.nuxeo.theme.styling.service;
020
021import java.io.IOException;
022import java.net.MalformedURLException;
023import java.net.URL;
024import java.util.ArrayList;
025import java.util.Collections;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029
030import org.apache.commons.io.IOUtils;
031import org.apache.commons.lang3.StringUtils;
032import org.apache.commons.logging.Log;
033import org.apache.commons.logging.LogFactory;
034import org.nuxeo.ecm.web.resources.api.Resource;
035import org.nuxeo.ecm.web.resources.api.ResourceType;
036import org.nuxeo.ecm.web.resources.api.service.WebResourceManager;
037import org.nuxeo.ecm.web.resources.core.ResourceDescriptor;
038import org.nuxeo.runtime.RuntimeMessage.Level;
039import org.nuxeo.runtime.RuntimeMessage.Source;
040import org.nuxeo.runtime.api.Framework;
041import org.nuxeo.runtime.logging.DeprecationLogger;
042import org.nuxeo.runtime.model.ComponentContext;
043import org.nuxeo.runtime.model.ComponentInstance;
044import org.nuxeo.runtime.model.ComponentName;
045import org.nuxeo.runtime.model.DefaultComponent;
046import org.nuxeo.runtime.model.RuntimeContext;
047import org.nuxeo.theme.styling.negotiation.Negotiator;
048import org.nuxeo.theme.styling.service.descriptors.FlavorDescriptor;
049import org.nuxeo.theme.styling.service.descriptors.FlavorPresets;
050import org.nuxeo.theme.styling.service.descriptors.IconDescriptor;
051import org.nuxeo.theme.styling.service.descriptors.LogoDescriptor;
052import org.nuxeo.theme.styling.service.descriptors.NegotiationDescriptor;
053import org.nuxeo.theme.styling.service.descriptors.NegotiatorDescriptor;
054import org.nuxeo.theme.styling.service.descriptors.PageDescriptor;
055import org.nuxeo.theme.styling.service.descriptors.PalettePreview;
056import org.nuxeo.theme.styling.service.descriptors.SassImport;
057import org.nuxeo.theme.styling.service.descriptors.SimpleStyle;
058import org.nuxeo.theme.styling.service.palettes.PaletteParseException;
059import org.nuxeo.theme.styling.service.palettes.PaletteParser;
060import org.nuxeo.theme.styling.service.registries.FlavorRegistry;
061import org.nuxeo.theme.styling.service.registries.NegotiationRegistry;
062import org.nuxeo.theme.styling.service.registries.PageRegistry;
063
064/**
065 * Default implementation for the {@link ThemeStylingService}
066 *
067 * @since 5.5
068 */
069public class ThemeStylingServiceImpl extends DefaultComponent implements ThemeStylingService {
070
071    private static final Log log = LogFactory.getLog(ThemeStylingServiceImpl.class);
072
073    protected static final String WR_EX = "org.nuxeo.ecm.platform.WebResources";
074
075    protected PageRegistry pageReg;
076
077    protected FlavorRegistry flavorReg;
078
079    protected NegotiationRegistry negReg;
080
081    // Runtime Component API
082
083    @Override
084    public void activate(ComponentContext context) {
085        super.activate(context);
086        pageReg = new PageRegistry();
087        flavorReg = new FlavorRegistry();
088        negReg = new NegotiationRegistry();
089    }
090
091    @Override
092    public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
093        if (contribution instanceof FlavorDescriptor) {
094            FlavorDescriptor flavor = (FlavorDescriptor) contribution;
095            log.info(String.format("Register flavor '%s'", flavor.getName()));
096            registerFlavor(flavor, contributor.getContext());
097            log.info(String.format("Done registering flavor '%s'", flavor.getName()));
098        } else if (contribution instanceof SimpleStyle) {
099            SimpleStyle style = (SimpleStyle) contribution;
100            log.info(String.format("Register style '%s'", style.getName()));
101            ComponentName compName = contributor.getName();
102            String message = String.format(
103                    "Style '%s' on component %s should now be contributed to extension "
104                            + "point '%s': a compatibility registration was performed but it may not be "
105                            + "accurate. Note that the 'flavor' processor should be used with this resource.",
106                    style.getName(), compName, WR_EX);
107            DeprecationLogger.log(message, "7.4");
108            addRuntimeMessage(Level.WARNING, message, Source.EXTENSION, compName.getName());
109            ResourceDescriptor resource = getResourceFromStyle(style);
110            registerResource(resource, contributor.getContext());
111            log.info(String.format("Done registering style '%s'", style.getName()));
112        } else if (contribution instanceof PageDescriptor) {
113            PageDescriptor page = (PageDescriptor) contribution;
114            log.info(String.format("Register page '%s'", page.getName()));
115            if (page.hasResources()) {
116                // automatically register a bundle for page resources
117                WebResourceManager wrm = Framework.getService(WebResourceManager.class);
118                wrm.registerResourceBundle(page.getComputedResourceBundle());
119            }
120            pageReg.addContribution(page);
121            log.info(String.format("Done registering page '%s'", page.getName()));
122        } else if (contribution instanceof ResourceDescriptor) {
123            ResourceDescriptor resource = (ResourceDescriptor) contribution;
124            log.info(String.format("Register resource '%s'", resource.getName()));
125            ComponentName compName = contributor.getName();
126            String message = String.format(
127                    "Resource '%s' on component %s should now be contributed to extension "
128                            + "point '%s': a compatibility registration was performed but it may not be accurate.",
129                    resource.getName(), compName, WR_EX);
130            DeprecationLogger.log(message, "7.4");
131            addRuntimeMessage(Level.WARNING, message, Source.EXTENSION, compName.getName());
132            // ensure path is absolute, consider that resource is in the war, and if not, user will have to declare it
133            // directly to the WRM endpoint
134            String path = resource.getPath();
135            if (path != null && !path.startsWith("/")) {
136                resource.setUri("/" + path);
137            }
138            registerResource(resource, contributor.getContext());
139            log.info(String.format("Done registering resource '%s'", resource.getName()));
140        } else if (contribution instanceof NegotiationDescriptor) {
141            NegotiationDescriptor neg = (NegotiationDescriptor) contribution;
142            log.info(String.format("Register negotiation for '%s'", neg.getTarget()));
143            negReg.addContribution(neg);
144            log.info(String.format("Done registering negotiation for '%s'", neg.getTarget()));
145        } else {
146            log.error(String.format("Unknown contribution to the theme " + "styling service, extension point '%s': '%s",
147                    extensionPoint, contribution));
148        }
149    }
150
151    @Override
152    public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
153        if (contribution instanceof FlavorDescriptor) {
154            FlavorDescriptor flavor = (FlavorDescriptor) contribution;
155            flavorReg.removeContribution(flavor);
156        } else if (contribution instanceof Resource) {
157            Resource resource = (Resource) contribution;
158            unregisterResource(resource);
159        } else if (contribution instanceof SimpleStyle) {
160            SimpleStyle style = (SimpleStyle) contribution;
161            unregisterResource(getResourceFromStyle(style));
162        } else if (contribution instanceof PageDescriptor) {
163            PageDescriptor page = (PageDescriptor) contribution;
164            if (page.hasResources()) {
165                WebResourceManager wrm = Framework.getService(WebResourceManager.class);
166                wrm.unregisterResourceBundle(page.getComputedResourceBundle());
167            }
168            pageReg.removeContribution(page);
169        } else if (contribution instanceof NegotiationDescriptor) {
170            NegotiationDescriptor neg = (NegotiationDescriptor) contribution;
171            negReg.removeContribution(neg);
172        } else {
173            log.error(String.format("Unknown contribution to the theme " + "styling service, extension point '%s': '%s",
174                    extensionPoint, contribution));
175        }
176    }
177
178    protected void registerFlavor(FlavorDescriptor flavor, RuntimeContext extensionContext) {
179        // set flavor presets files content
180        List<FlavorPresets> presets = flavor.getPresets();
181        if (presets != null) {
182            for (FlavorPresets myPreset : presets) {
183                String src = myPreset.getSrc();
184                URL url = getUrlFromPath(src, extensionContext);
185                if (url == null) {
186                    log.error(String.format("Could not find resource at '%s'", src));
187                } else {
188                    String content;
189                    try {
190                        content = new String(IOUtils.toByteArray(url));
191                    } catch (IOException e) {
192                        throw new RuntimeException(e);
193                    }
194                    myPreset.setContent(content);
195                }
196            }
197        }
198
199        // set flavor sass variables
200        List<SassImport> sassVars = flavor.getSassImports();
201        if (sassVars != null) {
202            for (SassImport var : sassVars) {
203                String src = var.getSrc();
204                URL url = getUrlFromPath(src, extensionContext);
205                if (url == null) {
206                    log.error(String.format("Could not find resource at '%s'", src));
207                } else {
208                    String content;
209                    try {
210                        content = new String(IOUtils.toByteArray(url));
211                    } catch (IOException e) {
212                        throw new RuntimeException(e);
213                    }
214                    var.setContent(content);
215                }
216            }
217        }
218
219        flavorReg.addContribution(flavor);
220    }
221
222    protected List<FlavorPresets> computePresets(FlavorDescriptor flavor, List<String> flavors) {
223        List<FlavorPresets> presets = new ArrayList<>();
224        if (flavor != null) {
225            List<FlavorPresets> localPresets = flavor.getPresets();
226            if (localPresets != null) {
227                presets.addAll(localPresets);
228            }
229            String extendsFlavorName = flavor.getExtendsFlavor();
230            if (!StringUtils.isBlank(extendsFlavorName)) {
231                if (flavors.contains(extendsFlavorName)) {
232                    // cyclic dependency => abort
233                    log.error("Cyclic dependency detected in flavor '" + flavor.getName() + "' hierarchy");
234                    return presets;
235                } else {
236                    // retrieve the extended presets
237                    flavors.add(flavor.getName());
238                    FlavorDescriptor extendedFlavor = getFlavor(extendsFlavorName);
239                    if (extendedFlavor != null) {
240                        List<FlavorPresets> parentPresets = computePresets(extendedFlavor, flavors);
241                        if (parentPresets != null) {
242                            presets.addAll(0, parentPresets);
243                        }
244                    } else {
245                        log.warn("Extended flavor '" + extendsFlavorName + "' not found");
246                    }
247                }
248            }
249        }
250        return presets;
251    }
252
253    protected void registerResource(Resource resource, RuntimeContext extensionContext) {
254        WebResourceManager wrm = Framework.getService(WebResourceManager.class);
255        wrm.registerResource(resource);
256    }
257
258    protected void unregisterResource(Resource resource) {
259        // unregister directly to the WebResourceManager service
260        WebResourceManager wrm = Framework.getService(WebResourceManager.class);
261        wrm.unregisterResource(resource);
262    }
263
264    protected ResourceDescriptor getResourceFromStyle(SimpleStyle style) {
265        // turn style into a resource
266        ResourceDescriptor resource = new ResourceDescriptor();
267        resource.setPath(style.getSrc());
268        String name = style.getName();
269        if (name.endsWith(ResourceType.css.name())) {
270            resource.setName(name);
271        } else {
272            resource.setName(name + "." + ResourceType.css.name());
273        }
274        resource.setProcessors(Collections.singletonList("flavor"));
275        return resource;
276    }
277
278    protected URL getUrlFromPath(String path, RuntimeContext extensionContext) {
279        if (path == null) {
280            return null;
281        }
282        URL url;
283        try {
284            url = new URL(path);
285        } catch (MalformedURLException e) {
286            url = extensionContext.getLocalResource(path);
287            if (url == null) {
288                url = extensionContext.getResource(path);
289            }
290        }
291        return url;
292    }
293
294    // service API
295
296    @Override
297    public String getDefaultFlavorName(String themePageName) {
298        if (pageReg != null) {
299            PageDescriptor themePage = pageReg.getPage(themePageName);
300            if (themePage != null) {
301                return themePage.getDefaultFlavor();
302            }
303        }
304        return null;
305    }
306
307    @Override
308    public FlavorDescriptor getFlavor(String flavorName) {
309        if (flavorReg != null) {
310            FlavorDescriptor flavor = flavorReg.getFlavor(flavorName);
311            if (flavor != null) {
312                FlavorDescriptor clone = flavor.clone();
313                clone.setLogo(computeLogo(flavor, new ArrayList<>()));
314                clone.setPalettePreview(computePalettePreview(flavor, new ArrayList<>()));
315                clone.setFavicons(computeIcons(flavor, new ArrayList<>()));
316                return clone;
317            }
318        }
319        return null;
320    }
321
322    @Override
323    public LogoDescriptor getLogo(String flavorName) {
324        FlavorDescriptor flavor = getFlavor(flavorName);
325        if (flavor != null) {
326            return flavor.getLogo();
327        }
328        return null;
329    }
330
331    protected LogoDescriptor computeLogo(FlavorDescriptor flavor, List<String> flavors) {
332        if (flavor != null) {
333            LogoDescriptor localLogo = flavor.getLogo();
334            if (localLogo == null) {
335                String extendsFlavorName = flavor.getExtendsFlavor();
336                if (!StringUtils.isBlank(extendsFlavorName)) {
337                    if (flavors.contains(extendsFlavorName)) {
338                        // cyclic dependency => abort
339                        log.error("Cyclic dependency detected in flavor '" + flavor.getName() + "' hierarchy");
340                        return null;
341                    } else {
342                        // retrieved the extended logo
343                        flavors.add(flavor.getName());
344                        FlavorDescriptor extendedFlavor = getFlavor(extendsFlavorName);
345                        if (extendedFlavor != null) {
346                            localLogo = computeLogo(extendedFlavor, flavors);
347                        } else {
348                            log.warn("Extended flavor '" + extendsFlavorName + "' not found");
349                        }
350                    }
351                }
352            }
353            return localLogo;
354        }
355        return null;
356    }
357
358    protected PalettePreview computePalettePreview(FlavorDescriptor flavor, List<String> flavors) {
359        if (flavor != null) {
360            PalettePreview localPalette = flavor.getPalettePreview();
361            if (localPalette == null) {
362                String extendsFlavorName = flavor.getExtendsFlavor();
363                if (!StringUtils.isBlank(extendsFlavorName)) {
364                    if (flavors.contains(extendsFlavorName)) {
365                        // cyclic dependency => abort
366                        log.error("Cyclic dependency detected in flavor '" + flavor.getName() + "' hierarchy");
367                        return null;
368                    } else {
369                        // retrieved the extended colors
370                        flavors.add(flavor.getName());
371                        FlavorDescriptor extendedFlavor = getFlavor(extendsFlavorName);
372                        if (extendedFlavor != null) {
373                            localPalette = computePalettePreview(extendedFlavor, flavors);
374                        } else {
375                            log.warn("Extended flavor '" + extendsFlavorName + "' not found");
376                        }
377                    }
378                }
379            }
380            return localPalette;
381        }
382        return null;
383    }
384
385    protected List<IconDescriptor> computeIcons(FlavorDescriptor flavor, List<String> flavors) {
386        if (flavor != null) {
387            List<IconDescriptor> localIcons = flavor.getFavicons();
388            if (localIcons == null || localIcons.isEmpty()) {
389                String extendsFlavorName = flavor.getExtendsFlavor();
390                if (!StringUtils.isBlank(extendsFlavorName)) {
391                    if (flavors.contains(extendsFlavorName)) {
392                        // cyclic dependency => abort
393                        log.error("Cyclic dependency detected in flavor '" + flavor.getName() + "' hierarchy");
394                        return null;
395                    } else {
396                        // retrieved the extended icons
397                        flavors.add(flavor.getName());
398                        FlavorDescriptor extendedFlavor = getFlavor(extendsFlavorName);
399                        if (extendedFlavor != null) {
400                            localIcons = computeIcons(extendedFlavor, flavors);
401                        } else {
402                            log.warn("Extended flavor '" + extendsFlavorName + "' not found");
403                        }
404                    }
405                }
406            }
407            return localIcons;
408        }
409        return null;
410    }
411
412    @Override
413    public List<String> getFlavorNames(String themePageName) {
414        if (pageReg != null) {
415            PageDescriptor themePage = pageReg.getPage(themePageName);
416            if (themePage != null) {
417                List<String> flavors = new ArrayList<>();
418                List<String> localFlavors = themePage.getFlavors();
419                if (localFlavors != null) {
420                    flavors.addAll(localFlavors);
421                }
422                // add flavors from theme for all pages
423                PageDescriptor forAllPage = pageReg.getConfigurationApplyingToAll();
424                if (forAllPage != null) {
425                    localFlavors = forAllPage.getFlavors();
426                    if (localFlavors != null) {
427                        flavors.addAll(localFlavors);
428                    }
429                }
430                // add default flavor if it's not listed there
431                String defaultFlavor = themePage.getDefaultFlavor();
432                if (defaultFlavor != null) {
433                    if (!flavors.contains(defaultFlavor)) {
434                        flavors.add(0, defaultFlavor);
435                    }
436                }
437                return flavors;
438            }
439        }
440        return null;
441    }
442
443    @Override
444    public List<FlavorDescriptor> getFlavors(String themePageName) {
445        List<String> flavorNames = getFlavorNames(themePageName);
446        if (flavorNames != null) {
447            List<FlavorDescriptor> flavors = new ArrayList<>();
448            for (String flavorName : flavorNames) {
449                FlavorDescriptor flavor = getFlavor(flavorName);
450                if (flavor != null) {
451                    flavors.add(flavor);
452                }
453            }
454            return flavors;
455        }
456        return null;
457    }
458
459    protected Map<String, Map<String, String>> getPresetsByCat(FlavorDescriptor flavor) {
460        String flavorName = flavor.getName();
461        List<FlavorPresets> presets = computePresets(flavor, new ArrayList<>());
462        Map<String, Map<String, String>> presetsByCat = new HashMap<>();
463        if (presets != null) {
464            for (FlavorPresets myPreset : presets) {
465                String content = myPreset.getContent();
466                if (content == null) {
467                    log.error("Null content for preset with source '" + myPreset.getSrc() + "' in flavor '" + flavorName
468                            + "'");
469                } else {
470                    String cat = myPreset.getCategory();
471                    Map<String, String> allEntries;
472                    if (presetsByCat.containsKey(cat)) {
473                        allEntries = presetsByCat.get(cat);
474                    } else {
475                        allEntries = new HashMap<>();
476                    }
477                    try {
478                        Map<String, String> newEntries = PaletteParser.parse(content.getBytes(), myPreset.getSrc());
479                        if (newEntries != null) {
480                            allEntries.putAll(newEntries);
481                        }
482                        if (allEntries.isEmpty()) {
483                            presetsByCat.remove(cat);
484                        } else {
485                            presetsByCat.put(cat, allEntries);
486                        }
487                    } catch (PaletteParseException e) {
488                        log.error(
489                                String.format("Could not parse palette for " + "preset with source '%s' in flavor '%s'",
490                                        myPreset.getSrc(), flavorName),
491                                e);
492                    }
493                }
494            }
495        }
496        return presetsByCat;
497    }
498
499    @Override
500    public Map<String, String> getPresetVariables(String flavorName) {
501        Map<String, String> res = new HashMap<>();
502        FlavorDescriptor flavor = getFlavor(flavorName);
503        if (flavor == null) {
504            return res;
505        }
506        Map<String, Map<String, String>> presetsByCat = getPresetsByCat(flavor);
507        for (String cat : presetsByCat.keySet()) {
508            Map<String, String> entries = presetsByCat.get(cat);
509            for (Map.Entry<String, String> entry : entries.entrySet()) {
510                res.put(entry.getKey() + " (" + ThemeStylingService.FLAVOR_MARKER + " " + cat + ")", entry.getValue());
511            }
512        }
513        return res;
514    }
515
516    @Override
517    public PageDescriptor getPage(String name) {
518        PageDescriptor page = pageReg.getPage(name);
519        if (page != null) {
520            // merge with global resources
521            PageDescriptor globalPage = pageReg.getPage("*");
522            mergePage(page, globalPage);
523        }
524        return page;
525    }
526
527    @Override
528    public List<PageDescriptor> getPages() {
529        List<PageDescriptor> pages = new ArrayList<>();
530        List<String> names = pageReg.getPageNames();
531        PageDescriptor globalPage = pageReg.getPage("*");
532        for (String name : names) {
533            if ("*".equals(name)) {
534                continue;
535            }
536            PageDescriptor page = pageReg.getPage(name);
537            if (page != null) {
538                // merge with global resources
539                mergePage(page, globalPage);
540            }
541            pages.add(page);
542        }
543        return pages;
544    }
545
546    protected void mergePage(PageDescriptor page, PageDescriptor globalPage) {
547        if (page != null && globalPage != null) {
548            // merge with global resources
549            PageDescriptor clone = globalPage.clone();
550            clone.setAppendFlavors(true);
551            clone.setAppendResources(true);
552            clone.setAppendStyles(true);
553            page.merge(clone);
554        }
555    }
556
557    @Override
558    public String negotiate(String target, Object context) {
559        String res = null;
560        NegotiationDescriptor negd = negReg.getNegotiation(target);
561        if (negd != null) {
562            List<NegotiatorDescriptor> nds = negd.getNegotiators();
563            for (NegotiatorDescriptor nd : nds) {
564                Class<Negotiator> ndc = nd.getNegotiatorClass();
565                try {
566                    Negotiator neg = ndc.getDeclaredConstructor().newInstance();
567                    neg.setProperties(nd.getProperties());
568                    res = neg.getResult(target, context);
569                    if (res != null) {
570                        break;
571                    }
572                } catch (ReflectiveOperationException e) {
573                    throw new RuntimeException(e);
574                }
575            }
576        }
577        return res;
578    }
579
580}