TransformMojo.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2014 wcm.io
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package io.wcm.maven.plugins.i18n;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.apache.maven.model.Build;
import org.apache.maven.model.Resource;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.util.FileUtils;
import org.codehaus.plexus.util.Scanner;
import org.sonatype.plexus.build.incremental.BuildContext;

import io.wcm.maven.plugins.i18n.readers.I18nReader;
import io.wcm.maven.plugins.i18n.readers.JsonI18nReader;
import io.wcm.maven.plugins.i18n.readers.PropertiesI18nReader;
import io.wcm.maven.plugins.i18n.readers.XmlI18nReader;

/**
 * Transform i18n resources in Java Properties, JSON or XML file format to Sling i18n Messages JSON or XML format.
 */
@Mojo(name = "transform", defaultPhase = LifecyclePhase.GENERATE_RESOURCES, requiresProject = true, threadSafe = true)
public class TransformMojo extends AbstractMojo {

  // file extensions
  private static final String FILE_EXTENSION_JSON = "json";
  private static final String FILE_EXTENSION_XML = "xml";
  private static final String FILE_EXTENSION_PROPERTIES = "properties";

  private static final String ALL_FILES = "**/*.";
  private static final String[] SOURCE_FILES_INCLUDES = new String[] {
      ALL_FILES + FILE_EXTENSION_PROPERTIES,
      ALL_FILES + FILE_EXTENSION_XML,
      ALL_FILES + FILE_EXTENSION_JSON
  };

  /**
   * Source path containing the i18n source .properties or .xml files.
   */
  @Parameter(defaultValue = "${basedir}/src/main/resources/i18n")
  private String source;

  /**
   * Relative target path for the generated resources.
   */
  @Parameter(defaultValue = "SLING-INF/app-root/i18n")
  private String target;

  /**
   * Output format. Possible values:
   * <ul>
   * <li><code>JSON</code>: Sling Message format serialized as JSON.</li>
   * <li><code>JSON_PROPERTIES</code>: Flat list of key/value pairs in JSON format.</li>
   * <li><code>XML</code>: Sling Message format serialized as JCR XML.</li>
   * <li><code>PROPERTIES</code>: Flat list of key/value pairs in Java Properties format.</li>
   * </ul>
   */
  @Parameter(defaultValue = "JSON")
  private String outputFormat;

  @Parameter(defaultValue = "generated-i18n-resources")
  private String generatedResourcesFolderPath;

  @Parameter(property = "project", required = true, readonly = true)
  private MavenProject project;

  @Component
  private BuildContext buildContext;

  private File generatedResourcesFolder;
  private List<File> i18nSourceFiles;

  @Override
  public void execute() throws MojoExecutionException, MojoFailureException {
    OutputFormat selectedOutputFormat = OutputFormat.valueOf(StringUtils.upperCase(outputFormat));
    try {
      File sourceDirectory = getSourceDirectory();
      intialize(sourceDirectory);

      // skip incremental build if no i18n source file was changed
      if (buildContext.isIncremental() && !isI18nSourceFileChanged(sourceDirectory)) {
        return;
      }

      List<File> sourceFiles = getI18nSourceFiles(sourceDirectory);
      for (File file : sourceFiles) {
        transformFile(file, selectedOutputFormat);
      }
    }
    catch (IOException ex) {
      throw new MojoFailureException("Failure to transform i18n resources", ex);
    }
  }

  private void transformFile(File file, OutputFormat selectedOutputFormat) throws MojoFailureException {
    try {
      // transform i18n files
      String languageKey = FileUtils.removeExtension(file.getName());
      I18nReader reader = getI18nReader(file);
      SlingI18nMap i18nMap = new SlingI18nMap(languageKey, reader.read(file));

      // write mappings to target file
      File targetFile = getTargetFile(file, selectedOutputFormat);
      writeTargetI18nFile(i18nMap, targetFile, selectedOutputFormat);

      getLog().info("Transformed " + file.getPath() + " to  " + targetFile.getPath());
    }
    catch (IOException ex) {
      throw new MojoFailureException("Unable to transform i18n resource: " + file.getPath(), ex);
    }
  }

  /**
   * Checks if and i18n source file was changes in incremental build.
   * @param sourceDirectory Source directory
   * @return true if changes detected
   */
  private boolean isI18nSourceFileChanged(File sourceDirectory) {
    Scanner scanner = buildContext.newScanner(sourceDirectory);
    Scanner deleteScanner = buildContext.newDeleteScanner(sourceDirectory);
    return isI18nSourceFileChanged(scanner) || isI18nSourceFileChanged(deleteScanner);
  }

  private boolean isI18nSourceFileChanged(Scanner scanner) {
    scanner.setIncludes(SOURCE_FILES_INCLUDES);
    scanner.addDefaultExcludes();
    scanner.scan();
    return scanner.getIncludedFiles().length > 0;
  }

  /**
   * Initialize parameters, which cannot get defaults from annotations. Currently only the root nodes.
   * @throws IOException I/O exception
   */
  private void intialize(File sourceDirectory) throws IOException {
    getLog().debug("Initializing i18n plugin...");

    // resource
    if (!getI18nSourceFiles(sourceDirectory).isEmpty()) {
      File myGeneratedResourcesFolder = getGeneratedResourcesFolder();
      addResource(myGeneratedResourcesFolder.getPath(), target);
    }

  }

