struts-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From lukaszlen...@apache.org
Subject [struts] branch master updated: TextProvider feature addition and cleanup: - Introduce (optional) control flag STRUTS_I18N_SEARCH_DEFAULTBUNDLES_FIRST to request TextProviders read from default resource bundles first, instead of their standard lookup ordering. Defaults to false. - Minor refactor of AbstractLocalizedTextProvider hierarchy to introduce getDefaultMessageWithAlternateKey() method that repackages existing logic used by the SrutsLocalizedTextProvider and GlobalLocalizedTextProvider. - Update GlobalLocalizedTe [...]
Date Wed, 06 Jan 2021 15:22:16 GMT
This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/master by this push:
     new f62384a  TextProvider feature addition and cleanup: - Introduce (optional) control flag STRUTS_I18N_SEARCH_DEFAULTBUNDLES_FIRST to request TextProviders read from default resource bundles first, instead of their standard lookup ordering.  Defaults to false. - Minor refactor of AbstractLocalizedTextProvider hierarchy to introduce getDefaultMessageWithAlternateKey() method that repackages existing logic used by the SrutsLocalizedTextProvider and GlobalLocalizedTextProvider. - Updat [...]
     new e870763  Merge pull request #467 from JCgH4164838Gh792C124B5/localS2_26_TextProvider_improvement_1
f62384a is described below

commit f62384a471bdf93ce53d7bf9f6e86b4287f4e23a
Author: JCgH4164838Gh792C124B5 <43964333+JCgH4164838Gh792C124B5@users.noreply.github.com>
AuthorDate: Sun Jan 3 15:33:03 2021 -0500

    TextProvider feature addition and cleanup:
    - Introduce (optional) control flag STRUTS_I18N_SEARCH_DEFAULTBUNDLES_FIRST
    to request TextProviders read from default resource bundles first, instead
    of their standard lookup ordering.  Defaults to false.
    - Minor refactor of AbstractLocalizedTextProvider hierarchy to introduce
    getDefaultMessageWithAlternateKey() method that repackages existing logic
    used by the SrutsLocalizedTextProvider and GlobalLocalizedTextProvider.
    - Update GlobalLocalizedTextProvider method comments to properly reflect
    the actual logic of some of its methods.
    - New unit tests to confirm standard and default bundle first lookup
    ordering.
    - Cleanup of some unused imports, a few typos, and code formatting items.
    - Cleanup of default.properties comments to make them more consistent.
---
 .../xwork2/util/AbstractLocalizedTextProvider.java |  63 ++++++-
 .../xwork2/util/GlobalLocalizedTextProvider.java   |  56 ++----
 .../xwork2/util/StrutsLocalizedTextProvider.java   |  43 +++--
 .../java/org/apache/struts2/StrutsConstants.java   |  11 ++
 .../org/apache/struts2/default.properties          |  24 +--
 .../util/StrutsLocalizedTextProviderTest.java      | 190 +++++++++++++++++++++
 .../com/opensymphony/xwork2/util/Bar.properties    |   2 +
 .../xwork2/util/LocalizedTextUtilTest.properties   |   1 +
 8 files changed, 313 insertions(+), 77 deletions(-)

diff --git a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
index 6ed6202..d197d05 100644
--- a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
+++ b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
@@ -61,6 +61,7 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
     protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new ConcurrentHashMap<>();
     protected boolean devMode = false;
     protected boolean reloadBundles = false;
+    protected boolean searchDefaultBundlesFirst = false;  // Search default resource bundles first.  Note: This flag may not be meaningful to all implementations.
 
     private final ConcurrentMap<MessageFormatKey, MessageFormat> messageFormats = new ConcurrentHashMap<>();
     private final ConcurrentMap<Integer, List<String>> classLoaderMap = new ConcurrentHashMap<>();
