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