package com.jvms.i18neditor.util; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.SortedMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang3.StringEscapeUtils; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import com.jvms.i18neditor.FileStructure; import com.jvms.i18neditor.Resource; import com.jvms.i18neditor.ResourceType; import com.jvms.i18neditor.io.ChecksumException; /** * This class provides utility functions for a {@link Resource}. * * @author Jacob van Mourik */ public final class Resources { private final static Charset UTF8_ENCODING; private final static String FILENAME_LOCALE_REGEX; static { UTF8_ENCODING = Charset.forName("UTF-8"); FILENAME_LOCALE_REGEX = Pattern.quote("{") + "(.*)" + Pattern.quote("LOCALE") + "(.*)" + Pattern.quote("}"); } /** * Gets all resources from the given <code>rootDir</code> directory path. * * <p>The <code>fileDefinition</code> is the filename definition of resource files to look for. * The definition consists of a filename including optional locale part (see <code>useLocaleDirs</code>). * The locale part should be in the format: <code>{LOCALE}</code>, where <code>{</code> and <code>}</code> tags * defines the start and end of the locale part and <code>LOCALE</code> the location of the locale itself.</p> * * <p>When a resource type is given, only resources of that type will returned.</p> * * <p>This function will not load the contents of the file, only its description.<br> * If you want to load the contents, use {@link #load(Resource)} afterwards.</p> * * @param root the root directory of the resources * @param fileDefinition the resource's file definition for lookup (using locale interpolation) * @param structure the file structure used for the lookup * @param type the type of the resource files to look for * @return list of found resources * @throws IOException if an I/O error occurs reading the directory. */ public static List<Resource> get(Path root, String fileDefinition, FileStructure structure, Optional<ResourceType> type) throws IOException { List<Resource> result = Lists.newLinkedList(); List<Path> files = Files.walk(root, 1).collect(Collectors.toList()); String defaultFileName = getFilename(fileDefinition, Optional.empty()); Pattern fileDefinitionPattern = Pattern.compile("^" + getFilenameRegex(fileDefinition) + "$"); for (Path file : files) { Path parent = file.getParent(); if (parent == null || Files.isSameFile(root, file) || !Files.isSameFile(root, parent)) { continue; } String filename = com.google.common.io.Files.getNameWithoutExtension(file.getFileName().toString()); for (ResourceType rt : ResourceType.values()) { if (!type.orElse(rt).equals(rt)) { continue; } if (structure == FileStructure.Nested && Files.isDirectory(file)) { Locale locale = Locales.parseLocale(filename); if (locale == null) { continue; } Path rf = Paths.get(root.toString(), locale.toString(), getFilename(fileDefinition, Optional.of(locale)) + rt.getExtension()); if (Files.isRegularFile(rf)) { result.add(new Resource(rt, rf, locale)); } } if (structure == FileStructure.Flat && Files.isRegularFile(file)) { Matcher matcher = fileDefinitionPattern.matcher(filename); if (!matcher.matches() && !filename.equals(defaultFileName)) { continue; } if (!matchesResourceType(file, rt)) { continue; } Locale locale = null; if (matcher.matches() && matcher.groupCount() > 0) { locale = Locales.parseLocale(matcher.group(1)); } result.add(new Resource(rt, file, locale)); } } }; return result; } /** * Loads the translations of a {@link Resource} from disk. * * <p>This function will store a checksum to the resource.</p> * * @param resource the resource. * @throws IOException if an I/O error occurs reading the file. */ public static void load(Resource resource) throws IOException { ResourceType type = resource.getType(); Path path = resource.getPath(); SortedMap<String,String> translations; if (type == ResourceType.Properties) { ExtendedProperties content = new ExtendedProperties(); content.load(path); translations = fromProperties(content); } else { String content = Files.lines(path, UTF8_ENCODING).collect(Collectors.joining()); if (type == ResourceType.ES6) { content = es6ToJson(content); } translations = fromJson(content); } resource.setTranslations(translations); resource.setChecksum(createChecksum(resource)); } /** * Writes the translations of the given {@link Resource} to disk. * Empty translation values will be skipped. * * <p>This function will perform a checksum check before saving * to see if the file on disk has been changed in the meantime.</p> * * <p>This function will store a checksum to the resource.</p> * * @param resource the resource to write. * @param prettyPrinting whether to pretty print the contents * @param plainKeys * @throws IOException if an I/O error occurs writing the file. */ public static void write(Resource resource, boolean prettyPrinting, boolean flattenKeys) throws IOException { if (resource.getChecksum() != null) { String checksum = createChecksum(resource); if (!checksum.equals(resource.getChecksum())) { throw new ChecksumException("File on disk has been changed."); } } ResourceType type = resource.getType(); if (type == ResourceType.Properties) { ExtendedProperties content = toProperties(resource.getTranslations()); content.store(resource.getPath()); } else { String content = toJson(resource.getTranslations(), prettyPrinting, flattenKeys); if (type == ResourceType.ES6) { content = jsonToEs6(content); } if (!Files.exists(resource.getPath())) { Files.createDirectories(resource.getPath().getParent()); Files.createFile(resource.getPath()); } Files.write(resource.getPath(), Lists.newArrayList(content), UTF8_ENCODING); } resource.setChecksum(createChecksum(resource)); } /** * Creates a new {@link Resource} with the given {@link ResourceType} in the given directory path. * This function should be used to create new resources. For creating an instance of an * existing resource on disk, see {@link #read(Path)}. * * <p>This function will store a checksum to the resource.</p> * * @param type the type of the resource to create. * @param root the root directory to write the resource to. * @param filenameDefinition the filename definition of the resource. * @param structure the file structure to use * @param locale the locale of the resource (optional). * @return The newly created resource. * @throws IOException if an I/O error occurs writing the file. */ public static Resource create(ResourceType type, Path root, String fileDefinition, FileStructure structure, Optional<Locale> locale) throws IOException { String extension = type.getExtension(); Path path; if (structure == FileStructure.Nested) { path = Paths.get(root.toString(), locale.get().toString(), getFilename(fileDefinition, locale) + extension); } else { path = Paths.get(root.toString(), getFilename(fileDefinition, locale) + extension); } Resource resource = new Resource(type, path, locale.orElse(null)); write(resource, false, false); return resource; } private static String getFilenameRegex(String fileDefinition) { return fileDefinition.replaceAll(FILENAME_LOCALE_REGEX, "$1(" + Locales.LOCALE_REGEX + ")$2"); } private static String getFilename(String fileDefinition, Optional<Locale> locale) { return fileDefinition.replaceAll(FILENAME_LOCALE_REGEX, locale.isPresent() ? ("$1" + locale.get().toString() + "$2") : ""); } private static SortedMap<String,String> fromProperties(Properties properties) { SortedMap<String,String> result = Maps.newTreeMap(); properties.forEach((key, value) -> { result.put((String)key, StringEscapeUtils.unescapeJava((String)value)); }); return result; } private static ExtendedProperties toProperties(Map<String,String> translations) { ExtendedProperties result = new ExtendedProperties(); translations.forEach((key, value) -> { if (!Strings.isNullOrEmpty(value)) { result.put(key, value); } }); return result; } private static SortedMap<String,String> fromJson(String json) { SortedMap<String,String> result = Maps.newTreeMap(); JsonElement elem = new JsonParser().parse(json); fromJson(null, elem, result); return result; } private static void fromJson(String key, JsonElement elem, Map<String,String> content) { if (elem.isJsonObject()) { elem.getAsJsonObject().entrySet().forEach(entry -> { String newKey = key == null ? entry.getKey() : ResourceKeys.create(key, entry.getKey()); fromJson(newKey, entry.getValue(), content); }); } else if (elem.isJsonPrimitive()) { content.put(key, StringEscapeUtils.unescapeJava(elem.getAsString())); } else if (elem.isJsonNull()) { content.put(key, ""); } else { throw new IllegalArgumentException("Found invalid json element."); } } private static String toJson(Map<String,String> translations, boolean prettify, boolean flattenKeys) { List<String> keys = Lists.newArrayList(translations.keySet()); JsonElement elem = !flattenKeys ? toJson(translations, null, keys) : toFlatJson(translations, keys); GsonBuilder builder = new GsonBuilder().disableHtmlEscaping(); if (prettify) { builder.setPrettyPrinting(); } return builder.create().toJson(elem); } private static JsonElement toFlatJson(Map<String, String> translations, List<String> keys) { JsonObject object = new JsonObject(); if (keys.size() > 0) { translations.forEach((k, v) -> { if (!Strings.isNullOrEmpty(translations.get(k))) { object.add(k, new JsonPrimitive(translations.get(k))); } }); } return object; } private static JsonElement toJson(Map<String,String> translations, String key, List<String> keys) { if (keys.size() > 0) { JsonObject object = new JsonObject(); ResourceKeys.uniqueRootKeys(keys).forEach(rootKey -> { String subKey = ResourceKeys.create(key, rootKey); List<String> subKeys = ResourceKeys.extractChildKeys(keys, rootKey); object.add(rootKey, toJson(translations, subKey, subKeys)); }); return object; } if (key == null) { return new JsonObject(); } if (Strings.isNullOrEmpty(translations.get(key))) { return JsonNull.INSTANCE; } return new JsonPrimitive(translations.get(key)); } private static String es6ToJson(String content) { return content.replaceAll("export +default", "").replaceAll("} *;", "}"); } private static String jsonToEs6(String content) { return "export default " + content + ";"; } private static String createChecksum(Resource resource) throws IOException { MessageDigest digest; try { digest = MessageDigest.getInstance("SHA-1"); } catch (NoSuchAlgorithmException e) { return null; } byte[] buffer = new byte[1024]; int bytesRead = 0; try (InputStream is = Files.newInputStream(resource.getPath())) { while ((bytesRead = is.read(buffer)) != -1) { digest.update(buffer, 0, bytesRead); } } String result = ""; byte[] bytes = digest.digest(); for (int i = 0; i < bytes.length; i++) { result += Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1); } return result; } private static boolean matchesResourceType(Path path, ResourceType type) { String fileExt = com.google.common.io.Files.getFileExtension(path.getFileName().toString()); return ("."+fileExt).equals(type.getExtension()); } }