@@ -68,7 +69,7 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
     private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap = new ConcurrentHashMap<>();
 
     /**
-     * Add's the bundle to the internal list of default bundles.
+     * Adds the bundle to the internal list of default bundles.
      * If the bundle already exists in the list it will be re-added.
      *
      * @param resourceBundleName the name of the bundle to add.
@@ -431,6 +432,20 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
     }
 
     /**
+     * Set the {@link #searchDefaultBundlesFirst} flag state.  This flag may be used by descendant TextProvider
+     * implementations to determine if default bundles should be searched for messages first (before the standard
+     * flow of the {@link LocalizedTextProvider} implementation the descendant provides).
+     * 
+     * @param searchDefaultBundlesFirst provide {@link String} "true" or "false" to set the flag state accordingly.
+     * 
+     * @since 2.6
+     */
+    @Inject(value = StrutsConstants.STRUTS_I18N_SEARCH_DEFAULTBUNDLES_FIRST, required = false)
+    public void setSearchDefaultBundlesFirst(String searchDefaultBundlesFirst) {
+        this.searchDefaultBundlesFirst = Boolean.parseBoolean(searchDefaultBundlesFirst);
+    }
+
+    /**
      * Finds the given resource bundle by it's name.
      * <p>
      * Will use <code>Thread.currentThread().getContextClassLoader()</code> as the classloader.
@@ -549,6 +564,42 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
     }
 
     /**
+     * A helper method that can be used by descendant classes to perform some common two-stage message lookup operations
+     * against the default resource bundles.  The default resource bundles are searched for a value using key first, then
+     * alternateKey when the first search fails, then utilizing defaultMessage (which may be <code>null</code>) if <em>both</em>
+     * key lookup operations fail.
+     * 
+     * <p>
+     * A known use case is when a key indexes a collection (e.g. user.phone[0]) for which some specific keys may exist, but not all,
+     * along with a general key (e.g. user.phone[*]).  In such cases the specific key would be passed in the key parameter and the
+     * general key would be passed in the alternateKey parameter.
+     * </p>
+     * 
+     * @param key             the initial key to search for a value within the default resource bundles.
+     * @param alternateKey    the alternate (fall-back) key to search for a value within the default resource bundles, if the initial key lookup fails.
+     * @param locale          the {@link Locale} to be used for the default resource bundle lookup.
+     * @param valueStack      the {@link ValueStack} associated with the operation. 
+     * @param args            the argument array for parameterized messages (may be <code>null</code>).
+     * @param defaultMessage  the default message {@link String} to use if both key lookup operations fail.
+     * @return the {@link GetDefaultMessageReturnArg} result containing the processed message lookup (by key first, then alternateKey if key's lookup fails).
+     *         If both key lookup operations fail, defaultMessage is used for processing.
+     *         If defaultMessage is <code>null</code> then the return result may be <code>null</code>.
+     */
+    protected GetDefaultMessageReturnArg getDefaultMessageWithAlternateKey(String key, String alternateKey, Locale locale, ValueStack valueStack,
+            Object[] args, String defaultMessage) {
+        GetDefaultMessageReturnArg result;
+        if (alternateKey == null || alternateKey.isEmpty()) {
+            result = getDefaultMessage(key, locale, valueStack, args, defaultMessage);
+        } else {
+            result = getDefaultMessage(key, locale, valueStack, args, null);
+            if (result == null || result.message == null) {
+                result = getDefaultMessage(alternateKey, locale, valueStack, args, defaultMessage);
+            }
+        }
+        return result;
+    }
+
+    /**
      * @return the message from the named resource bundle.
      */
     protected String getMessage(String bundleName, Locale locale, String key, ValueStack valueStack, Object[] args) {
@@ -556,12 +607,14 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
         if (bundle == null) {
             return null;
         }
-        if (valueStack != null)
+        if (valueStack != null) {
             reloadBundles(valueStack.getContext());
+        }
         try {
-        	String message = bundle.getString(key);
-        	if (valueStack != null)
-        		message = TextParseUtil.translateVariables(bundle.getString(key), valueStack);
+            String message = bundle.getString(key);
+            if (valueStack != null) {
+                message = TextParseUtil.translateVariables(bundle.getString(key), valueStack);
+            }
             MessageFormat mf = buildMessageFormat(message, locale);
             return formatWithNullDetection(mf, args);
         } catch (MissingResourceException e) {
diff --git a/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java b/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
index fce1b99..d86682f 100644
--- a/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
+++ b/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
@@ -19,7 +19,6 @@
 package com.opensymphony.xwork2.util;
 
 import com.opensymphony.xwork2.ActionContext;
-import com.opensymphony.xwork2.ModelDriven;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
@@ -27,8 +26,10 @@ import java.util.Locale;
 import java.util.ResourceBundle;
 
 /**
- * Provides support for localization in the framework, it can be used to read only default bundles,
- * or it can search the class hierarchy to find proper bundles.
+ * Provides support for localization in the framework, it can be used to read only default bundles.
+ * 
+ * Note that unlike {@link StrutsLocalizedTextProvider}, this class {@link GlobalLocalizedTextProvider} will
+ * <em>only</em> search the default bundles for localized text.
  */
 public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider {
 
@@ -62,22 +63,8 @@ public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * </p>
      *
      * <ol>
-     * <li>Look for message in aClass' class hierarchy.
-     * <ol>
-     * <li>Look for the message in a resource bundle for aClass</li>
-     * <li>If not found, look for the message in a resource bundle for any implemented interface</li>
-     * <li>If not found, traverse up the Class' hierarchy and repeat from the first sub-step</li>
-     * </ol></li>
-     * <li>If not found and aClass is a {@link ModelDriven} Action, then look for message in
-     * the model's class hierarchy (repeat sub-steps listed above).</li>
-     * <li>If not found, look for message in child property.  This is determined by evaluating
-     * the message key as an OGNL expression.  For example, if the key is
-     * <i>user.address.state</i>, then it will attempt to see if "user" can be resolved into an
-     * object.  If so, repeat the entire process fromthe beginning with the object's class as
-     * aClass and "address.state" as the message key.</li>
-     * <li>If not found, look for the message in aClass' package hierarchy.</li>
-     * <li>If still not found, look for the message in the default resource bundles.</li>
-     * <li>Return defaultMessage</li>
+     * <li>Look for the message in the default resource bundles.</li>
+     * <li>If not found, return defaultMessage</li>
      * </ol>
      *
      * <p>
@@ -115,22 +102,8 @@ public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * </p>
      *
      * <ol>
-     * <li>Look for message in aClass' class hierarchy.
-     * <ol>
-     * <li>Look for the message in a resource bundle for aClass</li>
-     * <li>If not found, look for the message in a resource bundle for any implemented interface</li>
-     * <li>If not found, traverse up the Class' hierarchy and repeat from the first sub-step</li>
-     * </ol></li>
-     * <li>If not found and aClass is a {@link ModelDriven} Action, then look for message in
-     * the model's class hierarchy (repeat sub-steps listed above).</li>
-     * <li>If not found, look for message in child property.  This is determined by evaluating
-     * the message key as an OGNL expression.  For example, if the key is
-     * <i>user.address.state</i>, then it will attempt to see if "user" can be resolved into an
-     * object.  If so, repeat the entire process fromthe beginning with the object's class as
-     * aClass and "address.state" as the message key.</li>
-     * <li>If not found, look for the message in aClass' package hierarchy.</li>
-     * <li>If still not found, look for the message in the default resource bundles.</li>
-     * <li>Return defaultMessage</li>
+     * <li>Look for the message in the default resource bundles.</li>
+     * <li>If not found, return defaultMessage</li>
      * </ol>
      *
      * <p>
@@ -145,7 +118,7 @@ public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * </p>
      *
      * <p>
-     * If a message is <b>not</b> found a WARN log will be logged.
+     * If a message is <b>not</b> found a DEBUG level log warning will be logged.
      * </p>
      *
      * @param aClass         the class whose name to use as the start point for the search
@@ -180,16 +153,7 @@ public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider {
         }
 
         // get default
-        GetDefaultMessageReturnArg result;
-        if (indexedTextName == null) {
-            result = getDefaultMessage(aTextName, locale, valueStack, args, defaultMessage);
-        } else {
-            result = getDefaultMessage(aTextName, locale, valueStack, args, null);
-            if (result != null && result.message != null) {
-                return result.message;
-            }
-            result = getDefaultMessage(indexedTextName, locale, valueStack, args, defaultMessage);
-        }
+        GetDefaultMessageReturnArg result = getDefaultMessageWithAlternateKey(aTextName, indexedTextName, locale, valueStack, args, defaultMessage);
 
         // could we find the text, if not log a warn
         if (unableToFindTextForKey(result) && LOG.isDebugEnabled()) {
diff --git a/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java b/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
index 1dc74b7..48acc42 100644
--- a/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
+++ b/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
@@ -27,9 +27,7 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import java.beans.PropertyDescriptor;
-import java.text.MessageFormat;
 import java.util.Locale;
-import java.util.MissingResourceException;
 import java.util.ResourceBundle;
 
 /**
@@ -122,6 +120,7 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * </p>
      *
      * <ol>
+     * <li>If {@link #searchDefaultBundlesFirst} is <code>true</code>, look for the message in the default resource bundles first.</li>
      * <li>Look for message in aClass' class hierarchy.
      * <ol>
      * <li>Look for the message in a resource bundle for aClass</li>
@@ -133,10 +132,11 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * <li>If not found, look for message in child property.  This is determined by evaluating
      * the message key as an OGNL expression.  For example, if the key is
      * <i>user.address.state</i>, then it will attempt to see if "user" can be resolved into an
-     * object.  If so, repeat the entire process fromthe beginning with the object's class as
+     * object.  If so, repeat the entire process from the beginning with the object's class as
      * aClass and "address.state" as the message key.</li>
      * <li>If not found, look for the message in aClass' package hierarchy.</li>
-     * <li>If still not found, look for the message in the default resource bundles.</li>
+     * <li>If still not found, look for the message in the default resource bundles 
+     * (Note: the lookup is not repeated again if {@link #searchDefaultBundlesFirst} was <code>true</code>).</li>
      * <li>Return defaultMessage</li>
      * </ol>
      *
@@ -175,6 +175,7 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * </p>
      *
      * <ol>
+     * <li>If {@link #searchDefaultBundlesFirst} is <code>true</code>, look for the message in the default resource bundles first.</li>
      * <li>Look for message in aClass' class hierarchy.
      * <ol>
      * <li>Look for the message in a resource bundle for aClass</li>
@@ -186,10 +187,11 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * <li>If not found, look for message in child property.  This is determined by evaluating
      * the message key as an OGNL expression.  For example, if the key is
      * <i>user.address.state</i>, then it will attempt to see if "user" can be resolved into an
-     * object.  If so, repeat the entire process fromthe beginning with the object's class as
+     * object.  If so, repeat the entire process from the beginning with the object's class as
      * aClass and "address.state" as the message key.</li>
      * <li>If not found, look for the message in aClass' package hierarchy.</li>
-     * <li>If still not found, look for the message in the default resource bundles.</li>
+     * <li>If still not found, look for the message in the default resource bundles 
+     * (Note: the lookup is not repeated again if {@link #searchDefaultBundlesFirst} was <code>true</code>).</li>
      * <li>Return defaultMessage</li>
      * </ol>
      *
@@ -205,7 +207,7 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
      * </p>
      *
      * <p>
-     * If a message is <b>not</b> found a WARN log will be logged.
+     * If a message is <b>not</b> found a DEBUG level log warning will be logged.
      * </p>
      *
      * @param aClass         the class whose name to use as the start point for the search
@@ -240,6 +242,20 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
             }
         }
 
+        // Allow for and track an early lookup for the message in the default resource bundles first, before searching the class hierarchy.
+        // The early lookup is only performed when the text provider has been configured to do so, otherwise follow the standard processing order.
+        boolean performedInitialDefaultBundlesMessageLookup = false;
+        GetDefaultMessageReturnArg result = null;
+
+        // If search default bundles first is set true, call alternative logic first.
+        if (searchDefaultBundlesFirst) {
+            result = getDefaultMessageWithAlternateKey(aTextName, indexedTextName, locale, valueStack, args, defaultMessage);
+            performedInitialDefaultBundlesMessageLookup = true;
+            if (!unableToFindTextForKey(result)) {
+                return result.message;  // Found a message in the default resource bundles for aTextName or indexedTextName.
+            }
+        }
+
         // search up class hierarchy
         String msg = findMessage(aClass, aTextName, indexedTextName, locale, args, null, valueStack);
 
@@ -342,15 +358,10 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
         }
 
         // get default
-        GetDefaultMessageReturnArg result;
-        if (indexedTextName == null) {
-            result = getDefaultMessage(aTextName, locale, valueStack, args, defaultMessage);
-        } else {
-            result = getDefaultMessage(aTextName, locale, valueStack, args, null);
-            if (result != null && result.message != null) {
-                return result.message;
-            }
-            result = getDefaultMessage(indexedTextName, locale, valueStack, args, defaultMessage);
+        // Note: The default bundles lookup may already have been performed (via alternate early lookup),
+        //       so we check first to avoid repeating the same operation twice.
+        if (!performedInitialDefaultBundlesMessageLookup) {
+            result = getDefaultMessageWithAlternateKey(aTextName, indexedTextName, locale, valueStack, args, defaultMessage);
         }
 
         // could we find the text, if not log a warn
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 45b44a6..9e6ff62 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -35,6 +35,17 @@ public final class StrutsConstants {
     /** The encoding to use for localization messages */
     public static final String STRUTS_I18N_ENCODING = "struts.i18n.encoding";
 
+    /** 
+     * Whether the default bundles should be searched for messages first.  Can be used to modify the
+     * standard processing order for message lookup in TextProvider implementations.
+     * <p>
+     * Note: This control flag may not be meaningful to all provider implementations, and should be false by default.
+     * </p>
+     * 
+     * @since 2.6
+     */
+    public static final String STRUTS_I18N_SEARCH_DEFAULTBUNDLES_FIRST = "struts.i18n.search.defaultbundles.first";
+
     /** Whether to reload the XML configuration or not */
     public static final String STRUTS_CONFIGURATION_XML_RELOAD = "struts.configuration.xml.reload";
 
diff --git a/core/src/main/resources/org/apache/struts2/default.properties b/core/src/main/resources/org/apache/struts2/default.properties
index 1bbe20c..c61c5d4 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -19,7 +19,7 @@
 ### START SNIPPET: complete_file
 
 ### Struts default properties
-###(can be overridden by a struts.properties file in the root of the classpath)
+### (can be overridden by a struts.properties file in the root of the classpath)
 ###
 
 ### This can be used to set your default locale and encoding scheme
@@ -57,15 +57,15 @@ struts.objectFactory.spring.enableAopSupport = false
 ###       using generics. com.opensymphony.xwork2.util.GenericsObjectTypeDeterminer was deprecated since XWork 2, it's
 ###       functions are integrated in DefaultObjectTypeDeterminer now.
 ###       To disable tiger support use the "notiger" property value here.
-#struts.objectTypeDeterminer = tiger
-#struts.objectTypeDeterminer = notiger
+# struts.objectTypeDeterminer = tiger
+# struts.objectTypeDeterminer = notiger
 
 ### Parser to handle HTTP POST requests, encoded using the MIME-type multipart/form-data
 # struts.multipart.parser=cos
 # struts.multipart.parser=pell
 # struts.multipart.parser=jakarta-stream
 struts.multipart.parser=jakarta
-# uses javax.servlet.context.tempdir by default
+### Uses javax.servlet.context.tempdir by default
 struts.multipart.saveDir=
 struts.multipart.maxSize=2097152
 
@@ -74,7 +74,7 @@ struts.multipart.maxSize=2097152
 
 ### How request URLs are mapped to and from actions
 ### Vy default 'struts' name is used which maps to DefaultActionMapper
-#struts.mapper.class=restful
+# struts.mapper.class=restful
 
 ### Used by the DefaultActionMapper
 ### You may provide a comma separated list, e.g. struts.action.extension=action,jnlp,do
@@ -136,7 +136,7 @@ struts.devMode = false
 
 ### when set to true, resource bundles will be reloaded on _every_ request.
 ### this is good during development, but should never be used in production
-### struts.i18n.reload=false
+# struts.i18n.reload=false
 
 ### Standard UI theme
 ### Change this to reflect which path should be used for JSP control tag templates by default
@@ -144,12 +144,12 @@ struts.ui.theme=xhtml
 struts.ui.templateDir=template
 ### Change this to use a different token to indicate template theme expansion
 struts.ui.theme.expansion.token=~~~
-#sets the default template type. Either ftl, vm, or jsp
+### Sets the default template type. Either ftl, vm, or jsp
 struts.ui.templateSuffix=ftl
 
 ### Configuration reloading
 ### This will cause the configuration to reload struts.xml when it is changed
-### struts.configuration.xml.reload=false
+# struts.configuration.xml.reload=false
 
 ### Location of velocity.properties file.  defaults to velocity.properties
 struts.velocity.configfile = velocity.properties
@@ -169,6 +169,10 @@ struts.url.includeParams = none
 ### Load custom default resource bundles
 # struts.custom.i18n.resources=testmessages,testmessages2
 
+### Control whether to search the default resource bundes for messages first (if true) or not (if false).
+### Default is false (when not set).
+# struts.i18n.search.defaultbundles.first=false
+
 ### workaround for some app servers that don't handle HttpServletRequest.getParameterMap()
 ### often used for WebLogic, Orion, and OC4J
 struts.dispatcher.parametersWorkaround = false
@@ -176,11 +180,11 @@ struts.dispatcher.parametersWorkaround = false
 ### configure the Freemarker Manager class to be used
 ### Allows user to plug-in customised Freemarker Manager if necessary
 ### MUST extends off org.apache.struts2.views.freemarker.FreemarkerManager
-#struts.freemarker.manager.classname=org.apache.struts2.views.freemarker.FreemarkerManager
+# struts.freemarker.manager.classname=org.apache.struts2.views.freemarker.FreemarkerManager
 
 ### Enables caching of FreeMarker templates
 ### Has the same effect as copying the templates under WEB_APP/templates
-### struts.freemarker.templatesCache=false
+# struts.freemarker.templatesCache=false
 
 ### Enables caching of models on the BeanWrapper
 struts.freemarker.beanwrapperCache=false
diff --git a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
index 2a3038f..f7669b1 100644
--- a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
+++ b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
@@ -371,6 +371,196 @@ public class StrutsLocalizedTextProviderTest extends XWorkTestCase {
             2, testStrutsLocalizedTextProvider.currentBundlesMapSize());
     }
 
+    /**
+     * Test the {@link StrutsLocalizedTextProvider#searchDefaultBundlesFirst} flag behaviour for basic correctness.
+     */
+    public void testSetSearchDefaultBundlesFirst() {
+        TestStrutsLocalizedTextProvider testStrutsLocalizedTextProvider = new TestStrutsLocalizedTextProvider();
+        assertFalse("Default setSearchDefaultBundlesFirst state is not false ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.TRUE.toString());
+        assertTrue("The setSearchDefaultBundlesFirst state is not true after explicit set ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.FALSE.toString());
+        assertFalse("The setSearchDefaultBundlesFirst state is not false after explicit set ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst("invalidstring");
+        assertFalse("The setSearchDefaultBundlesFirst state is not false after set with invalid value ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+    }
+
+    /**
+     * Test the {@link StrutsLocalizedTextProvider#getDefaultMessageWithAlternateKey(java.lang.String, java.lang.String, java.util.Locale, com.opensymphony.xwork2.util.ValueStack, java.lang.Object[], java.lang.String)}
+     * method for basic correctness.
+     */
+    public void testGetDefaultMessageWithAlternateKey() {
+        final String DEFAULT_MESSAGE = "This is the default message.";
+        final String DEFAULT_MESSAGE_WITH_PARAMS = DEFAULT_MESSAGE + "  We provide a couple of parameter placeholders: -{0}- and -{1}- for fun.";
+        final String param1 = "param1_String";
+        final String param2 = "param2_String";
+        final String[] paramArray = { param1, param2 };
+        TestStrutsLocalizedTextProvider testStrutsLocalizedTextProvider = new TestStrutsLocalizedTextProvider();
+
+        // Load some specific default bundles already provided and used by other tests within this module.
+        testStrutsLocalizedTextProvider.addDefaultResourceBundle("com/opensymphony/xwork2/util/LocalizedTextUtilTest");
+        testStrutsLocalizedTextProvider.addDefaultResourceBundle("com/opensymphony/xwork2/util/Bar");
+        testStrutsLocalizedTextProvider.addDefaultResourceBundle("com/opensymphony/xwork2/util/FindMe");
+
+        // Perform some standard checks on message retrieval using null or nonexistent keys and various default message combinations.
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        AbstractLocalizedTextProvider.GetDefaultMessageReturnArg getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey(null, null, Locale.ENGLISH, valueStack, null, null);
+        assertNull("GetDefaultMessageReturnArg result not null with null keys and null default message ?", getDefaultMessageReturnArg);
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("key_does_not_exist", "alternateKey_does_not_exist", Locale.ENGLISH, valueStack, null, null);
+        assertNull("GetDefaultMessageReturnArg result not null with nonexistent keys and null default message ?", getDefaultMessageReturnArg);
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("key_does_not_exist", "alternateKey_does_not_exist", Locale.ENGLISH, valueStack, null, DEFAULT_MESSAGE);
+        assertNotNull("GetDefaultMessageReturnArg result is null with nonexistent keys and non-null default message ?", getDefaultMessageReturnArg);
+        assertFalse("GetDefaultMessageReturnArg result with nonexistent keys indicates message found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertEquals("GetDefaultMessageReturnArg result with nonexistent keys indicates message found in bundle ?", DEFAULT_MESSAGE, getDefaultMessageReturnArg.message);
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("key_does_not_exist", "alternateKey_does_not_exist", Locale.ENGLISH, valueStack, paramArray, DEFAULT_MESSAGE_WITH_PARAMS);
+        assertNotNull("GetDefaultMessageReturnArg result is null with nonexistent keys and non-null default message ?", getDefaultMessageReturnArg);
+        assertFalse("GetDefaultMessageReturnArg result with nonexistent keys indicates message found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertNotNull("GetDefaultMessageReturnArg result message is null ?", getDefaultMessageReturnArg.message);
+        assertTrue("GetDefaultMessageReturnArg result message does not contain deafult message ?", getDefaultMessageReturnArg.message.contains(DEFAULT_MESSAGE));
+        assertTrue("GetDefaultMessageReturnArg result message does not contain param1 ?", getDefaultMessageReturnArg.message.contains(param1));
+        assertTrue("GetDefaultMessageReturnArg result message does not contain param2 ?", getDefaultMessageReturnArg.message.contains(param2));
+
+        // Perform some checks where the initial key is null or does not exist in the default bundles, but the alternate key does.
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey(null, "username", Locale.ENGLISH, valueStack, paramArray, null);
+        assertNotNull("GetDefaultMessageReturnArg result is null with alternate key that exists ?", getDefaultMessageReturnArg);
+        assertTrue("GetDefaultMessageReturnArg result with alternate key that exists indicates message not found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertTrue("GetDefaultMessageReturnArg result with alternate key that exists indicates message is null or empty ?", (getDefaultMessageReturnArg.message != null && !getDefaultMessageReturnArg.message.isEmpty()));
+        assertEquals("GetDefaultMessageReturnArg result with alternate key 'username' not as expected ?", "Santa", getDefaultMessageReturnArg.message);
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("key_does_not_exist", "invalid.fieldvalue.title", Locale.ENGLISH, valueStack, paramArray, null);
+        assertNotNull("GetDefaultMessageReturnArg result is null with alternate key that exists ?", getDefaultMessageReturnArg);
+        assertTrue("GetDefaultMessageReturnArg result with alternate key that exists indicates message not found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertTrue("GetDefaultMessageReturnArg result with alternate key that exists indicates message is null or empty ?", (getDefaultMessageReturnArg.message != null && !getDefaultMessageReturnArg.message.isEmpty()));
+        assertEquals("GetDefaultMessageReturnArg result with alternate key 'invalid.fieldvalue.title' not as expected ?", "Title is invalid!", getDefaultMessageReturnArg.message);
+
+        // Perform some checks where the initial key exists, but the alternate key is null or nonexistent.
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("username", null, Locale.ENGLISH, valueStack, paramArray, null);
+        assertNotNull("GetDefaultMessageReturnArg result is null with key that exists ?", getDefaultMessageReturnArg);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message not found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message is null or empty ?", (getDefaultMessageReturnArg.message != null && !getDefaultMessageReturnArg.message.isEmpty()));
+        assertEquals("GetDefaultMessageReturnArg result with key 'username' not as expected ?", "Santa", getDefaultMessageReturnArg.message);
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("invalid.fieldvalue.title", "key_does_not_exist", Locale.ENGLISH, valueStack, paramArray, null);
+        assertNotNull("GetDefaultMessageReturnArg result is null with key that exists ?", getDefaultMessageReturnArg);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message not found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message is null or empty ?", (getDefaultMessageReturnArg.message != null && !getDefaultMessageReturnArg.message.isEmpty()));
+        assertEquals("GetDefaultMessageReturnArg result with key 'invalid.fieldvalue.title' not as expected ?", "Title is invalid!", getDefaultMessageReturnArg.message);
+
+        // Perform some checks where the initial key exists, and the alternate key exists.  The result found for the initial key should be returned (not the alternate).
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("username", "invalid.fieldvalue.title", Locale.ENGLISH, valueStack, paramArray, null);
+        assertNotNull("GetDefaultMessageReturnArg result is null with key that exists ?", getDefaultMessageReturnArg);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message not found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message is null or empty ?", (getDefaultMessageReturnArg.message != null && !getDefaultMessageReturnArg.message.isEmpty()));
+        assertEquals("GetDefaultMessageReturnArg result with key 'username' not as expected ?", "Santa", getDefaultMessageReturnArg.message);
+        getDefaultMessageReturnArg = testStrutsLocalizedTextProvider.getDefaultMessageWithAlternateKey("invalid.fieldvalue.title", "username", Locale.ENGLISH, valueStack, paramArray, null);
+        assertNotNull("GetDefaultMessageReturnArg result is null with key that exists ?", getDefaultMessageReturnArg);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message not found in bundle ?", getDefaultMessageReturnArg.foundInBundle);
+        assertTrue("GetDefaultMessageReturnArg result with key that exists indicates message is null or empty ?", (getDefaultMessageReturnArg.message != null && !getDefaultMessageReturnArg.message.isEmpty()));
+        assertEquals("GetDefaultMessageReturnArg result with key 'invalid.fieldvalue.title' not as expected ?", "Title is invalid!", getDefaultMessageReturnArg.message);
+    }
+
+    /**
+     * Test the {@link StrutsLocalizedTextProvider#findText(java.lang.Class, java.lang.String, java.util.Locale, java.lang.String, java.lang.Object[], com.opensymphony.xwork2.util.ValueStack) }
+     * method for basic correctness.
+     * 
+     * It is the version of the method that will search the class hierarchy resource bundles first, unless {@link StrutsLocalizedTextProvider#searchDefaultBundlesFirst}
+     * is true (in which case it will search the default resource bundles first).  No matter the flag setting, it should search until it finds a match, or fails to find
+     * a match and returns the default message parameter that was passed.
+     */
+    public void testFindText_FullParameterSet_FirstParameterIsClass() {
+        final String DEFAULT_MESSAGE = "This is the default message.";
+        final String INDEXED_COLLECTION_ONLYGENERALFORM_EXISTS = "title.indexed[20]";  // Only title.indexed[*] exists.
+        final String EXISTS_IN_DEFAULT_AND_CLASS_BUNDLES = "compare.sameproperty.differentbundles";  // Exists in LocalizedTextUtilTest properties (default bundles), and Bar properties (class bundles only).
+        final String DEFAULT_MESSAGE_WITH_PARAMS = DEFAULT_MESSAGE + "  We provide a couple of parameter placeholders: -{0}- and -{1}- for fun.";
+        final String param1 = "param1_String";
+        final String param2 = "param2_String";
+        final String[] paramArray = { param1, param2 };
+        TestStrutsLocalizedTextProvider testStrutsLocalizedTextProvider = new TestStrutsLocalizedTextProvider();
+
+        // Load some specific default bundles already provided and used by other tests within this module.
+        // Note: Intentionally not including the Bar properties file as a default bundle so that we can test retrievals of items that are only available via the class
+        //       or the default bundles.
+        testStrutsLocalizedTextProvider.addDefaultResourceBundle("com/opensymphony/xwork2/util/LocalizedTextUtilTest");
+        testStrutsLocalizedTextProvider.addDefaultResourceBundle("com/opensymphony/xwork2/util/FindMe");
+
+        // Perform some standard checks on message retrieval both for correctness checks and code coverage (such as the NONEXISTENT_INDEXED_COLLECTION,
+        // which exercises the indexed name logic in findText())
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        Bar bar = new Bar();
+        assertFalse("Initial setSearchDefaultBundlesFirst state is not false ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        String messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "title", Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Bar class title property lookup result does not match expectations (missing or different) ?", "Title:", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), INDEXED_COLLECTION_ONLYGENERALFORM_EXISTS, Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Bar class general indexed collection lookup result does not match expectations (missing or different) ?", "Indexed title text for test!", messageResult);
+
+        // Test lookup with search default bundles first set true.  For properties that exist only with the class bundle, there should be no change.
+        // Repeat the tests with properties only in the class bundle.
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.TRUE.toString());
+        assertTrue("Updated setSearchDefaultBundlesFirst state is not true ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "title", Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Bar class title property lookup result does not match expectations (missing or different) ?", "Title:", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), INDEXED_COLLECTION_ONLYGENERALFORM_EXISTS, Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Bar class general indexed collection lookup result does not match expectations (missing or different) ?", "Indexed title text for test!", messageResult);
+
+        // Test with a property that is in both the class bundle and default bundles, with search default bundles first true.
+        // The property match from the default bundles should be returned.
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.TRUE.toString());
+        assertTrue("Updated setSearchDefaultBundlesFirst state is not true ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), EXISTS_IN_DEFAULT_AND_CLASS_BUNDLES, Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Result is not the property from the default bundles ?", "This is the value in the LocalizedTextUtilTest properties!", messageResult);
+
+        // Test with a property that is in both the class bundle and default bundles, with search default bundles first false.
+        // The property match from the Bar class bundle should be returned.
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.FALSE.toString());
+        assertFalse("Updated setSearchDefaultBundlesFirst state is not false ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), EXISTS_IN_DEFAULT_AND_CLASS_BUNDLES, Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Result is not the property from the Bar bundle ?", "This is the value in the Bar properties!", messageResult);
+
+        // Test with some different properties (including null and nonexistent ones), with search default bundles first false.
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.FALSE.toString());
+        assertFalse("Updated setSearchDefaultBundlesFirst state is not false ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), null, Locale.ENGLISH, null, paramArray, valueStack);
+        assertNull("Result with null key and null default message is not null ?", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "key_does_not_exist", Locale.ENGLISH, null, paramArray, valueStack);
+        assertNull("Result with nonexistent key and null default message is not null ?", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), null, Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Result with null key and non-null default message is not the default message ?", DEFAULT_MESSAGE, messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "key_does_not_exist", Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Result with nonexistent key and non-null default message is not the default message ?", DEFAULT_MESSAGE, messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "key_does_not_exist", Locale.ENGLISH, DEFAULT_MESSAGE_WITH_PARAMS, paramArray, valueStack);
+        assertNotNull("Result with nonexistent key and non-null default message is null ?", messageResult);
+        assertTrue("Result with parameterized default message does not contain deafult message ?", messageResult.contains(DEFAULT_MESSAGE));
+        assertTrue("Result with parameterized default message does not contain param1 ?", messageResult.contains(param1));
+        assertTrue("Result with parameterized default message does not contain param2 ?", messageResult.contains(param2));
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "username", Locale.ENGLISH, null, paramArray, valueStack);
+        assertEquals("Result of username lookup not as expected ?", "Santa", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "bean.name", Locale.ENGLISH, null, paramArray, valueStack);
+        assertEquals("Result of bean.name lookup not as expected ?", "Haha you cant FindMe!", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "bean2.name", Locale.ENGLISH, null, paramArray, valueStack);
+        assertEquals("Result of bean2.name lookup not as expected ?", "Okay! You found Me!", messageResult);
+
+        // Test with some different properties (including null and nonexistent ones), with search default bundles first true.
+        testStrutsLocalizedTextProvider.setSearchDefaultBundlesFirst(Boolean.TRUE.toString());
+        assertTrue("Updated setSearchDefaultBundlesFirst state is not true ?", testStrutsLocalizedTextProvider.searchDefaultBundlesFirst);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), null, Locale.ENGLISH, null, paramArray, valueStack);
+        assertNull("Result with null key and null default message is not null ?", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "key_does_not_exist", Locale.ENGLISH, null, paramArray, valueStack);
+        assertNull("Result with nonexistent key and null default message is not null ?", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), null, Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Result with null key and non-null default message is not the default message ?", DEFAULT_MESSAGE, messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "key_does_not_exist", Locale.ENGLISH, DEFAULT_MESSAGE, paramArray, valueStack);
+        assertEquals("Result with nonexistent key and non-null default message is not the default message ?", DEFAULT_MESSAGE, messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "key_does_not_exist", Locale.ENGLISH, DEFAULT_MESSAGE_WITH_PARAMS, paramArray, valueStack);
+        assertNotNull("Result with nonexistent key and non-null default message is null ?", messageResult);
+        assertTrue("Result with parameterized default message does not contain deafult message ?", messageResult.contains(DEFAULT_MESSAGE));
+        assertTrue("Result with parameterized default message does not contain param1 ?", messageResult.contains(param1));
+        assertTrue("Result with parameterized default message does not contain param2 ?", messageResult.contains(param2));
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "username", Locale.ENGLISH, null, paramArray, valueStack);
+        assertEquals("Result of username lookup not as expected ?", "Santa", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "bean.name", Locale.ENGLISH, null, paramArray, valueStack);
+        assertEquals("Result of bean.name lookup not as expected ?", "Haha you cant FindMe!", messageResult);
+        messageResult = testStrutsLocalizedTextProvider.findText(bar.getClass(), "bean2.name", Locale.ENGLISH, null, paramArray, valueStack);
+        assertEquals("Result of bean2.name lookup not as expected ?", "Okay! You found Me!", messageResult);
+    }
+
     @Override
     protected void setUp() throws Exception {
         super.setUp();
diff --git a/core/src/test/resources/com/opensymphony/xwork2/util/Bar.properties b/core/src/test/resources/com/opensymphony/xwork2/util/Bar.properties
index 75eeb3b..99a1f1a 100644
--- a/core/src/test/resources/com/opensymphony/xwork2/util/Bar.properties
+++ b/core/src/test/resources/com/opensymphony/xwork2/util/Bar.properties
@@ -18,3 +18,5 @@
 #
 title=Title:
 invalid.fieldvalue.title=Title is invalid!
+title.indexed[*]=Indexed title text for test!
+compare.sameproperty.differentbundles=This is the value in the Bar properties!
diff --git a/core/src/test/resources/com/opensymphony/xwork2/util/LocalizedTextUtilTest.properties b/core/src/test/resources/com/opensymphony/xwork2/util/LocalizedTextUtilTest.properties
index 1475796..16cb9e6 100644
--- a/core/src/test/resources/com/opensymphony/xwork2/util/LocalizedTextUtilTest.properties
+++ b/core/src/test/resources/com/opensymphony/xwork2/util/LocalizedTextUtilTest.properties
@@ -19,3 +19,4 @@
 test.format.date={0,date,short}
 xw377=xw377
 username=Santa
+compare.sameproperty.differentbundles=This is the value in the LocalizedTextUtilTest properties!


Mime
View raw message