001/**
002 * Copyright 2005-2016 The Kuali Foundation
003 *
004 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
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 */
016package org.kuali.rice.krad.theme.postprocessor;
017
018import org.apache.commons.io.IOUtils;
019import org.apache.commons.lang.StringUtils;
020import org.apache.log4j.Logger;
021import org.kuali.rice.krad.theme.util.ThemeBuilderConstants;
022import org.kuali.rice.krad.theme.util.ThemeBuilderUtils;
023
024import java.io.File;
025import java.io.FileOutputStream;
026import java.io.FileReader;
027import java.io.IOException;
028import java.io.InputStreamReader;
029import java.io.OutputStream;
030import java.io.OutputStreamWriter;
031import java.util.ArrayList;
032import java.util.Collection;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036import java.util.Properties;
037
038/**
039 * Base class for the JS and CSS theme file processors (or post-processors) that act on a given theme
040 *
041 * <p>
042 * The post processing of JS and CSS files is orchestrated in this base class with the specific configuration
043 * for each provided through abstract methods. This {@link #process()} method performs the major calls of
044 * the process
045 * </p>
046 *
047 * <p>
048 * Base class also provides some helper methods such as {@link #getPropertyValueAsPluginDirs(java.lang.String)}
049 * and {@link #addMissingPluginDirs(java.util.List<java.io.File>)}
050 * </p>
051 *
052 * @author Kuali Rice Team (rice.collab@kuali.org)
053 */
054public abstract class ThemeFilesProcessor {
055    private static final Logger LOG = Logger.getLogger(ThemeFilesProcessor.class);
056
057    protected static final String PLUGIN_FILES_KEY = "plugin";
058    protected static final String SUBDIR_FILES_KEY = "subdir";
059
060    protected String themeName;
061    protected File themeDirectory;
062    protected Properties themeProperties;
063    protected Map<String, File> themePluginDirsMap;
064    protected File workingDir;
065    protected String projectVersion;
066
067    public ThemeFilesProcessor(String themeName, File themeDirectory, Properties themeProperties,
068            Map<String, File> themePluginDirsMap, File workingDir, String projectVersion) {
069        this.themeName = themeName;
070        this.themeDirectory = themeDirectory;
071        this.themeProperties = themeProperties;
072        this.themePluginDirsMap = themePluginDirsMap;
073        this.workingDir = workingDir;
074        this.projectVersion = projectVersion;
075    }
076
077    /**
078     * Carries out the theme files post process
079     *
080     * <p>
081     * Processing of each file type includes the following:
082     *
083     * <ul>
084     * <li>Collect the theme files for each type</li>
085     * <li>Perform a sorting process on the files to form the correct order of sourcing or merging</li>
086     * <li>Merge all the files for the type into a single file. Here subclasses can perform alterations on
087     * the merged contents</li>
088     * <li>Minifiy the merged file using a compressor that is appropriate for the file type</li>
089     * <li>Write out the listing of files for the type to the theme's properties file</li>
090     * </ul>
091     * </p>
092     *
093     * <p>
094     * Any {@link IOException} that occur are caught and thrown as runtime exceptions
095     * </p>
096     */
097    public void process() {
098        Map<String, List<File>> themeFilesMap = collectThemeFiles();
099
100        // perform any custom sorting configured in the theme properties
101        List<File> themeFiles = sortThemeFiles(themeFilesMap.get(PLUGIN_FILES_KEY), themeFilesMap.get(
102                SUBDIR_FILES_KEY));
103
104        File mergedFile = createMergedFile(false);
105        try {
106            mergeFiles(themeFiles, mergedFile);
107        } catch (IOException e) {
108            throw new RuntimeException("Exception encountered while merging files for type: "
109                    + getFileTypeExtension());
110        }
111
112        File minifiedFile = createMergedFile(true);
113        try {
114            minify(mergedFile, minifiedFile);
115        } catch (IOException e) {
116            throw new RuntimeException("Exception encountered while minifying files for type: "
117                    + getFileTypeExtension());
118        }
119
120        // add listing of file paths for this type to the theme properties
121        // (to be read in dev mode by the view theme)
122        List<String> themeFilePaths = ThemeBuilderUtils.getRelativePaths(this.workingDir, themeFiles);
123
124        this.themeProperties.put(getFileListingConfigKey(), StringUtils.join(themeFilePaths, ","));
125    }
126
127    /**
128     * Collects the file names to include for the theme, separated by whether they come from a plugin directory
129     * or the theme directory
130     *
131     * <p>
132     * First all plugin directories that are included for the theme are listed based on the include for that
133     * file type {@link #getFileIncludes()}. Individual plugin files can be excluded with the property
134     * <code>pluginFileExcludes</code>, or the global excludes for the file type {@link #getFileExcludes()}
135     *
136     * Next the subdirectory of the theme that holds the file type, given by {@link #getFileTypeSubDirectory()},
137     * is listed to pick up include files. Again the global file includes and excludes for the type is used
138     *
139     * Finally, subclasses can add additional files by implementing {@link #addAdditionalFiles(java.util.List)}
140     * </p>
141     *
142     * @return map containing an entry for plugin file names, and theme file names. Keys are given by
143     *         {@link #PLUGIN_FILES_KEY} and {@link #SUBDIR_FILES_KEY}
144     * @see #getFileIncludes()
145     * @see #getFileExcludes()
146     * @see #getFileTypeSubDirectory()
147     * @see #addAdditionalFiles(java.util.List)
148     */
149    protected Map<String, List<File>> collectThemeFiles() {
150        Map<String, List<File>> themeFiles = new HashMap<String, List<File>>();
151
152        String[] fileIncludes = getFileIncludes();
153        String[] fileExcludes = getFileExcludes();
154
155        // add files from plugins first
156        String[] pluginFileExcludes = null;
157        if (this.themeProperties.containsKey(ThemeBuilderConstants.ThemeConfiguration.PLUGIN_FILE_EXCLUDES)) {
158            pluginFileExcludes = ThemeBuilderUtils.getPropertyValueAsArray(
159                    ThemeBuilderConstants.ThemeConfiguration.PLUGIN_FILE_EXCLUDES, this.themeProperties);
160        }
161
162        // global file excludes should also apply to plugins
163        if (fileExcludes != null) {
164            if (pluginFileExcludes == null) {
165                pluginFileExcludes = fileExcludes;
166            } else {
167                pluginFileExcludes = ThemeBuilderUtils.addToArray(pluginFileExcludes, fileExcludes);
168            }
169        }
170
171        // for convenience we don't require the extension on the patterns, but it must be added before
172        // we use the patterns for matching
173        ThemeBuilderUtils.addExtensionToPatterns(fileIncludes, getFileTypeExtension());
174        ThemeBuilderUtils.addExtensionToPatterns(fileExcludes, getFileTypeExtension());
175        ThemeBuilderUtils.addExtensionToPatterns(pluginFileExcludes, getFileTypeExtension());
176
177        List<File> pluginThemeFiles = new ArrayList<File>();
178        for (Map.Entry<String, File> pluginMapping : this.themePluginDirsMap.entrySet()) {
179            String pluginName = pluginMapping.getKey();
180            File pluginDirectory = pluginMapping.getValue();
181
182            // adjust plugin file excludes to not include the top directory
183            String[] adjustedFileExcludes = null;
184            if (pluginFileExcludes != null) {
185                adjustedFileExcludes = new String[pluginFileExcludes.length];
186
187                for (int i = 0; i < pluginFileExcludes.length; i++) {
188                    adjustedFileExcludes[i] = StringUtils.removeStart(pluginFileExcludes[i], pluginName + "/");
189                }
190            }
191
192            pluginThemeFiles.addAll(ThemeBuilderUtils.getDirectoryFiles(pluginDirectory, fileIncludes,
193                    adjustedFileExcludes));
194        }
195
196        themeFiles.put(PLUGIN_FILES_KEY, pluginThemeFiles);
197
198        // now add files in the subdirectory for this file type directory
199        List<File> subDirThemeFiles = new ArrayList<File>();
200
201        subDirThemeFiles.addAll(ThemeBuilderUtils.getDirectoryFiles(getFileTypeSubDirectory(), fileIncludes,
202                fileExcludes));
203
204        // allow additional files to be added based on the file type
205        addAdditionalFiles(subDirThemeFiles);
206
207        themeFiles.put(SUBDIR_FILES_KEY, subDirThemeFiles);
208
209        if (LOG.isDebugEnabled()) {
210            LOG.debug("Found " + subDirThemeFiles.size() + "file(s) for theme " + this.themeName);
211        }
212
213        return themeFiles;
214    }
215
216    /**
217     * Builds array of patterns used to find files to include for the type, by default picks up all
218     * files that have the extension for the type being processed
219     *
220     * @return array of string patterns to include
221     * @see #getFileTypeExtension()
222     */
223    protected String[] getFileIncludes() {
224        return new String[] {"**/*" + getFileTypeExtension()};
225    }
226
227    /**
228     * Builds array of patterns used to exclude files to include for the type
229     *
230     * <p>
231     * Each file type has a configuration property where exclude patterns can be listed. This property
232     * key is retrieved by {@link #getExcludesConfigKey()}, and then split by the comma to get the array
233     * of patterns
234     * </p>
235     *
236     * @return array of string patterns to exclude
237     * @see #getExcludesConfigKey();
238     */
239    protected String[] getFileExcludes() {
240        String[] excludes = null;
241
242        if (this.themeProperties.containsKey(getExcludesConfigKey())) {
243            String excludesString = this.themeProperties.getProperty(getExcludesConfigKey());
244
245            excludes = excludesString.split(",");
246        }
247
248        return excludes;
249    }
250
251    /**
252     * Returns the File object that points to the theme subdirectory that contains files for the
253     * file type
254     *
255     * <p>
256     * Sub directory is formed by finding the directory with name {@link #getFileTypeDirectoryName()} within
257     * the theme directory
258     * </p>
259     *
260     * @return sub directory for the file type
261     * @see #getFileTypeDirectoryName()
262     */
263    protected File getFileTypeSubDirectory() {
264        File subDirectory = new File(this.themeDirectory, getFileTypeDirectoryName());
265
266        if (!subDirectory.exists()) {
267            throw new RuntimeException(
268                    "Directory for file type " + getFileTypeDirectoryName() + " does not exist for theme: "
269                            + this.themeName);
270        }
271
272        return subDirectory;
273    }
274
275    /**
276     * Creates a new file that will hold the merged or minified contents
277     *
278     * <p>
279     * The merged file name is constructed by taking the theme name, concatenated with "." and the project version.
280     * To form the minified file name, the min suffix ".min" is appended to the merged file name
281     * </p>
282     *
283     * @param minified indicates whether to add the minified suffix
284     * @return file object pointing to the merged or minified file
285     */
286    protected File createMergedFile(boolean minified) {
287        String mergedFileName = this.themeName + "." + this.projectVersion;
288
289        if (minified) {
290            mergedFileName += ThemeBuilderConstants.MIN_FILE_SUFFIX;
291        }
292
293        mergedFileName += getFileTypeExtension();
294
295        return new File(getFileTypeSubDirectory(), mergedFileName);
296    }
297
298    /**
299     * Merges the content from the list of files into the given merge file
300     *
301     * <p>
302     * Contents are read for each file in the order they appear in the files list. Before adding the contents
303     * to the merged file, the method {@link #processMergeFileContents(java.lang.String, java.io.File, java.io.File)}
304     * is invoked to allow subclasses to alter the contents
305     * </p>
306     *
307     * @param filesToMerge list of files whose content should be merged
308     * @param mergedFile file that should receive the merged content
309     * @throws IOException
310     */
311    protected void mergeFiles(List<File> filesToMerge, File mergedFile) throws IOException {
312        OutputStream out = null;
313        OutputStreamWriter outWriter = null;
314        InputStreamReader reader = null;
315
316        LOG.info("Creating merged file: " + mergedFile.getPath());
317
318        try {
319            out = new FileOutputStream(mergedFile);
320            outWriter = new OutputStreamWriter(out);
321
322            for (File fileToMerge : filesToMerge) {
323                reader = new FileReader(fileToMerge);
324
325                String fileContents = IOUtils.toString(reader);
326                if ((fileContents == null) || "".equals(fileContents)) {
327                    continue;
328                }
329
330                fileContents = processMergeFileContents(fileContents, fileToMerge, mergedFile);
331
332                outWriter.append(fileContents);
333                outWriter.flush();
334            }
335        } finally {
336            if (out != null) {
337                out.close();
338            }
339
340            if (reader != null) {
341                reader.close();
342            }
343        }
344    }
345
346    /**
347     * Extension (ex. 'css') for the file type being processed
348     *
349     * @return file type extension
350     */
351    protected abstract String getFileTypeExtension();
352
353    /**
354     * Name of the directory relative to the theme directory which contains files for the type
355     *
356     * @return directory name
357     */
358    protected abstract String getFileTypeDirectoryName();
359
360    /**
361     * Key for the property within the theme's properties file that can be configured to exlcude files
362     * of the type being processed
363     *
364     * @return property key for file type excludes
365     */
366    protected abstract String getExcludesConfigKey();
367
368    /**
369     * Key for the property that will be written to the theme derived properties to list the included files
370     * for the file type
371     *
372     * @return property key for file type listing
373     */
374    protected abstract String getFileListingConfigKey();
375
376    /**
377     * Invoked during the collection of files to allow additional files to be added to the theme's list
378     *
379     * @param themeFiles list of additional files to included for the theme
380     */
381    protected abstract void addAdditionalFiles(List<File> themeFiles);
382
383    /**
384     * Invoked to build the final sorted list of files for the type, files from plugins and from the theme's
385     * sub directory are passed separately so special treatment can be given to those for sorting
386     *
387     * @param pluginFiles list of files that will be included and come from a plugin directory
388     * @param subDirFiles list of files that will be included and come from the theme subdirectory
389     * @return list of all files to include for the theme in the correct source order
390     */
391    protected abstract List<File> sortThemeFiles(List<File> pluginFiles, List<File> subDirFiles);
392
393    /**
394     * Invoked during the merge process to alter the given file contents before they are appended to the
395     * merge file
396     *
397     * @param fileContents contents of the file that will be added
398     * @param fileToMerge file the contents were pulled from
399     * @param mergedFile file receiving the merged contents
400     * @return file contents to merge (possibly altered)
401     * @throws IOException
402     */
403    protected abstract String processMergeFileContents(String fileContents, File fileToMerge, File mergedFile)
404            throws IOException;
405
406    /**
407     * Invoked after the merged file has been created to create the minified version
408     *
409     * @param mergedFile file containing the merged contents
410     * @param minifiedFile file created to receive the minified contents
411     * @throws IOException
412     */
413    protected abstract void minify(File mergedFile, File minifiedFile) throws IOException;
414
415    /**
416     * Helper method that retrieves the value for the given property from the theme's properties as
417     * a list of strings
418     *
419     * @param propertyKey key for the property to retrieve the value for
420     * @return list of string values parsed from the property value
421     */
422    protected List<String> getThemePropertyValue(String propertyKey) {
423        return ThemeBuilderUtils.getPropertyValueAsList(propertyKey, this.themeProperties);
424    }
425
426    /**
427     * Helper method that retrieves the value for the given property from the theme's properties as a
428     * list of file objects that point to plugin directories
429     *
430     * @param propertyKey key for the property to retrieve the value for
431     * @return list of files (plugin directories) parsed from the property value
432     */
433    protected List<File> getPropertyValueAsPluginDirs(String propertyKey) {
434        List<File> pluginDirs = null;
435
436        List<String> pluginNames = ThemeBuilderUtils.getPropertyValueAsList(propertyKey, this.themeProperties);
437
438        if (pluginNames != null && !pluginNames.isEmpty()) {
439            pluginDirs = new ArrayList<File>();
440
441            for (String pluginName : pluginNames) {
442                pluginName = pluginName.toLowerCase();
443
444                if (!this.themePluginDirsMap.containsKey(pluginName)) {
445                    throw new RuntimeException(
446                            "Invalid plugin name: " + pluginName + " in configuration for property " + propertyKey);
447                }
448
449                pluginDirs.add(this.themePluginDirsMap.get(pluginName));
450            }
451        }
452
453        return pluginDirs;
454    }
455
456    /**
457     * Helper method to add any plugin directories for the theme being processed to the given list if they
458     * are not already contained in the list
459     *
460     * @param pluginList list of plugin directories to complete
461     * @return list of plugin directories that includes all plugins for the theme
462     */
463    protected List<File> addMissingPluginDirs(List<File> pluginList) {
464        List<File> allPluginDirs = new ArrayList<File>();
465
466        if (pluginList != null) {
467            allPluginDirs.addAll(pluginList);
468        }
469
470        Collection<File> allPlugins = this.themePluginDirsMap.values();
471        for (File pluginDir : allPlugins) {
472            if (!allPluginDirs.contains(pluginDir)) {
473                allPluginDirs.add(pluginDir);
474            }
475        }
476
477        return allPluginDirs;
478    }
479}