/** * */ package net.frontlinesms.ui.i18n.legacy; import java.io.File; import java.io.FileFilter; import java.io.FileNotFoundException; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.TreeSet; import net.frontlinesms.ui.i18n.TextResourceKeyOwner; import org.jdom.Document; import org.jdom.Element; import org.jdom.JDOMException; import org.jdom.input.SAXBuilder; import thinlet.Thinlet; /** * Tool for checking if all required internationalisation strings are available in all language bundles, and if there are any extraneous translation strings in the bundles. * @author Alex */ public class LanguageChecker { //> STATIC CONSTANTS /** Filter for sorting XML layout files */ private static final FileFilter LAYOUT_FILE_FILTER = new FileFilter() { public boolean accept(File file) { return file.isDirectory() || file.getAbsolutePath().endsWith(".xml"); } }; //> INSTANCE PROPERTIES /** Map of i18n keys found in code, with reference to their location */ private final Map<String, Set<Field>> i18nKeysInCode = new HashMap<String, Set<Field>>(); /** Map of i18n keys found in XML, with reference to their location */ private final Map<String, Set<File>> i18nKeysInXml = new HashMap<String, Set<File>>(); /** Map of text found in XML which is not internationalised, with reference to their location */ private final Map<String, Set<File>> uni18nTextInXml = new HashMap<String, Set<File>>(); /** Ignored fields. <fieldName,classInWhichTheFieldIsFound> */ private final Map<String, Set<Field>> ignoredFields = new HashMap<String, Set<Field>>(); //> CONSTRUCTORS /** * @param uiJavaControllerClasses * @throws IllegalArgumentException * @throws IllegalAccessException */ private LanguageChecker(Class<?>[] uiJavaControllerClasses) throws IllegalArgumentException, IllegalAccessException { // parse the controller classes for i18n strings for(Class<?> controllerClass : uiJavaControllerClasses) { for(Field field : controllerClass.getDeclaredFields()) { addFieldReference(controllerClass, field); } } } /** * @param uiJavaControllerClasses * @param uiXmlLayoutDirectory * @throws IOException * @throws JDOMException * @throws IllegalAccessException * @throws IllegalArgumentException */ LanguageChecker(Class<?>[] uiJavaControllerClasses, File uiXmlLayoutDirectory) throws JDOMException, IOException, IllegalArgumentException, IllegalAccessException { this(uiJavaControllerClasses); // parse the XML layout files for i18n strings, making sure to check for non-i18n strings as well extractI18nKeys(uiXmlLayoutDirectory); } /** * @param uiJavaControllerClasses * @param uiXmlLayoutDirectories * @throws IOException * @throws JDOMException * @throws IllegalAccessException * @throws IllegalArgumentException */ LanguageChecker(Class<?>[] uiJavaControllerClasses, String[] uiXmlLayoutDirectories) throws JDOMException, IOException, IllegalArgumentException, IllegalAccessException { this(uiJavaControllerClasses); for(String uiXmlLayoutDirectory : uiXmlLayoutDirectories) { // parse the XML layout files for i18n strings, making sure to check for non-i18n strings as well extractI18nKeys(new File(uiXmlLayoutDirectory)); } } //> ACCESSORS /** * Gets all i18nKeys * @return set of all i18n keys that are referenced */ public Set<String> getAllI18nKeys() { TreeSet<String> allKeys = new TreeSet<String>(); allKeys.addAll(this.i18nKeysInCode.keySet()); allKeys.addAll(this.i18nKeysInXml.keySet()); return Collections.unmodifiableSet(allKeys); } /** @return {@link #i18nKeysInCode} */ public Map<String, Set<Field>> getI18nKeysInCode() { return Collections.unmodifiableMap(this.i18nKeysInCode); } /** @return {@link #i18nKeysInXml} */ public Map<String, Set<File>> getI18nKeysInXml() { return Collections.unmodifiableMap(this.i18nKeysInXml); } //> INSTANCE HELPER METHODS /** * Adds a field reference to this {@link LanguageChecker}. * @param clazz The class that the field has come from * @param field The field * @throws IllegalArgumentException * @throws IllegalAccessException */ private void addFieldReference(Class<?> clazz, Field field) throws IllegalArgumentException, IllegalAccessException { field.setAccessible(true); if(shouldProcess(clazz, field)) { trace("Processing field: " + field.getName()); if(field.getType().equals(String.class)) { String fieldValue = field.get(null).toString(); addFieldValue(field, fieldValue); } else if(field.getType().equals(String[].class)) { String[] fieldValue = (String[]) field.get(null); for(String value : fieldValue) { addFieldValue(field, value); } } else { throw new IllegalStateException("Unknown field type: " + field.getType()); } } else trace("Ignoring field: " + field.getName()); } /** * Adds an i18n key gleaned from a {@link Field}. * @param field * @param fieldValue */ private void addFieldValue(Field field, String fieldValue) { if(fieldValue.indexOf('.') != -1 && fieldValue.indexOf('/') == -1) { if(!this.i18nKeysInCode.containsKey(fieldValue)) { this.i18nKeysInCode.put(fieldValue, new HashSet<Field>()); } this.i18nKeysInCode.get(fieldValue).add(field); } else { if(!this.ignoredFields.containsKey(fieldValue)) { this.ignoredFields.put(fieldValue, new HashSet<Field>()); } this.ignoredFields.get(fieldValue).add(field); } } /** * Produces a report about the specified language bundle with respect to this {@link LanguageChecker}. * @param baseTextResource the base text resource, or <code>null</code> if we are testing the base text resource or it's translations * @param languageBundle the language bundle to compare to this {@link LanguageChecker} * @return a report * @throws IllegalAccessException * @throws NoSuchFieldException * @throws IllegalArgumentException * @throws SecurityException * @throws IOException * @throws FileNotFoundException */ I18nReport produceReport(Map<String, String> baseTextResource, File languageBundle) throws SecurityException, IllegalArgumentException, NoSuchFieldException, IllegalAccessException, FileNotFoundException, IOException { I18nReport report = new I18nReport(this, baseTextResource, languageBundle); return report; } /** * Searches for XML layout files, and when they are found they are parsed for i18n keys, * and text that is not internationalised. * @param layoutFile * @throws IOException * @throws JDOMException */ private void extractI18nKeys(File layoutFile) throws JDOMException, IOException { if(layoutFile.isDirectory()) { // Pass directory contents back into this method for(File child : layoutFile.listFiles(LAYOUT_FILE_FILTER)) { extractI18nKeys(child); } } else if(layoutFile.isFile()) { // Parse file for text attributes Document xmlLayoutDocument = new SAXBuilder().build(layoutFile); extractI18nKeys(xmlLayoutDocument.getRootElement(), layoutFile); } else throw new IllegalStateException("Cannot understand file: " + layoutFile); } /** * Parses XML elements, and when they are found they are parsed for i18n keys, * and text that is not internationalised. * @param element * @param xmlFile The XML file. Provided here for reference purposes. */ private void extractI18nKeys(Element element, File xmlFile) { // parse any children this element has for(Object kid : element.getChildren()) { if(kid instanceof Element) { extractI18nKeys((Element) kid, xmlFile); } } // Check for text attribute String textValue = element.getAttributeValue(Thinlet.TEXT); if(textValue != null) { if(!textValue.startsWith(Thinlet.TEXT_I18N_PREFIX)) { // Found a string that was NOT internationalised if(!this.uni18nTextInXml.containsKey(textValue)) { this.uni18nTextInXml.put(textValue, new HashSet<File>()); } this.uni18nTextInXml.get(textValue).add(xmlFile); } else { // Found a string that WAS internationalised String i18nKey = textValue.substring(Thinlet.TEXT_I18N_PREFIX.length()); if(!this.i18nKeysInXml.containsKey(i18nKey)) { this.i18nKeysInXml.put(i18nKey, new HashSet<File>()); } this.i18nKeysInXml.get(i18nKey).add(xmlFile); } } } //> STATIC FACTORIES //> STATIC HELPER METHODS /** * @param s */ private void trace(String s) { if(false) System.out.println(s); } /** * @param clazz * @param field * @return <code>true</code> if the field should be processed, <code>false</code> if it should be ignored * @throws IllegalArgumentException * @throws IllegalAccessException */ private boolean shouldProcess(Class<?> clazz, Field field) throws IllegalArgumentException, IllegalAccessException { if(Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) { boolean prefixMatches = false; for(String possiblePrefix : clazz.getAnnotation(TextResourceKeyOwner.class).prefix()) { if(field.getName().startsWith(possiblePrefix)) { prefixMatches = true; break; } } if(prefixMatches && (field.getType().equals(String.class) || field.getType().equals(String[].class))) { return true; } } return false; } }