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