/****************************************************************************** * Copyright (c) 2016 Oracle and Other Contributors * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Konstantin Komissarchik - initial implementation and ongoing maintenance * IBM - implementation inspired by org.eclipse.osgi.util.NLS ******************************************************************************/ package org.eclipse.sapphire; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.math.BigInteger; import java.security.AccessController; import java.security.PrivilegedAction; import java.text.MessageFormat; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import org.eclipse.sapphire.internal.ValueSnapshot; import org.eclipse.sapphire.services.ValueLabelService; /** * Represents a string that can be localized by substituting a translated version based on * the current locale. Instances of LocalizableText are held as static fields in a class and must be * initialized by calling the init method on the class. * * <p>The text lookup searches resource files from most specific to most generic, based on the * current locale. When looking at the resource files, the key is the field name. If a corresponding * resource is not found, the text specified by the @Text annotation is used.</p> * * <p>In the following example, two LocalizableText fields are used to implement a simple validator.</p> * * <pre><code> public class Validator * { * @Text( "Value must be specified." ) * private static LocalizableText mustBeSpecifiedMessage; * * @Text( "Value must not be larger than {0}." ) * private static LocalizableText mustNotBeLargerThanMessage; * * static * { * LocalizableText.init( ExampleValidationService.class ); * } * * public String validate( Integer value, Integer max ) * { * if( value == null ) * { * return mustBeSpecifiedMessage.text(); * } * else if( max != null && value.intValue() > max.intValue() ) * { * return mustNobeLargerThanMessage.format( max ); * } * * return null; * } * }</code></pre> * * <p>If the locale is set to en_US, the search order will be as follows:</p> * * <ol> * <li>org/eclipse/example/Validator_en_US.properties</li> * <li>org/eclipse/example/Validator_en.properties</li> * <li>org/eclipse/example/Validator.properties</li> * <li>@Text annotation on the field</li> * </ol> * * @author <a href="mailto:konstantin.komissarchik@oracle.com">Konstantin Komissarchik</a> */ public final class LocalizableText { private static final String EXTENSION = ".properties"; private static String[] suffixes; private final String text; /** * Initializes all LocalizableText static fields present in the specified class. The semantics of this * method are equivalent to calling <code>init( clazz, null )</code>. * * @param clazz the class that contains static LocalizableText fields to initialize * @throws IllegalArgumentException if clazz is null */ public static void init( final Class<?> clazz ) { init( clazz, null ); } /** * Initializes all LocalizableText static fields present in the specified class. * * @param clazz the class that contains static LocalizableText fields to initialize * @param resource the qualified base name of a resource file; defaults to class name if not specified * @throws IllegalArgumentException if clazz is null */ public static void init( final Class<?> clazz, final String resource ) { if( clazz == null ) { throw new IllegalArgumentException(); } final String resourceBaseName = ( resource == null ? clazz.getName() : resource ); if( System.getSecurityManager() == null ) { load( clazz, resourceBaseName ); } else { AccessController.doPrivileged ( new PrivilegedAction<Object>() { public Object run() { load( clazz, resourceBaseName ); return null; } } ); } } private static void load( final Class<?> clazz, final String resource ) { final Map<String,Field> fields = new HashMap<String,Field>(); for( final Field field : clazz.getDeclaredFields() ) { if( ( field.getModifiers() & Modifier.STATIC ) != 0 && field.getType() == LocalizableText.class ) { field.setAccessible( true ); fields.put( field.getName(), field ); } } ClassLoader loader = clazz.getClassLoader(); if( loader == null ) { loader = ClassLoader.getSystemClassLoader(); } if( suffixes == null ) { // Build an array of the relevant property file suffixes in the order from most specific to most generic. // For instance, in FR_fr locale, the array will contain "_FR_fr.properties", then "_fr.properties" and // finally ".properties". String nl = Locale.getDefault().toString(); final List<String> result = new ArrayList<String>( 4 ); while( true ) { result.add( '_' + nl + EXTENSION ); final int lastSeparator = nl.lastIndexOf( '_' ); if( lastSeparator == -1 ) { break; } nl = nl.substring(0, lastSeparator); } result.add( EXTENSION ); suffixes = result.toArray( new String[ result.size() ] ); } final String root = resource.replace( '.', '/' ); for( final String suffix : suffixes ) { final String variant = root + suffix; final InputStream input = loader.getResourceAsStream( variant ); if( input != null ) { Properties properties = null; try( InputStream in = input ) { properties = new Properties(); properties.load( new InputStreamReader( input, "UTF-8" ) ); } catch( final IOException e ) { System.err.println( "Error loading " + variant ); e.printStackTrace(); } if( properties != null ) { for( final Map.Entry<Object,Object> entry : properties.entrySet() ) { final String key = (String) entry.getKey(); final Field field = fields.get( key ); if( field == null ) { final String msg = "Unused message: " + key + " in: " + resource; System.err.println( msg ); } else { try { if( field.get( null ) == null ) { // Extra care is taken to be sure we create a String with its own backing char[] (bug 287183) // This is to ensure we do not keep the key chars in memory. field.set( null, new LocalizableText( new String( ( (String) entry.getValue() ).toCharArray() ) ) ); } } catch( Exception e ) { System.err.println( "Exception setting field " + field.getName() ); e.printStackTrace(); } } } } } } for( final Field field : fields.values() ) { try { if( field.get( null ) == null ) { final Text annotation = field.getAnnotation( Text.class ); final String value; if( annotation != null ) { value = annotation.value(); } else { value = "Missing message: " + field.getName() + " in: " + resource; } field.set( null, new LocalizableText( value ) ); } } catch( Exception e ) { System.err.println( "Exception setting field " + field.getName() ); e.printStackTrace(); } } } /** * The constructor must not be called directly. Instances are created by calling {@link #init(Class)}. */ private LocalizableText( final String text ) { this.text = text; } /** * Returns the raw text as it was found during initialization. * * @return the raw text */ public String text() { return this.text; } /** * Formats a message using this text as a template. The semantics of this method are equivalent * to {@link MessageFormat#format( String template, Object... bindings )}. * * @param bindings the bindings to substitute into the template * @return the formatted message */ public String format( final Object... bindings ) { if( bindings == null || bindings.length == 0 ) { return this.text; } boolean foundValueInBindings = false; for( final Object binding : bindings ) { if( binding instanceof Value || binding instanceof ValueSnapshot ) { foundValueInBindings = true; break; } } final Object[] objects; if( foundValueInBindings ) { objects = new Object[ bindings.length ]; for( int i = 0; i < bindings.length; i++ ) { final Object binding = bindings[ i ]; if( binding instanceof Value ) { final Value<?> value = (Value<?>) binding; objects[ i ] = format( value.definition(), value.text() ); } else if( binding instanceof ValueSnapshot ) { final ValueSnapshot snapshot = (ValueSnapshot) binding; objects[ i ] = format( snapshot.property(), snapshot.text() ); } else { objects[ i ] = binding; } } } else { objects = bindings; } return MessageFormat.format( this.text, objects ); } private static String format( final ValueProperty property, final String text ) { String formatted = property.service( ValueLabelService.class ).provide( text ); if( ! ( property.isOfType( Byte.class ) || property.isOfType( Short.class ) || property.isOfType( Integer.class ) || property.isOfType( Long.class ) || property.isOfType( Float.class ) || property.isOfType( Double.class ) || property.isOfType( BigInteger.class ) || property.isOfType( BigDecimal.class ) || property.isOfType( Boolean.class ) ) ) { formatted = "\"" + formatted + "\""; } return formatted; } /** * Returns the raw text as it was found during initialization. The semantics of this method * are equivalent to {@link #text()}. * * @return the raw text */ @Override public String toString() { return this.text; } }