  private void addResource(String generatedResourcesDirectory, String targetPath) {

    // construct resource
    Resource resource = new Resource();
    resource.setDirectory(generatedResourcesDirectory);
    resource.setTargetPath(targetPath);

    // add to build
    Build build = this.project.getBuild();
    build.addResource(resource);
    getLog().debug("Added resource: " + resource.getDirectory() + " -> " + resource.getTargetPath());
  }

  /**
   * Fetches i18n source files from source directory.
   * @param sourceDirectory Source directory
   * @return a list of XML files
   */
  private List<File> getI18nSourceFiles(File sourceDirectory) throws IOException {

    if (i18nSourceFiles == null) {
      if (!sourceDirectory.isDirectory()) {
        i18nSourceFiles = Collections.emptyList();
      }
      else {
        // get list of source files
        String includes = StringUtils.join(SOURCE_FILES_INCLUDES, ",");
        String excludes = FileUtils.getDefaultExcludesAsString();

        i18nSourceFiles = FileUtils.getFiles(sourceDirectory, includes, excludes);
      }
    }

    return i18nSourceFiles;
  }

  /**
   * Get directory containing source i18n files.
   * @return directory containing source i18n files.
   */
  private File getSourceDirectory() throws IOException {
    File file = new File(source);
    if (!file.isDirectory()) {
      getLog().debug("Could not find directory at '" + source + "'");
    }
    return file.getCanonicalFile();
  }

  /**
   * Writes mappings to file in Sling compatible JSON format.
   * @param i18nMap mappings
   * @param targetfile target file
   * @param selectedOutputFormat Output format
   */
  private void writeTargetI18nFile(SlingI18nMap i18nMap, File targetfile, OutputFormat selectedOutputFormat) throws IOException {
    switch (selectedOutputFormat) {
      case XML:
        FileUtils.fileWrite(targetfile, StandardCharsets.UTF_8.name(), i18nMap.getI18nXmlString());
        break;
      case PROPERTIES:
        FileUtils.fileWrite(targetfile, StandardCharsets.ISO_8859_1.name(), i18nMap.getI18nPropertiesString());
        break;
      case JSON:
        FileUtils.fileWrite(targetfile, StandardCharsets.UTF_8.name(), i18nMap.getI18nJsonString());
        break;
      case JSON_PROPERTIES:
        FileUtils.fileWrite(targetfile, StandardCharsets.UTF_8.name(), i18nMap.getI18nJsonPropertiesString());
        break;
      default:
        throw new IllegalArgumentException("Unsupported ouptut format: " + selectedOutputFormat);

    }
    buildContext.refresh(targetfile);
  }

  /**
   * Get the JSON file for source file.
   * @param sourceFile the source file
   * @param selectedOutputFormat Output format
   * @return File with name and path based on file parameter
   */
  private File getTargetFile(File sourceFile, OutputFormat selectedOutputFormat) throws IOException {

    File sourceDirectory = getSourceDirectory();
    String relativePath = StringUtils.substringAfter(sourceFile.getAbsolutePath(), sourceDirectory.getAbsolutePath());
    String relativeTargetPath = FileUtils.removeExtension(relativePath) + "." + selectedOutputFormat.getFileExtension();

    File jsonFile = new File(getGeneratedResourcesFolder().getPath() + relativeTargetPath);

    jsonFile = jsonFile.getCanonicalFile();

    File parentDirectory = jsonFile.getParentFile();
    if (!parentDirectory.exists()) {
      if (!parentDirectory.mkdirs()) {
        throw new IOException("Unable to create directory: " + parentDirectory.getPath());
      }
      buildContext.refresh(parentDirectory);
    }

    return jsonFile;
  }

  private File getGeneratedResourcesFolder() throws IOException {
    if (generatedResourcesFolder == null) {
      generatedResourcesFolder = new File(this.project.getBuild().getDirectory(), generatedResourcesFolderPath);
      if (!generatedResourcesFolder.exists()) {
        if (!generatedResourcesFolder.mkdirs()) {
          throw new IOException("Unable to create directory: " + generatedResourcesFolder.getPath());
        }
        buildContext.refresh(generatedResourcesFolder);
      }
    }
    return generatedResourcesFolder;
  }

  /**
   * Get i18n reader for source file.
   * @param sourceFile Source file
   * @return I18n reader
   */
  private I18nReader getI18nReader(File sourceFile) throws MojoFailureException {
    String extension = FileUtils.getExtension(sourceFile.getName());
    if (StringUtils.equalsIgnoreCase(extension, FILE_EXTENSION_PROPERTIES)) {
      return new PropertiesI18nReader();
    }
    if (StringUtils.equalsIgnoreCase(extension, FILE_EXTENSION_XML)) {
      return new XmlI18nReader();
    }
    if (StringUtils.equalsIgnoreCase(extension, FILE_EXTENSION_JSON)) {
      return new JsonI18nReader();
    }
    throw new MojoFailureException("Unsupported file extension '" + extension + "': " + sourceFile.getAbsolutePath());
  }

}