Imported web4j 4.10.0 web4j-4.10.0
authorTomas Zeman <tzeman@volny.cz>
Wed, 04 Dec 2013 17:00:31 +0100
changeset 0 3060119b1292
child 1 990eebf2b175
Imported web4j 4.10.0
LICENSE.txt
README.txt
build.public.xml
classes/hirondelle/web4j/ApplicationInfo.java
classes/hirondelle/web4j/BuildImpl.java
classes/hirondelle/web4j/CheckModelObjects.java
classes/hirondelle/web4j/Controller.java
classes/hirondelle/web4j/LoginTasksHelper.java
classes/hirondelle/web4j/StartupTasks.java
classes/hirondelle/web4j/TESTAll.java
classes/hirondelle/web4j/action/Action.java
classes/hirondelle/web4j/action/ActionImpl.java
classes/hirondelle/web4j/action/ActionTemplateListAndEdit.java
classes/hirondelle/web4j/action/ActionTemplateSearch.java
classes/hirondelle/web4j/action/ActionTemplateShowAndApply.java
classes/hirondelle/web4j/action/Operation.java
classes/hirondelle/web4j/action/ResponsePage.java
classes/hirondelle/web4j/action/package.html
classes/hirondelle/web4j/database/ConnectionSource.java
classes/hirondelle/web4j/database/ConvertColumn.java
classes/hirondelle/web4j/database/ConvertColumnImpl.java
classes/hirondelle/web4j/database/DAOException.java
classes/hirondelle/web4j/database/Db.java
classes/hirondelle/web4j/database/DbConfig.java
classes/hirondelle/web4j/database/DbTx.java
classes/hirondelle/web4j/database/DbUtil.java
classes/hirondelle/web4j/database/DuplicateException.java
classes/hirondelle/web4j/database/DynamicSql.java
classes/hirondelle/web4j/database/ForeignKeyException.java
classes/hirondelle/web4j/database/ModelBuilder.java
classes/hirondelle/web4j/database/ModelFromRow.java
classes/hirondelle/web4j/database/Report.java
classes/hirondelle/web4j/database/ReportBuilder.java
classes/hirondelle/web4j/database/ReportBuilderUnformatted.java
classes/hirondelle/web4j/database/SqlEditor.java
classes/hirondelle/web4j/database/SqlFetcher.java
classes/hirondelle/web4j/database/SqlId.java
classes/hirondelle/web4j/database/SqlStatement.java
classes/hirondelle/web4j/database/StoredProcedureTemplate.java
classes/hirondelle/web4j/database/Tx.java
classes/hirondelle/web4j/database/TxIsolationLevel.java
classes/hirondelle/web4j/database/TxSimple.java
classes/hirondelle/web4j/database/TxTemplate.java
classes/hirondelle/web4j/database/ValueFromRow.java
classes/hirondelle/web4j/database/mysql.sql
classes/hirondelle/web4j/database/package.html
classes/hirondelle/web4j/model/AppException.java
classes/hirondelle/web4j/model/AppResponseMessage.java
classes/hirondelle/web4j/model/BadRequestException.java
classes/hirondelle/web4j/model/Check.java
classes/hirondelle/web4j/model/Code.java
classes/hirondelle/web4j/model/ConvertParam.java
classes/hirondelle/web4j/model/ConvertParamError.java
classes/hirondelle/web4j/model/ConvertParamImpl.java
classes/hirondelle/web4j/model/DateTime.java
classes/hirondelle/web4j/model/DateTimeFormatter.java
classes/hirondelle/web4j/model/DateTimeInterval.java
classes/hirondelle/web4j/model/DateTimeParser.java
classes/hirondelle/web4j/model/Decimal.java
classes/hirondelle/web4j/model/Id.java
classes/hirondelle/web4j/model/MessageList.java
classes/hirondelle/web4j/model/MessageListImpl.java
classes/hirondelle/web4j/model/ModelCtorException.java
classes/hirondelle/web4j/model/ModelCtorUtil.java
classes/hirondelle/web4j/model/ModelFromRequest.java
classes/hirondelle/web4j/model/ModelUtil.java
classes/hirondelle/web4j/model/TESTCheck.java
classes/hirondelle/web4j/model/TESTComparePossiblyNull.java
classes/hirondelle/web4j/model/TESTDateTime.java
classes/hirondelle/web4j/model/TESTDateTimeFormatter.java
classes/hirondelle/web4j/model/TESTDateTimeInterval.java
classes/hirondelle/web4j/model/TESTDecimal.java
classes/hirondelle/web4j/model/TESTEqualsUtil.java
classes/hirondelle/web4j/model/TESTHashCodeUtil.java
classes/hirondelle/web4j/model/TESTMessageSerialization.java
classes/hirondelle/web4j/model/TESTPerformanceSnapshot.java
classes/hirondelle/web4j/model/ToStringUtil.java
classes/hirondelle/web4j/model/Validator.java
classes/hirondelle/web4j/model/package.html
classes/hirondelle/web4j/package.html
classes/hirondelle/web4j/readconfig/Config.java
classes/hirondelle/web4j/readconfig/ConfigReader.java
classes/hirondelle/web4j/readconfig/DbConfigParser.java
classes/hirondelle/web4j/readconfig/ParseDecimalFormat.java
classes/hirondelle/web4j/readconfig/ParseUntrustedProxy.java
classes/hirondelle/web4j/readconfig/TESTDbConfigParser.java
classes/hirondelle/web4j/readconfig/TextBlockReader.java
classes/hirondelle/web4j/readconfig/package.html
classes/hirondelle/web4j/request/DateConverter.java
classes/hirondelle/web4j/request/Formats.java
classes/hirondelle/web4j/request/LocaleSource.java
classes/hirondelle/web4j/request/LocaleSourceImpl.java
classes/hirondelle/web4j/request/RequestParameter.java
classes/hirondelle/web4j/request/RequestParser.java
classes/hirondelle/web4j/request/RequestParserImpl.java
classes/hirondelle/web4j/request/TimeZoneSource.java
classes/hirondelle/web4j/request/TimeZoneSourceImpl.java
classes/hirondelle/web4j/request/package.html
classes/hirondelle/web4j/security/ApplicationFirewall.java
classes/hirondelle/web4j/security/ApplicationFirewallImpl.java
classes/hirondelle/web4j/security/CsrfDAO.java
classes/hirondelle/web4j/security/CsrfFilter.java
classes/hirondelle/web4j/security/CsrfModifiedResponse.java
classes/hirondelle/web4j/security/FetchIdentifierOwner.java
classes/hirondelle/web4j/security/LoginTasks.java
classes/hirondelle/web4j/security/PermittedCharacters.java
classes/hirondelle/web4j/security/PermittedCharactersImpl.java
classes/hirondelle/web4j/security/SafeText.java
classes/hirondelle/web4j/security/SpamDetector.java
classes/hirondelle/web4j/security/SpamDetectorImpl.java
classes/hirondelle/web4j/security/SuppressUnwantedSessions.java
classes/hirondelle/web4j/security/TESTPermittedCharactersImpl.java
classes/hirondelle/web4j/security/UntrustedProxyForUserId.java
classes/hirondelle/web4j/security/UntrustedProxyForUserIdImpl.java
classes/hirondelle/web4j/security/package.html
classes/hirondelle/web4j/ui/tag/AlternatingRow.java
classes/hirondelle/web4j/ui/tag/HighlightCurrentPage.java
classes/hirondelle/web4j/ui/tag/Pager.java
classes/hirondelle/web4j/ui/tag/Populate.java
classes/hirondelle/web4j/ui/tag/PopulateHelper.java
classes/hirondelle/web4j/ui/tag/ShowDate.java
classes/hirondelle/web4j/ui/tag/ShowDateTime.java
classes/hirondelle/web4j/ui/tag/ShowForRole.java
classes/hirondelle/web4j/ui/tag/TESTFormPopulator.java
classes/hirondelle/web4j/ui/tag/TESTHighlightCurrentPage.java
classes/hirondelle/web4j/ui/tag/TagHelper.java
classes/hirondelle/web4j/ui/tag/package.html
classes/hirondelle/web4j/ui/translate/Messages.java
classes/hirondelle/web4j/ui/translate/TESTText.java
classes/hirondelle/web4j/ui/translate/TESTTranslateTextFlow.java
classes/hirondelle/web4j/ui/translate/TESTTranslateTooltip.java
classes/hirondelle/web4j/ui/translate/Text.java
classes/hirondelle/web4j/ui/translate/TextFlow.java
classes/hirondelle/web4j/ui/translate/Tooltips.java
classes/hirondelle/web4j/ui/translate/Translation.java
classes/hirondelle/web4j/ui/translate/Translator.java
classes/hirondelle/web4j/ui/translate/package.html
classes/hirondelle/web4j/util/Args.java
classes/hirondelle/web4j/util/Consts.java
classes/hirondelle/web4j/util/EscapeChars.java
classes/hirondelle/web4j/util/Regex.java
classes/hirondelle/web4j/util/Stopwatch.java
classes/hirondelle/web4j/util/TESTEscapeChars.java
classes/hirondelle/web4j/util/TESTRegex.java
classes/hirondelle/web4j/util/TESTUtil.java
classes/hirondelle/web4j/util/TESTWebUtil.java
classes/hirondelle/web4j/util/TimeSource.java
classes/hirondelle/web4j/util/TimeSourceImpl.java
classes/hirondelle/web4j/util/Util.java
classes/hirondelle/web4j/util/WebUtil.java
classes/hirondelle/web4j/util/package.html
classes/hirondelle/web4j/webmaster/BadResponseDetector.java
classes/hirondelle/web4j/webmaster/Emailer.java
classes/hirondelle/web4j/webmaster/EmailerImpl.java
classes/hirondelle/web4j/webmaster/LoggingConfig.java
classes/hirondelle/web4j/webmaster/LoggingConfigImpl.java
classes/hirondelle/web4j/webmaster/PerformanceMonitor.java
classes/hirondelle/web4j/webmaster/PerformanceSnapshot.java
classes/hirondelle/web4j/webmaster/TroubleTicket.java
classes/hirondelle/web4j/webmaster/package.html
classes/overview.html
lib/activation.jar
lib/jsp-api.jar
lib/junit.jar
lib/mail.jar
lib/servlet-api.jar
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE.txt	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,25 @@
+ Copyright (c) 2002-2013, Hirondelle Systems
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+     * Redistributions of source code must retain the above copyright
+       notice, this list of conditions and the following disclaimer.
+     * Redistributions in binary form must reproduce the above copyright
+       notice, this list of conditions and the following disclaimer in the
+       documentation and/or other materials provided with the distribution.
+     * Neither the name of Hirondelle Systems nor the
+       names of its contributors may be used to endorse or promote products
+       derived from this software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY HIRONDELLE SYSTEMS ''AS IS'' AND ANY
+ EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL HIRONDELLE SYSTEMS BE LIABLE FOR ANY
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.txt	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,29 @@
+Thank you for your interest in WEB4J.
+
+This is a distribution of the web4j source code.
+ 
+You may use any build tool you wish with this code.
+
+An ANT script is provided as a convenience to those 
+who wish to use it as a starting point. 
+
+For more information on WEB4J, see:
+http://www.web4j.com/
+
+-- VERSION OF JDK AND SERVLET SPEC -------------
+Web4j is compiled against :
+- JDK 1.5+ 
+- Servlet 2.4+ 
+- Java Server Pages 2.0+
+
+-- LICENSE -----------------------------------------
+WEB4J is open source software, released under a BSD License.
+See LICENSE.txt for more information.
+
+-- REQUIRED JARS ---------------------------------
+
+The jars that web4j is being compiled against are included. 
+Be aware that the junit.jar provided in the lib directory is not modern.
+
+A MANIFEST.MF file is included; you may want to edit it's content.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/build.public.xml	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,138 @@
+<project name="web4j-jar" default='all' basedir="." >
+
+   <!-- 
+    This is a courtesy build script provided for your use.
+    Please edit to suit your needs.
+
+    You can use whatever compiler suits your needs.
+    The 'official' web4j.jar uses JDK 1.5, in order to remain compatible 
+    with older environments. But you can use more modern JDK's if you wish.
+   -->  
+  
+  <description>
+    Web4j.jar
+  </description>
+  
+  <!-- Override default property values with an external properties file, if present. -->
+  <property file='build.properties'/>
+
+  <!-- Default property values, if not overridden elsewhere: -->
+  <property name='build' location='build' />
+  <property name='app.version' value='1.0.0'/>
+  <property name='app.name' value='web4j'/>
+  <property name='distro-name' value='web4j'/>
+  <tstamp><format property='build.time' pattern='yyyy-MM-dd HH:mm:ss'/></tstamp>
+
+  <path id='compile.classpath'>
+    <fileset dir='lib'>
+       <include name='*.jar'/>
+    </fileset>
+  </path>
+
+  <!-- Simply extends the compile.classpath with your own compiled classes. -->
+  <path id='run.classpath'>
+    <path refid='compile.classpath'/>
+    <path location='classes'/>
+  </path>
+  
+  <fileset id='class.files' dir='classes'>
+     <include name='**/*.class'/>
+  </fileset>
+
+  <fileset id='files.for.jar' dir='classes'>
+    <exclude name='**/*.java'/>
+    <exclude name='**/doc-files/'/>
+  </fileset>
+  
+  <fileset id='test.classes' dir='classes'>
+    <include name='**/TEST*.java'/>
+  </fileset>
+
+  <!-- A connection to this URL is used when building javadoc. -->
+  <condition property='jdk.javadoc.visible' value='true' else='false'> 
+    <http url='http://docs.oracle.com/javase/6/docs/api/' />
+  </condition>
+  
+  <echo>
+    Application: ${app.name} ${app.version}
+    Build File : ${ant.file} 
+    Run Date   : ${build.time}
+    Run by     : ${user.name}
+    Build Dir  : ${build}
+    Base Dir   : ${basedir}
+    Java Home  : ${java.home}
+    Connected to the web   : ${jdk.javadoc.visible}
+  </echo>
+    
+  <echo message='Create build directory, and its subdirectories.'/>
+  <mkdir dir="${build}/javadoc"/>
+  <mkdir dir="${build}/dist"/>
+
+  <!-- Now define the targets, which use the properties and datatypes defined above. -->
+
+   <target name='clean' description="Delete all build artifacts." >
+     <delete dir='${build}'/>
+     <delete>
+      <fileset refid='class.files'/>
+     </delete>
+     <mkdir dir="${build}/javadoc"/>
+     <mkdir dir="${build}/dist"/>
+   </target>
+  
+   <target name='compile' description='Compile source files and place beside source.'>
+     <javac srcdir="classes">   
+       <classpath refid='compile.classpath'/>
+     </javac>
+     <!-- Here's a simple way of debugging a path, fileset, or patternset, using its refid: -->
+     <echo>Classpath: ${toString:compile.classpath}</echo>
+   </target> 
+  
+   <target name='jar' description='Create a jar file for distribution.' depends='compile'>
+    <jar destfile='${build}/dist/${distro-name}.jar' duplicate='preserve'>
+      <fileset refid='files.for.jar'/>
+      <manifest>
+        <attribute name='Specification-Version' value='${app.version}'/> 
+        <attribute name='Specification-Title' value='${app.name}' />
+        <attribute name='Implementation-Version' value='${app.version}'/> 
+        <attribute name='Implementation-Title' value='${app.name}' />
+      </manifest>
+    </jar>
+   </target>
+  
+  <target name='javadoc' description='Generate javadoc.' >
+    <javadoc  
+      use='true' author='true'  version='true'
+      overview='classes\overview.html'  
+      access='package' 
+      sourcepath='classes'
+      packagenames='*.*' 
+      destdir='${build}/javadoc'
+      windowtitle='${app.name} ${app.version}'
+      noqualifier='java.*:javax.*:com.sun.*'   
+      linksource='true'
+    >
+      <classpath refid='compile.classpath'/>
+      <link href='http://docs.oracle.com/javase/6/docs/api/'/>      
+      <header><![CDATA[<h1>${app.name} ${app.version}</h1>]]></header>
+    </javadoc>
+  </target>
+  
+  <target name='distro-binary' description='Create zip file with executable jar, docs.' depends='jar, javadoc'>
+    <zip destfile='${build}/dist/${distro-name}-binary.zip' duplicate='preserve'>
+      <zipfileset dir='${build}/dist/' includes='${distro-name}.jar'/>
+      <zipfileset dir='${build}/javadoc' prefix='javadoc' />
+    </zip>
+  </target>
+  
+  <target name='distro-source' description='Create zip file with project source code.'>
+    <zip destfile='${build}/dist/${distro-name}-src.zip' duplicate='preserve' >
+      <!-- exclude items specific to the author's IDE setup: -->
+      <zipfileset dir='.' excludes='.classpath, .project'/>
+    </zip>
+  </target>
+  
+  <target name='all' description='Create all build artifacts.' depends='clean, compile, jar, javadoc, distro-binary, distro-source'>
+    <echo>Finished creating all build artifacts.</echo>
+  </target>
+  
+</project>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ApplicationInfo.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,67 @@
+package hirondelle.web4j;
+
+import hirondelle.web4j.model.DateTime;
+
+/**
+ Static, descriptive information for a web application.    
+ 
+ <P>Implemenations of this interface are usually quite simple. 
+ Here is the 
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/AppInfo.html">implementation</a> 
+ used in the WEB4J example application. 
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forApplicationInfo()} 
+ returns the configured implementation of this interface.
+
+<P>Upon startup, WEB4J creates an object which implements this interface, 
+ and places it into application scope under a {@link ApplicationInfo#KEY}, 
+ where it is accessible to response pages. 
+ (For example, it may be referenced in a footer.)
+ For access in code, the caller can simply refer directly to 
+ {@link hirondelle.web4j.BuildImpl#forApplicationInfo()}. 
+ In addition, this data is logged during startup, and contributes to  
+ the content of {@link hirondelle.web4j.webmaster.TroubleTicket} emails.
+ 
+ <P>No method in this interface returns a <tt>null</tt> object reference.
+*/
+public interface ApplicationInfo {
+
+  /**
+   The name of this web application.
+  */
+  String getName();
+  
+  /**
+   The version of this web application.
+  
+   <P>The content is arbitrary, and make take any desired form. Examples : 
+   "<tt>1.2.3</tt>", "<tt>Build 1426</tt>".
+  */
+  String getVersion();
+  
+  /**
+   The author of this web application.
+  */
+  String getAuthor();
+  
+  /**
+   The date which this binary version of the web application was built.
+  */
+  DateTime getBuildDate();
+  
+  /**
+   URL for more information regarding this application.
+  */
+  String getLink();
+  
+  /**
+   A general message of arbitrary content.
+  */
+  String getMessage();
+
+  /**
+   Key for the <tt>ApplicationInfo</tt> object placed in application scope.
+  */
+  public static final String KEY = "web4j_key_for_app_info";
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/BuildImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,668 @@
+package hirondelle.web4j;
+
+import hirondelle.web4j.database.ConnectionSource;
+import hirondelle.web4j.database.ConvertColumn;
+import hirondelle.web4j.database.ConvertColumnImpl;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.ConvertParam;
+import hirondelle.web4j.model.ConvertParamError;
+import hirondelle.web4j.model.ConvertParamImpl;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelCtorUtil;
+import hirondelle.web4j.request.DateConverter;
+import hirondelle.web4j.request.LocaleSource;
+import hirondelle.web4j.request.LocaleSourceImpl;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.request.RequestParserImpl;
+import hirondelle.web4j.request.TimeZoneSource;
+import hirondelle.web4j.request.TimeZoneSourceImpl;
+import hirondelle.web4j.security.ApplicationFirewall;
+import hirondelle.web4j.security.ApplicationFirewallImpl;
+import hirondelle.web4j.security.LoginTasks;
+import hirondelle.web4j.security.PermittedCharacters;
+import hirondelle.web4j.security.PermittedCharactersImpl;
+import hirondelle.web4j.security.SpamDetector;
+import hirondelle.web4j.security.SpamDetectorImpl;
+import hirondelle.web4j.security.UntrustedProxyForUserId;
+import hirondelle.web4j.security.UntrustedProxyForUserIdImpl;
+import hirondelle.web4j.ui.translate.Translator;
+import hirondelle.web4j.util.TimeSource;
+import hirondelle.web4j.util.TimeSourceImpl;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.webmaster.Emailer;
+import hirondelle.web4j.webmaster.EmailerImpl;
+import hirondelle.web4j.webmaster.LoggingConfig;
+import hirondelle.web4j.webmaster.LoggingConfigImpl;
+
+import java.lang.reflect.Constructor;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import javax.servlet.ServletConfig;
+
+/**
+ Return concrete instances of configured implementation classes. This is a Service Locator class.
+ 
+ <P>WEB4J requires the application programmer to supply concrete implementations of a number of  
+ interfaces and a single abstract class. <tt>BuildImpl</tt> returns instances of those abstractions. Over half 
+ of these items have default implementations, which can be used by the application programmer 
+ without any configuration effort at all.
+ 
+ <P>When the framework needs a specific implementation, it uses the services of this class. 
+ (If the application programmer needs to refer to such an implementation, they have the option of using 
+ the methods in this class, instead of referring directly to their implementation.)
+
+ <P><h3>Configuration Styles</h3>
+ Concrete implementation classes can be configured in three ways:
+ <ul>
+ <li>do nothing at all. In this case, a default implementation defined by <tt>WEB4J</tt> will be used. 
+ Several of the WEB4J abstractions (such as {@link ConnectionSource}) do 
+ not have a default implementation, so this style of configuration is not always possible. 
+ <li>implement a concrete class <em>of a conventional package and name</em>. The conventional <em>package</em> name 
+ is always '<tt>hirondelle.web4j.config</tt>', while the conventional <em>class</em> name varies - 
+ see <a href="#Listing">below</a>. 
+ <li>implement a concrete class of a <em>non-conventional</em> package and name, 
+ and add an <tt>init-param</tt> setting to <tt>web.xml</tt> of the form:
+ <PRE>
+  &lt;init-param&gt;
+   &lt;param-name&gt;ImplementationFor.hirondelle.web4j.ApplicationInfo&lt;/param-name&gt;
+   &lt;param-value&gt;com.xyz.MyAppInfo&lt;/param-value&gt;
+   &lt;description&gt;
+     Package-qualified name of class describing simple, 
+     high level information about this application. 
+   &lt;/description&gt;
+  &lt;/init-param&gt; 
+ </PRE>
+ </ul>
+ 
+ <P>The {@link #init(Map)} method will look for implementations in the reverse of the above order. 
+ That is, 
+<ol>
+ <li>an <em>explicit</em> <tt>ImplementationFor.*</tt> setting in <tt>web.xml</tt>
+ <li>a class of a <em>conventional</em> package and name
+ <li>the <em>default</em> WEB4J implementation (if a default implementation exists)
+</ol>
+ 
+ <P><a name="Listing"></a><h3>Listing of Interfaces and Conventional Names</h3>
+ Here's a listing of all interfaces used by WEB4J, along with conventional class names, and either a default 
+ or example implementation. The package for conventional class names is always '<tt>hirondelle.web4j.config</tt>'.
+ 
+ <P><table BORDER CELLSPACING=0 CELLPADDING=3 >
+ <tr>
+ <th>Question</th>
+ <th>Interface</th>
+ <th>Conventional Impl Name, in <tt>hirondelle.web4j.config</tt></th>
+ <th>Default/Example Implementation</th>
+ </tr>
+ <tr valign="top">
+ <td>What is the application's name, version, build date, and so on?</td>
+ <td>{@link hirondelle.web4j.ApplicationInfo}</td>
+ <td><tt>AppInfo</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/AppInfo.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What tasks need to be performed during startup?</td>
+ <td>{@link hirondelle.web4j.StartupTasks}</td>
+ <td><tt>Startup</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/Startup.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What tasks need to be performed after user login?</td>
+ <td>{@link hirondelle.web4j.security.LoginTasks}</td>
+ <td><tt>Login</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/Login.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What <tt>Action</tt> is related to each request?</td>
+ <td>{@link hirondelle.web4j.request.RequestParser} (an ABC)</td>
+ <td><tt>RequestToAction</tt></td>
+ <td>{@link hirondelle.web4j.request.RequestParserImpl}</a></td>
+ </tr>
+ <tr valign="top">
+ <td>Which requests should be treated as malicious attacks?</td>
+ <td>{@link hirondelle.web4j.security.ApplicationFirewall}</td>
+ <td><tt>AppFirewall</tt></td>
+ <td>{@link hirondelle.web4j.security.ApplicationFirewallImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>Which requests use untrusted proxies for the user id?</td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserId}</td>
+ <td><tt>OwnerFirewall</tt></td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is spam distinguished from regular user input?</td>
+ <td>{@link hirondelle.web4j.security.SpamDetector}</td>
+ <td><tt>SpamDetect</tt></td>
+ <td>{@link hirondelle.web4j.security.SpamDetectorImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is a request param translated into a given target type?</td>
+ <td>{@link hirondelle.web4j.model.ConvertParam}</td>
+ <td><tt>ConvertParams</tt></td>
+ <td>{@link hirondelle.web4j.model.ConvertParamImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How does the application respond when a low level conversion error takes place when parsing user input?</td>
+ <td>{@link hirondelle.web4j.model.ConvertParamError}</td>
+ <td><tt>ConvertParamErrorImpl</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/ConvertParamErrorImpl.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What characters are permitted for text input fields?</td>
+ <td>{@link hirondelle.web4j.security.PermittedCharacters}</td>
+ <td><tt>PermittedChars</tt></td>
+ <td>{@link hirondelle.web4j.security.PermittedCharactersImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is a date formatted and parsed?</td>
+ <td>{@link hirondelle.web4j.request.DateConverter}</td>
+ <td><tt>DateConverterImpl</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/DateConverterImpl.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>How is a <tt>Locale</tt> derived from the request?</td>
+ <td>{@link hirondelle.web4j.request.LocaleSource}</td>
+ <td><tt>LocaleSrc</tt></td>
+ <td>{@link hirondelle.web4j.request.LocaleSourceImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is the system clock defined?</td>
+ <td>{@link hirondelle.web4j.util.TimeSource}</td>
+ <td><tt>TimeSrc</tt></td>
+ <td>{@link hirondelle.web4j.util.TimeSourceImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is a <tt>TimeZone</tt> derived from the request?</td>
+ <td>{@link hirondelle.web4j.request.TimeZoneSource}</td>
+ <td><tt>TimeZoneSrc</tt></td>
+ <td>{@link hirondelle.web4j.request.TimeZoneSourceImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>What is the translation of this text, for a given <tt>Locale</tt>?</td>
+ <td>{@link hirondelle.web4j.ui.translate.Translator}</td>
+ <td><tt>TranslatorImpl</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/TranslatorImpl.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>How does the application obtain a database <tt>Connection</tt>?</td>
+ <td>{@link hirondelle.web4j.database.ConnectionSource}</td>
+ <td><tt>ConnectionSrc</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/ConnectionSrc.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>How is a <tt>ResultSet</tt> column translated into a given target type?</td>
+ <td>{@link hirondelle.web4j.database.ConvertColumn}</td>
+ <td><tt>ColToObject</tt></td>
+ <td>{@link hirondelle.web4j.database.ConvertColumnImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How should an email be sent when a problem occurs?</td>
+ <td>{@link hirondelle.web4j.webmaster.Emailer}</td>
+<td><tt>Email</tt></td>
+ <td>{@link hirondelle.web4j.webmaster.EmailerImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How should the logging system be configured?</td>
+ <td>{@link hirondelle.web4j.webmaster.LoggingConfig}</td>
+ <td><tt>LogConfig</tt></td>
+ <td>{@link hirondelle.web4j.webmaster.LoggingConfigImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>Does this request/operation have a data ownership constraint?</td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserId}</td>
+ <td><tt>OwnerFirewall</tt></td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}</td>
+ </tr>
+ </table>
+ 
+ <p><span class="highlight">No conflict between the classes of different
+ applications will result, if application code is placed in the usual locations
+ under <tt>WEB-INF</span></tt>, and not in <i>shared</i> locations accessible
+ to multiple web applications. Since version 2.2 of the Servlet API, each
+ web application gets its own {@link java.lang.ClassLoader}, so no conflict
+ will result, as long as classes are placed in non-shared locations (which
+ is almost always the case).
+
+ <P>This class does not cache objects in any way.
+*/
+public final class BuildImpl {
+  
+  /*
+   Note: some might make better use of generics here. BUT from the point of view of the 
+   caller, the forXXX methods should remain. That way the caller does not have to 
+   remember the interface class literal. (As well, the config settings for 
+   intf/impl are text, not class literals.)
+  */
+  
+  /**
+   Called by the framework upon startup. 
+   
+   <P>Extract all configuration which maps names of abstractions to names of corresponding  
+   concrete implementations. Confirm both that all required interfaces 
+   have configured implementations, and that they can be loaded.
+   
+   <P>The implementation of {@link TimeSource} and {@link LoggingConfig} are treated slightly 
+   differently than the rest. Their implementations are found and used earlier than the others, since they 
+   are of immediate use. 
+   
+   <P>See class comment for more information.
+   @param aConfig contains information regarding custom implementations, and information
+   for logging config (if any); in a servlet context, this information is extracted by the framework
+   from settings in <tt>web.xml</tt>.
+  */
+  public static void init(Map<String, String> aConfig) throws AppException {
+    useConfigSettingsFirst(aConfig);
+    doTimeSourceConfig();
+    doLoggingConfig(aConfig);
+    fLogger.config("________________________ STARTUP :Initializing WEB4J Controller. Reading in settings in web.xml._________");
+    useStandardOrDefaultNameSecond();
+    fLogger.config("Mapping of implementation classes : " + Util.logOnePerLine(fClassMapping));
+  }
+  
+  /**
+   Intended for contexts outside of normal servlet operation, where the caller wants to use the 
+   web4j database and model layer only. (For example, a command-line application.)
+   This method uses these interfaces, in the stated order:
+   <ul>
+     <li>{@link TimeSource} 
+     <li>{@link LoggingConfig}
+     <li>{@link ConnectionSource}
+     <li>{@link ConvertColumn}
+     <li>{@link ConvertParam} (for the list of supported types)
+     <li>{@link PermittedCharacters} (used by the <tt>Id</tt> class in the model objects returned by the database)
+     <li>{@link DateConverter} (if supplied in the config; used when generating formatted reports)
+     <li>{@link SpamDetector} (used when checking input for spam)
+   </ul> 
+   Usually, the caller will need to supply only one of the above - <tt>ConnectionSource</tt>.
+   For the other items, the default implementations will usually be adequate, and no action is required.
+  */
+  public static void initDatabaseLayer(Map<String, String> aConfig) throws AppException {
+    useConfigSettingsFirst(aConfig);
+    //two items done early
+    doTimeSourceConfig();
+    doLoggingConfig(aConfig);
+    fLogger.config("For items *not* specified in config, searching for implementations with 'standard' name.");
+    fLogger.config("If no 'standard' implementation found, then will use the WEB4J 'default' implementation.");
+    //does NOT include the items done 'early'
+    addStandardDefaultIfNotInConfig(CONNECTION_SOURCE);
+    addStandardDefaultIfNotInConfig(CONVERT_COLUMN);
+    addStandardDefaultIfNotInConfig(CONVERT_PARAM); //for supported types
+    addStandardDefaultIfNotInConfig(PERMITTED_CHARACTERS); //for Id class
+    addStandardDefaultIfNotInConfig(DATE_CONVERTER); //for reports; there's no default impl for this
+    addStandardDefaultIfNotInConfig(SPAM_DETECTOR); //for Checking spam
+    fLogger.config("Mapping of implementation classes : " + Util.logOnePerLine(fClassMapping));
+  }
+
+  /**
+   Map a fully-qualified <tt>aAbstractionName</tt> into a concrete implementation.
+  
+   <P>This method should only be used for 'non-standard' items not covered by more  
+   specific methods in this class. For example, when looking for the implementation of {@link LocaleSource},
+   the {@link #forLocaleSource()} method should always be used instead of this method.
+   
+   <P>Implementation classes accessed by this method must have a <tt>public</tt> no-argument 
+   constructor. (This method is best suited for interfaces, and not abstract base classes.)
+   
+   <P>Uses {@link Class#newInstance}, with no arguments. If a problem occurs, a 
+   {@link RuntimeException} is thrown. 
+  
+   @param aAbstractionName package-qualified name of an interface or abstract base class, as in 
+   "<tt>hirondelle.web4j.ApplicationInfo</tt>".
+  */
+  public static Object forAbstraction(String aAbstractionName){
+    Class<?> implementationClass = fClassMapping.get(aAbstractionName);
+    if ( implementationClass == null )  {
+      throw new IllegalArgumentException(
+        "No mapping to an implementation class found, for interface or abstract base class named " + Util.quote(aAbstractionName)
+      );
+    }
+    Object result = null;
+    try {
+      result = implementationClass.newInstance();
+    }
+    catch (InstantiationException ex){
+      handleCtorProblem(ex, implementationClass);
+    }
+    catch (IllegalAccessException ex) {
+      handleCtorProblem(ex, implementationClass);
+    }
+    return result;
+  }
+  
+  /**
+   Map a fully-qualified <tt>aAbstractBaseClassName</tt> into a concrete implementation.
+  
+   <P>Intended for abstract base classes (ABC's) having a <tt>public</tt> 
+   constructor with known arguments. For example, this method is used by 
+   the {@link hirondelle.web4j.Controller} to build an implementation of 
+   {@link hirondelle.web4j.request.RequestParser}, by passing in a <tt>request</tt> 
+   and <tt>response</tt> object. (Implementations of that ABC are always expected to 
+   take those two particular constructor arguments.)
+   
+   <P>If a problem occurs, a {@link RuntimeException} is thrown.
+   
+   @param aAbstractBaseClassName package-qualified name of an Abstract Base Class, as in 
+   "<tt>hirondelle.web4j.ui.RequestParser</tt>".
+   @param aCtorArguments <tt>List</tt> of arguments to be passed to the constructor of an  
+   implementation class; the size of this list determines the selected constructor (by 
+   matching the number of parameters), and the iteration order of its items corresponds 
+   to the order of appearance of the formal constructor parameters.
+  */
+  public static Object forAbstractionPassCtorArgs(String aAbstractBaseClassName, List<Object> aCtorArguments){
+    Object result = null;
+    Class implClass = fClassMapping.get(aAbstractBaseClassName);
+    Constructor ctor = ModelCtorUtil.getConstructor(implClass, aCtorArguments.size());
+    try {
+      result = ModelCtorUtil.buildModelObject(ctor, aCtorArguments);
+    }
+    catch (ModelCtorException ex){
+      handleCtorProblem(ex, implClass);
+    }
+    return result;
+  }
+  
+  /** Return the configured implementation of {@link ApplicationInfo}.  */
+  public static ApplicationInfo forApplicationInfo(){
+    return (ApplicationInfo)forAbstraction(APPLICATION_INFO.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link StartupTasks}.  */
+  public static StartupTasks forStartupTasks(){
+    return (StartupTasks)forAbstraction(STARTUP_TASKS.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link LoginTasks}.  */
+  public static LoginTasks forLoginTasks(){
+    return (LoginTasks)forAbstraction(LOGIN_TASKS.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link ConvertParamError}.  */
+  public static ConvertParamError forConvertParamError(){
+    return (ConvertParamError)forAbstraction(CONVERT_PARAM_ERROR.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link ConvertColumn}.  */
+  public static ConvertColumn forConvertColumn(){
+    return (ConvertColumn)forAbstraction(CONVERT_COLUMN.getAbstraction());
+  }
+
+  /** Return the configured implementation of {@link PermittedCharacters}.  */
+  public static PermittedCharacters forPermittedCharacters(){
+    return (PermittedCharacters)forAbstraction(PERMITTED_CHARACTERS.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link ConnectionSource}.  */
+  public static ConnectionSource forConnectionSource(){
+    return (ConnectionSource)forAbstraction(CONNECTION_SOURCE.getAbstraction());    
+  }
+  
+  /** Return the configured implementation of {@link LocaleSource}.  */
+  public static LocaleSource forLocaleSource(){
+    return (LocaleSource)forAbstraction(LOCALE_SRC.getAbstraction());
+  }
+
+  /** 
+  Return the configured implementation of {@link TimeSource}.
+  
+  <P>When testing, an application may call this method in order to use a 'fake'
+  system time.
+  
+   <P>Internally, WEB4J will always use this method when it needs the current time.
+   This allows a fake system time to be shared between your application and WEB4J.
+  */
+  public static TimeSource forTimeSource(){
+    return (TimeSource)forAbstraction(TIME_SRC.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link TimeZoneSource}.  */
+  public static TimeZoneSource forTimeZoneSource(){
+    return (TimeZoneSource)forAbstraction(TIME_ZONE_SRC.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link DateConverter}.  */
+  public static DateConverter forDateConverter(){
+    return (DateConverter)forAbstraction(DATE_CONVERTER.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link Translator}.  */
+  public static Translator forTranslator(){
+    return (Translator)forAbstraction(TRANSLATOR.getAbstraction());
+  }
+
+  /** Return the configured implementation of {@link ApplicationFirewall}.  */
+  public static ApplicationFirewall forApplicationFirewall() {
+    return (ApplicationFirewall)forAbstraction(APP_FIREWALL.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link SpamDetector}.  */
+  public static SpamDetector forSpamDetector() {
+    return (SpamDetector)forAbstraction(SPAM_DETECTOR.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link Emailer}.  */
+  public static Emailer forEmailer() {
+    return (Emailer)forAbstraction(EMAILER.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link ConvertParam}.  */
+  public static ConvertParam forConvertParam() {
+    return (ConvertParam)forAbstraction(CONVERT_PARAM.getAbstraction());
+  }
+  
+  /** Return the configured implementation of {@link UntrustedProxyForUserId}.  */
+  public static UntrustedProxyForUserId forOwnershipFirewall() {
+    return (UntrustedProxyForUserId)forAbstraction(OWNER_FIREWALL.getAbstraction());
+  }
+  
+  /**
+    Add an implementation - intended for testing only.
+      
+     <P>This method allows testing code to configure a specific implementation class. 
+     Example: <PRE>BuildImpl.adHocImplementationAdd(TimeSource.class, MyTimeSource.class);</PRE>
+     Calls to this method (often in a JUnit <tt>setUp()</tt> method) should be paired with a 
+     subsequent call to {@link #adHocImplementationRemove(Class)}. 
+  */
+  public static void adHocImplementationAdd(Class aInterface, Class aImplementationClass){
+     fClassMapping.put(aInterface.getName(), aImplementationClass);
+  }
+  
+  /**
+    Remove an implementation - intended for testing only.
+     
+    <P>This method allows testing code to configure a specific implementation class. 
+    Example: <PRE>BuildImpl.adHocImplementationRemove(TimeSource.class);</PRE> 
+    Calls to this method (often in a JUnit <tt>tearDown()</tt> method) should be paired with 
+    a previous call to {@link #adHocImplementationAdd(Class, Class)}. 
+  */
+  public static void adHocImplementationRemove(Class aInterface){
+    fClassMapping.remove(aInterface.getName());
+  }
+  
+  // PRIVATE 
+  
+  /**
+   Key - interface name (String)
+   Value - implementation class (Class) 
+  */
+  private static final Map<String, Class<?>> fClassMapping = new LinkedHashMap<String, Class<?>>();
+  
+  private static final String IMPLEMENTATION_FOR = "ImplementationFor.";
+  private static final Logger fLogger = Util.getLogger(BuildImpl.class);
+  
+  private BuildImpl() {
+    //prevent construction by caller
+  }
+  
+  /*
+   Implementation Note. 
+   Early versions of this class did not work with class literals. Problem disappeared?
+  */
+  
+  private static final String STANDARD_PACKAGE = "hirondelle.web4j.config.";
+  
+  //Items with no WEB4J default
+  private static final StandardDefault APPLICATION_INFO = new StandardDefault(ApplicationInfo.class.getName(),"AppInfo");
+  private static final StandardDefault STARTUP_TASKS = new StandardDefault(StartupTasks.class.getName(), "Startup");
+  private static final StandardDefault LOGIN_TASKS = new StandardDefault(LoginTasks.class.getName(), "Login");
+  private static final StandardDefault CONNECTION_SOURCE = new StandardDefault(ConnectionSource.class.getName(), "ConnectionSrc");
+  private static final StandardDefault CONVERT_PARAM_ERROR = new StandardDefault(ConvertParamError.class.getName(), "ConvertParamErrorImpl");
+  private static final StandardDefault TRANSLATOR = new StandardDefault(Translator.class.getName(), "TranslatorImpl");
+  private static final StandardDefault DATE_CONVERTER = new StandardDefault(DateConverter.class.getName(), "DateConverterImpl");
+  
+  //Items with a WEB4J default
+  private static final StandardDefault LOGGING_CONFIG = new StandardDefault(LoggingConfig.class.getName(), "LogConfig", LoggingConfigImpl.class.getName());
+  private static final StandardDefault REQUEST_PARSER = new StandardDefault(RequestParser.class.getName(), "RequestToAction", RequestParserImpl.class.getName());
+  private static final StandardDefault APP_FIREWALL = new StandardDefault(ApplicationFirewall.class.getName(), "AppFirewall", ApplicationFirewallImpl.class.getName());
+  private static final StandardDefault CONVERT_COLUMN = new StandardDefault(ConvertColumn.class.getName(), "ConvertColumns", ConvertColumnImpl.class.getName());
+  private static final StandardDefault LOCALE_SRC = new StandardDefault(LocaleSource.class.getName(), "LocaleSrc", LocaleSourceImpl.class.getName());
+  private static final StandardDefault TIME_SRC = new StandardDefault(TimeSource.class.getName(), "TimeSrc", TimeSourceImpl.class.getName());
+  private static final StandardDefault TIME_ZONE_SRC = new StandardDefault(TimeZoneSource.class.getName(), "TimeZoneSrc", TimeZoneSourceImpl.class.getName());
+  private static final StandardDefault SPAM_DETECTOR = new StandardDefault(SpamDetector.class.getName(), "SpamDetect", SpamDetectorImpl.class.getName());
+  private static final StandardDefault EMAILER = new StandardDefault(Emailer.class.getName(), "Email", EmailerImpl.class.getName());
+  private static final StandardDefault CONVERT_PARAM = new StandardDefault(ConvertParam.class.getName(), "ConvertParams", ConvertParamImpl.class.getName());
+  private static final StandardDefault PERMITTED_CHARACTERS = new StandardDefault(PermittedCharacters.class.getName(), "PermittedChars", PermittedCharactersImpl.class.getName());
+  private static final StandardDefault OWNER_FIREWALL = new StandardDefault(UntrustedProxyForUserId.class.getName(), "OwnerFirewall", UntrustedProxyForUserIdImpl.class.getName());
+  
+  //OTHERS? MUST add below as well...
+
+  private static void useConfigSettingsFirst(Map<String, String> aConfig) throws AppException {
+    for(String name : aConfig.keySet()){
+      if ( name.startsWith(IMPLEMENTATION_FOR) ) {
+        String interfaceName = name.substring(IMPLEMENTATION_FOR.length());
+        String className = aConfig.get(name);
+        fClassMapping.put(interfaceName, buildClassFromConfig(className));
+      }
+    }
+  }
+  
+  private static void handleCtorProblem(Exception ex, Class<?> aImplementationClass){
+    String message = "Object construction by reflection failed for " + aImplementationClass.toString();
+    fLogger.severe(message);
+    throw new RuntimeException(message, ex);
+  }
+  
+  /** The system time must be done first, since used everywhere, including the logging system. */
+  private static void doTimeSourceConfig() throws AppException {
+    addStandardDefaultIfNotInConfig(TIME_SRC); 
+  }
+
+  /**  Return the configured implementation of {@link LoggingConfig}.  */ 
+  private static LoggingConfig forLoggingConfig(){
+    return (LoggingConfig)forAbstraction(LOGGING_CONFIG.getAbstraction());
+  }
+  
+  /** Extract and execute the LoggingConfig, earlier than all others.  */
+  private static void doLoggingConfig(Map<String, String> aConfig) throws AppException {
+    addStandardDefaultIfNotInConfig(LOGGING_CONFIG); 
+    executeLoggingConfig(aConfig);
+  }
+  
+  private static void useStandardOrDefaultNameSecond() throws AppException {
+    fLogger.config("For items *not* specified in config, searching for implementations with 'standard' name.");
+    fLogger.config("If no 'standard' implementation found, then will use the WEB4J 'default' implementation.");
+    //does NOT include the items done 'early'
+    addStandardDefaultIfNotInConfig(APPLICATION_INFO );
+    addStandardDefaultIfNotInConfig(CONNECTION_SOURCE );
+    addStandardDefaultIfNotInConfig(CONVERT_PARAM_ERROR );
+    addStandardDefaultIfNotInConfig(TRANSLATOR );
+    addStandardDefaultIfNotInConfig(DATE_CONVERTER );
+    addStandardDefaultIfNotInConfig(STARTUP_TASKS );
+    addStandardDefaultIfNotInConfig(LOGIN_TASKS );
+    addStandardDefaultIfNotInConfig(REQUEST_PARSER );
+    addStandardDefaultIfNotInConfig(APP_FIREWALL );
+    addStandardDefaultIfNotInConfig(CONVERT_COLUMN );
+    addStandardDefaultIfNotInConfig(LOCALE_SRC );
+    addStandardDefaultIfNotInConfig(TIME_ZONE_SRC );
+    addStandardDefaultIfNotInConfig(SPAM_DETECTOR );
+    addStandardDefaultIfNotInConfig(EMAILER );
+    addStandardDefaultIfNotInConfig(CONVERT_PARAM );
+    addStandardDefaultIfNotInConfig(PERMITTED_CHARACTERS );
+    addStandardDefaultIfNotInConfig(OWNER_FIREWALL );
+  }
+  
+  private static boolean isAlreadySpecified(String aInterfaceName){
+    return fClassMapping.keySet().contains(aInterfaceName);
+  }
+
+  private static Class<?> buildClassFromConfig(String aClassName) throws AppException {
+    Class<?> result = null;
+    try {
+      result = Class.forName(aClassName);
+    }
+    catch (ClassNotFoundException ex){
+      throw new AppException(
+        "Load of configured (or default) implementation class has failed. Class.forName() failed for " + Util.quote(aClassName), ex
+      );
+    }
+    return result;
+  }
+
+  private static void addStandardDefaultIfNotInConfig(StandardDefault aNames) throws AppException {
+    if ( ! isAlreadySpecified(aNames.getAbstraction()) ){
+      fClassMapping.put(aNames.getAbstraction(), buildStandardOrDefaultClass(aNames.getStandard(), aNames.getDefault()));
+    }
+  }
+  
+  private static Class<?> buildStandardOrDefaultClass(String aStandardName, String aDefaultName) throws AppException {
+    Class<?> result = null;
+    try {
+      result = Class.forName(aStandardName);
+    }
+    catch (ClassNotFoundException ex){
+      if( Util.textHasContent(aDefaultName) ){
+        fLogger.config("Cannot see any class with standard name " + Util.quote(aStandardName) + ". Will use default WEB4J implementation instead, named " + Util.quote(aDefaultName));
+        result = useDefault(aDefaultName);
+      }
+      else {
+        reportMissing(aStandardName, ex);
+      }
+    }
+    return result;
+  }
+
+  private static Class<?> useDefault(String aDefaultName) throws AppException {
+    Class<?> result = null;
+    try {
+      result = Class.forName(aDefaultName);
+    }
+    catch (ClassNotFoundException exception){
+      throw new AppException(
+        "Load of default implementation has failed. Class.forName() failed for " + Util.quote(aDefaultName), exception
+      );
+    }
+    return result;
+  }
+
+  private static void reportMissing(String aStandardName, ClassNotFoundException ex) throws AppException {
+    String msg = "Load of configured implementation class has failed. Class.forName() failed for " + Util.quote(aStandardName);
+    throw new AppException(msg, ex);
+  }
+
+  /** Execute the configured implementation of {@link LoggingConfig}.  */
+  private static void executeLoggingConfig(Map<String, String> aConfig) throws AppException {
+    LoggingConfig loggingConfig = forLoggingConfig();
+    loggingConfig.setup(aConfig);
+  }
+  
+  /** Gathers the standard and default class names related to an abstraction.   */
+  private static final class StandardDefault {
+    StandardDefault(String aAbstraction, String aStandard){
+      fAbstraction = aAbstraction;
+      fStandard = STANDARD_PACKAGE + aStandard;
+    }
+    StandardDefault(String aAbstraction, String aStandard, String aDefault){
+      fAbstraction = aAbstraction;
+      fStandard = STANDARD_PACKAGE + aStandard;
+      fDefault = aDefault;
+    }
+    String getAbstraction() {  return fAbstraction;    }
+    String getDefault() {   return fDefault;   }
+    String getStandard() {    return fStandard;   }
+    private String fAbstraction;
+    private String fStandard;
+    private String fDefault;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/CheckModelObjects.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,119 @@
+package hirondelle.web4j;
+
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.readconfig.ConfigReader;
+import java.util.*;
+import java.lang.reflect.*;
+import java.util.logging.*;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.model.ConvertParam;
+import hirondelle.web4j.BuildImpl;
+
+/**
+ Scan Model Objects for Cross-Site Scripting vulnerabilities, and use of unsupported base objects.
+  
+ <P>Here, 'base object' refers to <tt>Integer</tt>, <tt>Date</tt>, 
+ and so on -- the basic building blocks passed to a Model Object constructor.
+ 
+ <P>Model Objects are defined as public concrete classes having a constructor that throws 
+ {@link hirondelle.web4j.model.ModelCtorException}. 
+ 
+ <P>The checks performed by this class are :
+<ul>
+ <li>any <tt>public</tt> <tt>getXXX</tt> methods that return a <tt>String</tt> are identified as
+ Cross-Site Scripting vulnerabilities. Model Objects that return text in general should use {@link SafeText} instead 
+ of {@link String}.
+ <li>any constructors taking an argument whose class is not supported according to 
+ {@link hirondelle.web4j.model.ConvertParam#isSupported(Class)} are identified as errors.
+</ul> 
+ 
+ <P>When one of these checks fails, the incident is logged as a warning.
+*/ 
+final class CheckModelObjects {
+  
+  /** Performs checks on all Model Objects.  */
+  void performChecks(){
+    fLogger.config("Performing checks on Model Objects for Cross-Site Scripting vulnerabilities and unsupported constructor arguments.");
+    Set<Class> allClasses = ConfigReader.fetchConcreteClasses();
+    for (Class implClass: allClasses){
+      if ( isPublicModelObject(implClass) ){
+        scanForMethodsReturningString(implClass);
+        scanForUnsupportedCtorArgs(implClass);
+      }
+    }
+    logResults();
+  }
+  
+  // PRIVATE //
+  private final ConvertParam fConvertParam = BuildImpl.forConvertParam();
+  private static final Logger fLogger = Util.getLogger(CheckModelObjects.class);
+  private int fCountXSS;
+  private int fCountUnsupportedCtorArg;
+  
+  private boolean isPublicModelObject(Class aConcreteClass){
+    boolean result = false;
+    if ( isPublic(aConcreteClass) ) {
+      Constructor[] ctors = aConcreteClass.getDeclaredConstructors();
+      for (Constructor ctor : ctors){
+        List<Class> exceptionClasses = Arrays.asList(ctor.getExceptionTypes());
+        if (exceptionClasses.contains(ModelCtorException.class)) {
+          result = true;
+          break;
+        }
+      }
+    }
+    return result;
+  }
+  
+  private boolean isPublic(Class aClass){
+    return Modifier.isPublic(aClass.getModifiers());
+  }
+  
+  private boolean isPublic(Method aMethod){
+    return Modifier.isPublic(aMethod.getModifiers());
+  }
+  
+  private void scanForMethodsReturningString(Class aModelObject){
+    Method[] methods = aModelObject.getDeclaredMethods();
+    for(Method method : methods){
+      if( isVulnerableMethod(method) ) {
+        fLogger.warning("Public Model Object named " + aModelObject.getName() + " has a public method named " + Util.quote(method.getName()) + " that returns a String. Should it return SafeText instead? Possible vulnerability to Cross-Site Scripting attack.");
+        fCountXSS++;
+      }
+    }
+  }
+  
+  private boolean isVulnerableMethod(Method aMethod){
+    return isPublic(aMethod) && aMethod.getName().startsWith("get") && aMethod.getReturnType().equals(String.class);
+  }
+  
+  private void scanForUnsupportedCtorArgs(Class aClass){
+    Constructor[] ctors = aClass.getDeclaredConstructors();
+    for (Constructor ctor : ctors){
+      List<Class> argClasses = Arrays.asList(ctor.getParameterTypes());
+      for(Class argClass: argClasses){
+        if( ! fConvertParam.isSupported(argClass) ){
+          fLogger.warning("Model Object: " + aClass + ". Constructor has argument not supported by ConvertParam: " + argClass);
+          fCountUnsupportedCtorArg++;
+        }
+      }
+    }
+  }
+  
+  private void logResults(){
+    if(fCountXSS == 0){
+      fLogger.config("** SUCCESS *** : Scanned Model Objects for Cross-Site Scripting vulnerabilities. Found 0 incidents.");
+    }
+    else {
+      fLogger.warning("Scanned Model Objects for Cross-Site Scripting vulnerabilities. Found " + fCountXSS + " incident(s).");
+    }
+    
+    if( fCountUnsupportedCtorArg == 0 ) {
+      fLogger.config("** SUCCESS *** : Scanned Model Objects for unsupported constructor arguments. Found 0 incidents.");
+    }
+    else {
+      fLogger.warning("Scanned Model Objects for unsupported constructor arguments. Found " + fCountUnsupportedCtorArg + " incident(s).");
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/Controller.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,840 @@
+package hirondelle.web4j; 
+
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.action.ResponsePage;
+import hirondelle.web4j.database.ConnectionSource;
+import hirondelle.web4j.database.DAOException;
+import hirondelle.web4j.database.DbConfig;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.BadRequestException;
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.ConfigReader;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.request.RequestParserImpl;
+import hirondelle.web4j.security.ApplicationFirewall;
+import hirondelle.web4j.security.ApplicationFirewallImpl;
+import hirondelle.web4j.security.FetchIdentifierOwner;
+import hirondelle.web4j.security.UntrustedProxyForUserId;
+import hirondelle.web4j.util.Stopwatch;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+import hirondelle.web4j.webmaster.TroubleTicket;
+
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javax.servlet.RequestDispatcher;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import javax.servlet.jsp.JspFactory;
+
+/**
+  Single point of entry for serving dynamic pages.
+ 
+  <P>The application can serve content both directly (by simple, direct reference to 
+  a JSP's URL), and indirectly, through this <tt>Controller</tt>.
+ 
+ <P>Like almost all servlets, this class is safe for multi-threaded environments. 
+ 
+  <P>Validates user input and request parameters, interacts with a datastore, 
+  and places problem domain model objects in scope for eventual rendering by a JSP. 
+  Performs either a forward or a redirect, according to the instructions of the 
+  {@link Action}.
+ 
+  <P>Emails are sent to the webmaster when :
+ <ul>
+  <li>an unexpected problem occurs (the email will include extensive diagnostic 
+  information, including a stack trace)
+  <li>servlet response times degrade to below a configured level
+ </ul>
+ 
+ <P>This class is in a distinct package for two reasons :
+ <ul>
+ <li>to make it easier to find, since it is at the very top of the hierarchy
+ <li>to force the <tt>Controller</tt> to use only the public aspects of 
+  the <tt>ui</tt> package. This ensures it remains at a high level of abstraction.
+ </ul>
+ 
+  <P>There are key-names defined in this class (see below). Their names need to be 
+  long-winded (<tt>web4j_key_for_...</tt>), unfortunately, in order to 
+  avoid conflict with other tools, including your application. 
+*/
+public class Controller extends HttpServlet {
+
+  /**
+   Name and version number of the WEB4J API. 
+   
+   <P>Value: {@value}.
+   <P>Upon startup, this item is logged at <tt>CONFIG</tt> level. (This item is  
+   is simply a hard-coded field in this class. It is not configured in <tt>web.xml</tt>.) 
+  */
+  public static final String WEB4J_VERSION = "WEB4J/4.10.0";
+  
+  /**
+   Key name for the application's character encoding, placed in application scope
+   as a <tt>String</tt> upon startup. This character encoding (charset) is set 
+   as an HTTP header for every reponse.
+   
+   <P>Key name: {@value}.
+   <P>Configured in <tt>web.xml</tt>. The value <tt>UTF-8</tt> is highly recommended.
+  */
+  public static final String CHARACTER_ENCODING = "web4j_key_for_character_encoding";
+
+  /**
+   Key name for the webmaster email address, placed in application scope
+   as a <tt>String</tt> upon startup.
+   
+   <P>Key name: {@value}.
+   <P>Configured in <tt>web.xml</tt>.
+  */
+  public static final String WEBMASTER = "web4j_key_for_webmaster";
+  
+  /**
+   Key name for the default {@link Locale}, placed in application scope
+   as a <tt>Locale</tt> upon startup.
+   
+   <P>Key name: {@value}.
+   <P>The application programmer is encouraged to use this key for any 
+   <tt>Locale</tt> stored in <em>session</em> scope : the <em>default</em> implementation 
+   of {@link hirondelle.web4j.request.LocaleSource} will always search for this 
+   key in increasingly larges scopes. Thus, the default mechanism will 
+   automatically use the user-specific <tt>Locale</tt> as an override to 
+   the default one.
+   
+   <P>Configured in <tt>web.xml</tt>.
+  */
+  public static final String LOCALE = "web4j_key_for_locale";
+  
+  /**
+   Key name for the default {@link TimeZone}, placed in application scope
+   as a <tt>TimeZone</tt> upon startup.
+   
+   <P>Key name: {@value}.
+   <P>The application programmer is encouraged to use this key for any 
+   <tt>TimeZone</tt> stored in <em>session</em> scope : the <em>default</em> implementation 
+   of {@link hirondelle.web4j.request.TimeZoneSource} will always search for this 
+   key in increasingly larges scopes. Thus, the default mechanism will 
+   automatically use the user-specific <tt>TimeZone</tt> as an override to 
+   the default one.
+   
+   <P>Configured in <tt>web.xml</tt>.
+  */
+  public static final String TIME_ZONE = "web4j_key_for_time_zone";
+
+  /**
+   Key name for the most recent {@link TroubleTicket}, placed in application scope when a 
+   problem occurs.
+   <P>Key name: {@value}.
+  */
+  public static final String MOST_RECENT_TROUBLE_TICKET = "web4j_key_for_most_recent_trouble_ticket";
+  
+  /**
+   Key name for the startup time, placed in application scope as a {@link DateTime} upon startup.
+   <P>Key name: {@value}.
+  */
+  public static final String START_TIME = "web4j_key_for_start_time";
+  
+  /**
+   Key name for the URI for the current request, placed in request scope as a <tt>String</tt>.
+   
+   <P>Key name: {@value}.
+   <P>Somewhat bizarrely, the servlet API does not allow direct access to this item.
+  */
+  public static final String CURRENT_URI = "web4j_key_for_current_uri";
+  
+  /**
+   Perform operations to be executed only upon startup of 
+   this application, and not during its regular operation. 
+   
+   <P>Operations include :
+   <ul>
+   <li>log version and configuration information
+   <li>distribute configuration information in <tt>web.xml</tt> to the various
+   parts of WEB4J
+   <li>place an {@link ApplicationInfo} object into application scope
+   <li>place the configured character encoding into application scope, for use in JSPs
+   <li>call {@link StartupTasks#startApplication(ServletConfig, String)}, to 
+   allow the application to perform its own startup tasks
+   <li>perform various validations
+   </ul>
+   
+   <P>One or more of the application's databases may not be running when 
+   the web application starts. Upon startup, this Controller first queries each database 
+   for simple name and version information. If that query fails, then the database is 
+   assumed to be "down", and the app's implementation of {@link StartupTasks} 
+   (which usually fetches code tables from the database) is not called. 
+   
+   <P>The web app, however, will not terminate. Instead, this Controller will keep 
+   attempting to connect for each incoming request. When all databases are 
+   determined to be healthy, the Controller will perform the database initialization 
+   tasks it usually performs upon startup, and the app will then function normally.
+   
+   <P>If the database subsequently goes down again, then this Controller will not take 
+   any special action. Instead, the container's connection pool should be configured to 
+   attempt to reconnect automatically on the application's behalf. 
+  */
+  @Override public final void init(ServletConfig aConfig) throws ServletException {
+    super.init(aConfig);
+    fServletConfig = aConfig;
+    try {
+      Stopwatch stopwatch = new Stopwatch();
+      stopwatch.start();
+      
+      Map<String, String> configMap = asMap(aConfig);
+      //the Config class stores settings internally as static items
+      //after this point, any class can get its config data just by using Config as a normal object
+      Config.init(configMap);
+      fConfig = new Config();
+      
+      //any use of logging before this line will fail
+      //first load of application-specific classes; configures and begins logging as well
+      BuildImpl.init(configMap);
+    
+      displaySystemProperties();
+      displayConfigInfo(aConfig); //all items, for both app and framework
+      setCharacterEncodingAndPutIntoAppScope(aConfig);
+      putWebmasterEmailAddressIntoAppScope(aConfig);
+      putDefaultLocaleIntoAppScope(aConfig);
+      putDefaultTimeZoneIntoAppScope(aConfig);
+      putStartTimeIntoAppScope(aConfig);
+      fLogger.fine("System properties and first app-scope items completed " + stopwatch + " after start.");
+      
+      /* 
+       Implementation Note
+       There are strong order dependencies here: ConfigReader is used later in the 
+       init of SqlStatement, for example.
+      */
+      ConfigReader.init(aConfig.getServletContext());
+      WebUtil.init(aConfig);
+      
+      //This will be the first loading of application-specific classes.
+      //This will cause static fields to be initialized.
+      ApplicationInfo appInfo = BuildImpl.forApplicationInfo();
+      displayVersionInfo(aConfig, appInfo);
+      placeAppInfoIntoAppScope(aConfig, appInfo);
+  
+      TroubleTicket.init(aConfig);
+      
+      fLogger.config("Calling ConnectionSource.init(ServletConfig).");
+      ConnectionSource connSource = BuildImpl.forConnectionSource();
+      connSource.init(configMap);
+      fLogger.fine("Init of internal classes, ConnectionSource completed " + stopwatch + " after start.");
+      Config.checkDbNamesInSettings(BuildImpl.forConnectionSource().getDatabaseNames());
+  
+      tryDatabaseInitAndStartupTasks(connSource);
+      fLogger.fine("Database init and startup tasks " + stopwatch + " after start.");
+      
+      CheckModelObjects checkModelObjects = new CheckModelObjects();
+      checkModelObjects.performChecks();
+      stopwatch.stop();
+      fLogger.fine("Cross-Site Scripting scan completed " + stopwatch + " after start.");
+      
+      fLogger.config("*** SUCCESS : STARTUP COMPLETED SUCCESSFULLY for " + appInfo + ". Total startup time : " + stopwatch );
+    }
+    catch (AppException ex) {
+      throw new ServletException(ex);
+    } 
+  }
+
+  /** Log the name and version of the application. */
+  @Override public void destroy() {
+    ApplicationInfo appInfo = BuildImpl.forApplicationInfo();
+    fLogger.config("Shutting Down Controller for " + appInfo.getName() + "/" + appInfo.getVersion());
+  }
+
+  /** Call {@link #processRequest}.  */
+  @Override public final void doGet(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException {
+    logClasses(aRequest, aResponse);
+    processRequest(aRequest, aResponse);
+  }
+
+  /** Call {@link #processRequest}.  */
+  @Override public final void doPost(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException {
+    logClasses(aRequest, aResponse);
+    processRequest(aRequest, aResponse);
+  }
+
+  /** Call {@link #processRequest}.  PUT can be called by <tt>XmlHttpRequest</tt>. */
+  @Override public final void doPut(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException {
+    logClasses(aRequest, aResponse);
+    processRequest(aRequest, aResponse);
+  }
+  
+  /** Call {@link #processRequest}.  DELETE can be called by <tt>XmlHttpRequest</tt>. */
+  @Override public final void doDelete(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException {
+    logClasses(aRequest, aResponse);
+    processRequest(aRequest, aResponse);
+  }
+
+  /**
+   Handle all HTTP requests for <tt>GET</tt>, <tt>POST</tt>, <tt>PUT</tt>, and <tt>DELETE</tt> requests.
+   All of these HTTP methods will funnel through this Java method; any other methods will be handled by the container.
+   If a subclass needs to know the underlying HTTP method, then it must call {@link HttpServletRequest#getMethod()}.  
+   
+   <P>This method can be overridden, if desired. The great majority of applications will not need 
+   to override this method. 
+   
+   <P>Operations include :
+   <ul>
+   <li>set the request character encoding (using the value configured in <tt>web.xml</tt>)
+   <li>set the <tt>charset</tt> HTTP header for the response (using the value configured in <tt>web.xml</tt>)
+   <li>react to a successful user login, using the configured implementation of {@link hirondelle.web4j.security.LoginTasks}
+   <li>get an instance of {@link RequestParser}
+   <li>get its {@link Action}, and execute it 
+   <li>check for an ownership constraint (see {@link UntrustedProxyForUserId})
+   <li>perform either a forward or a redirect to the Action's  {@link hirondelle.web4j.action.ResponsePage}
+   <li>if an unexpected problem occurs, create a {@link TroubleTicket}, log it, and 
+   email it to the webmaster email address configured in <tt>web.xml</tt>
+   <li>if the response time exceeds a configured threshold, build a 
+   {@link TroubleTicket}, log it, and email it to the webmaster address configured in <tt>web.xml</tt>
+   </ul>
+  */
+  protected void processRequest(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException {
+    Stopwatch stopwatch = new Stopwatch();
+    stopwatch.start();
+    
+    aRequest.setCharacterEncoding(fConfig.getCharacterEncoding()); 
+    aResponse.setCharacterEncoding(fConfig.getCharacterEncoding());
+    
+    addCurrentUriToRequest(aRequest, aResponse);
+    RequestParser requestParser = RequestParser.getInstance(aRequest, aResponse);
+    try {
+      LoginTasksHelper loginHelper = new LoginTasksHelper();
+      loginHelper.reactToNewLogins(aRequest);
+      Action action = requestParser.getWebAction();
+      ApplicationFirewall appFirewall = BuildImpl.forApplicationFirewall();
+      appFirewall.doHardValidation(action, requestParser);
+      logAttributesForAllScopes(aRequest);
+      recheckBadDatabasesAndFinishStartup();
+      ResponsePage responsePage = checkOwnershipThenExecuteAction(action, requestParser);
+      if ( responsePage.hasBinaryData() ) {
+        fLogger.fine("Serving binary data. Controller not performing a forward or redirect.");
+      }
+      else {
+        if ( responsePage.getIsRedirect() ) {
+          redirect(responsePage, aResponse);
+        }
+        else {
+          forward(responsePage, aRequest, aResponse);
+        }
+      }
+      stopwatch.stop();
+      if ( stopwatch.toValue() >= fConfig.getPoorPerformanceThreshold() ) {
+        logAndEmailPerformanceProblem(stopwatch.toString(), aRequest);
+      }
+    }
+    catch (BadRequestException ex){
+      //NOTE : sendError() commits the response.
+      if( Util.textHasContent(ex.getErrorMessage()) ){
+        aResponse.sendError(ex.getStatusCode(), ex.getErrorMessage());      
+      }
+      else {
+        aResponse.sendError(ex.getStatusCode());      
+      }
+    }
+    catch (Throwable ex) {
+      //Includes AppException, Bugs, or rare conditions, for example datastore failure
+      logAndEmailSeriousProblem(ex, aRequest);
+      aResponse.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+    }
+  }
+
+  /**
+   Change the {@link ResponsePage} according to {@link Locale}.
+   
+   <P>This overridable default implementation does nothing, and returns <tt>null</tt>.
+   If the return value of this method is <tt>null</tt>, then the nominal <tt>ResponsePage</tt>
+   will be used without alteration. If the return value of this method is not <tt>null</tt>,
+   then it will be used to override the nominal <tt>ResponsePage</tt>.
+   
+   <P>This method is intended for applications that use different JSPs for different Locales.
+   For example, if the nominal response is a forward to <tt>Blah_en.jsp</tt>, and the "real"
+   response should be <tt>Blah_fr.jsp</tt>, then this method can be overridden to return the 
+   appropriate {@link ResponsePage}. <span class="highlight">This method is called only for 
+   forward operations. If it is overridden, then its return value must also correspond to a forward 
+   operation.</span>
+   
+   <P><span class="highlight">This style of implementing translation is not recommended.</span>
+   Instead, please use the services of the <tt>hirondelle.web4j.ui.translate</tt> package. 
+  */
+  protected ResponsePage swapResponsePage(ResponsePage aResponsePage, Locale aLocale){
+    return null; //does nothing
+  }
+  
+  /**
+   Inform the webmaster of an unexpected problem with the deployed application.
+   
+   <P>Typically called when an unexpected <tt>Exception</tt> occurs in 
+   {@link #processRequest}. Uses {@link TroubleTicket#mailToRecipients()}.
+   
+    <P>Also, stores the trouble ticket in application scope, for possible 
+    later examination. 
+  */
+  protected final void logAndEmailSeriousProblem (Throwable aException, HttpServletRequest aRequest) {
+    TroubleTicket troubleTicket = new TroubleTicket(aException, aRequest);
+    fLogger.severe("TOP LEVEL CATCHING Throwable");
+    fLogger.severe( troubleTicket.toString() ); 
+    log("SERIOUS PROBLEM OCCURRED.");
+    log( troubleTicket.toString() );
+    fServletConfig.getServletContext().setAttribute(MOST_RECENT_TROUBLE_TICKET, troubleTicket);
+    try {
+      troubleTicket.mailToRecipients();
+    }
+    catch (AppException exception){
+      fLogger.severe("Unable to send email: " + exception.getMessage());
+    }
+  }
+
+  /**
+   Inform the webmaster of a performance problem.
+   
+   <P>Called only when the response time of a request is above the threshold 
+   value configured in <tt>web.xml</tt>.
+   
+   <P>Builds a <tt>Throwable</tt> with a description of the problem, then creates and 
+   emails a {@link TroubleTicket} to the webmaster.
+   
+   @param aMilliseconds response time of a request in milliseconds
+  */
+  protected final void logAndEmailPerformanceProblem(String aMilliseconds, HttpServletRequest aRequest)  {
+    String message = 
+      "Response time of web application exceeds configured performance threshold." + NEW_LINE + 
+      "Time : " + aMilliseconds
+    ;
+    Throwable ex = new Throwable(message);
+    TroubleTicket troubleTicket = new TroubleTicket(ex, aRequest);
+    fLogger.severe("Poor response time : " + aMilliseconds);
+    fLogger.severe( troubleTicket.toString() ); 
+    log("Poor response time : " + aMilliseconds + " milliseconds");
+    log( troubleTicket.toString() );
+    try {
+      troubleTicket.mailToRecipients();
+    }
+    catch(AppException exception){
+      fLogger.severe("Unable to send email: " + exception.getMessage());
+    }
+  }
+
+  // PRIVATE 
+
+  /**
+   Mutable field. Must be accessed in a thread-safe way after init finished.
+   Assumes that all databases are initially down; each is removed from this set, when it 
+   has been detected as being up. Possibly empty.
+  */
+  private Set<String> fBadDatabases = new LinkedHashSet<String>();
+  
+  /** Mutable field. Must be accessed in a thread-safe way after init finished. */
+  private StartupTasks fStartupTasks;
+  
+  /**
+   The config must be saved. It is not accessible from a request, or from the context. 
+   It may be needed after startup, should no db connections be initially available.  
+  */
+  private static ServletConfig fServletConfig;
+  private Config fConfig; 
+  
+//  /** Item configured in web.xml.  */
+//  private static final InitParam fPoorPerformanceThreshold = new InitParam(
+//    "PoorPerformanceThreshold", "20"
+//  ); 
+//  /**
+//   If any request takes longer than this many nanoseconds to be processed, then 
+//   an email is sent to the webmaster. The web.xml states this configured time in 
+//   seconds, but nanoseconds is used by this class to perform the comparison.
+//  */
+//  private static long fPOOR_PERFORMANCE_THRESHOLD;
+//  
+//  /** Item configured in web.xml.  */
+//  private static final InitParam fCharacterEncoding = new InitParam(
+//    "CharacterEncoding", "UTF-8"
+//  ); 
+//  /**
+//   Character encoding for this application. 
+//   
+//   <P>The Controller will assume that every request will have this 
+//   character encoding. In addition, this value will be placed in an 
+//   application scope attribute named {@link Controller#CHARACTER_ENCODING}; 
+//  */
+//  private static String fCHARACTER_ENCODING;
+//  
+//  /** Item configured in web.xml.  */
+//  private static final InitParam fDefaultLocale = new InitParam(
+//    "DefaultLocale", "en"
+//  );
+//  /**
+//   Default Locale for this application. 
+//   
+//   <P>Placed in an app scope attribute named {@link Controller#LOCALE} (as a 
+//   Locale object, not as a String). 
+//  */
+//  private static String fDEFAULT_LOCALE;
+//  
+//  private static final InitParam fDefaultTimeZone = new InitParam(
+//    "DefaultUserTimeZone", "GMT"
+//  );
+//  /**
+//   Default TimeZone for this application. 
+//   
+//   <P>Placed in an app scope attribute named {@link Controller#TIME_ZONE} (as a 
+//   TimeZone object, not as a String). 
+//  */
+//  private static String fDEFAULT_TIME_ZONE;
+//
+//  /** Item configured in web.xml.  */
+//  private static final InitParam fWebmaster = new InitParam("Webmaster");
+//  
+//  /**
+//  Webmaster email address for this application. 
+//   
+//   <P>This value will be placed in an application scope attribute 
+//   named {@link Controller#WEBMASTER}; 
+//  */
+//  private static String fWEBMASTER;
+  
+  private static final boolean DO_NOT_CREATE_SESSION = false;
+  
+  private static final String OWNERSHIP_NO_SESSION =  
+    "According to the configured UntrustedProxyForUserId implementation, the requested operation has an ownership constraint. " + 
+    "However, this request has no session, and ownership constraints work only when the user has logged in." 
+  ;
+ 
+  private static final String OWNERSHIP_NO_LOGIN = 
+    "According to the configured UntrustedProxyForUserId implementation, the requested operation has an ownership constraint. " + 
+    "A session exists, but there is no valid login, and ownership constraints work only when the user has logged in." 
+  ;
+  
+  private static final Logger fLogger = Util.getLogger(Controller.class);
+
+  private void logClasses(HttpServletRequest aRequest, HttpServletResponse aResponse) {
+    fLogger.finest("Request class :" + aRequest.getClass());
+    fLogger.finest("Response class :" + aResponse.getClass());
+  }
+
+  private void redirect (
+    ResponsePage aDestinationPage, HttpServletResponse aResponse
+  ) throws IOException {
+    String urlWithSessionID = aResponse.encodeRedirectURL(aDestinationPage.toString());
+    fLogger.fine("REDIRECT: " + Util.quote(urlWithSessionID));
+    aResponse.sendRedirect( urlWithSessionID );
+  }
+
+  private void forward (
+    ResponsePage aResponsePage, HttpServletRequest aRequest, HttpServletResponse aResponse
+  ) throws ServletException, IOException {
+    ResponsePage responsePage = possiblyAlterForLocale(aResponsePage, aRequest);
+    RequestDispatcher dispatcher = aRequest.getRequestDispatcher(responsePage.toString());
+    fLogger.fine("Forward : " + responsePage);
+    dispatcher.forward(aRequest, aResponse);
+  }
+  
+  private ResponsePage possiblyAlterForLocale(ResponsePage aNominalForward, HttpServletRequest aRequest){
+    Locale locale = BuildImpl.forLocaleSource().get(aRequest);
+    ResponsePage langSpecificForward = swapResponsePage(aNominalForward, locale);
+    if ( langSpecificForward != null && langSpecificForward.getIsRedirect() ){
+      throw new RuntimeException(
+        "A 'forward' ResponsePage has been altered for Locale, but is no longer a forward : " + langSpecificForward
+      );
+    }
+    return (langSpecificForward != null) ?  langSpecificForward : aNominalForward;
+  }
+  
+  private Map<String, String> asMap(ServletConfig aConfig){
+    Map<String, String> result = new LinkedHashMap<String, String>();
+    Enumeration initParamNames = aConfig.getInitParameterNames();
+    while ( initParamNames.hasMoreElements() ){
+      String name = (String)initParamNames.nextElement();
+      String value = aConfig.getInitParameter(name);
+      result.put(name, value);
+    }
+    return result;
+  }
+  
+  private void displaySystemProperties(){
+    String sysProps = Util.logOnePerLine(System.getProperties());
+    fLogger.config("System Properties " + sysProps);
+  }
+  
+  private void displayVersionInfo(ServletConfig aConfig, ApplicationInfo aAppInfo){
+    ServletContext context = aConfig.getServletContext();
+    Map<String, String> info = new LinkedHashMap<String, String>();
+    info.put("Application", aAppInfo.getName() + "/" +  aAppInfo.getVersion());
+    info.put("Server", context.getServerInfo());
+    info.put("Servlet API Version", context.getMajorVersion() + "." +  context.getMinorVersion() );
+    if( JspFactory.getDefaultFactory() != null) {
+      //this item is null when outside the normal runtime environment.
+      info.put("Java Server Page API Version", JspFactory.getDefaultFactory().getEngineInfo().getSpecificationVersion());
+    }
+    info.put("Java Runtime Environment (JRE)", System.getProperty("java.version"));
+    info.put("Operating System", System.getProperty("os.name") + "/" + System.getProperty("os.version") );
+    info.put("WEB4J Version", WEB4J_VERSION);
+    fLogger.config("Versions" + Util.logOnePerLine(info));
+  }
+  
+  private void displayConfigInfo(ServletConfig aConfig){
+    fLogger.config(
+      "Context Name : " + Util.quote(aConfig.getServletContext().getServletContextName()) 
+    );
+    
+    Enumeration ctxParamNames = aConfig.getServletContext().getInitParameterNames();
+    Map<String, String> ctxParams = new LinkedHashMap<String, String>();
+    while ( ctxParamNames.hasMoreElements() ){
+      String name = (String)ctxParamNames.nextElement();
+      String value = aConfig.getServletContext().getInitParameter(name);
+      ctxParams.put(name, value);
+    }
+    fLogger.config( "Context Params : " + Util.logOnePerLine(ctxParams));
+    
+    Enumeration initParamNames = aConfig.getInitParameterNames();
+    Map<String, String> initParams = new LinkedHashMap<String, String>();
+    while ( initParamNames.hasMoreElements() ){
+      String name = (String)initParamNames.nextElement();
+      String value = aConfig.getInitParameter(name);
+      initParams.put(name, value);
+    }
+    fLogger.config( "Servlet Params : " + Util.logOnePerLine(initParams));
+  }
+
+  private void setCharacterEncodingAndPutIntoAppScope(ServletConfig aConfig){
+    aConfig.getServletContext().setAttribute(CHARACTER_ENCODING, fConfig.getCharacterEncoding());
+  }
+  
+  private void putWebmasterEmailAddressIntoAppScope(ServletConfig aConfig){
+    aConfig.getServletContext().setAttribute(WEBMASTER, fConfig.getWebmaster());
+  }
+
+  private void putDefaultLocaleIntoAppScope(ServletConfig aConfig){
+    aConfig.getServletContext().setAttribute(LOCALE, fConfig.getDefaultLocale());
+  }
+  
+  private void putDefaultTimeZoneIntoAppScope(ServletConfig aConfig){
+    aConfig.getServletContext().setAttribute(TIME_ZONE, fConfig.getDefaultUserTimeZone());
+  }
+  
+  private void putStartTimeIntoAppScope(ServletConfig aConfig){
+    aConfig.getServletContext().setAttribute(START_TIME, DateTime.now(fConfig.getDefaultUserTimeZone()));
+  }
+  
+  private void placeAppInfoIntoAppScope(ServletConfig aConfig, ApplicationInfo aAppInfo){
+    aConfig.getServletContext().setAttribute(
+      ApplicationInfo.KEY, aAppInfo
+    );
+  }
+
+  /**
+  Log attributes stored in the various scopes.
+  */
+  private void logAttributesForAllScopes(HttpServletRequest aRequest){
+    //the following style is conservative, and is meant to avoid calls which may be expensive
+    //remember that the level of the HANDLER affects whether the item is emitted as well.
+    if( fLogger.getLevel() != null &&  fLogger.getLevel().equals(Level.FINEST) ) {
+      fLogger.finest("Application Scope Items " + Util.logOnePerLine(getApplicationScopeObjectsForLogging(aRequest)));
+      fLogger.finest("Session Scope Items " + Util.logOnePerLine(getSessionScopeObjectsForLogging(aRequest)));
+      fLogger.finest("Request Parameter Names " + Util.logOnePerLine(getRequestParamNamesForLogging(aRequest)));
+    }
+  }
+
+  /**
+  Return Map of name-value pairs of items in application scope. 
+ 
+  <P>In many cases, the actual data will be quite lengthy. For instance, translation data is often 
+  sizeable. Thus, this should be called only when logging at the highest level. 
+  Logging should only be performed after the {@link ApplicationFirewall} has executed.
+  */
+  private Map<String, Object> getApplicationScopeObjectsForLogging(HttpServletRequest aRequest){
+    Map<String, Object> result = new LinkedHashMap<String, Object>();
+    HttpSession session = aRequest.getSession(DO_NOT_CREATE_SESSION);
+    if ( session != null ){
+      ServletContext appScope = session.getServletContext();
+      Enumeration objNames = appScope.getAttributeNames();
+      while ( objNames.hasMoreElements() ){
+        String name = (String)objNames.nextElement();
+        result.put(name, appScope.getAttribute(name));
+      }
+    }
+    return result;
+  }
+  
+  /**
+   Return a Map of keys and objects for each session attribute.
+   Logging should only be performed after the {@link ApplicationFirewall} has executed.
+  */
+  private Map<String, Object> getSessionScopeObjectsForLogging(HttpServletRequest aRequest){
+    Map<String, Object> result = new LinkedHashMap<String, Object>();
+    HttpSession session = aRequest.getSession(DO_NOT_CREATE_SESSION);
+    if ( session != null ){
+      result.put( "(Session Created) : ", DateTime.forInstant(session.getCreationTime(), fConfig.getDefaultUserTimeZone()));
+      result.put( "(Session Timeout - seconds) : ",  new Integer(session.getMaxInactiveInterval()) );
+      Enumeration objNames = session.getAttributeNames();
+      while ( objNames.hasMoreElements() ){
+        String name = (String)objNames.nextElement();
+        result.put(name, session.getAttribute(name));
+      }
+    }
+    return result;
+  }
+  
+  /**
+   Return a Map of key names, objects for each request scope attribute.
+   Logging should only be performed after the {@link ApplicationFirewall} has executed.
+  */
+  private Map<String, Object> getRequestParamNamesForLogging(HttpServletRequest aRequest) {
+    Map<String, Object> result = new LinkedHashMap<String, Object>();
+    Map input = aRequest.getParameterMap();
+    Iterator iter = input.keySet().iterator();
+    while( iter.hasNext() ) {
+      String key = (String)iter.next();
+        result.put(key, aRequest.getAttribute(key));
+    }
+    return result;
+  }
+  
+  private void addCurrentUriToRequest(HttpServletRequest aRequest, HttpServletResponse aResponse){
+    String currentURI = WebUtil.getOriginalRequestURL(aRequest, aResponse);
+    aRequest.setAttribute(CURRENT_URI, currentURI);
+  }
+
+   private void tryDatabaseInitAndStartupTasks(ConnectionSource aConnSrc) throws DAOException, AppException {
+    fLogger.config("Trying database init and startup tasks.");
+    fStartupTasks = BuildImpl.forStartupTasks();
+    Set<String> dbNames = aConnSrc.getDatabaseNames();
+    fBadDatabases.addAll(dbNames); //guilty till proven innocent
+    if (aConnSrc.getDatabaseNames().isEmpty()) {
+      fLogger.config("No databases in this application, since ConnectionSource returns an empty Set for database names.");
+      startTasksWithNoDb();
+    }
+    else {
+      fLogger.config("Attempting data layer startup tasks.");
+      Set<String> healthyDbs = DbConfig.initDataLayer(); //reads in .sql
+      for (String healthyDb : healthyDbs){
+        fBadDatabases.remove(healthyDb);
+      }
+      startTasksWithNoDb();
+      //start-tasks for the good databases can be run now; the bad ones run later, when they get healthy
+      for(String dbName : dbNames){
+        if (! fBadDatabases.contains(dbName)){
+          fLogger.config("Startup tasks for database: " + dbName);
+          fStartupTasks.startApplication(fServletConfig, dbName);
+        }
+      }
+      if (! fBadDatabases.isEmpty()){
+        fLogger.config("Databases seen to be down at startup: " + fBadDatabases);
+      }
+    }
+  }
+   
+  private void startTasksWithNoDb() throws AppException{
+    initDefaultImplementations();
+    fLogger.config("Startup tasks not needing a database.");
+    fStartupTasks.startApplication(fServletConfig, ""); //tasks not related to a database at all are done first
+  }
+
+  /** 
+   Warning - this method is called after startup. Therefore it must be thread-safe.
+   When a database goes from 'bad' to 'good', then this Controller needs to acquire 
+   a lock on an object; in a sense, it temporarily goes back to 'init-mode', which is 
+   single-threaded. That is, it's possible that N callers can detect a 
+   bad-to-good transition quasi-simultaneously; they will need to compete for the lock. 
+   But this only happens when there's a change; it doesn't happen for every 
+   invocation of this method. In practice, this small amount of possible blocking 
+   will be acceptable.  
+  */
+  private void recheckBadDatabasesAndFinishStartup() throws AppException{
+    if (! fBadDatabases.isEmpty()){
+      Iterator<String> bad = fBadDatabases.iterator();
+      while(bad.hasNext()){
+        String thisDb = bad.next();
+        boolean healthy = DbConfig.checkHealthOf(thisDb);
+        if (healthy) {
+          synchronized (fBadDatabases) {
+            if(fBadDatabases.contains(thisDb)){ //note the second check, to avoid race conditions
+              fStartupTasks.startApplication(fServletConfig, thisDb);
+              bad.remove();
+            }
+          }
+        }
+      }
+    }
+  }
+  
+  /**
+   Must call just before {@link StartupTasks}. 
+   
+   <P>This ensures WEB4J does not mistakenly perform such initialization at a time other than   
+   that available to {@link StartupTasks}. If custom impl's are used, the only place to init them 
+   is in StartupTasks. It is prudent to do the init of default impls at the same place, to ensure 
+   the defaults don't 'cheat', or have any unfair advantage over custom impls.
+  */
+  private void initDefaultImplementations(){
+    fLogger.config("Initializing web4j default implementations.");
+    ApplicationFirewallImpl.init();
+    RequestParserImpl.initWebActionMappings();  
+  }
+  
+  private ResponsePage checkOwnershipThenExecuteAction(Action aAction, RequestParser aRequestParser) throws AppException, BadRequestException {
+    UntrustedProxyForUserId ownershipFirewall = BuildImpl.forOwnershipFirewall();
+    if ( ownershipFirewall.usesUntrustedIdentifier(aRequestParser) ) {
+      fLogger.fine("This request has an ownership constraint.");
+      enforceOwnershipConstraint(aAction, aRequestParser);
+    }
+    else {
+      fLogger.fine("No ownership constraint detected.");
+      if(aAction instanceof FetchIdentifierOwner) {
+        fLogger.warning("Action implements FetchIdentifierOwner, but no ownership constraint is defined in web.xml for this specific operation.");
+      }
+    }
+    return aAction.execute();
+  }
+
+  private void enforceOwnershipConstraint(Action aAction, RequestParser aRequestParser) throws AppException, BadRequestException {
+    if (aAction instanceof FetchIdentifierOwner ) {
+      FetchIdentifierOwner constraint = (FetchIdentifierOwner)aAction;
+      Id owner = constraint.fetchOwner();
+      String ownerText = (owner == null ? null : owner.getRawString());
+      HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE_SESSION);
+      if( session == null ) {
+        ownershipConstraintNotImplementedCorrectly(OWNERSHIP_NO_SESSION);
+      }
+      if( aRequestParser.getRequest().getUserPrincipal() == null ) {
+        ownershipConstraintNotImplementedCorrectly(OWNERSHIP_NO_LOGIN);
+      }
+      String loggedInUserName = aRequestParser.getRequest().getUserPrincipal().getName();
+      if ( ! loggedInUserName.equals(ownerText) ) {
+        fLogger.severe(
+          "Violation of an ownership constraint! " + 
+          "The currently logged in user-name ('" + loggedInUserName + "') does not match the name of the data-owner ('" + ownerText + "')." 
+        );
+        throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST, "Ownership Constraint has been violated.");
+      }
+    }
+    else {
+      ownershipConstraintNotImplementedCorrectly(
+        "According to the configured UntrustedProxyForUserId implementation, the requested operation has an ownership constraint. " + 
+        "Such constraints require the Action to implement the FetchIdentifierOwner interface, but this Action doesn't implement that interface." 
+      );
+    }
+    fLogger.fine("Ownership constraint has been validated.");
+  }
+  
+  private void ownershipConstraintNotImplementedCorrectly(String aMessage){
+    fLogger.severe(aMessage + " Please see the User Guide for more information on Ownership Constraints.");
+    throw new RuntimeException("Ownership Constraint not implemented correctly.");
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/LoginTasksHelper.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,39 @@
+package hirondelle.web4j;
+
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.security.LoginTasks;
+import hirondelle.web4j.util.Util;
+
+import java.util.logging.Logger;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+final class LoginTasksHelper {
+
+  void reactToNewLogins(HttpServletRequest aRequest) throws AppException {
+    HttpSession session = aRequest.getSession(DO_NOT_CREATE);
+    if ( sessionExists(session) ){
+      if( userHasLoggedIn(aRequest) ){
+        LoginTasks loginTasks = BuildImpl.forLoginTasks();
+        if(! loginTasks.hasAlreadyReacted(session)){
+          fLogger.fine("New login detected.");
+          loginTasks.reactToUserLogin(session, aRequest);          
+        }
+      }
+    }
+  }
+  
+  // PRIVATE 
+  
+  private static final boolean DO_NOT_CREATE = false;
+  private static final Logger fLogger = Util.getLogger(Controller.class);
+  
+  private boolean sessionExists(HttpSession aSession){
+    return aSession != null;
+  }
+  
+  private boolean userHasLoggedIn(HttpServletRequest aRequest){
+    return aRequest.getUserPrincipal() != null;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/StartupTasks.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,79 @@
+package hirondelle.web4j;
+
+import javax.servlet.ServletConfig;
+
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.database.ConnectionSource;
+
+/**
+ Perform startup tasks.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forStartupTasks()} 
+ returns the configured implementation of this interface.
+
+ <P>Allows the application programmer to perform any needed initialization tasks. 
+ 
+ <P>These tasks are performed only once, upon startup (caveat below). 
+ For example, the application may need to place items into application scope. Here's an example 
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/Startup.html">implementation</a> 
+ taken from an example application.
+ 
+ <P>
+ See {@link hirondelle.web4j.database.ConnectionSource#init(java.util.Map)}, for startup 
+ tasks related to database connections. 
+ 
+ <P>Startup tasks often depend on a database. If all of your databases are running when your app 
+ starts up, then all startup tasks will be completed during start-up. 
+ 
+ <P>However, sometimes a database may be down when your application starts up.
+ In that case, web4j will keep track of which databases are down. Later, when a request comes into its Controller,
+ it will re-test each offending database, to see if it's now running; when the database is seen to be running, then 
+ web4j will then pass the name of the database to {@link #startApplication(ServletConfig, String)}.
+ (This processing takes place after startup, and thus, in a multi-threaded environment. The framework performs 
+ the necessary external synchronization of this class.) 
+*/
+public interface StartupTasks {
+  
+  /**
+   Perform any startup tasks needed by the application.
+   <P>Possible tasks include:
+   <ul>
+   <li>disseminate values configured in <tt>web.xml</tt>
+   <li>place items into application scope, such as code tables read from the database
+   <li>set the default {@link java.util.TimeZone}
+   </ul>
+   
+   <P>This method is called by WEB4J near the end of startup processing.  
+   It's first called with an empty String for the database name; this is intended for any 
+   tasks that may be unrelated to any database at all.
+   It's then called again, once for each database defined by {@link ConnectionSource#getDatabaseNames()} (in 
+   the iteration order of the <tt>Set</tt> returned by that method).  
+   
+   <P>Example of a typical implementation:
+<pre>
+public void startApplication(ServletConfig aConfig, String aDbName) throws DAOException {
+  if (!Util.textHasContent(aDbName)){
+    //tasks not related to any db
+  }
+  else if (ConnectionSrc.DEFAULT.equals(aDbName)){
+    //tasks related to this db
+  }
+  else if (ConnectionSrc.TRANSLATION.equals(aDbName)){
+    //tasks related to this db
+  }
+}</pre>
+
+ <P>This method is called N+1 times by the framework, where N is the number of databases. 
+ The framework has confirmed that the database is running before it calls this method.
+   
+   @param aConfig is not a Map, as in other interfaces, since startup tasks 
+   often need more than just settings in <tt>web.xml</tt> - for example, placing code tables in app scope will 
+   need access to the <tt>ServletContext</tt>.
+   @param aDatabaseName refers to one of the names defined by {@link ConnectionSource#getDatabaseNames()}, 
+   or an empty string. Implementations of this method should reference those names directly, instead of 
+   hard-coding strings.
+  */
+  void startApplication(ServletConfig aConfig, String aDatabaseName) throws AppException;
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/TESTAll.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,177 @@
+package hirondelle.web4j;
+
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.model.TESTCheck;
+import hirondelle.web4j.model.TESTComparePossiblyNull;
+import hirondelle.web4j.model.TESTDateTime;
+import hirondelle.web4j.model.TESTDateTimeFormatter;
+import hirondelle.web4j.model.TESTDateTimeInterval;
+import hirondelle.web4j.model.TESTDecimal;
+import hirondelle.web4j.model.TESTEqualsUtil;
+import hirondelle.web4j.model.TESTHashCodeUtil;
+import hirondelle.web4j.model.TESTMessageSerialization;
+import hirondelle.web4j.model.TESTPerformanceSnapshot;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.TESTDbConfigParser;
+import hirondelle.web4j.request.DateConverter;
+import hirondelle.web4j.security.PermittedCharacters;
+import hirondelle.web4j.security.PermittedCharactersImpl;
+import hirondelle.web4j.security.TESTPermittedCharactersImpl;
+import hirondelle.web4j.ui.tag.TESTFormPopulator;
+import hirondelle.web4j.ui.tag.TESTHighlightCurrentPage;
+import hirondelle.web4j.ui.translate.TESTText;
+import hirondelle.web4j.ui.translate.TESTTranslateTextFlow;
+import hirondelle.web4j.ui.translate.TESTTranslateTooltip;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.TESTEscapeChars;
+import hirondelle.web4j.util.TESTRegex;
+import hirondelle.web4j.util.TESTUtil;
+import hirondelle.web4j.util.TESTWebUtil;
+import hirondelle.web4j.util.TimeSource;
+import hirondelle.web4j.util.TimeSourceImpl;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import junit.framework.Test;
+import junit.framework.TestCase;
+import junit.framework.TestSuite;
+
+/**
+ Runs all <a href="http://www.junit.org">JUnit</a> tests defined for WEB4J.
+
+ <P>This class is not useful for the application programmer.
+ 
+ <P>Note that:
+<ul>
+ <li>not all classes have an associated JUnit test.
+ <li>these tests may depend on details regarding the development environment, such as 
+ file locations.
+</ul>
+*/
+public final class TESTAll extends TestCase {
+  
+  /** Run the test cases.  */
+   public static void main(String args[]) {
+     //just use the default implementations
+     BuildImpl.adHocImplementationAdd(TimeSource.class, TimeSourceImpl.class);
+     BuildImpl.adHocImplementationAdd(PermittedCharacters.class,  PermittedCharactersImpl.class);
+     //use an impl for testing
+     BuildImpl.adHocImplementationAdd(DateConverter.class,  DateConverterImpl.class);
+     
+     String[] testCaseName = { TESTAll.class.getName()};
+     
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+     
+     //turn off logging if desired :
+     //fLogger.setLevel(Level.OFF);
+  }
+   
+  public static Test suite ( ) {
+    TestSuite suite= new TestSuite("All JUnit Tests");
+
+    //model package
+    suite.addTest(new TestSuite(TESTCheck.class));
+    suite.addTest(new TestSuite(TESTPerformanceSnapshot.class));
+    suite.addTest(new TestSuite(TESTDecimal.class));
+    suite.addTest(new TestSuite(TESTDateTime.class));
+    suite.addTest(new TestSuite(TESTDateTimeFormatter.class));
+    suite.addTest(new TestSuite(TESTDateTimeInterval.class));
+    suite.addTest(new TestSuite(TESTComparePossiblyNull.class));
+    suite.addTest(new TestSuite(TESTHashCodeUtil.class));
+    suite.addTest(new TestSuite(TESTEqualsUtil.class)); 
+    suite.addTest(new TestSuite(TESTMessageSerialization.class));
+    
+    //util package
+    suite.addTest(new TestSuite(TESTRegex.class));
+    suite.addTest(new TestSuite(TESTEscapeChars.class));
+    suite.addTest(new TestSuite(TESTUtil.class));
+    
+    //webUtil package
+    suite.addTest(new TestSuite(TESTWebUtil.class));
+    
+    //ui.tag package
+    suite.addTest(new TestSuite(TESTHighlightCurrentPage.class));
+    suite.addTest(new TestSuite(TESTFormPopulator.class));
+    
+    //ui.translate
+    suite.addTest(new TestSuite(TESTTranslateTextFlow.class));
+    suite.addTest(new TestSuite(TESTTranslateTooltip.class));
+    suite.addTest(new TestSuite(TESTText.class));
+    
+    //database package
+    suite.addTest(new TestSuite(TESTDbConfigParser.class));
+    
+    //security package 
+    suite.addTest(new TestSuite(TESTPermittedCharactersImpl.class));
+    
+    return suite;
+  }
+
+  /** An implementation of DateConverter, used during unit testing. */
+  public static final class DateConverterImpl implements DateConverter {
+    //Dates:
+    public String formatEyeFriendly(Date aDate, Locale aLocale, TimeZone aTimeZone) {
+      SimpleDateFormat format = new SimpleDateFormat("MM/dd/yyyy");
+      //format.setTimeZone(aTimeZone);
+      return format.format(aDate);
+    }
+    public Date parseHandFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone) {
+      return parseDate(aInputValue, HAND_FRIENDLY_REGEX);
+    }
+    public Date parseEyeFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone) {
+      return parseDate(aInputValue, EYE_FRIENDLY_REGEX);
+    }
+    //DateTimes:
+    public String formatEyeFriendlyDateTime(DateTime aDateTime, Locale aLocale) {
+      return aDateTime.format("MM/DD/YYYY", aLocale);
+    }
+    public DateTime parseEyeFriendlyDateTime(String aInputValue, Locale aLocale) {
+      return parseDateTime(aInputValue, EYE_FRIENDLY_REGEX);
+    }
+    public DateTime parseHandFriendlyDateTime(String aInputValue, Locale aLocale) {
+      return parseDateTime(aInputValue, HAND_FRIENDLY_REGEX);
+    }
+    private static final Pattern HAND_FRIENDLY_REGEX = 
+     Pattern.compile(Regex.MONTH + Regex.DAY_OF_MONTH + "(\\d\\d\\d\\d)");
+    ;
+    private static final Pattern EYE_FRIENDLY_REGEX = 
+      Pattern.compile(Regex.MONTH + "/" + Regex.DAY_OF_MONTH + "/" + "(\\d\\d\\d\\d)")
+    ;
+    private Date parseDate(String aInputValue, Pattern aRegex){
+      Date result = null;
+      Matcher matcher = aRegex.matcher(aInputValue);
+      if( matcher.matches() ) {
+        Integer month = new Integer(matcher.group(Regex.FIRST_GROUP));
+        Integer day = new Integer(matcher.group(Regex.SECOND_GROUP));
+        Integer year = new Integer( matcher.group(Regex.THIRD_GROUP) );
+        Calendar cal = new GregorianCalendar(year.intValue(), month.intValue() - 1, day.intValue(), 0,0,0);
+        result = cal.getTime();
+      }
+      return result;
+    }
+    private DateTime parseDateTime(String aInputValue, Pattern aRegex){
+      DateTime result = null;
+      Matcher matcher = aRegex.matcher(aInputValue);
+      if( matcher.matches() ) {
+        Integer month = new Integer(matcher.group(Regex.FIRST_GROUP));
+        Integer day = new Integer(matcher.group(Regex.SECOND_GROUP));
+        Integer year = new Integer( matcher.group(Regex.THIRD_GROUP) );
+        result = DateTime.forDateOnly(year, month, day);
+      }
+      return result;
+    }
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/Action.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,36 @@
+package hirondelle.web4j.action;
+
+import hirondelle.web4j.model.AppException;
+
+/**
+ <span class="highlight">
+ Process an HTTP request, and return a {@link ResponsePage}.
+ </span>
+
+ <P><b>This interface is likely the most important 
+ abstraction in WEB4J.</b> Almost every feature implemented by the programmer will 
+ need an implementation of this interface. 
+ 
+ <P>Typically, one of the <em>ActionXXX</em> abstract base classes are used to 
+ build implementations of this interface.
+*/
+public interface Action {
+  
+  /**
+  Execute desired operation.
+   
+  <P>Typical operations include : 
+  <ul>
+  <li>validate user input
+  <li>interact with the database 
+  <li>place required objects in the appropriate scope
+  <li>set the appropriate {@link ResponsePage}.
+  </ul>
+  
+  <P>Returns an identifier for the resource (for example a JSP) which 
+  will display the end result of this <tt>Action</tt> (using either a 
+  forward or a redirect).
+  */
+  ResponsePage execute() throws AppException;
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/ActionImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,583 @@
+package hirondelle.web4j.action;
+
+import static hirondelle.web4j.util.Consts.SPACE;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.database.DynamicSql;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.model.MessageList;
+import hirondelle.web4j.model.MessageListImpl;
+import hirondelle.web4j.request.LocaleSource;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.request.TimeZoneSource;
+import hirondelle.web4j.security.CsrfFilter;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.logging.Logger;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpSession;
+
+/**
+ Abstract Base Class (ABC) for implementations of the {@link Action} interface.
+
+ <P>This ABC provides concise methods for common operations, which will make 
+ implementations read more clearly, concisely, and at a higher level of abstraction.
+
+ <P>A simple fetch-and-display operation can often be implemented using this class 
+ as a base class. However, operations involving user input and/or edits to the 
+ datastore should very likely use other abstract base classes, such as 
+ {@link ActionTemplateListAndEdit}, {@link ActionTemplateSearch}, and 
+ {@link hirondelle.web4j.action.ActionTemplateShowAndApply}.
+ 
+ <P>This class places success/fail messages in session scope, not request scope. 
+ This is because such messages often need to survive a redirect operation. 
+ For example, when a successful edit to the database occurs, a <em>redirect</em> is 
+ usually performed, to avoid problems with browser reloads. 
+ The only way a success message can survive a redirect is by being placed 
+ in session scope.
+
+<P><em>This class assumes that a session already exists</em>. 
+ If a session does not already exist, then calling such methods will result in an error.
+ In practice, the user will almost always have already logged in, and this will not 
+ be a problem.  As a backup, actions can always explicitly create a session, if needed, 
+ by calling 
+ <PRE>getRequestParser.getRequest().getSession(true);</PRE>
+*/
+public abstract class ActionImpl implements Action {
+  
+  
+  /**
+   Value {@value} - identifies a {@link hirondelle.web4j.model.MessageList}, placed 
+   in session scope, to hold error information for the end user. 
+   These errors are used by both WEB4J and the application programmer.
+  */
+  public static final String ERRORS = "web4j_key_for_errors";
+   
+  /**
+   Value {@value} - identifies a {@link hirondelle.web4j.model.MessageList}, placed 
+   in session scope, to hold messages for the end user. These messages are 
+   used by both WEB4J and the application programmer. They typically hold success 
+   and information messages.
+  */
+  public static final String MESSAGES = "web4j_key_for_messages";
+
+  /**
+  Value {@value} - identifies the user's id, placed in session scope. 
+   
+  <P>Many applications will benefit from having <i>both</i> the user id <i>and</i> the user login name 
+  placed in session scope upon login. The Servlet Container will place the user <i>login name</i> 
+  in session scope upon login, but it will not place the corresponding <i>user id</i> 
+  (the database's primary key of the user record) in session scope. 
+  
+  <P>If an application chooses to place the user's underlying database id into session scope under 
+  this USER_ID key, then the user's id will be returned by {@link #getUserId()}. 
+  */
+  public static final String USER_ID = "web4j_key_for_user_id";
+  
+  /** 
+   Value {@value} - generic key for an object placed in scope for a JSP.
+   <P>Not mandatory to use this generic key. Provided simply as a convenience. 
+  */
+  public static final String ITEM_FOR_EDIT = "itemForEdit";
+  
+  /** 
+   Value {@value} - generic key for a collection of objects placed in scope for a JSP. 
+   <P>Not mandatory to use this generic key. Provided simply as a convenience. 
+  */
+  public static final String ITEMS_FOR_LISTING = "itemsForListing";
+  
+  /** 
+  Value {@value} - generic key for a single 'data' object placed in scope for a JSP.
+  Usually used with structured data, such as JSON, XML, CSV, and so on. 
+  <P>Not mandatory to use this generic key. Provided simply as a convenience. 
+  */
+  public static final String DATA = "data";
+
+  /**
+   Constructor. 
+   
+   <P>This constructor will add an attribute named <tt>'Operation'</tt> to the request. Its 
+   value is deduced as specified by {@link #getOperation()}. This attribute is intended for JSPs, 
+   which can use it to access the <tt>Operation</tt> regardless of its original source.
+   
+   @param aNominalPage simply one of the possible {@link ResponsePage}s, 
+   arbitrarily chosen as a "default". It may be changed after construction 
+   by calling {@link #setResponsePage}. Recommended that the "success" page
+   be chosen as the nominal page. If not, then selection of any of the 
+   possible {@link ResponsePage}s is acceptable.
+   
+   @param aRequestParser allows parsing of request parameters into higher level java objects.
+  */
+  protected ActionImpl(ResponsePage aNominalPage, RequestParser aRequestParser) {
+    fFinalResponsePage = aNominalPage;
+    fRequestParser = aRequestParser;
+    fErrors = new AppException();
+    fMessages = new MessageListImpl();
+    fLocale = BuildImpl.forLocaleSource().get(fRequestParser.getRequest());
+    fTimeZone = BuildImpl.forTimeZoneSource().get(fRequestParser.getRequest());
+    fOperation = parseOperation();
+    addToRequest("Operation", fOperation);
+    fLogger.fine("Operation: " + fOperation);
+  }
+
+  public abstract ResponsePage execute() throws AppException;
+
+  /** Return the resource which will render the final result.  */
+  public final ResponsePage getResponsePage(){
+    return fFinalResponsePage;
+  }
+  
+  /** 
+   Return the {@link Operation} associated with this <tt>Action</tt>, if any.
+   
+   <P>The <tt>Operation</tt> is found as follows :
+   <ol>
+   <li>if there is a request parameter named <tt>'Operation'</tt>, and it has a value, pass its value to 
+   {@link Operation#valueFor(String)} 
+   <li>if the above style fails, then the 'extension' is examined. For example, a request to <tt>.../MyAction.list?x=1</tt> 
+    would result in {@link Operation#List} being added to request scope, since the extension value <tt>list</tt> is known to 
+    {@link Operation#valueFor(String)}.
+    This style is useful for implementing fine-grained <tt>&lt;security-constraint&gt;</tt>
+    items in <tt>web.xml</tt>. See the User Guide for more information.
+    <li>if both of the above methods fail, return <tt>null</tt>
+   </ol>
+   
+   <P>When using the 'extension' style, please note that <tt>web.xml</tt> contains related <tt>servlet-mapping</tt> settings.
+   Such settings control which HTTP requests (as defined by a <tt>url-pattern</tt>) are passed from the Servlet Container to  
+   your application in the first place. Thus, <b>any 'extensions' which your application intends to use must have a corresponding  
+   <tt>servlet-mapping</tt> setting in your <tt>web.xml</tt></b>. 
+  */
+  protected final Operation getOperation(){
+    return fOperation;
+  }
+
+  /**
+  Return the name of the logged in user. 
+  
+  <P>By definition in the servlet specification, a successfully logged in user 
+  will always have a non-<tt>null</tt> return value for 
+  {@link javax.servlet.http.HttpServletRequest#getUserPrincipal()}.
+  
+  <P>If the user is not logged in, this method will always return <tt>null</tt>.
+  
+  <P>This method returns {@link SafeText}, not a <tt>String</tt>. 
+  The user name is often rendered in the view. Since in general the user name
+   may contain special characters, it is appropriate to model it as 
+  <tt>SafeText</tt>. 
+  */
+  protected final SafeText getLoggedInUserName(){
+    Principal principal = fRequestParser.getRequest().getUserPrincipal();
+    return principal == null ? null : new SafeText(principal.getName());
+  }
+  
+  /**
+  Return the {@link Id} stored in session scope under the key {@link #USER_ID}.
+  If no such item is found, then return <tt>null</tt>.
+  
+   <P><style class='highlight'>This internal database identifier should never be served to the client, since that 
+   would be a grave security risk.</style> The user id should only be used in server-side code, and never
+   presented to the user in a JSP.
+  */
+  protected final Id getUserId(){
+    Id result = (Id) getFromSession(USER_ID);
+    return result;
+  }
+  
+  /**
+   Called by subclasses if the final {@link ResponsePage}
+   differs from the nominal one passed to the constructor.
+  
+   <P>If an implementation calls this method, it is usually 
+   called in {@link #execute}.
+  */
+  protected final void setResponsePage(ResponsePage aNewResponsePage){
+    fFinalResponsePage = aNewResponsePage;
+  }
+  
+  /**
+   Add a name-object pair to request scope.
+  
+   <P>If the pair already exists, it is <em>updated</em> with <tt>aObject</tt>.
+  
+   @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
+   @param aObject if <tt>null</tt> and a corresponding name-object pair exists, then
+   the pair is <em>removed</em> from request scope.
+  */
+  protected final void addToRequest(String aName, Object aObject){
+    Args.checkForContent(aName);
+    fRequestParser.getRequest().setAttribute(aName, aObject);
+  }
+
+  /**
+   Return the existing session. 
+   <P>If a session does not already exist, then an error will result.
+  */
+  protected final HttpSession getExistingSession(){
+    HttpSession result = fRequestParser.getRequest().getSession(DO_NOT_CREATE);
+    if ( result == null ) {
+      String MESSAGE = "No session currently exists. Either require user to login, or create a session explicitly.";
+      fLogger.severe(MESSAGE);
+      throw new UnsupportedOperationException(MESSAGE);
+    }
+    return result;
+  }
+  
+  /**
+   Add a name-object pair to an existing session.
+  
+   <P>If the pair already exists, it is <em>updated</em> with <tt>aObject</tt>.
+  
+   @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
+   @param aObject if <tt>null</tt> and a corresponding name-object pair exists, then
+   the pair is <em>removed</em> from session scope.
+  */
+  protected final void addToSession(String aName, Object aObject){
+    Args.checkForContent(aName);
+    getExistingSession().setAttribute(aName, aObject);
+  }
+  
+  /** Synonym for <tt>addToSession(aName, null)</tt>.   */
+  protected final void removeFromSession(String aName){
+    addToSession(aName, null);
+  }
+  
+  /**
+   Retrieve an object from an existing session, or <tt>null</tt> if no 
+   object is paired with <tt>aName</tt>. 
+  
+   @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
+  */
+  protected final Object getFromSession(String aName){
+    Args.checkForContent(aName);
+    return getExistingSession().getAttribute(aName);
+  }
+
+  /**
+   Place an object which is in an existing session into request scope
+   as well.
+  
+  <P>When serving the last page in a session, some session 
+   items may still be needed for rendering the final page.
+   
+   <P>For example, a log off page in a mutlilingual application might present a 
+   "goodbye" message in the language that the user was using. Since the 
+   session is being destroyed, the {@link Locale} stored in the session must be 
+   first copied into request scope before the session is killed.
+  
+   @param aName identifies an <tt>Object</tt> which is currently in session scope, and satisfies 
+   {@link hirondelle.web4j.util.Util#textHasContent(String)}. If no attribute of the given name 
+   is found in the current session, then a <tt>null</tt> is added to the request scope 
+   under this name.
+  */
+  protected final void copyFromSessionToRequest(String aName){
+    addToRequest( aName, getFromSession(aName) );
+  }
+
+  /** 
+   If a session exists, then it is invalidated.
+   This method should be called only when the user is logging out.  
+  */
+  protected final void endSession(){
+    if ( hasExistingSession() ) {
+      fLogger.fine("Session exists, and will now be ended.");
+      getExistingSession().invalidate();
+    }
+    else {
+      fLogger.fine("Session does not currently exist, so cannot be ended.");
+    }
+  }
+
+  /**
+   Add a simple {@link hirondelle.web4j.model.AppResponseMessage} describing a 
+   failed validation of user input, or a  failed datastore operation. 
+   <P>One of the <tt>addError</tt> methods must be called when a failure occurs.
+  */
+  protected final void addError(String aMessage){
+    fErrors.add(aMessage);
+    placeErrorsInSession();
+  }
+  
+  /**
+   Add a compound {@link hirondelle.web4j.model.AppResponseMessage} describing a 
+   failed validation of user input, or a  failed datastore operation. 
+   <P>One of the <tt>addError</tt> methods must be called when a failure occurs.
+  */
+  protected final void addError(String aMessage, Object... aParams){
+    fErrors.add(aMessage, aParams);
+    placeErrorsInSession();
+  }
+  
+  /**
+   Add all the error messages attached to <tt>aEx</tt>.  
+   <P>One of the <tt>addError</tt> methods must be called when a failure occurs.
+  */
+  protected final void addError(AppException aEx){
+    fErrors.add(aEx);
+    placeErrorsInSession();
+  }
+  
+  /**
+   Return all the errors passed to all <tt>addError</tt> methods.
+  */
+  protected final MessageList getErrors(){
+    return fErrors;
+  }
+  
+  /**
+   Return <tt>true</tt> only if at least one <tt>addError</tt> method has been called.
+  */
+  protected final boolean hasErrors(){
+    return fErrors.isNotEmpty();
+  }
+  
+  /**
+   Add a simple {@link hirondelle.web4j.model.AppResponseMessage}, to be displayed 
+   to the end user.
+  */
+  protected final void addMessage(String aMessage){
+    fMessages.add(aMessage);
+    placeMessagesInSession();
+  }
+  
+  /**
+   Add a compound {@link hirondelle.web4j.model.AppResponseMessage}, to be displayed 
+   to the end user.
+  */
+  protected final void addMessage(String aMessage, Object... aParams){
+    fMessages.add(aMessage, aParams);
+    placeMessagesInSession();
+  }
+  
+  /**
+   Return all messages passed to all <tt>addMessage</tt> methods
+  */
+  protected final MessageList getMessages(){
+    return fMessages;
+  }
+
+  /**
+   Return the {@link Locale} associated with the underlying request. 
+   
+   <P>The configured implementation of {@link LocaleSource} defines how 
+   <tt>Locale</tt> is looked up.  
+  */
+  protected final Locale getLocale(){
+    return fLocale;
+  }
+  
+  /**
+   Return the {@link TimeZone} associated with the underlying request. 
+   
+   <P>The configured implementation of {@link TimeZoneSource} defines how 
+   <tt>TimeZone</tt> is looked up.  
+  */
+  protected final TimeZone getTimeZone(){
+    return fTimeZone;
+  }
+  
+  /** Return the {@link RequestParser} passed to the constructor. */
+  protected final RequestParser getRequestParser(){
+    return fRequestParser;
+  }
+  
+  /**
+   Convenience method for retrieving a parameter as a simple <tt>Id</tt>.
+   
+   <P>Synonym for <tt>getRequestParser().toId(RequestParameter)</tt>.
+  */
+  protected final Id getIdParam(RequestParameter aReqParam){
+    return fRequestParser.toId(aReqParam);
+  }
+  
+  /**
+  Convenience method for retrieving a multivalued parameter as a simple {@code Collection<Id>}.
+  
+  <P>Synonym for <tt>getRequestParser().toIds(RequestParameter)</tt>.
+ */
+  protected final Collection<Id> getIdParams(RequestParameter aReqParam){
+    return fRequestParser.toIds(aReqParam);
+  }
+  
+  /**
+   Convenience method for retrieving a parameter as {@link SafeText}.
+   
+   <P>Synonym for <tt>getRequestParser().toSafeText(RequestParameter)</tt>.
+  */
+  protected final SafeText getParam(RequestParameter aReqParam){
+    return fRequestParser.toSafeText(aReqParam);
+  }
+  
+  /**
+   Convenience method for retrieving a parameter as raw text, with no escaped 
+   characters.
+   
+   <P>This method call is unsafe in the sense that it returns <tt>String</tt> 
+   instead of {@link SafeText}.  It is usually preferable to use {@link SafeText}, 
+   since it protects against Cross-Site Scripting attacks.
+   
+   <P>If, however, the caller needs to use a request parameter 
+   value <em>to perform a computation</em>, as opposed to presenting user 
+   data in markup, then this method is provided as a convenience.
+  */
+  protected final String getParamUnsafe(RequestParameter aReqParam){
+    SafeText result = fRequestParser.toSafeText(aReqParam);
+    return result == null ? null : result.getRawString();
+  }
+  
+  /**
+   Return an <tt>ORDER BY</tt> clause for an SQL statement.
+   
+   <P>Provided as a convenience for the common task of creating an 
+   <tt>ORDER BY</tt> clause from request parameters.
+    
+   @param aSortColumn carries a <tt>ResultSet</tt> column identifer, either a 
+   numeric column index, or the name of the column itself.
+   @param aOrder carries the value <tt>ASC</tt> or <tt>DESC</tt> (ignores case).
+   @param aDefaultOrderBy default text to be used if the request parameters are not 
+   present, or have no content.
+  */
+  protected final DynamicSql getOrderBy(RequestParameter aSortColumn, RequestParameter aOrder, String aDefaultOrderBy){
+    String result = aDefaultOrderBy;
+    String column = getRequestParser().getRawParamValue(aSortColumn);
+    String order = getRequestParser().getRawParamValue(aOrder); 
+    if ( Util.textHasContent(column) && Util.textHasContent(order) ) {
+      if ( ! "ASC".equalsIgnoreCase(order) && ! "DESC".equalsIgnoreCase(order)) {
+        String message = "Sort Order must take value 'ASC' or 'DESC' (ignoring case). Actual value :" + Util.quote(order);
+        fLogger.severe(message);
+        throw new RuntimeException(message);
+      }
+      result = DynamicSql.ORDER_BY + column + SPACE + order;      
+    }
+    return new DynamicSql(result);
+  }
+  
+  /**
+   Create a new session  (if one doesn't already exist) <b>outside of the usual user login</b>, 
+   and add a CSRF token to the new session to defend against Cross-Site Request Forgery (CSRF) attacks.
+   
+   <P>This method exists to extend the {@link CsrfFilter}, to allow it to apply to a form/action that does not already have a 
+   user logged in. 
+   
+   <P><b>Warning:</b> you can only call this method in Actions for which the 
+   {@link hirondelle.web4j.security.SuppressUnwantedSessions} filter is <i>NOT</i> in effect.
+   
+   <P><b>Warning:</b> This method should be used with care when using Tomcat.
+   This method creates an 'anonymous' session, unattached to any user login.
+   Should the user log in afterwards, a robust web application should assign a <i>new</i>
+   session id. (See <a href='http://www.owasp.org/'>OWASP</a> for more information.)
+   The problem is that Tomcat 5 and 6 do <i>not</i> follow this rule, and will retain any existing 
+   session id when the user logs in. 
+   
+   <P><b>This method is needed only when the user has not yet logged in.</b>
+   An excellent example of operations <i>not</i> requiring a login are operations that deal with 
+   account management on a typical public web site :
+   <ul>
+    <li>registering users
+    <li>regaining lost passwords
+   </ul>
+   
+   <P>For such forms, it's strongly recommended that corresponding Actions call this method.
+   This will allow the {@link CsrfFilter} mechanism to be used to defend such forms against CSRF attack.  
+   As a second benefit, it will also allow information messages sent to the end user to survive <i>redirect</i> operations.
+  */
+  protected final void createSessionAndCsrfToken(){
+    boolean CREATE_IF_MISSING = true;
+    HttpSession session = getRequestParser().getRequest().getSession(DO_NOT_CREATE);
+    if( session == null ) {
+      fLogger.fine("No session exists. Creating new session, outside of regular login.");
+      session = getRequestParser().getRequest().getSession(CREATE_IF_MISSING);
+      fLogger.fine("Adding CSRF token to the new session, to defend against CSRF attacks.");
+      CsrfFilter csrfFilter = new CsrfFilter();
+      try { 
+        csrfFilter.addCsrfToken(getRequestParser().getRequest());
+      }
+      catch (ServletException ex){
+        throw new RuntimeException(ex);
+      }
+    }
+    else {
+      fLogger.fine("Not creating a new session, since one already exists. Assuming the session contains a CSRF token.");
+    }
+  }
+  
+  // PRIVATE //
+  
+  /* 
+   Design Note :
+   This abstract base class (ABC) does not use protected fields.
+   Instead, its fields are private, and subclasses which need to operate on 
+   fields do so indirectly, by calling <tt>final</tt> convenience methods
+   such as {@link #addToRequest}.
+  
+   This style was chosen because, in this case, it seems to be simpler. 
+   Subclasses need only a small number of interactions with these fields. If a 
+   a large number of interactions were needed, then changing field scope to 
+   protected would become more attractive.
+  
+   As well, note how most methods are declared as <tt>final</tt>, except 
+   for the <tt>abstract</tt> ones.
+  */
+
+  private ResponsePage fFinalResponsePage;
+  private final RequestParser fRequestParser;
+  private final Locale fLocale;
+  private final TimeZone fTimeZone;
+  private final Operation fOperation; 
+
+  /* Control the creation of sessions.  */
+  private static final boolean DO_NOT_CREATE = false;
+  
+  private final MessageList fErrors;
+  private final MessageList fMessages;
+  
+  private static final Logger fLogger = Util.getLogger(ActionImpl.class);
+  
+  /**
+   Fetch first from request parameter; if not there, use the 'file extension' instead.
+   If still none, return <tt>null</tt>.
+  */
+  private Operation parseOperation(){
+    String opValue = getRequestParser().getRequest().getParameter("Operation");
+    if( ! Util.textHasContent(opValue) ) {
+      opValue = getFileExtension();
+    }
+    return Operation.valueFor(opValue);
+  }
+  
+  private String getFileExtension(){
+    String uri = getRequestParser().getRequest().getRequestURI();
+    fLogger.finest("URI : " + uri);
+    return WebUtil.getFileExtension(uri);
+  }
+  
+  private boolean hasExistingSession() {
+    return fRequestParser.getRequest().getSession(DO_NOT_CREATE) != null;
+  }
+  
+  /**
+   If {@link #getErrors} has content, place it in session scope under the name {@value #ERRORS}.
+   If it is already in the session, then it is updated.
+  */
+  private void placeErrorsInSession(){
+    if ( getErrors().isNotEmpty() ) {
+      addToSession(ERRORS, getErrors());
+    }
+  }
+   
+  /**
+   If {@link #getMessages} has content, place it in session scope under the name {@value #MESSAGES}.
+   If it is already in the session, then it is updated.
+  */
+  private void placeMessagesInSession(){
+    if ( getMessages().isNotEmpty() ) {
+      addToSession(MESSAGES, getMessages());
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/ActionTemplateListAndEdit.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,220 @@
+package hirondelle.web4j.action;
+
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.database.DAOException;
+
+/** 
+ <b>Template</b> for "all-in-one" {@link hirondelle.web4j.action.Action}s, which perform
+  common operations on a Model Object. 
+ 
+ <P>Typically a single JSP is used, for displaying both a listing of Model Objects, 
+ and an accompanying form for editing these Model Objects one at a time. This style 
+ is practical when : 
+ <ul>
+  <li>the number of items in the listing is not excessively large.
+  <li>the Model Objects can be rendered reasonably well in a "one-per-line" style. 
+  (If the  Model Object itself has a large number of items, it may be difficult to 
+  render them well in such a listing.)
+ </ul>
+ 
+ <P>The {@link #SupportedOperation}s for this template are a subset of the members of the 
+ {@link Operation} enumeration. If other operations are desired, then this template class cannot be used.
+  
+ <P>This class interacts a bit with its JSP - the form changes from "Add" mode to "Change" mode 
+ according to the value of the {@link Operation}.
+ 
+ <P>If an operation is not appropriate in a given case, then simply provide an empty implementation of 
+ its corresponding abstract method (or an implementation that throws an 
+ {@link java.lang.UnsupportedOperationException}).
+ 
+ <P><span class="highlight">To communicate messages to the end user, the implementation 
+ must use the various <tt>addMessage</tt> and <tt>addError</tt> methods</span>.
+*/
+public abstract class ActionTemplateListAndEdit extends ActionImpl {
+
+  /**
+   Constructor.
+   
+   @param aForward used for {@link Operation#List} and {@link Operation#FetchForChange} 
+   operations, and also for <em>failed</em> {@link Operation#Add}, {@link Operation#Change},
+   and {@link Operation#Delete} operations. This is the default response.
+   @param aRedirect used for <em>successful</em> {@link Operation#Add}, 
+   {@link Operation#Change}, and {@link Operation#Delete} operations. 
+   @param aRequestParser passed to the superclass constructor.
+  */
+  protected ActionTemplateListAndEdit (
+    ResponsePage aForward, ResponsePage aRedirect, RequestParser aRequestParser
+  ){
+    super(aForward, aRequestParser);
+    fRedirect = aRedirect;
+  }
+
+  /**
+   The operations supported by this template.
+   
+   <P>This action supports :
+   <ul>
+   <li> {@link Operation#List}
+   <li> {@link Operation#Add}
+   <li> {@link Operation#FetchForChange}
+   <li> {@link Operation#Change}
+   <li> {@link Operation#Delete}
+   </ul>
+   
+   The source of the <tt>Operation</tt> is described by {@link ActionImpl#getOperation()}.
+  */
+  public static final RequestParameter SupportedOperation = RequestParameter.withRegexCheck(
+    "Operation", "(" + 
+      Operation.List + "|" + Operation.Add + "|" + Operation.FetchForChange + "|" + 
+      Operation.Change + "|" + Operation.Delete + 
+    ")"
+  );
+   
+  /**
+   <b>Template</b> method.
+   
+   <P>In order to clearly understand the operation of this method, here is the 
+   core of its implementation, with all abstract methods in <em>italics</em> :
+   <PRE>
+    if (Operation.List == getOperation() ){
+      <em>doList();</em>
+    }
+    else if (Operation.FetchForChange == getOperation()){
+      <em>attemptFetchForChange();</em>
+    }
+    else if (Operation.Add == getOperation()) {
+      <em>validateUserInput();</em>
+      if ( ! hasErrors() ){
+        <em>attemptAdd();</em>
+        ifNoErrorsRedirectToListing();
+      }
+    }
+    else if (Operation.Change == getOperation()) {
+      <em>validateUserInput();</em>
+      if ( ! hasErrors() ){
+        <em>attemptChange();</em>
+        ifNoErrorsRedirectToListing();
+      }
+    }
+    else if(Operation.Delete == getOperation()) {
+      <em>attemptDelete();</em>
+      ifNoErrorsRedirectToListing();
+    }
+    //Fresh listing WITHOUT a redirect is required if there is an error, 
+    //and for successful FetchForChange operations.
+    if( hasErrors() || Operation.FetchForChange == getOperation() ){
+      <em>doList();</em>
+    }
+   </PRE>
+  */
+  @Override public final ResponsePage execute() throws AppException {
+    //the default operation is a forward to the nominal page
+    if (Operation.List == getOperation()){
+      doList();
+    }
+    else if (Operation.FetchForChange == getOperation()){
+      attemptFetchForChange();
+    }
+    else if (Operation.Add == getOperation()) {
+      validateUserInput();
+      if ( ! hasErrors() ){
+        attemptAdd();
+        ifNoErrorsRedirectToListing();
+      }
+    }
+    else if (Operation.Change == getOperation()) {
+      validateUserInput();
+      if ( ! hasErrors() ){
+        attemptChange();
+        ifNoErrorsRedirectToListing();
+      }
+    }
+    else if(Operation.Delete == getOperation()) {
+      attemptDelete();
+      ifNoErrorsRedirectToListing();
+    }
+    else {
+      throw new AssertionError("Unexpected kind of Operation for this Action template : " + getOperation());
+    }
+    
+    //A fresh listing WITHOUT a redirect is required if there is an error, and for 
+    //successful FetchForChange operations.
+    if( hasErrors() || Operation.FetchForChange == getOperation() ){
+      doList();
+    }
+    
+    return getResponsePage();
+  }
+
+  /**
+   Validate items input by the user into a form.
+   
+   <P>Applied to {@link Operation#Add} and {@link Operation#Change}. If an error occurs, then 
+   <tt>addError</tt> must be called.
+   
+   <P>Example of a typical implementation :
+   <PRE>
+  protected void validateUserInput() {
+    try {
+      ModelFromRequest builder = new ModelFromRequest(getRequestParser());
+      fResto = builder.build(Resto.class, RESTO_ID, NAME, LOCATION, PRICE, COMMENT);
+    }
+    catch (ModelCtorException ex){
+      addError(ex);
+    }    
+  }
+   </PRE>
+   
+   <P>Note that the Model Object constructed in this example (<tt>fResto</tt>) is retained 
+   as a field, for later use when applying an edit to the database. This is the recommended style.
+  */
+  protected abstract void validateUserInput();
+  
+  /**
+   Retrieve a listing of Model Objects from the database (<tt>SELECT</tt> operation).  
+  */
+  protected abstract void doList() throws DAOException;
+  
+  /**
+   Attempt an <tt>INSERT</tt> operation on the database. The data will first be validated using 
+   {@link #validateUserInput}.
+  */
+  protected abstract void attemptAdd() throws DAOException;
+  
+  /**
+   Attempt to fetch a single Model Object from the database, in preparation for 
+   editing it (<tt>SELECT</tt> operation). 
+  */
+  protected abstract void attemptFetchForChange() throws DAOException;
+  
+  /**
+   Attempt an <tt>UPDATE</tt> operation on the database. The data will first be validated 
+   using {@link #validateUserInput()}. 
+  */
+  protected abstract void attemptChange() throws DAOException;
+  
+  /**
+   Attempt a <tt>DELETE</tt> operation on the database. 
+  */
+  protected abstract void attemptDelete() throws DAOException;
+  
+  /**
+   Add a dynamic query parameter to the redirect {@link ResponsePage}.
+   
+   <P>This method will URL-encode the name and value.
+  */
+  protected void addDynamicParameterToRedirectPage(String aParamName, String aParamValue){
+    fRedirect = fRedirect.appendQueryParam(aParamName, aParamValue); //ResponsePage is immutable
+  }
+  
+  // PRIVATE //
+  private ResponsePage fRedirect;
+  
+  private void ifNoErrorsRedirectToListing(){
+    if ( ! hasErrors() ) {
+      setResponsePage(fRedirect);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/ActionTemplateSearch.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,115 @@
+package hirondelle.web4j.action;
+
+import java.util.logging.*;
+import java.util.regex.Pattern;
+
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.model.AppException;
+
+/**
+ <b>Template</b> for search screens.
+
+ <P>Here, a search action has the following :  
+ <ul>
+  <li>it uses a form that allows the user to input search criteria
+  <li>the form must have <tt>GET</tt> as its method, not <tt>POST</tt>
+  <li>the underlying database operation is a <tt>SELECT</tt>, and does not edit the database in any way
+ </ul>
+
+ <P>Search operations never require a redirect operation (since they do not edit the database).
+
+ <P>Search operations have an interesting property : if you build a Model Object to validate and represent 
+  user input into the search form, then its <tt>getXXX</tt> methods can usually be made package-private, instead 
+  of <tt>public</tt>. The reason is that such Model Objects are usually not used by JSPs directly. 
+  If desired, such methods can safely return <tt>String</tt> instead of 
+  {@link hirondelle.web4j.security.SafeText}. (The Model Objects themselves cannot be made package-private, since
+  the {@link hirondelle.web4j.model.ModelFromRequest} class works only with <tt>public</tt> classes.)
+*/
+public abstract class ActionTemplateSearch extends ActionImpl {
+  
+  /**
+  Constructor.
+  
+  @param aForward renders the result of the search
+  @param aRequestParser passed to the superclass constructor.
+  */
+  protected ActionTemplateSearch(ResponsePage aForward, RequestParser aRequestParser){
+    super(aForward, aRequestParser);
+  }
+
+  /**
+  The operations supported by this template.
+  
+  <P>The supported operations are :
+  <ul>
+  <li> {@link Operation#Show}
+  <li> {@link Operation#Search}
+  </ul>
+  
+  The source of the <tt>Operation</tt> is described by {@link ActionImpl#getOperation()}.
+  */
+  public static final RequestParameter SUPPORTED_OPERATION = RequestParameter.withRegexCheck(
+    "Operation", Pattern.compile("(" + Operation.Show + "|" + Operation.Search +  ")") 
+  );
+  
+  /**
+  <b>Template</b> method.
+  
+  <P>In order to clearly understand the operation of this method, here is the 
+  core of its implementation, with all abstract methods in <em>italics</em> :
+  <PRE>
+    if (Operation.Show == getOperation() ){
+      //default forward
+    }
+    else if ( Operation.Search == getOperation() ){
+      <em>validateUserInput();</em>
+      if( ! hasErrors() ){
+        <em>listSearchResults();</em>
+        if ( ! hasErrors() ){
+          fLogger.fine("List executed successfully.");
+        }
+      }
+    }
+  </PRE>
+  */
+  @Override public ResponsePage execute() throws AppException {
+    if (Operation.Show == getOperation() ){
+      //default forward
+    }
+    else if ( Operation.Search == getOperation() ){
+      fLogger.fine("'Search' Operation.");
+      validateUserInput();
+      if( ! hasErrors() ){
+        fLogger.fine("Passes validation.");
+        listSearchResults();
+        if ( ! hasErrors() ){
+          fLogger.fine("List executed successfully.");
+        }
+      }
+    }
+    else {
+      throw new AssertionError("Unexpected value for Operation : " + getOperation());
+    }
+    return getResponsePage();
+  }
+
+  /**
+  Validate items input by the user into a form.
+  
+  <P>The form is used to define the criteria for the search (if any).
+  
+  <P>Applies only for {@link Operation#Search}. If an error occurs, then 
+  <tt>addError</tt> must be called.
+  */
+  protected abstract void validateUserInput() throws AppException;
+
+  /**
+  Query the database, and place the results (usually) into request scope.
+  */
+  protected abstract void listSearchResults() throws AppException;
+
+  // PRIVATE //
+  private static final Logger fLogger = Util.getLogger(ActionTemplateSearch.class);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/ActionTemplateShowAndApply.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,121 @@
+package hirondelle.web4j.action;
+
+import java.util.logging.*;
+import java.util.regex.Pattern;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.model.AppException;
+
+/**
+ <b>Template</b> for "first show, then validate and apply" groups of operations.
+ 
+ <P>A good example is a page allowing the user to change their preferences : from the 
+ point of view of each user, there is only one set of user preferences. Often, a single form can be used 
+ to allow editing of preferences. Here, there is no listing of multiple items. 
+ 
+ <P>There are two operations in such cases :
+ <ul>
+ <li>show me the form with the current data (if any)
+ <li>allow me to post my (validated) changes
+ </ul> 
+*/
+public abstract class ActionTemplateShowAndApply extends ActionImpl {
+  
+  /**
+   Constructor.
+    
+   @param aForward used for {@link Operation#Show} operations, and also for <em>failed</em> 
+   {@link Operation#Apply} operations. This is the default response.
+   @param aRedirect used for <em>successful</em> {@link Operation#Apply} operations. 
+   @param aRequestParser passed to the superclass constructor.
+  */
+  protected ActionTemplateShowAndApply(ResponsePage aForward, ResponsePage aRedirect, RequestParser aRequestParser){
+    super(aForward, aRequestParser);
+    fRedirect = aRedirect;
+  }
+  
+  /**
+   The operations supported by this template.
+   
+   <P>The supported operations are :
+   <ul>
+   <li> {@link Operation#Show}
+   <li> {@link Operation#Apply}
+   </ul>
+   
+   The source of the <tt>Operation</tt> is described by {@link ActionImpl#getOperation()}.
+  */
+  public static final RequestParameter SUPPORTED_OPERATION = RequestParameter.withRegexCheck(
+    "Operation", Pattern.compile("(" + Operation.Show + "|" + Operation.Apply +  ")") 
+  );
+  
+  /**
+   <b>Template</b> method.
+   
+   <P>In order to clearly understand the operation of this method, here is the 
+   core of its implementation, with all abstract methods in <em>italics</em> :
+   <PRE>
+    if ( Operation.Show == getOperation() ){
+      <em>show();</em>
+    }
+    else if ( Operation.Apply == getOperation() ){
+      <em>validateUserInput();</em>
+      if( ! hasErrors() ){
+        <em>apply();</em>
+        if ( ! hasErrors() ){
+          setResponsePage(fRedirect);
+        }
+      }
+    }
+   </PRE>
+  */
+  @Override public ResponsePage execute() throws AppException {
+    if ( Operation.Show == getOperation() ){
+      fLogger.fine("'Show' Operation.");
+      show();
+    }
+    else if ( Operation.Apply == getOperation() ){
+      fLogger.fine("'Apply' Operation.");
+      validateUserInput();
+      if( ! hasErrors() ){
+        fLogger.fine("Passes validation.");
+        apply();
+        if ( ! hasErrors() ){
+          fLogger.fine("Applied successfully.");
+          setResponsePage(fRedirect);
+        }
+      }
+    }
+    else {
+      throw new AssertionError("Unexpected value for Operation : " + getOperation());
+    }
+    
+    return getResponsePage();
+  }
+
+  /**
+   Show the input form.
+   
+   <P>The form may or may not be populated.
+  */
+  protected abstract void show() throws AppException;
+
+  /**
+   Validate items input by the user into a form.
+  
+   <P>Applied to {@link Operation#Apply}.
+    If an error occurs, then <tt>addError</tt> must be called.
+  */  
+  protected abstract void validateUserInput() throws AppException;
+  
+  /**
+   Validate the user input, and then apply an edit to the database. 
+    If an error occurs, then <tt>addError</tt> must be called.
+  */
+  protected abstract void apply() throws AppException;
+  
+  // PRIVATE //
+  private ResponsePage fRedirect;
+  private static final Logger fLogger = Util.getLogger(ActionTemplateShowAndApply.class);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/Operation.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,207 @@
+package hirondelle.web4j.action; 
+
+import hirondelle.web4j.util.Util;
+import java.util.logging.*;
+
+/** 
+ Type-safe enumeration for common operations.
+
+ <P>This <a href="http://www.javapractices.com/Topic1.cjp">type-safe enumeration</a> 
+ is somewhat unusual, since its elements are not meant to form a strictly distinct 
+ set. For example, one might choose to identify the same operation using 
+ either {@link #Save} or {@link #Add}, simply according to taste. Elements may be used as 
+ desired, but please note that {@link #isDatastoreEdit} and {@link #hasSideEffects()} return
+ <tt>true</tt> only for specified items. 
+ 
+ <P>Many {@link Action} implementations can benefit from using a request parameter named 
+ <tt>'Operation'</tt>, whose value corresponds to a specific <em>subset</em> of the members of this enumeration.
+ For example, {@link hirondelle.web4j.action.ActionTemplateSearch} expects only {@link #Show} and {@link #Search}
+ operations. See {@link hirondelle.web4j.action.ActionImpl#getOperation()} as well. 
+
+ <P>Occasionally, you may need to define just such a subset of operations for your own actions. 
+ Typically, your code would use the following code snippets. 
+ <P>
+ Define an <tt>Operation</tt> {@link hirondelle.web4j.request.RequestParameter} in the {@link Action} using :
+<PRE>
+  {@code
+public static final RequestParameter SUPPORTED_OP = RequestParameter.withRegexCheck(
+    "Operation", 
+    Pattern.compile("(" + Operation.Show + "|" + Operation.Search +  ")") 
+  );
+  }
+</PRE>
+  Set the value of a corresponding <tt>Operation</tt> field in the {@link Action} constructor using :
+<PRE>
+  {@code
+fOperation = Operation.valueOf(aRequestParser.toString(SUPPORTED_OP));
+  }
+</PRE>
+
+ <P>Your {@link Action#execute} method will then branch according to the value of 
+ the <tt>fOperation</tt> field.
+ 
+ <P><em>Note regarding forms submitted by hitting the Enter key.</em><br>
+ One must exercise care for the possible submission of forms by hitting the Enter key. 
+ Browser behavior is not specified exactly by HTML 4, and various browsers exhibit 
+ different behaviors. A common workaround is to place  the <tt>Operation</tt> in a 
+ <tt>HIDDEN</tt> form item, to ensure that it is always submitted, regardless of 
+ the submission mechanism. 
+ 
+ <P>See as well this 
+ <a href="http://www.javapractices.com/Topic203.cjp">discussion</a> of <tt>Submit</tt>
+ buttons in multilingual applications.
+*/
+public enum Operation  { 
+
+  /** Add an item to the datastore. */
+  Add,
+  
+  /** Post a change to an item to the datastore.  */
+  Change,
+  
+  /** Delete an item from the datastore.  */
+  Delete,
+
+  /**  Delete all items from the datastore.  */
+  DeleteAll,
+
+  /** Save an edit to the datastore.  */
+  Save,
+  
+  /** Apply an edit to the datastore.  */
+  Apply,
+
+  /**
+   Activate an item.
+   
+   <P>The reverse of {@link #Inactivate}
+  */
+  Activate,
+  
+  /**
+   Inactivate an item.
+   
+   <P>Often used to implement an <em>abstract</em> <tt>delete</tt> operation, where 
+   an item is nominally removed (perhaps by setting the value of a certain column),
+   but no physical deletion of records occurs. 
+  */
+  Inactivate,
+  
+  /** 
+   Start some process.
+    
+   <P>The reverse of {@link #Stop}. 
+  */
+  Start,
+   
+  /** 
+   Stop some process. 
+   
+   <P>The reverse of {@link #Start}.
+  */
+  Stop,
+  
+  /** Retrieve a single item, for read-only display.  */
+  Fetch,
+  
+  /** Fetch a single item, in preparation for editing the item.  */
+  FetchForChange,
+
+  /** 
+   Fetch items needed before adding a new item.
+   An example use case is adding a new item which has a parent of some sort.
+   The parent will need to be fetched before showing the form for adding the child.
+  */
+  FetchForAdd,
+  
+  /** Retrieve what is usually a list of many items, for read-only display.  */
+  List,
+  
+  /** Retrieve what is usually a list of many items, in preparation for editing the items.  */
+  ListForChange,
+  
+  /** Show an item.  */
+  Show,
+  
+  /** Generate a result.  */
+  Generate,
+  
+  /**  Render a result.  */
+  Render,
+  
+  /**  Display a result.  */
+  Display,
+  
+  /** Search for one or more items.  */
+  Search,
+  
+  /** Fetch the next item in a list.  */
+  Next,
+  
+  /** Fetch the previous item in a list.  */
+  Previous,
+  
+  /** Fetch the first item in a list.  */
+  First,
+  
+  /** Fetch the last item in a list.  */
+  Last,
+  
+  /** Generic operation meant as a catch-all. */
+  Do;
+  
+  /**
+   Return <tt>true</tt> only if this <tt>Operation</tt> represents an action which 
+   has edited the datastore : {@link #Add}, {@link #Change}, {@link #Delete}, 
+   {@link #DeleteAll}, {@link #Save}, {@link #Apply}, {@link #Inactivate}, or {@link #Activate}.
+   
+   <P>Intended to identify actions which very likely require 
+   <a href="http://www.javapractices.com/Topic181.cjp">a redirect instead of a forward</a>. 
+  */
+  public boolean isDatastoreEdit(){
+    return (
+      this == Add || this == Change || this == Delete || this == DeleteAll ||
+      this == Save || this == Apply || this == Inactivate || this == Activate
+    );
+  }
+  
+  /** 
+   Returns <tt>true</tt> only if this <tt>Operation</tt> <tt>isDataStoreEdit()</tt>, 
+   or is {@link #Start} or {@link #Stop}.
+     
+   <P>Intended to identify cases that need a <tt>POST</tt> request.
+  */
+  public boolean hasSideEffects(){
+    return isDatastoreEdit() || this == Start || this == Stop;
+  }
+  
+  /**
+   Parse a parameter value into an <tt>Operation</tt> (not case-sensitive).
+   
+   <P>Similar to {@link #valueOf}, but not case-sensitive, and has alternate behavior when a problem is found. 
+   If <tt>aOperation</tt> has no content, or has an unknown value, then a message 
+   is logged at <tt>SEVERE</tt> level, and <tt>null</tt> is returned.
+  */
+  public static Operation valueFor(String aOperation)  {
+    Operation result = null;
+    if ( Util.textHasContent(aOperation) ) {
+      for (Operation operation : Operation.values()) {
+        if ( operation.name().equalsIgnoreCase(aOperation)) {
+          result = operation;
+          break;
+        }
+      }
+      if( result == null ) {
+        fLogger.severe("'Operation' has an unknown value: " + Util.quote(aOperation));
+      }
+    }
+    else {
+      String message = "'Operation' has an empty or null value.";
+      fLogger.severe(message);
+    }
+    return result;
+  }
+  
+  // PRIVATE //
+  private static final Logger fLogger = Util.getLogger(Operation.class);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/ResponsePage.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,243 @@
+package hirondelle.web4j.action;
+
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.util.WebUtil;
+
+/**
+ <span class="highlight">Response page served to the user at the end of an {@link Action}.</span>
+ 
+ <P>Identifies the page as a resource. Does not include its content, but is rather  
+ a <em>reference</em> to the page. 
+ 
+ <P>The {@link hirondelle.web4j.Controller} will either redirect or forward to the
+ resource identified by {@link #toString}, according to the value of {@link #getIsRedirect}.
+ The default value of {@link #getIsRedirect} varies according to constructor:
+<ul>
+ <li>{@link #ResponsePage(String)} defaults to a <em>redirect</em>
+ <li>all other constructors default to a <em>forward</em>
+</ul> 
+ 
+ <P>These defaults are almost always the desired values. They can be changed, if 
+ necessary, by calling {@link #setIsRedirect}. It's important to note that that method 
+ returns a brand new object - it doesn't change the state of an existing object. 
+ 
+ <P>See the <a href="http://www.javapractices.com/Topic181.cjp">forward-versus-redirect</a> 
+ topic on javapractices.com for more information.
+*/
+public final class ResponsePage {
+
+  /**
+   Constructor which uses the WEB4J template mechanism.
+   
+   @param aTitle text of the <tt>&lt;TITLE&gt;</tt> tag to be presented in the 
+   template page ; appended to <tt>aTemplateURL</tt> as a query parameter
+   @param aBodyJsp body of the templated page, where most of the content of interest 
+   lies ; appended to <tt>aTemplateURL</tt> as a query parameter
+   @param aTemplateURL identifies the templated page which dynamically includes 
+   <tt>aBodyJsp</tt> in its content. Some applications will have only a single 
+   template for the entire application. 
+  */
+  public ResponsePage(String aTitle, String aBodyJsp, String aTemplateURL){
+    this(aTitle, aBodyJsp, aTemplateURL, null);
+  }  
+
+  /** 
+   Constructor which uses the WEB4J template mechanism, with a conventional template.
+   <P>As in {@link #ResponsePage(String, String, String, Class)}, 
+   but with the template URL taking the conventional value of <tt>'../Template.jsp'</tt>. 
+  */
+  public ResponsePage(String aTitle, String aBodyJsp, Class aRepresentativeClass) {
+    this(aTitle, aBodyJsp, "../Template.jsp",  aRepresentativeClass);
+  }
+  
+  /**
+   Constructor which uses the WEB4J template mechanism.
+   
+   <P><span class="highlight">
+   This constructor allows for an unusual but useful policy : placing JSPs in the 
+   same directory as related code, under <tt>WEB-INF/classes</tt>, instead 
+   of under the document root of the application.</span> 
+   Such a style is beneficial since it allows all (or nearly all) items related 
+   to a given feature to be placed in the same directory - JSPs, Action,
+   Model Objects, Data Access Objects, and <tt>.sql</tt> files.
+   This is the recommended style. It allows 
+   <a href="http://www.javapractices.com/Topic205.cjp">package-by-feature</a>.
+   
+   @param aTitle text of the <tt>&lt;TITLE&gt;</tt> tag to be presented in the 
+   template page ; appended to <tt>aTemplateURL</tt> as a query parameter
+   @param aBodyJsp body of the templated page, where most of the content of interest 
+   lies ; appended to <tt>aTemplateURL</tt> as a query parameter
+   @param aTemplateURL identifies the templated page which dynamically includes 
+   <tt>aBodyJsp</tt> in its content
+   @param aClass class literal of any java class related to the given feature; the 
+   <em>package</em> of this class will be used to construct the 'real' path to <tt>aBodyJsp</tt>, 
+   as in '<tt>WEB-INF/classes/&lt;package-as-directory-path&gt;/&lt;aBodyJsp&gt;</tt>'. These  
+   paths are completely internal, are known only to the {@link hirondelle.web4j.Controller}, and are 
+   never visible to the user in the URL. 
+  */
+  public ResponsePage(String aTitle, String aBodyJsp, String aTemplateURL, Class<?> aClass){
+    fIsRedirect = FOR_FORWARD;
+    String url = WebUtil.setQueryParam(aTemplateURL, "TTitle", aTitle);
+    url = WebUtil.setQueryParam(url, "TBody", getPathPrefix(aClass) + aBodyJsp);
+    fURL = url;
+    fIsBinary = false;
+  }  
+  
+  /**
+   Constructor for a non-templated response. 
+  
+   <P>This constructor does not use the WEB4J template mechanism.  
+   This constructor is used both for response pages that require a redirect, and for 
+   serving pages that do not use the templating mechanism.
+   
+   <P><tt>aURL</tt> identifies the resource which will be used by an {@link Action}
+   as its destination page. 
+   Example values for <tt>aURL</tt> :
+   <ul>
+   <li><tt>ViewAccount.do</tt> (suitable for a redirect)
+   <li><tt>/ProblemHasBeenLogged.html</tt> (suitable for a forward to an item 
+   which is <em>not</em> templated)
+   </ul> 
+   
+   <P>This constructor returns a redirect. If a forward is desired, call {@link #setIsRedirect(Boolean)}.
+  */
+  public ResponsePage(String aURL) {
+    this(aURL, FOR_REDIRECT);
+  }
+  
+  /**
+    Constructor for a non-templated response located under <tt>/WEB-INF/</tt>.
+    
+   <P><span class="highlight">
+   This constructor allows for an unusual but useful policy : placing JSPs in the 
+   same directory as related code, under <tt>WEB-INF/classes</tt>, instead 
+   of under the document root of the application.</span> 
+   Such a style is beneficial since it allows all (or nearly all) items related 
+   to a given feature to be placed in the same directory - JSPs, Action,
+   Model Objects, Data Access Objects, and <tt>.sql</tt> files.
+   This is the recommended style. It allows 
+   <a href="http://www.javapractices.com/Topic205.cjp">package-by-feature</a>.
+    
+   <P>This constructor defaults the response to a forward operation. 
+    
+   @param aJsp simple name of a JSP, as in 'view.jsp' 
+   @param aClass class literal of any java class in the same package as the given JSP.
+   The <em>package</em> of this class will be used to construct the 'real' path to the JSP, 
+   as in <PRE>WEB-INF/classes/&lt;package-as-directory-path&gt;/&lt;aJsp&gt;</PRE>These  
+   paths are completely internal, are known only to the {@link hirondelle.web4j.Controller}, and are 
+   never visible to the user in the URL. 
+   */
+  public ResponsePage(String aJsp, Class<?> aClass){
+    fIsRedirect = FOR_FORWARD;
+    fURL = getPathPrefix(aClass) + aJsp;
+    fIsBinary = false;
+  }
+
+  /**
+   Factory method for binary responses. 
+   An example of a binary reponse is serving a report in <tt>.pdf</tt> format.
+   
+   <P>With such <tt>ResponsePage</tt>s, the normal templating mechanism is not 
+   available (since it is based on text), and no forward/redirect is performed by the 
+   <tt>Controller</tt>. In essence, the <tt>Action</tt> becomes entirely responsible 
+   for generating the response. 
+   
+   <P>When serving a binary response, your <tt>Action</tt> will typically : 
+   <ul>
+   <li>set the <tt>content-type</tt> response header.
+   <li>generate the output stream containing the desired binary content.
+   <li>close any resources, if necessary. For example, if generating a chart dynamically, then you 
+   may need to recover resources used in the generation of an image.
+   </ul>
+  */
+  public static ResponsePage withBinaryData(){
+    return new ResponsePage();
+  }
+  
+  /** Return <tt>true</tt> only if {@link #withBinaryData()} was called.  */
+  public Boolean hasBinaryData(){
+    return fIsBinary;
+  }
+
+  /**
+   Return the URL passed to the constructor, with any query parameters added by 
+   {@link #appendQueryParam} appended on the end.
+  */
+  @Override public String toString() { 
+    return fURL;  
+  } 
+
+  /** See class comment. */
+  public Boolean getIsRedirect(){
+    return fIsRedirect;
+  }
+  
+  /**
+   Return a new <tt>ResponsePage</tt>, having the specified forward/redirect behavior.
+   See class comment.
+    
+   <P>This method returns a new <tt>ResponsePage</tt> having the updated URL. 
+   (This will ensure that <tt>ResponsePage</tt> objects are immutable.) 
+  */
+  public ResponsePage setIsRedirect(Boolean aValue){
+    return new ResponsePage(fURL, aValue);    
+  }
+
+  /**
+   Append a query parameter to the URL of a <tt>ResponsePage</tt>.
+   
+   <P>This method returns a new <tt>ResponsePage</tt> having the updated URL. 
+   (This will ensure that <tt>ResponsePage</tt> objects are immutable.) 
+   <P>This method uses {@link WebUtil#setQueryParam}. 
+  */
+  public ResponsePage appendQueryParam(String aParamName, String aParamValue){
+    String newURL = WebUtil.setQueryParam(fURL, aParamName, aParamValue);
+    return new ResponsePage(newURL, fIsRedirect);
+  }
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      ResponsePage that = (ResponsePage) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;    
+  }
+  
+  @Override public int hashCode(){
+    return ModelUtil.hashCodeFor(getSignificantFields());
+  }
+   
+  // PRIVATE 
+  private final String fURL;
+  private final Boolean fIsRedirect;
+  private final Boolean fIsBinary;
+  private static final Boolean FOR_REDIRECT = Boolean.TRUE;
+  private static final Boolean FOR_FORWARD = Boolean.FALSE;
+  
+  private String getPathPrefix(Class aClass){
+    String result = "";
+    if ( aClass != null ){
+      result = "/WEB-INF/classes/" + aClass.getPackage().getName().replace('.', '/') + "/"; 
+    }
+    return result;
+  }
+  
+  /** Private constructor. */
+  private ResponsePage(String aURL, Boolean aIsRedirect){
+    fIsRedirect = aIsRedirect;
+    fURL = aURL;
+    fIsBinary = false;
+  }
+
+  /** Private constructor for binary responses. */
+  private ResponsePage(){
+    fIsRedirect = FOR_FORWARD; //not really used
+    fURL = null; //not usd
+    fIsBinary = true;
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[] {fURL, fIsRedirect, fIsBinary};
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/action/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,30 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle System">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>User Interface</title>
+</head>
+<body>
+Execute the desired operation.
+
+<P>In general, <tt>Action</tt> classes are the main public face of each feature. In addition, 
+they are also the 'glue' that brings together all the other items related to the 
+feature - Model Object, DAO, JSP, and SQL statements. Thus, an <tt>Action</tt> typically 
+references all of these kinds of items, in some way.
+
+<P>{@link hirondelle.web4j.action.ActionImpl} is a base implementation of <tt>Action</tt>, 
+and has a number of methods for common operations. It in turn has several 
+template subclasses (<em>ActionTemplateXXX</em>), corresponding to specific combinations of operations.
+
+<P>An important point to understand is the separation of validation into two distinct parts - 
+<em>hard validation</em>, and <em>soft validation</em> - see {@link hirondelle.web4j.security.ApplicationFirewall} 
+for more information.
+
+<P>Mapping of incoming requests to an {@link hirondelle.web4j.action.Action} is done by 
+{@link hirondelle.web4j.request.RequestParserImpl}. By default, it uses a simple mapping scheme which requires no configuration.
+
+<P>The main tool for building Model Objects out of submitted forms is {@link hirondelle.web4j.model.ModelFromRequest}.
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ConnectionSource.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,108 @@
+package hirondelle.web4j.database;
+
+import java.sql.Connection;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ Return a {@link Connection} to a database, using a policy defined by the application. 
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forConnectionSource()} 
+ returns the configured implementation of this interface.
+ 
+ <P>Here is an example 
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/ConnectionSrc.html">implementation</a>, 
+ taken from the WEB4J example application.
+ 
+ <P>There are many alternatives for creating or accessing database connections.
+ The application programmer can use any technique whatsoever to implement this 
+ interface. For example, an implementation may :  
+ <ul>
+ <li>use a connection pool configured in the server environment (the style  
+ used in the example application)
+ <li>create a {@link Connection} directly, using the database driver. (Some modern 
+ drivers have connection pooling built directly into the driver.)
+ <li>use some other means
+ </ul>
+
+ <P><span class="highlight">The design of this interface is slightly skewed towards applications 
+ that use more than one database.</span> 
+ If only one database is used in an application, then this interface is simply implemented
+ 'as if' there were more than one. In practice, this should be only a minor nuisance. In addition, 
+ if a single-database application is extended to use more than one database, the changes will 
+ be absorbed naturally by the existing implementation. 
+ 
+ <P>If no database is used at all, then {@link #getDatabaseNames()} returns an emtpy <tt>Set</tt>.
+
+ <P>See {@link SqlId} for related information.
+*/
+public interface ConnectionSource {
+  
+  /**
+   Read in any necessary configuration parameters.
+   
+   <P>This method is called by the framework early during startup processing.
+   If your implementation of this interface does not use any sort of config 
+   information, then it can simply ignore the given <tt>Map</tt> parameter.
+   
+   <P>In the context of a web application, the given <tt>Map</tt> is populated 
+   with the servlet's <tt>init-param</tt> settings in <tt>web.xml</tt>. 
+  */
+  void init(Map<String, String> aConfig);
+  
+  /**
+   Return the database names accepted by {@link #getConnection(String)}.
+   
+   <P>Return a <tt>Set</tt> with one or more entries. Each entry must have content.
+   If your application does not use a database at all, then you must return an empty <tt>Set</tt>.
+  */
+  Set<String> getDatabaseNames();  
+
+  /**
+   Return a {@link Connection} to the <em>default</em> database.
+   
+   <P>The default database is selected by the application programmer as representing 
+   the principal or main database in the application, against which the majority of 
+   SQL statements are executed. (For most applications, this choice will be obvious.) 
+   In <tt>.sql</tt> files, SQL statements against the default database 
+   do not need an extra qualifier in their identifiers, while those against a non-default 
+   database do need an extra qualifier.
+   
+   <P>Implementations must translate any exceptions into a {@link DAOException}.
+   Examples of exceptions that may need translation are <tt>SQLException</tt> 
+   and <tt>javax.naming.NamingException</tt>.
+  */
+  Connection getConnection() throws DAOException;
+  
+  /**
+   Return a {@link Connection} for a specified database (default or non-default). 
+   
+   <P>The {@link #getConnection()} method is intended for the "default" or 
+   principal database used in the application, while this method is 
+   intended for all other databases (but may be used for the default as well).
+   
+   <P>See {@link SqlId} for related information.
+   
+   <span class="highlight">Here, <tt>aDatabaseName</tt> is a prefix used 
+   in <tt>.sql</tt> files as a qualifer to a SQL statement identifier.</span> 
+   For example, in an <tt>.sql</tt> file, the SQL statement identifier: 
+   <ul>
+   <li><tt>LIST_MEMBERS</tt> refers to the default database (no qualifier)
+   <li><tt>TRANSLATION.LOCALE_LIST</tt> refers to the '<tt>TRANSLATION</tt>' database. 
+   <em>It is this string </em>- '<tt>TRANSLATION</tt>' - <em>which is passed to this method</em>. 
+   </ul>  
+   
+   <P>The values taken by <tt>aDatabaseName</tt> are those returned by {@link #getDatabaseNames()}.
+   They may be chosen by the application programmer in any desired way, to clarify the SQL 
+   statement identifiers appearing in the <tt>.sql</tt> file(s).
+   Typically, an implementation of this interface will internally map these database 
+   identifiers into specific connection strings, such as 
+   '<tt>java:comp/env/jdbc/blah</tt>' (or whatever is required).
+    
+   <P>Implementations must translate any exceptions into a {@link DAOException}.
+   Examples of exceptions that may need translation are <tt>SQLException</tt> 
+   and <tt>javax.naming.NamingException</tt>.
+  */
+  Connection getConnection(String aDatabaseName) throws DAOException;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ConvertColumn.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,39 @@
+package hirondelle.web4j.database;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ Convert <tt>ResultSet</tt> column values into common 'building block' objects.
+ <P>
+ Here, a <em>building block</em> class is one of the 'base' objects from which Model
+ Objects can in turn be built - <tt>Integer</tt>, <tt>BigDecimal</tt>, <tt>Date</tt>,
+ and so on. 
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forConvertColumn()} 
+ returns the configured implementation of this interface.
+ 
+ <P>{@link ConvertColumnImpl} is provided as a default implementation. It is likely that 
+ most applications will find this implementation adequate. 
+*/
+public interface ConvertColumn {
+
+  /**
+   Translate a single column value of a <tt>ResultSet</tt> into a possibly-null 'building block' object.
+   
+   <P>A building block object is like <tt>Integer</tt>, <tt>BigDecimal</tt>, <tt>Date</tt>, and so on.
+   
+   <P>It is required that implementations use 
+   {@link hirondelle.web4j.model.ConvertParam#isSupported(Class)} to 
+   verify that <tt>aSupportedTargetType</tt> is indeed supported. 
+   This ensures the front end and back end are synchronized, and support the same set of classes.
+   
+   @param aRow from iteration over a <tt>ResultSet</tt>.  
+   @param aColumnIdx specific column in <tt>aRow</tt>.
+   @param aSupportedTargetType class of the desired return value. Implementations are not required to 
+   support all possible target classes.
+  */
+  public <T> T convert(ResultSet aRow, int aColumnIdx, Class<T> aSupportedTargetType) throws SQLException;
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ConvertColumnImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,183 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.ConvertParam;
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.model.Decimal;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Util;
+
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.sql.Clob;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ Default implementation of {@link ConvertColumn}, suitable for most applications.
+ 
+ <P>This class converts non-<tt>null</tt> items using :
+ <table border='1' cellspacing='0' cellpadding='3'>
+  <tr><th>Target Class</th><th>Use</th></tr>
+  <tr><td><tt> SafeText </tt></td><td><tt> ResultSet.getString(), or ResultSet.getClob() </tt></td></tr>
+  <tr><td><tt> String (if allowed) </tt></td><td><tt> ResultSet.getString(), or ResultSet.getClob() </tt></td></tr>
+  <tr><td><tt> Integer </tt></td><td><tt>ResultSet.getInt()</tt></td></tr>
+  <tr><td><tt> Long </tt></td><td><tt> ResultSet.getLong() </tt></td></tr>
+  <tr><td><tt> Boolean </tt></td><td><tt> ResultSet.getBoolean() </tt></td></tr>
+  <tr><td><tt> BigDecimal </tt></td><td><tt> ResultSet.getBigDecimal() </tt></td></tr>
+  <tr><td><tt> Decimal</tt></td><td><tt> ResultSet.getBigDecimal()</tt></td></tr>
+  <tr><td><tt> Id </tt></td><td><tt> ResultSet.getString(), new Id(String)</tt></td></tr>
+  <tr><td><tt> DateTime </tt></td><td><tt> ResultSet.getString()</tt>, pass to {@link DateTime#DateTime(String)}</td></tr>
+  <tr><td><tt> Date </tt></td><td><tt> ResultSet.getTimestamp()</tt>, possibly with hint provided in <tt>web.xml</tt></td></tr>
+  <tr><td><tt> Locale </tt></td><td><tt> ResultSet.getString(), {@link hirondelle.web4j.util.Util#buildLocale(String)}</tt></td></tr>
+  <tr><td><tt> TimeZone </tt></td><td><tt> ResultSet.getString(), {@link hirondelle.web4j.util.Util#buildTimeZone(String)}</tt></td></tr>
+  <tr><td><tt> InputStream</tt></td><td><tt> ResultSet.getBinaryStream()</tt></td></tr>
+ </table>
+ 
+ <P>This implementation supports the same building block classes defined by another  
+ default implementation : {@link hirondelle.web4j.model.ConvertParamImpl#isSupported(Class)}.
+ See that class for important information on the conditional support of <tt>String</tt>.
+*/
+public class ConvertColumnImpl implements ConvertColumn {
+
+  /**
+   Defines policies for converting a column of a <tt>ResultSet</tt> into a possibly-null
+   <tt>Object</tt>.
+  
+   @param aRow of a <tt>ResultSet</tt>
+   @param aColumnIdx particular column of aRow
+   @param aSupportedTargetType is a class supported by the configured implementation of 
+   {@link ConvertParam#isSupported(Class)}.
+  */
+  public <T> T convert(ResultSet aRow, int aColumnIdx, Class<T> aSupportedTargetType) throws SQLException {
+    if( ! fConvertParam.isSupported(aSupportedTargetType) ){
+      throw new IllegalArgumentException("Unsupported Target Type : " + Util.quote(aSupportedTargetType)); 
+    }
+    
+    Object result = null;
+    if (aSupportedTargetType == SafeText.class){
+      result = convertToSafeText(aRow, aColumnIdx);
+    }
+    else if (aSupportedTargetType == String.class) {
+      result = convertToString(aRow, aColumnIdx);
+    }
+    else if (aSupportedTargetType == Integer.class ||  aSupportedTargetType == int.class){
+      int value = aRow.getInt(aColumnIdx);
+      result = aRow.wasNull() ? null : new Integer(value);
+    }
+    else if (aSupportedTargetType == Boolean.class || aSupportedTargetType == boolean.class){
+      boolean value = aRow.getBoolean(aColumnIdx);
+      result = aRow.wasNull() ? null : Boolean.valueOf(value);
+    }
+    else if (aSupportedTargetType == BigDecimal.class){
+      result =  aRow.getBigDecimal(aColumnIdx);
+    }
+    else if (aSupportedTargetType == java.util.Date.class){
+      result = getDate(aRow, aColumnIdx);
+    }
+    else if (aSupportedTargetType == DateTime.class){
+      result = getDateTime(aRow, aColumnIdx);
+    }
+    else if (aSupportedTargetType == Long.class || aSupportedTargetType == long.class){
+      long value = aRow.getLong(aColumnIdx);
+      result = aRow.wasNull() ? null : new Long(value);
+    }
+    else if (aSupportedTargetType == Id.class){
+      String value = aRow.getString(aColumnIdx);
+      result = aRow.wasNull() ? null : new Id(value);
+    }
+    else if (aSupportedTargetType == Locale.class){
+      String value = aRow.getString(aColumnIdx);
+      result = value == null ? null : Util.buildLocale(value);
+    }
+    else if (TimeZone.class == aSupportedTargetType){
+      String value = aRow.getString(aColumnIdx);
+      result = value == null ? null : Util.buildTimeZone(value);
+    }
+    else if (aSupportedTargetType == Decimal.class){
+      BigDecimal value =  aRow.getBigDecimal(aColumnIdx);
+      result = value == null ? null : new Decimal(value);
+    }
+    else if (InputStream.class.isAssignableFrom(aSupportedTargetType)){
+      InputStream value =  aRow.getBinaryStream(aColumnIdx);
+      result = value == null ? null : value; 
+    }
+    else {
+      throw new AssertionError(
+        "Unsupported type cannot be translated to an object:" + aSupportedTargetType
+      );
+    }
+    fLogger.finest(
+      "Successfully converted ResultSet column idx " + Util.quote(aColumnIdx) + 
+      " into a " + aSupportedTargetType.getName() 
+    );
+    return (T)result; //this cast is unavoidable, and safe.
+  }
+
+  // PRIVATE 
+  private final ConvertParam fConvertParam = BuildImpl.forConvertParam();
+  private static final Logger fLogger = Util.getLogger(ConvertColumnImpl.class);
+  
+  private SafeText convertToSafeText(ResultSet aRow, int aColumnIdx) throws SQLException {
+    String result = convertToString(aRow, aColumnIdx);
+    return result == null ? null : new SafeText(result);
+  }
+  
+  private String convertToString(ResultSet aRow, int aColumnIdx) throws SQLException {
+    String result = null;
+    if ( isClob(aRow, aColumnIdx) ) {
+      result = convertClobToString(aRow, aColumnIdx);
+    }
+    else {
+      result = aRow.getString(aColumnIdx);
+    }
+    return aRow.wasNull() ? null : result;
+  }
+  
+  private static boolean isClob(ResultSet aRow, int aColumnIdx) throws SQLException {
+    ResultSetMetaData metaData = aRow.getMetaData();
+    boolean result = metaData.getColumnType(aColumnIdx) == Types.CLOB;
+    return result;
+  }
+
+  private static String convertClobToString(ResultSet aRow, int aColumnIdx) throws SQLException {
+    String result = null;
+    Clob clob = aRow.getClob(aColumnIdx);
+    if (clob != null){
+      int length = new Long(clob.length()).intValue();
+      result = clob.getSubString(1, length);
+    }
+    return result;
+  }
+  
+  private java.util.Date getDate(ResultSet aRow, int aColumnIdx) throws SQLException {
+    java.util.Date result = null;
+    Config config = new Config();
+    if ( config.hasTimeZoneHint() ){
+      result = aRow.getTimestamp(aColumnIdx, config.getTimeZoneHint());
+    }
+    else {
+      result = aRow.getTimestamp(aColumnIdx);
+    }
+    return result;
+  }
+  
+  private DateTime getDateTime(ResultSet aRow, int aColumnIdx) throws SQLException {
+    //the ctor takes any String; if malformed, the DateTime will blow up later, when you call most other methods
+    String rawDateTime = aRow.getString(aColumnIdx);
+    if(fLogger.getLevel() == Level.FINEST){
+      fLogger.finest("DateTime column, raw value from database is: " + Util.quote(rawDateTime) + 
+       ". SQL date-time formatting functions can be used to render the format compatible with the hirondelle.web4.model.DateTime class, if needed."
+      );
+    }
+    return rawDateTime == null ? null :  new DateTime(rawDateTime);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/DAOException.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,37 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.model.AppException;
+
+/**
+ The only checked exception (excluding subclasses of this class) emitted 
+ by the data layer.
+
+ <P>This class is an example of 
+ <a href="http://www.javapractices.com/Topic77.cjp">Data Exception Wrapping</a>, 
+ and hides the various exceptions which arise from the various flavors
+ of datastore implementation, such as <tt>SQLException</tt>, 
+ <tt>IOException</tt>, and <tt>BackingStoreException</tt>.  
+
+ <P>Thrown when a low-level, unusual problem is encountered with the data store.
+ Examples of such a problem might include : 
+ <ul>
+ <li>faulty db connection
+ <li>failed file input-output
+ <li>inaccesible network connection
+</ul> 
+*/
+public class DAOException extends AppException { 
+  
+  /**
+   Constructor. 
+   
+   <P>Both arguements are passed to 
+   {@link AppException#AppException(String, Throwable)}.
+   
+   @param aMessage text describing the problem. Must have content.
+   @param aThrowable root cause underlying the problem.
+  */
+  public DAOException(String aMessage, Throwable aThrowable){
+    super(aMessage, aThrowable);
+  }
+} 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/Db.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,356 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.ConfigReader;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ <span class="highlight">Utility class for the most common database tasks.</span>
+ 
+ <P>This class allows many DAO methods to be implemented in one or two lines 
+ of simple code.
+
+<h3><a name="Parameters"></a>SQL Parameters</h3>
+ SQL statement parameters are passed to this class using an <tt>Object...</tt> 
+ sequence parameter. The objects in these arrays must be one of the classes 
+ supported by {@link hirondelle.web4j.database.ConvertColumn}.
+  
+ <span class="highlight">The number and order of these parameter objects must match 
+ the number and order of the '<tt>?</tt>' parameters in the underlying SQL 
+ statement</span>.
+
+ <P>For <tt>Id</tt> objects, the underlying column must be modeled as text, not a number. If
+ the underlying column is numeric, then the caller must convert an {@link Id} into a numeric form
+ using {@link Id#asInteger} or {@link Id#asLong}. 
+
+ <P><tt>Locale</tt> and <tt>TimeZone</tt> objects represent a bit of a special case.
+ Take for example a table storing user preferences, which stores locale and time zone. 
+ How would you store that information? There are 2 basic styles: 
+ <ul>
+  <li>just place the <tt>Locale</tt> and <tt>TimeZone</tt> identifiers (en_CA, America/Montreal) in their own 
+  columns, as text; this style will repeat the identifiers, and will not be 'normalized data', in database terminology.
+  <li>define code tables for <tt>Locale</tt> and <tt>TimeZone</tt>, to define the accepted values, and link the user preferences
+  table to them, using a foreign key. 
+ </ul> 
+ 
+ <P>The second form is usually more robust, since it's normalized. However, when it is used, passing <tt>Locale</tt> and 
+ <tt>TimeZone</tt> objects directly to an <tt>INSERT</tt> statement, for example, using this class, is not appropriate.
+ Instead, you'll need to treat them as any other code table, and translate the en_CA (for example) into 
+ a corresponding foreign key identifier. In this case, the fact that <tt>Db</tt> supports  <tt>Locale</tt> and <tt>TimeZone</tt>
+ becomes irrelevant, since you will translate them into an <tt>Id</tt> anyway.
+  
+<h3><a name="ConvertColumn"></a>Parsing Columns Into Objects</h3>
+ For operations involving a <tt>ResultSet</tt>, this class will always use the application's 
+ {@link ConvertColumn} implementation to convert columns into various building block objects. 
+ 
+ <P>In addition, it uses an ordering convention to map <tt>ResultSet</tt> columns to Model 
+ Object constructor arguments. See the package overview for more information on this important 
+ point. 
+
+ <h3><a name="CompoundObjects"></a>Compound Objects</h3>
+ Occasionally, it is desirable to present a large amount of summary information to the user on a single page.
+ In this case, an application needs a single large Model Object (a parent) containing collections 
+ of other Model Objects (children). Here, these are called <em>Compound Objects</em>.   
+ 
+ <P>Constructing an arbitrary Compound Object can always be performed in multiple steps: first 
+ fetch the children, and then construct the parent, by passing the children to 
+ the parent's constructor.
+ 
+ <P><em>For the simplest cases</em>, this can be performed conveniently in a single step, using 
+ the <tt>fetchCompound</tt> and <tt>listCompound</tt> methods of this class. These 
+ methods process a <tt>ResultSet</tt> in a fundamentally different way : instead of translating a 
+ <em>single</em> row into a single Model Object, they can translate <em>groups</em> of 1..N rows into 
+ a single Model Object instead.
+   
+ <P>Here is an illustration. The target Model Object constructor has the form (for example):
+ <PRE>
+ public UserRole (String aUserName, List&lt;Id&gt; aRoles)  {
+   ...
+ }
+ </PRE>
+ <span class="highlight">That is, the constructor takes a <em>single</em> {@link List} of Model Objects at 
+ the <em>end</em> of its list of arguments.</span> Here, a <tt>List</tt> of {@link Id} objects appears at the end.
+ The <tt>List</tt> can be a <tt>List</tt> of Model Objects, or a <tt>List</tt> of Base Objects supported by 
+ {@link hirondelle.web4j.model.ConvertParam}. 
+  
+ <P><span class="highlight">The underlying SELECT statement returns data across a <tt>0..N</tt> relation, with data 
+ in the first N columns repeating the parent data, and with the remaining M columns containing the child data</span>. 
+ For example:
+ <PRE>
+ SELECT Name, Role FROM UserRole ORDER BY Role
+ </PRE>
+ which has a <tt>ResultSet</tt> of the form :
+ <table border=1 cellpadding=3 cellspacing=1>
+  <tr><th>Name</th><th>Role</th></tr>
+  <tr><td>kenarnold</td><td>access-control</td></tr>
+  <tr><td>kenarnold</td><td>user-general</td></tr>
+  <tr><td>kenarnold</td><td>user-president</td></tr>
+  <tr><td>davidholmes</td><td>user-general</td></tr>
+ </table>
+ 
+ <P>That is, the repeated parent data (Name) comes first and is attached to the parent, while the 
+ child data (Role) <em>appears only in the final columns</em>. <span class="highlight">In addition, changes to the 
+ value in the <em>first</em> column must indicate that a new parent has started.</span>
+
+ <P>If the above requirements are satisfied, then a {@code List<UserRole>} is built using 
+ {@link #listCompound(Class, Class, int, SqlId, Object[])}, as in: 
+ <PRE>
+ Db.listCompound(UserRole.class, Id.class, 1, ROLES_LIST_SQL);
+ </PRE>
+*/ 
+public final class Db {
+
+  /**
+   <tt>SELECT</tt> operation which returns a single Model Object. 
+    
+   @param aClass class of the returned Model Object.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return <tt>null</tt> if no record is found.
+  */
+  public static <T> T fetch(Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    ModelFromRow<T> builder = new ModelFromRow<T>(aClass);
+    return fetcher.fetchObject(builder);
+  }
+
+  /**
+   <tt>SELECT</tt> operation which returns a single 'building block' value such as <tt>Integer</tt>, <tt>BigDecimal</tt>, and so on.
+  
+   @param aSupportedTargetClass class supported by the configured 
+   implementation of {@link ConvertColumn}.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return <tt>null</tt> if no record is found.
+  */
+  public static <T> T fetchValue(Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass); 
+    return fetcher.fetchObject(builder);
+  }
+  
+  /**
+   <tt>SELECT</tt> operation which returns <tt>0..N</tt> Model Objects, one per row.  
+    
+   @param aClass class of the returned Model Objects.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+  */
+  public static <T> List<T> list(Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+  
+  /**
+   <tt>SELECT</tt> operation which returns a <tt>List</tt> of 'building block' values such 
+   as <tt>Integer</tt>, <tt>BigDecimal</tt>, and so on.
+  
+   @param aSupportedTargetClass class supported by the configured 
+   implementation of {@link ConvertColumn}.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return an unmodifiable {@link List} of building block objects. The list may be empty.
+  */
+  public static <T> List<T> listValues(Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass); 
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+  
+  /**
+   <tt>SELECT</tt> operation that returns a <tt>List</tt> of Model Objects "subsetted" to 
+   a particular range of rows.
+   
+   <P>This method is intended for paging through long listings. When the underlying 
+   <tt>SELECT</tt> returns many pages of items, the records can be "subsetted" by 
+   calling this method.
+   
+   <P>See {@link hirondelle.web4j.ui.tag.Pager}.
+   @param aClass class of the returned Model Objects.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aStartIndex 1-based index indentifying the first row to be returned.
+   @param aPageSize number of records to be returned.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+  */
+  public static <T> List<T> listRange(Class<T> aClass, SqlId aSqlId, Integer aStartIndex, Integer aPageSize, Object... aParams) throws DAOException  {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    fetcher.limitRowsToRange(aStartIndex, aPageSize);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClass); 
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+
+  /**
+   <tt>SELECT</tt> operation for listing the result of a user's search with the given {@link DynamicSql} 
+   and corresponding parameter values.
+   
+   <P>This method is called only if the exact underlying criteria are not known beforehand, but are rather
+   determined <em>dynamically</em> by user selections. See {@link DynamicSql} for more information.
+    
+   @param aClass class of the returned Model Objects.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aSearchCriteria criteria for the given search, containing <tt>WHERE</tt> and <tt>ORDER BY</tt> clauses.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement, corresponding to the given criteria.
+   @return an unmodifiable {@link List} of Model Objects, corresponding to the input criteria. The list may be empty.
+  */
+  public static <T> List<T> search(Class<T> aClass, SqlId aSqlId, DynamicSql aSearchCriteria, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forSearch(aSqlId, aSearchCriteria, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+   
+  /**
+   <tt>INSERT</tt>, <tt>UPDATE</tt>, or <tt>DELETE</tt> operations which take parameters.
+    
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return the number of records affected by this edit operation.
+  */
+  public static int edit(SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException {
+    SqlEditor change = SqlEditor.forSingleOp(aSqlId, aParams);
+    return change.editDatabase();
+  }
+  
+  /**
+   <tt>INSERT</tt> operation which returns the database identifier of the added record.
+  
+   <P>This operation is not supported by all databases. See 
+   {@link java.sql.Statement} for more information.
+   
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+  */
+  public static Id add(SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException  {
+    SqlEditor add = SqlEditor.forSingleOp(aSqlId, aParams);
+    return new Id(add.addRecord());
+  }
+
+  /**
+   <tt>DELETE</tt> operation which takes parameters.
+    
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams identifies the item to be deleted. Often 1 or more {@link Id} objects.
+   @return the number of deleted records.
+  */
+  public static int delete(SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlEditor delete = SqlEditor.forSingleOp(aSqlId, aParams);
+    return delete.editDatabase();
+  }
+  
+  /**
+   <tt>SELECT</tt> operation which typically returns a single item with a <tt>0..N</tt> relation. 
+    
+   <P>The <tt>ResultSet</tt> is parsed into a single parent Model Object having a <tt>List</tt> of 
+   <tt>0..N</tt> child Model Objects. 
+   See note on <a href="#CompundObjects">compound objects</a> for more information.
+     
+   @param aClassParent class of the parent Model Object.
+   @param aClassChild class of the child Model Object.
+   @param aNumTrailingColsForChildList number of columns appearing at the end of the <tt>ResultSet</tt> which 
+   are passed to the <em>child</em> constructor.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+  */
+  public static <T> T fetchCompound(Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+    return fetcher.fetchObject(builder);
+  }
+
+  /**
+   <tt>SELECT</tt> operation which typically returns mutliple items item with a <tt>0..N</tt> relation. 
+    
+   <P>The <tt>ResultSet</tt> is parsed into a <tt>List</tt> of parent Model Objects, each having <tt>0..N</tt> 
+   child Model Objects. See note on <a href="#CompoundObjects">compound objects</a> for more information.
+     
+   @param aClassParent class of the parent Model Object.
+   @param aClassChild class of the child Model Object.
+   @param aNumTrailingColsForChildList number of columns appearing at the end of the <tt>ResultSet</tt> which 
+   are passed to the <em>child</em> constructor.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+  */
+  public static <T> List<T> listCompound(Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+    fetcher.fetchObjects(builder, result);
+    return result;
+  }
+
+  /**
+   Add an <tt>Id</tt> to a list of parameters already extracted from a Model Object. 
+   
+   <P>This method exists to avoid repetition in your DAOs regarding the parameters
+   passed to <em>add</em> and <em>change</em> operations. 
+   
+   <P>Take the following example :
+   <PRE>
+  INSERT INTO Resto (Name, Location, Price, Comment) VALUES (?,?,?,?)
+  UPDATE Resto SET Name=?, Location=?, Price=?, Comment=? WHERE Id=?
+  </PRE>
+   In this case, the parameters are exactly the same, and appear in the same order, 
+   <em>except</em> for the <tt>Id</tt> at the end of the <tt>UPDATE</tt> statement.
+   
+   <P>In such cases, this method can be used to simply append the <tt>Id</tt> to an 
+   already existing list of parameters.
+   
+   @param aBaseParams all parameters used in an <tt>INSERT</tt> statement
+   @param aId the <tt>Id</tt> parameter to append to <tt>aBaseParams</tt>,
+   @return parameters needed for a <em>change</em> operation 
+  */
+  public static Object[] addIdTo(Object[] aBaseParams, Id aId){
+    List<Object> result = new ArrayList<Object>();
+    for(Object thing: aBaseParams){
+      result.add(thing);
+    }
+    result.add(aId);
+    return result.toArray(); 
+  }
+  
+  /**
+   Initialize the web4j database layer only.
+   This method is intended for using the web4j database layer outside of a servlet container (for example, 
+   in a command-line application).
+    
+   <P>The idea is that the services of the web4j data layer can be used in any application, not just web applications.
+   Callers will also need to supply an implementation of {@link ConnectionSource}, in the usual way, by either 
+   making use of the standard package name and class name ('hirondelle.web4j.config.ConnectionSrc'), or by specifying 
+   the class name in the provided settings parameter. Callers that make use of the <tt>Reports</tt> class may also need to 
+   supply an implementation of {@link hirondelle.web4j.request.DateConverter}. 
+   
+   @param aSettings correspond to the same name-value pairs defined in <tt>web.xml</tt>, and take the same values.
+   Callers should pay particular attention to the settings related to the database. 
+   See the <a href='http://www.web4j.com/UserGuide.jsp'>User Guide</a> for more information.
+   @param aRawSql each String in this list corresponds to the contents of a single <tt>.sql</tt> file. 
+  */
+  public static void initStandalone(Map<String, String> aSettings, List<String> aRawSql) throws AppException{
+    Config.init(aSettings); 
+    BuildImpl.initDatabaseLayer(aSettings);  //not all interfaces init-ed
+    Map<String, String> processedSql = ConfigReader.processRawSql(aRawSql);
+    SqlStatement.initSqlStatementsManually(processedSql); 
+  }
+
+  // PRIVATE 
+  
+  private Db() {
+    //prevent construction by the caller
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/DbConfig.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,167 @@
+package hirondelle.web4j.database;
+
+import static hirondelle.web4j.util.Consts.FAILURE;
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+import static hirondelle.web4j.util.Consts.SUCCESS;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.util.Util;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.logging.Logger;
+
+/**
+ (UNPUBLISHED) Initialize the data layer and return related configuration information.
+
+ <P>Acts as the single source of configuration information needed by the 
+ data layer. The intent is to centralize the dependencies of the data 
+ layer on its environment. For example, this is the only class in this package 
+ which knows about <tt>ServletConfig</tt>.
+
+ <P>This class carries simple static, immutable data, populated upon startup.
+ It is safe to use this class in a multi-threaded environment.
+
+ <P>In addition to reading in <tt>web.xml</tt> settings, this class :
+<ul>
+ <li>initializes connection sources
+ <li>logs the name and version of both the database and the database driver
+ <li>logs the support for transaction isolation levels (see {@link TxIsolationLevel})
+ <li>reads in the <tt>*.sql</tt> file(s) (see package summary for more information)
+</ul>
+
+ <P>See <tt>web.xml</tt> for more information on the items encapsulated by this class.
+ 
+ @un.published
+*/
+public final class DbConfig {
+  
+  /**
+   Configure the data layer. Called upon startup. 
+   
+   <P> If <tt>aIndicator</tt> is <tt>YES</tt>, then 
+   hard-coded test settings will be used. (This is intended solely as a developer 
+   convenience for testing database connectivity. If called outside of a web 
+   container, then <tt>aConfig</tt> must be <tt>null</tt>.)
+   
+   @return the names of all databases that were seen to be <em>up</em>.
+  */
+  public static Set<String> initDataLayer() throws DAOException {
+    Set<String> result = new LinkedHashSet<String>();    
+    //there are order dependencies here!!
+    ConnectionSource connSrc = BuildImpl.forConnectionSource();
+    for (String dbName : connSrc.getDatabaseNames()){
+      boolean healthy = checkHealthOf(dbName);
+      if(healthy) {
+        result.add(dbName);
+      }
+    }
+    
+    int numDbs = connSrc.getDatabaseNames().size();    
+    if((numDbs > 0) && (result.size() == numDbs)) {
+      fLogger.config("*** SUCCESS : ALL DATABASES DETECTED OK! *** Num databases: " + numDbs);
+    }
+    
+    SqlStatement.readSqlFile();
+    return result;
+  }
+  
+  /** 
+   Check the health of the given database connection. For healthy connections, log 
+   basic info as a side-effect. Return <tt>true</tt> only if healthy.
+  */
+  public static boolean checkHealthOf(String dbName) throws DAOException {
+    boolean result = logDatabaseAndDriverNames(dbName);
+    if(result) {
+      fLogger.config("Success : Database named " + Util.quote(dbName) + " detected OK.");
+      queryTxIsolationLevelSupport(dbName);
+    }
+    else {
+      fLogger.severe("Cannot connect to database named " + Util.quote(dbName) + ". Is database running?");
+    }
+    return result;
+  }
+  
+  // PRIVATE 
+  
+  private static final Logger fLogger = Util.getLogger(DbConfig.class);
+  
+  private DbConfig(){
+    //empty - prevent construction by the caller
+  }
+  
+  /** 
+   Log name and version info, for both the database and driver.
+   
+   <P>This is the first place where the database is exercised. Returns false only if an error occurs. 
+   This indicates to the caller that db init tasks have not completed normally.
+   The caller must check the return value.
+  */
+  private static boolean logDatabaseAndDriverNames(String aDbName) throws DAOException {
+    boolean result = SUCCESS;
+    Connection connection = null;
+    try { 
+      connection = BuildImpl.forConnectionSource().getConnection(aDbName);
+      DatabaseMetaData db = connection.getMetaData();
+      String dbName = db.getDatabaseProductName() + "/" + db.getDatabaseProductVersion();
+      String dbDriverName = db.getDriverName() + "/" + db.getDriverVersion();
+      String message = NEW_LINE;
+      message = message + "  Database Id passed to ConnectionSource: " + aDbName + NEW_LINE; 
+      message = message + "  Database name: " + dbName + NEW_LINE;
+      message = message + "  Database driver name: " + dbDriverName + NEW_LINE;
+      message = message + "  Database URL: " + db.getURL() + NEW_LINE;
+      boolean supportsScrollable = db.supportsResultSetConcurrency(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
+      message = message + "  Supports scrollable ResultSets (TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY): " + supportsScrollable;
+      if ( ! supportsScrollable ){
+        fLogger.severe("Database/driver " + aDbName + " does not support scrollable ResultSets. Parsing of ResultSets by ModelFromRow into 'Parent+List<Children>' kinds of objects will not work, since it depends on scrollable ResultSets. Parsing into 'ordinary' Model Objects will still work, however, since they do not depend on scrollable ResultSets.)");
+      }
+      fLogger.config(message);
+    }
+    catch (Throwable ex) {
+      result = FAILURE;
+    }
+    finally {
+      DbUtil.close(connection);
+    }
+    return result;
+  }
+
+  /** For each {@link TxIsolationLevel}, log if it is supported (true/false).   */  
+  private static void queryTxIsolationLevelSupport(String aDbName) throws DAOException {
+    Connection connection = BuildImpl.forConnectionSource().getConnection(aDbName);
+    try { 
+      int defaultTxLevel = connection.getMetaData().getDefaultTransactionIsolation();
+      DatabaseMetaData db = connection.getMetaData();
+      for(TxIsolationLevel level: TxIsolationLevel.values()){
+        fLogger.config(getTxIsolationLevelMessage(db, level, defaultTxLevel)  );
+      }
+    }
+    catch (SQLException ex) {
+      throw new DAOException("Cannot query database for transaction level support", ex);
+    }
+    finally {
+      DbUtil.close(connection);
+    }
+  }
+
+  /** Create a message describing support for aTxIsolationLevel.  */
+  private static String getTxIsolationLevelMessage (DatabaseMetaData aDb, TxIsolationLevel aLevel, int aDefaultTxLevel)  {
+    StringBuilder result = new StringBuilder();
+    result.append("Supports Tx Isolation Level " + aLevel.toString() + ": ");
+    try { 
+      boolean supportsLevel = aDb.supportsTransactionIsolationLevel(aLevel.getInt());
+      result.append(supportsLevel);
+    }
+    catch(SQLException ex){
+      fLogger.warning("Database driver doesn't support calla to supportsTransactionalIsolationLevel(int).");      
+      result.append( "Unknown");
+    }
+    if ( aLevel.getInt() == aDefaultTxLevel ) {
+      result.append(" (default)");
+    }
+    return result.toString();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/DbTx.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,205 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.model.Id;
+import java.sql.Connection;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ Version of {@link Db} for use in a transaction. 
+ 
+ <P>The {@link Db} class uses an internal connection to execute single operations. 
+ If more than one operation needs to be performed as an atomic transaction, then 
+ this class is used instead.
+ 
+ <P>This class is identical to {@link Db}, except that it uses a {@link Connection} passed
+ by the caller.
+ 
+<h3><a name="Parameters"></a>SQL Parameters</h3>
+ The parameters for SQL statements used by this class have the same behavior as defined by 
+ the <a href='Db.html#Parameters'>Db class</a>.
+*/
+public final class DbTx {
+
+  /**
+   <tt>SELECT</tt> operation which returns a single Model Object. 
+    
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aClass class of the returned Model Object.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+  */
+  public static <T> T fetch(Connection aConnection, Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    ModelFromRow<T> builder = new ModelFromRow<T>(aClass);
+    return fetcher.fetchObject(builder);
+  }
+
+  /**
+   <tt>SELECT</tt> operation which returns a single 'building block' value such as <tt>Integer</tt>, <tt>BigDecimal</tt>, and so on.
+  
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aSupportedTargetClass class supported by the configured 
+   implementation of {@link ConvertColumn}.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+  */
+  public static <T> T fetchValue(Connection aConnection, Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass); 
+    return fetcher.fetchObject(builder);
+  }
+  
+  /**
+   <tt>SELECT</tt> operation which returns <tt>0..N</tt> Model Objects, one per row.  
+    
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aClass class of the returned Model Objects.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+  */
+  public static <T> List<T> list(Connection aConnection, Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+  
+  /**
+   <tt>SELECT</tt> operation which returns a <tt>List</tt> of 'building block' values such 
+   as <tt>Integer</tt>, <tt>BigDecimal</tt>, and so on.
+  
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aSupportedTargetClass class supported by the configured 
+   implementation of {@link ConvertColumn}.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return an unmodifiable {@link List} of building block objects. The list may be empty.
+  */
+  public static <T> List<T> listValues(Connection aConnection, Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass); 
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+  
+  /**
+   <tt>SELECT</tt> operation that returns a <tt>List</tt> of Model Objects "subsetted" to 
+   a particular range of rows.
+   
+   <P>This method is intended for paging through long listings. When the underlying 
+   <tt>SELECT</tt> returns many pages of items, the records can be "subsetted" by 
+   calling this method.
+   
+   <P>See {@link hirondelle.web4j.ui.tag.Pager}.
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aClass class of the returned Model Objects.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aStartIndex 1-based index indentifying the first row to be returned.
+   @param aPageSize number of records to be returned.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+  */
+  public static <T> List<T> listRange(Connection aConnection, Class<T> aClass, SqlId aSqlId, Integer aStartIndex, Integer aPageSize, Object... aParams) throws DAOException  {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    fetcher.limitRowsToRange(aStartIndex, aPageSize);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClass); 
+    fetcher.fetchObjects(builder, result);
+    return Collections.unmodifiableList(result);
+  }
+
+  /**
+   <tt>INSERT</tt>, <tt>UPDATE</tt>, or <tt>DELETE</tt> operations which take parameters.
+    
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+   @return the number of records affected by this edit operation.
+  */
+  public static int edit(Connection aConnection, SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException {
+    SqlEditor change = SqlEditor.forTx(aSqlId, aConnection, aParams);
+    return change.editDatabase();
+  }
+  
+  /**
+   <tt>INSERT</tt> operation which returns the database identifier of the added record.
+  
+   <P>This operation is not supported by all databases. See 
+   {@link java.sql.Statement} for more information.
+   
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+  */
+  public static Id add(Connection aConnection, SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException  {
+    SqlEditor add = SqlEditor.forTx(aSqlId, aConnection, aParams);
+    return new Id(add.addRecord());
+  }
+
+  /**
+   <tt>DELETE</tt> operation which takes parameters.
+    
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams identifies the item to be deleted. Often 1 or more {@link Id} objects.
+   @return the number of deleted records.
+  */
+  public static int delete(Connection aConnection, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlEditor delete = SqlEditor.forTx(aSqlId, aConnection, aParams);
+    return delete.editDatabase();
+  }
+  
+  /**
+   <tt>SELECT</tt> operation which typically returns a single item with a <tt>0..N</tt> relation. 
+    
+   <P>The <tt>ResultSet</tt> is parsed into a single parent Model Object having a <tt>List</tt> of 
+   <tt>0..N</tt> child Model Objects. 
+   See note on <a href="#CompundObjects">compound objects</a> for more information.
+     
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aClassParent class of the parent Model Object.
+   @param aClassChild class of the child Model Object.
+   @param aNumTrailingColsForChildList number of columns appearing at the end of the <tt>ResultSet</tt> which 
+   are passed to the <em>child</em> constructor.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+  */
+  public static <T> T fetchCompound(Connection aConnection, Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+    return fetcher.fetchObject(builder);
+  }
+
+  /**
+   <tt>SELECT</tt> operation which typically returns mutliple items item with a <tt>0..N</tt> relation. 
+    
+   <P>The <tt>ResultSet</tt> is parsed into a <tt>List</tt> of parent Model Objects, each having <tt>0..N</tt> 
+   child Model Objects. See note on <a href="#CompundObjects">compound objects</a> for more information.
+     
+   @param aConnection single connection shared by all operations in the transaction.
+   @param aClassParent class of the parent Model Object.
+   @param aClassChild class of the child Model Object.
+   @param aNumTrailingColsForChildList number of columns appearing at the end of the <tt>ResultSet</tt> which 
+   are passed to the <em>child</em> constructor.
+   @param aSqlId identifies the underlying SQL statement.
+   @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+ */
+  public static <T> List<T> listCompound(Connection aConnection, Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+    List<T> result = new ArrayList<T>();
+    SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+    ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+    fetcher.fetchObjects(builder, result);
+    return result;
+  }
+  
+  // PRIVATE 
+  
+  private DbTx() {
+    //prevent construction by the caller
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/DbUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,86 @@
+package hirondelle.web4j.database;
+
+import java.sql.*;
+import java.util.logging.*;
+
+import hirondelle.web4j.util.Util;
+
+/**
+ Static methods for relational databases, which avoid code repetition.
+*/
+final class DbUtil {
+  
+  /**
+   Close a <tt>Statement</tt> first, then its associated <tt>Connection</tt>.
+   <P>Parameters are permitted to be <tt>null</tt>.
+  */
+  static void close(PreparedStatement aStatement, Connection aConnection) throws DAOException {
+    try {
+      if (aStatement != null) aStatement.close();
+      if (aConnection != null) aConnection.close();
+    }
+    catch (SQLException ex){
+      throw new DAOException("Cannot close statement or connection: " + ex, ex);
+    }
+  }
+  
+  /**
+   Close a possibly-null <tt>Connection</tt>.
+  */
+  static void close(Connection aConnection) throws DAOException {
+    close(null, aConnection);
+  }
+    
+  /** Close a possibly-null <tt>Statement</tt>.  */
+  static void close(PreparedStatement aStatement) throws DAOException {
+    close(aStatement, null);
+  }
+  
+  /**
+   If <tt>aStatement</tt> has any warnings, then log them using "severe" level.
+  */
+  static void logWarnings(Statement aStatement) {
+    try {
+      SQLWarning warning = aStatement.getWarnings();
+      logWarning(warning);
+    }
+    catch (SQLException exception) {
+      //do nothing, since not critical
+    }
+  }
+  
+  /**
+   If <tt>aConnection</tt> has any warnings, then log them using "severe" level.
+  */
+  static void logWarnings(Connection aConnection) {
+    try {
+      SQLWarning warning = aConnection.getWarnings();
+      logWarning(warning);
+    }
+    catch (SQLException exception) {
+      //do nothing, since not critical
+    }
+  }
+  
+  // PRIVATE //
+  
+  private static final Logger fLogger = Util.getLogger(DbUtil.class);  
+  
+  private static void logWarning(SQLWarning aWarning){
+    //minor optimisation for the nominal case
+    if (aWarning == null) return;
+
+    final StringBuilder message = new StringBuilder();
+    SQLWarning warning = aWarning;
+    while ( warning!= null ){
+      message.append("***SQL WARNING***: ");
+      message.append( warning );
+      message.append(" SQL ErrorId Code:");
+      message.append( warning.getErrorCode() );
+      message.append(" SQL State:");
+      message.append( warning.getSQLState());
+      warning = warning.getNextWarning();
+    }
+    fLogger.severe(message.toString());
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/DuplicateException.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,62 @@
+package hirondelle.web4j.database;
+
+/**
+ Thrown when a uniqueness problem occurs in the datastore
+ during an <tt>ADD</tt> or <tt>CHANGE</tt> operation.
+
+ <P>This type of exception is singled out since it is so common. It allows 
+ {@link hirondelle.web4j.action.Action}s to catch this specific kind of exception.
+
+ <P>For relational databases, this exception should be thrown for <tt>INSERT</tt>
+ and <tt>UPDATE</tt> operations which may violate a <tt>UNIQUE</tt> or 
+ <tt>PRIMARY KEY</tt> constraint, or similar item.
+ {@link Db}, {@link DbTx}, and {@link TxTemplate} will throw a <tt>DuplicateException</tt> 
+ exception for {@link java.sql.SQLException}s having an error code matching the 
+ <tt>ErrorCodeForDuplicateKey</tt> configured in <tt>web.xml</tt>. 
+ See <tt>web.xml</tt> for more information.
+
+ <h3>Typical Use Case</h3>
+ Here, an {@link hirondelle.web4j.action.Action} is calling a DAO method which may throw 
+ a <tt>DuplicateException</tt>: 
+<PRE>
+private void addSomething throws DAOException {
+  //this try..catch is needed only if the operation 
+  //can have a duplicate problem
+  try {
+    dao.addSomething();
+  }
+  catch (DuplicateException ex){
+    addError("Cannot add. That item already exists.");
+  }
+}
+</PRE>
+ <P>Note that if the operation cannot have a duplicate problem, then the <tt>Action</tt>
+ should not attempt to catch <tt>DuplicateException</tt>. 
+<P>
+ Here is the DAO operation which may have a duplicate problem. 
+<PRE>
+//It is highly recommended, but optional, to declare 
+//DuplicateException in this method header, to bring 
+//it to the attention of the caller
+public addSomething() throws DAOException, DuplicateException {
+  //...elided
+}
+</PRE>
+ Again, if the operation cannot have a duplicate problem, then the DAO should not 
+ declare a <tt>DuplicateException</tt> in its throws clause.
+ 
+ <P>The {@link Db#add(SqlId, Object[])} and {@link Db#edit(SqlId, Object[])} methods can throw a 
+ <tt>DuplicateException</tt>.
+*/
+public final class DuplicateException extends DAOException {
+
+  /**
+   Constructor.
+   
+   <P>Arguments are passed to {@link DAOException#DAOException(String, Throwable)}.
+  */
+  public DuplicateException(String aMessage, Throwable aRootCause) {
+    super(aMessage, aRootCause);
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/DynamicSql.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,137 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.util.Util;
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+
+/**
+  Dynamic SQL statement created in code. The SQL statement can be either: 
+  <ul>
+   <li>a complete statement 
+   <li>a fragment of a statement, to be appended to the end of a static base statement
+  </ul>
+  
+  <P>This class is intended for two use cases: 
+  <ul>
+   <li>creating a statement entirely in code
+   <li>creating <tt>WHERE</tt> and <tt>ORDER BY</tt> clauses dynamically, by building sort and filter criteria from 
+   user input
+  </ul>
+  
+  <P><b>The creation of SQL in code is dangerous. 
+  You have to exercise care that your code will not be subject to 
+  <a href='http://en.wikipedia.org/wiki/SQL_injection'>SQL Injection attacks</a>. 
+  If you don't know what such attacks are all about, then you are in danger of creating 
+  very large, dangerous security flaws in your application.</b>
+  
+  <P>The main means of protecting yourself from these attacks is to ensure that the sql strings you pass to 
+  this class never contain data that has come directly from the user, in an unescaped form.
+  You achieve this by <tt>parameterizing</tt> user input, and proceeding in 2 steps:
+  <ol>
+   <li>create an SQL statement that always uses a <tt>?</tt> placeholder for data entered by the user 
+   <li>pass all user-entered data as parameters to the above statement
+  </ol>
+  
+  The above corresponds to the correct use of a <tt>PreparedStatement</tt>.
+  After you have built your dynamic SQL, you will usually pass it to   
+  {@link hirondelle.web4j.database.Db#search(Class, SqlId, DynamicSql, Object[])} to retrieve the data. 
+  
+  <h3>Entries in .sql Files</h3>
+  <P>The SQL string you pass to this class is always <em>appended</em> (using {@link #toString}) to 
+  a (possibly-empty) base SQL statement already defined (as usual), in your <tt>.sql</tt> file. 
+  That entry can take several forms. The criteria on the entry are:
+  <ul>
+   <li>it can be precompiled by WEB4J upon startup, if desired.
+   <li>it contains only <i>static</i> elements of the final SQL statement
+  </ul> 
+
+  <em>It's important to note that the static base SQL can be completely empty.</em>
+  For example, the entry in your <tt>.sql file</tt> can look something like this: 
+  <pre>MY_DYNAMIC_REPORT {
+    -- this sql is generated in code
+}</pre>
+  As you can see, there's only a comment here; there's no real SQL.
+  In this case, you will need to build the entire SQL statement in code. 
+  (Even though the above entry is empty, it's still necessary, since it's where you specify any 
+  non-default target database name. It also ensures that the same mechanism web4j applies 
+  to processing <tt>SqlId</tt> objects will remain in effect, which is useful.)
+
+  <P>You are encouraged to implement joins between tables using the <tt>JOIN</tt> syntax. 
+  The alternative is to implement joins using expressions in the <tt>WHERE</tt> clause.
+  This usually isn't desirable, since it mixes up two distinct items - joins and actual criteria. 
+  Using <tt>JOIN</tt> allows these items to remain separate and distinct.  
+
+  <h3>See Also</h3>
+  Other items closely related to this class are :  
+   <ul>
+   <li> {@link hirondelle.web4j.action.ActionImpl#getOrderBy(hirondelle.web4j.request.RequestParameter,hirondelle.web4j.request.RequestParameter, String)} - 
+   convenience method for constructing an <tt>ORDER BY</tt> clause from request parameters.
+   <li> {@link hirondelle.web4j.database.Db#search(Class, SqlId, DynamicSql, Object[])} 
+   <li> the {@link hirondelle.web4j.database.Report} class. 
+   </ul>
+   
+  <h3>Constants</h3>
+  The {@link #WHERE}, {@link #AND}, and other constants are included in this class as a simple 
+  convenience. Note that each value includes a leading a trailing space, to avoid trivial spacing errors.
+  
+  <P>This class is non-final, and can be overridden, if desired. The reason is that some applications may wish to try to 
+  validate that the SQL passed to this class has been properly parameterized. 
+*/
+public class DynamicSql {;
+  
+  /** Value - {@value}, convenience value for building a <tt>WHERE</tt> clause.*/
+  public static final String WHERE = " WHERE ";
+  
+  /** Value - {@value}, convenience value for building a <tt>WHERE</tt> clause. */
+  public static final String AND = " AND ";
+  
+  /** Value - {@value}, convenience value for building a <tt>WHERE</tt> clause. */
+  public static final String OR = " OR ";
+  
+  /** Value - {@value}, convenience value for building an <tt>ORDER BY</tt> clause. */
+  public static final String ORDER_BY = " ORDER BY ";
+  
+  /** Value - {@value}, convenience value for building an <tt>ORDER BY</tt> clause. */
+  public static final String ASC = " ASC ";
+  
+  /** Value - {@value}, convenience value for building an <tt>ORDER BY</tt> clause. */
+  public static final String DESC = " DESC ";
+
+  /**
+   Represents the absence of any criteria. The value of this item is simply <tt>null</tt>.
+   <P>If a method allows a <tt>null</tt> object to indicate the absence of any criteria, 
+   then it is recommended that this reference be used instead of <tt>null</tt>.
+  */
+  public static final DynamicSql NONE = null;
+  
+  /**
+   Constructor.
+   <P>This constructor will slightly modify the given parameter: it will trim it, and prepend a new line to the result.
+   @param aSql must have content; it will be trimmed by this method.
+  */
+  public DynamicSql(String aSql){
+    if( ! Util.textHasContent(aSql) ){
+      throw new IllegalArgumentException("The SQL text has no content.");
+    }
+    fSql = NEW_LINE + aSql.trim();
+  }
+  
+  
+  /** Convenience constructor, forwards to {@link #DynamicSql(String)}.  */
+  public DynamicSql(StringBuilder aSql){
+    this(aSql.toString());
+  }
+  
+  /**
+   Return the String passed to the constructor, trimmed.
+   
+  <P>The returned value is appended by the framework to an existing (possibly empty) entry in an <tt>.sql</tt> file.
+  */
+  @Override final public String toString(){
+    return fSql;
+  }
+
+  // PRIVATE 
+  
+  private String fSql = "";
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ForeignKeyException.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,54 @@
+package hirondelle.web4j.database;
+
+/**
+ Thrown when a violation of a foreign key constraint occurs in the datastore
+ during an <tt>ADD</tt>, <tt>CHANGE</tt>, or <tt>DELETE</tt> operation.
+
+ <P>This type of exception is singled out since it is so common. It allows 
+ {@link hirondelle.web4j.action.Action}s to catch this specific kind of exception.
+
+ <P>For relational databases, this exception should be thrown for <tt>INSERT</tt>, 
+ <tt>UPDATE</tt>, or <tt>DELETE</tt> operation which may violate a foreign key constraint.
+ {@link Db}, {@link DbTx}, and {@link TxTemplate} will throw a <tt>ForeignKeyException</tt> 
+ exception for {@link java.sql.SQLException}s having an error code matching the 
+ <tt>ErrorCodeForForeignKey</tt> setting configured in <tt>web.xml</tt>. 
+ See <tt>web.xml</tt> for more information.
+
+ <h3>Typical Use Case</h3>
+ Here, an {@link hirondelle.web4j.action.Action} is calling a DAO method which may throw 
+ a <tt>ForeignKeyException</tt>: 
+<PRE>
+private void deleteSomething throws DAOException {
+  //this try..catch is needed only if the operation 
+  //can have a foreign key problem
+  try {
+    dao.deleteSomething();
+  }
+  catch (ForeignKeyException ex){
+    addError("Cannot delete. Referenced by some other item.");
+  }
+}
+</PRE>
+<P>
+ Here is the DAO operation which may have a foreign key constraint problem. 
+<PRE>
+//It is highly recommended, but optional, to declare 
+//ForeignKeyException in this method header, to bring 
+//it to the attention of the caller
+public deleteSomething() throws DAOException, ForeignKeyException {
+  //...elided
+}
+</PRE>
+*/
+public final class ForeignKeyException extends DAOException {
+
+  /**
+   Constructor.
+   
+   <P>Arguments are passed to {@link DAOException#DAOException(String, Throwable)}.
+  */
+  public ForeignKeyException(String aMessage, Throwable aRootCause) {
+    super(aMessage, aRootCause);
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ModelBuilder.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,53 @@
+package hirondelle.web4j.database;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import hirondelle.web4j.model.ModelCtorException;
+
+/**
+ Abstract Base Class for building Model Objects from a <tt>ResultSet</tt>.  
+ 
+ <P>This class exists for two reasons : 
+ <ul>
+ <li>to translate exceptions
+ <li>to allow the construction of both Model Objects and building block 
+ objects such as <tt>Date</tt>, <tt>Integer</tt>, and so on. These two differ 
+ since building block objects usually have multiple constructors with the same 
+ number of arguments.
+ </ul>
+*/
+abstract class ModelBuilder<E> {
+  
+  /**
+   <b>Template Method</b> for parsing a single row into an arbitrary Object. 
+   The returned <tt>Object</tt> often represents a Model Object, but can also 
+   represent an <tt>Integer</tt> value, a <tt>BigDecimal</tt> value, a <tt>Map</tt>, 
+   or any other object whatsoever.
+  
+   <P>This template method calls the abstract method {@link #build}. This 
+   method exists only to translate any exceptions thrown by
+   {@link #buildObject(ResultSet)} into a {@link DAOException}.
+  
+   @param aRow contains data which is to be parsed into an Object of type <tt>E</tt>.
+  */
+  final E buildObject(ResultSet aRow) throws DAOException {
+    E result = null;
+    try {
+      result = build(aRow);
+    }
+    catch (SQLException ex){
+      throw new DAOException("Database problem. Cannot parse row into Object. " + ex.getMessage(), ex);
+    }
+    catch(ModelCtorException ex){
+      throw new DAOException("Constructor problem. Cannot parse into Object. "  + ex.getMessage(), ex);
+    }
+    return result;
+  }
+   
+  /**
+   Parse a single row into an arbitrary Object, throwing any exceptions.
+  
+   <P>The argument and return value satisfy the same conditions as in {@link #buildObject}.
+  */
+  abstract E build(ResultSet aRow) throws SQLException, ModelCtorException;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ModelFromRow.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,206 @@
+package hirondelle.web4j.database;
+
+import java.util.*;
+import java.util.logging.*;
+import java.sql.*;
+import java.lang.reflect.Constructor;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.ConvertParam;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelCtorUtil;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Args;
+
+/**
+ Construct a Model Object from a <tt>ResultSet</tt>.
+ 
+ <P>Uses all columns. See package level comment for more details.
+ <P>Can construct either simple Model Objects, passing only building block objects to the constructor, 
+ or may construct limited forms of compound objects as well. See {@link Db} for more details.
+*/
+class ModelFromRow<E> extends ModelBuilder<E> {
+
+  /**
+   Simple case of a typical Model Object, whose constructor takes only building block values.
+    
+   @param aClass class literal for the target Model Object.
+  */
+  ModelFromRow(Class<E> aClass){
+    fClass = aClass;  
+  }
+  
+  /**
+   Extended case of a compound Model Object, which takes a single <tt>List</tt> of child objects at the 
+   end of its constructor.
+     
+   @param aParentClass class literal for the target parent Model Object.
+   @param aChildClass class literal for the target child Model Object. The child object appears as a <tt>List</tt>
+   at the end of the parent's constructor.
+   @param aNumTrailingColsForChildList number of columns at the end of the <tt>ResultSet</tt> that are 
+   used to construct a child object.
+  */
+  ModelFromRow(Class<E> aParentClass, Class<?> aChildClass, int aNumTrailingColsForChildList){
+    fClass = aParentClass;
+    fChildClass = aChildClass;
+    Args.checkForPositive(aNumTrailingColsForChildList);
+    fNumColsForChildList = aNumTrailingColsForChildList;
+  }
+  
+  E build(ResultSet aRow) throws SQLException, ModelCtorException {
+    E result = null;
+    if( isSimple() ){
+      result = buildSimple(aRow);
+    }
+    else {
+      result = buildWithChildList(aRow);
+    }
+    return result;
+  }
+
+  // PRIVATE //
+  
+  private final Class<E> fClass;
+  private Class<?> fChildClass;
+  private int fNumColsForChildList;
+  
+  private ConvertColumn fColumnToObject = BuildImpl.forConvertColumn();
+  private static final boolean INCLUDE_LAST_CTOR_ARG = true;
+  private static final boolean EXCLUDE_LAST_CTOR_ARG = false;
+  private static final int FIRST_COLUMN = 1;
+  private static final Logger fLogger = Util.getLogger(ModelFromRow.class);
+  
+  private boolean isSimple(){
+    return fChildClass == null;
+  }
+  
+  private E buildSimple(ResultSet aRow) throws SQLException, ModelCtorException {
+    Constructor<E> modelCtor = ModelCtorUtil.getConstructor(fClass, getNumColsInResultSet(aRow)); 
+    List<Object> ctorArgValues = getCtorArgValues(modelCtor, aRow, INCLUDE_LAST_CTOR_ARG);
+    return ModelCtorUtil.buildModelObject(modelCtor, ctorArgValues); 
+  }
+  
+  private int getNumColsInResultSet(ResultSet aRow) throws SQLException {
+    return aRow.getMetaData().getColumnCount();
+  }
+  
+  private List<Object> getCtorArgValues(Constructor<E> aConstructor, ResultSet aRow, boolean aIncludeLastArg) throws SQLException {
+    List<Object> result = new ArrayList<Object>();
+    int columnIdx = 1;
+    Class<?>[] targetTypes = aConstructor.getParameterTypes();
+    for(Class argType: targetTypes){
+      if( isNotLastArg(columnIdx, targetTypes) ){
+        result.add(fColumnToObject.convert(aRow, columnIdx, argType));
+      }
+      else {
+        if(aIncludeLastArg){
+          result.add(fColumnToObject.convert(aRow, columnIdx, argType));
+        }
+      }
+      ++columnIdx;
+    }
+    return result;
+  }
+  
+  private boolean isNotLastArg(int aColIndex, Class[] aTargetTypes){
+    return aColIndex != aTargetTypes.length;
+  }
+  
+  /*
+    Remaining methods all deal with children.
+  */
+  
+  private E buildWithChildList(ResultSet aRow) throws SQLException, ModelCtorException {
+    fLogger.fine("Building with Child List at end of constructor.");
+    //first gather parent args (but do not construct yet)
+    Constructor<E> parentCtor = ModelCtorUtil.getConstructor(fClass, getNumColsForParent(aRow) + 1); 
+    List<Object> ctorArgsMinusChildren = getCtorArgValues(parentCtor, aRow, EXCLUDE_LAST_CTOR_ARG);
+    fLogger.finest("Parent constructor arg values, minus children : " + ctorArgsMinusChildren);
+    
+    //build a child for each collection of rows that belongs to the given parent
+    //identify new parents using changes in FIRST col only
+    Constructor<?> childCtor = ModelCtorUtil.getConstructor(fChildClass, fNumColsForChildList);
+    Id parentId = new Id(aRow.getString(FIRST_COLUMN));
+    fLogger.finest("Building Child List for Parent Id: " + parentId);
+    Id currentId = null;
+    List<Object> childList = new ArrayList<Object>();
+    do {
+      addChildToList(childCtor, childList, aRow);
+      aRow.next();
+      if ( ! aRow.isAfterLast() ){
+        currentId = new Id(aRow.getString(FIRST_COLUMN));
+        fLogger.finest("Updated current id: " + currentId);
+      }
+    }
+    while( !aRow.isAfterLast() && currentId.equals(parentId) );
+    //back up to PREVIOUS row, in preparation for next section/full model object, if any.
+    aRow.previous();
+    return buildFinalModelObjectWithChildList(parentCtor, ctorArgsMinusChildren, childList);
+  }
+  
+  private int getNumColsForParent(ResultSet aRow) throws SQLException {
+    int numCols = aRow.getMetaData().getColumnCount();
+    int result = numCols - fNumColsForChildList;
+    if ( result < 1){
+      throw new RuntimeException("Number of columns available for Parent constructor < 1 : " + Util.quote(result));
+    }
+    return result;
+  }
+
+  private void addChildToList(Constructor<?> aChildCtor, List<Object> aChildren, ResultSet aRow) throws SQLException, ModelCtorException {
+    ConvertParam convertParam = BuildImpl.forConvertParam();
+    if( convertParam.isSupported(aChildCtor.getDeclaringClass())) {
+      addBaseChildToList(aChildCtor.getDeclaringClass(), aChildren, aRow);
+    }
+    else {
+      //regular model object, not a base object
+      addRegularChildToList(aChildCtor, aChildren, aRow);
+    }
+  }
+  
+  /** Child object is a regular Model Object, whose ctor takes Base Objects supported by ConvertColumn. */
+  private void addRegularChildToList(Constructor<?> aChildCtor, List<Object> aChildren, ResultSet aRow) throws SQLException, ModelCtorException {
+    fLogger.fine("Building a regular child, and adding to the child list.");
+    Class<?>[] targetTypes = aChildCtor.getParameterTypes();
+    List<Object> argValues = new ArrayList<Object>();
+    int columnIdx = getNumColsForParent(aRow) + 1; //ignore the leading columns belonging to parent
+    //some outer joins will have all child columns null
+    boolean atLeastOneHasContent = false;
+    for (Class argType: targetTypes){
+      Object value = fColumnToObject.convert(aRow, columnIdx, argType);
+      if ( value != null ){
+        atLeastOneHasContent = true;
+      }
+      argValues.add(value);
+      ++columnIdx;
+    }
+    if( atLeastOneHasContent ) {
+      fLogger.finest("At least one child column has content."); 
+      Object child = ModelCtorUtil.buildModelObject(aChildCtor, argValues);
+      aChildren.add(child);
+    }
+    else {
+      //do not add anthing to the list of children
+      fLogger.finest("ALL columns for Child object are empty/null - no child object to construct.");
+    }
+  }
+  
+  /** Child object is simply a Base Object, supported by ConvertColumn. */
+  private <T> void addBaseChildToList(Class<T> aBaseClass, List<Object> aChildren, ResultSet aRow) throws SQLException {
+    //Note : this extra branch was added when String was removed from supported Base Objects.
+    fLogger.fine("Building a base object child, and adding to the child list.");
+    int lastColumn = getNumColsInResultSet(aRow); 
+    T baseObject = fColumnToObject.convert(aRow, lastColumn, aBaseClass); //possibly-null
+    aChildren.add(baseObject);
+  }
+  
+  private E buildFinalModelObjectWithChildList(Constructor<E> aParentCtor, List<Object> aArgValuesMinusChildren, List<Object> aChildren) throws ModelCtorException {
+    fLogger.fine("Building complete Parent with Child List.");
+    List<Object> allArgs = new ArrayList<Object>();
+    allArgs.addAll(aArgValuesMinusChildren);
+    allArgs.add(aChildren);
+    fLogger.finest("Building complete Parent Model Object.");
+    return ModelCtorUtil.buildModelObject(aParentCtor, allArgs);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/Report.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,173 @@
+package hirondelle.web4j.database;
+
+import java.util.*;
+
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Util;
+
+/**
+ Utility for producing reports quickly from a <tt>ResultSet</tt>.
+ 
+ <P>Most web applications need to produce reports of summary or transaction information.
+ If a Model Object already exists for the given data, then it may be used to fetch and render the data, likely 
+ with {@link Db}.
+ 
+ <P>If a Model Object does <em>not</em> already exist for the given report data, then this class may 
+ be used to quickly implement the report, without the need to create a full Model Object. 
+ 
+ <P>This class translates each <tt>ResultSet</tt> row into a <tt>Map</tt> of some form. This <tt>Map</tt> is meant not as a 
+ robust Model Object, but rather as a rather dumb data carrier, built only for the purpose of reporting.
+ The <tt>Map</tt> key is always the column name, and the <tt>Map</tt> value takes various forms, according 
+ to how the <tt>ResultSet</tt> is processed :
+ <ul>
+ <li><tt>formatted</tt> - column values are parsed using 
+ {@link ConvertColumn} into <tt>Integer</tt>, <tt>Date</tt>, and so on, and then standard formatting is applied 
+ using {@link Formats#objectToText(Object)}.  (This is the recommended style.)
+ <li><tt>unformatted</tt> - column values are parsed using {@link ConvertColumn} into  
+ <tt>Integer</tt>, <tt>Date</tt>, and so on, but formatting is deferred to the JSP. 
+ <li><tt>raw</tt> - column values are all treated as simple text. To allow for various escaping styles in the view, 
+ the text is actually returned as {@link SafeText}. By default, the text will be properly escaped for presentation 
+ in HTML. If desired, some formatting can be applied directly in the underlying SQL statement itself, using database 
+ formatting functions.
+ </ul>
+ 
+ <P>Example of using the <tt>Map</tt> in a JSP. Here, column values are assumed to be already formatted, using 
+ either <tt>raw</tt> or <tt>formatted</tt>:
+<PRE>
+{@code
+<c:forEach var="row" items="${reportMap}" >
+ <tr>
+  <td>${row['Name']}</td>  
+  <td>${row['Visits']}</td>  
+ </tr>
+</c:forEach>
+  }
+</PRE>
+
+ <P>If <tt>unformatted</tt> is used to build the <tt>Map</tt>, then formatting 
+ of the resulting objects must be applied in the JSP. 
+ 
+ <h3>Recommended Style</h3>
+ The recommended style is to use <tt>formatted</tt>.
+  
+ <P>If <tt>raw</tt> or <tt>unformatted</tt> is used, then 
+ the question usually arises of where to apply formatting: 
+ <ul>
+ <li>format with database formatting functions - then there will often be much repetition of formatting function calls 
+ across different <tt>SELECT</tt>s. 
+ <li>format in the JSP (usually with JSTL) - be aware that there is often  
+ significant work involved. Not one but three operations can be necessary: a parse operation, 
+ a format operation, and possibly a check-for-null.
+ </ul>
+ 
+<h3><a name="emptyContent"></a>Empty Values</h3>
+ When the <tt>Map</tt> returned by this class has values as text, then any <tt>Strings</tt> which do not satisfy
+ {@link Util#textHasContent(String)} are replaced with the 
+ return value of {@link hirondelle.web4j.request.Formats#getEmptyOrNullText} (which is in 
+ turn configured in <tt>web.xml</tt>). This is a workaround for the fact that most browsers do 
+ not render empty <tt>TD</tt> tags very well when the cell has a border. An alternate
+ (and likely superior) workaround is to set the 
+ <tt><a href="http://www.w3.org/TR/REC-CSS2/tables.html#empty-cells">empty-cells</a></tt> 
+ property of Cascading Style Sheets to <tt>'show'</tt>.
+*/
+public final class Report {
+  
+  /**
+   Return column values without any processing. 
+   
+   <P>For the returned {@code Map<String, SafeText>} objects, 
+   <ul>
+   <li>key is the column name
+   <li>value is the unprocessed column value, passed to a {@link SafeText}. <tt>SafeText</tt> is 
+   used instead of <tt>String</tt> to allow easy escaping of special characters in the view.
+   </ul> 
+   
+   @param aSqlId identifies the underlying <tt>SELECT</tt> statement
+   @param aCriteria possible <em>dynamic</em> <tt>WHERE</tt> or <tt>ORDER BY</tt> clause. If no dynamic criteria, then 
+   just pass {@link DynamicSql#NONE}. 
+   @param aParams parameters for the <tt>SELECT</tt> statement, in the same order as in the underlying <tt>SELECT</tt> statement
+  */
+  public static List<Map<String, SafeText>> raw(SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException {
+    List<Map<String,SafeText>> result = new ArrayList<Map<String, SafeText>>();
+    ModelBuilder<Map<String, SafeText>> builder = new ReportBuilder();
+    SqlFetcher fetcher = getFetcher(aSqlId, aCriteria, aParams);
+    fetcher.fetchObjects(builder, result);
+    return result;
+  }
+
+  /**
+   Return column values after processing into formatted building block objects.
+   
+   <P>For the returned {@code Map<String, SafeText>} objects, 
+   <ul>
+   <li>key is the column name
+   <li>value is the processed column value, as <tt>SafeText</tt>. Column values are first parsed into 
+   building block objects using {@link ConvertColumn}. Then the objects are formatted in a 'standard' 
+   way using the configured {@link Formats}.
+   </ul> 
+  
+   @param aTargetClasses defines the target class for each column. 
+   The order of the classes in the array corresponds one-to-one with the column order of the underlying <tt>ResultSet</tt>. 
+   The size of the array matches the number of columns.
+   Each class in the array must be supported by the configured {@link ConvertColumn}. 
+   @param aLocale <tt>Locale</tt> returned by {@link hirondelle.web4j.request.LocaleSource}
+   @param aTimeZone <tt>TimeZone</tt> returned by {@link hirondelle.web4j.request.TimeZoneSource}
+   @param aSqlId identifies the underlying <tt>SELECT</tt> statement
+   @param aCriteria possible <em>dynamic</em> <tt>WHERE</tt> or <tt>ORDER BY</tt> clause. If no dynamic criteria, then 
+   just pass {@link DynamicSql#NONE}. 
+   @param aParams parameters for the <tt>SELECT</tt> statement, in the same order as in the underlying <tt>SELECT</tt> statement
+  */
+  public static List<Map<String, SafeText>> formatted(Class<?>[] aTargetClasses, Locale aLocale, TimeZone aTimeZone, SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException {
+    List<Map<String, SafeText>> result = new ArrayList<Map<String, SafeText>>();
+    //any date columns must be formatted in a Locale-sensitive manner
+    Formats formats = new Formats(aLocale, aTimeZone);
+    ModelBuilder<Map<String, SafeText>> builder = new ReportBuilder(aTargetClasses, formats); 
+    SqlFetcher fetcher = getFetcher(aSqlId, aCriteria, aParams);
+    fetcher.fetchObjects(builder, result);
+    return result;
+  }
+  
+  /**
+   Return column values as unformatted building block objects.
+   
+   <P>For the returned {@code Map<String, Object>} objects, 
+   <ul>
+   <li>key is the column name
+   <li>value is the processed column value, parsed into a building block Object using {@link ConvertColumn}. 
+   </ul> 
+  
+   @param aTargetClasses defines the target class for each column. 
+   The order of the classes in the array corresponds one-to-one with the column order of the underlying <tt>ResultSet</tt>. 
+   The size of the array matches the number of columns.
+   Each class in the array must be supported by the configured {@link ConvertColumn}. 
+   @param aSqlId identifies the underlying <tt>SELECT</tt> statement
+   @param aCriteria possible <em>dynamic</em> <tt>WHERE</tt> or <tt>ORDER BY</tt> clause. If no dynamic criteria, then 
+   just pass {@link DynamicSql#NONE}. 
+   @param aParams parameters for the <tt>SELECT</tt> statement, in the same order as in the underlying <tt>SELECT</tt> statement
+  */
+  public static List<Map<String, Object>> unformatted(Class<?>[] aTargetClasses, SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException {
+    List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
+    ModelBuilder<Map<String, Object>> builder = new ReportBuilderUnformatted(aTargetClasses); 
+    SqlFetcher fetcher = getFetcher(aSqlId, aCriteria, aParams);
+    fetcher.fetchObjects(builder, result);
+    return result;
+  }
+  
+  // PRIVATE //
+  
+  private Report(){
+    //empty - prevent construction by caller
+  }
+  
+  private static SqlFetcher getFetcher(SqlId aSqlId, DynamicSql aCriteria, Object[] aParams) throws DAOException {
+    SqlFetcher result = null;
+    if( DynamicSql.NONE == aCriteria ) {
+      result = SqlFetcher.forSingleOp(aSqlId, aParams);
+    }
+    else {
+      result = SqlFetcher.forSearch(aSqlId, aCriteria, aParams);
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ReportBuilder.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,143 @@
+package hirondelle.web4j.database;
+
+import java.util.*;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.ResultSetMetaData;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Args;
+
+/**
+ Translates a <tt>ResultSet</tt> row into a <tt>Map</tt>.
+  
+ <P>The returned {@code Map<String, SafeText>} has : 
+ <ul>
+ <li>key - column name.
+ <li>value - column value as {@link SafeText}. The column values may be formatted 
+ using  {@link Formats#objectToTextForReport(Object)}, according to which constructor 
+ is called.
+ </ul>
+ 
+ <P><tt>SafeText</tt> is used to protect the caller against unescaped special characters, 
+ and Cross Site Scripting attacks.
+*/
+final class ReportBuilder extends ModelBuilder<Map<String, SafeText>> {
+  
+  /**
+   Builds a <tt>Map</tt> whose values are simply the <em>raw, unprocessed text</em> of the
+   underlying <tt>ResultSet</tt>. These values are placed into {@link SafeText}
+   objects, to allow for various styles of escaping special characters when presenting 
+   the data in the view.
+  
+   <P>Database formatting functions can be used in the underlying SELECT statement
+   to format values to the desired form.
+  */
+  ReportBuilder(){
+    /* 
+     Implementation Note
+     This is a relatively rare instance where is it makes sense to
+     declare an empty constructor.
+    */
+  }
+
+  /**
+   Constructor for applying some processing to column values, instead of using the raw text.
+  
+   <P><tt>aColumnTypes</tt> is used to convert each column into an <tt>Object</tt>.
+   The supported types are the same as for {@link ConvertColumn}.
+   
+   <P>After conversion into the desired class, 
+   {@link hirondelle.web4j.request.Formats#objectToTextForReport(Object)} is used to apply 
+   formats to the various objects.
+    
+   @param aColumnTypes defines the type of each column, in order from left to right, as 
+   they appear in the <tt>ResultSet</tt>; contains <tt>N</tt> class literals, 
+   where <tt>N</tt> is the number of columns in the <tt>ResultSet</tt>; contains only 
+   the classes supported by {@link ConvertColumn}; this constructor will create a defensive 
+   copy of this parameter. 
+   @param aFormats used to apply the 
+   formats specified in <tt>web.xml</tt> and 
+   {@link hirondelle.web4j.request.Formats#objectToTextForReport(Object)}, 
+   localized using {@link hirondelle.web4j.request.LocaleSource}.
+  */
+  ReportBuilder(Class<?>[] aColumnTypes, Formats aFormats){
+    Args.checkForPositive(aColumnTypes.length);
+    fColumnTypes = defensiveCopy(aColumnTypes);
+    fFormats = aFormats;
+    fColumnToObject = BuildImpl.forConvertColumn();
+  }
+  
+  /**
+   Returns an unmodifiable <tt>Map</tt>.
+   
+   <P>See class description.
+  */
+  Map<String, SafeText> build(ResultSet aRow) throws SQLException {
+    LinkedHashMap<String, SafeText> result = new LinkedHashMap<String, SafeText>();
+    SafeText value = null;
+    ResultSetMetaData metaData = aRow.getMetaData();
+    for (int columnIdx = 1; columnIdx <= metaData.getColumnCount(); ++columnIdx) {
+      if ( doNotTranslateToObjects() ) {
+        value = getSafeText(aRow, columnIdx);
+        //fLogger.finest("Raw unformatted text value: " + value.getRawString());
+      }
+      else {
+        if ( fColumnTypes.length != metaData.getColumnCount() ) {
+          throw new IllegalArgumentException(
+            "Class[] has different number of elements than ResultSet: " + fColumnTypes.length + 
+            " versus " + metaData.getColumnCount () + ", respectively."
+          );
+        }
+        value = getFormattedObject(aRow, columnIdx);
+        //security risk to log data: fLogger.fine("Formatted object: " + value);
+      }
+      result.put(metaData.getColumnName(columnIdx), value);
+    }
+    return Collections.unmodifiableMap(result);
+  }
+  
+  // PRIVATE //
+
+  /** The types to which ResultSet columns will be converted.  */
+  private Class<?>[] fColumnTypes; 
+  
+  /** Applies formatting to objects created from ResultSet columns.  */
+  private Formats fFormats;
+  
+  /** Translates ResultSet columns into desired Objects. */
+  private ConvertColumn fColumnToObject;
+  
+  private Class[] defensiveCopy(Class[] aClassArray){
+    Class[] result = new Class[aClassArray.length];
+    System.arraycopy(aClassArray, 0, result, 0, aClassArray.length);
+    return result;
+  }
+  
+  private boolean doNotTranslateToObjects() {
+    return fColumnTypes == null;
+  }
+
+  private SafeText getSafeText(ResultSet aRow, int aColumn) throws SQLException {
+    SafeText result = null;
+    String text = aRow.getString(aColumn);
+    if ( text == null ) {
+      result = new SafeText(Formats.getEmptyOrNullText()); 
+    }
+    else {
+      result = new SafeText(text);
+    }
+    return result;
+  }
+
+  private SafeText getFormattedObject(ResultSet aRow, int aColumnIdx) throws SQLException {
+    Object translatedObject = getUnformattedObject(aRow, aColumnIdx);
+    return fFormats.objectToTextForReport(translatedObject);
+  }
+
+  private Object getUnformattedObject(ResultSet aRow, int aColumnIdx) throws SQLException {
+    return fColumnToObject.convert(aRow, aColumnIdx, fColumnTypes[aColumnIdx-1]);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ReportBuilderUnformatted.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,79 @@
+package hirondelle.web4j.database;
+
+import java.util.*;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.ResultSetMetaData;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.util.Args;
+
+/**
+ Translates a <tt>ResultSet</tt> row into a <tt>Map</tt>.
+ 
+ <P>The returned {@code Map<String, Object>} has : 
+ <ul>
+ <li>key - column name.
+ <li>value - column value as an unformatted object. The class of each object is controlled 
+ by the class array passed to the constructor.
+ </ul>
+*/
+final class ReportBuilderUnformatted extends ModelBuilder<Map<String, Object>> {
+  
+  /**
+   Constructor for applying some processing to raw column values.
+  
+   <P><tt>aColumnTypes</tt> is used to convert each column into an <tt>Object</tt>.
+   The supported types are the same as for {@link ConvertColumn}.
+   
+   @param aColumnTypes defines the type of each column, in order from left to right, as 
+   they appear in the <tt>ResultSet</tt>; contains <tt>N</tt> class literals, 
+   where <tt>N</tt> is the number of columns in the <tt>ResultSet</tt>; contains only 
+   the classes supported by {@link ConvertColumn}; this constructor will create a defensive 
+   copy of this parameter. 
+  */
+  ReportBuilderUnformatted(Class<?>[] aColumnTypes){
+    Args.checkForPositive(aColumnTypes.length);
+    fColumnTypes = defensiveCopy(aColumnTypes);
+    fColumnToObject = BuildImpl.forConvertColumn();
+  }
+  
+  /**
+   Returns an unmodifiable <tt>Map</tt>.
+  
+   <P>See class constructor.
+  */
+  Map<String, Object> build(ResultSet aRow) throws SQLException {
+    LinkedHashMap<String, Object> result = new LinkedHashMap<String, Object>();
+    Object value = null;
+    ResultSetMetaData metaData = aRow.getMetaData();
+    for (int columnIdx = 1; columnIdx <= metaData.getColumnCount(); ++columnIdx) {
+      if ( fColumnTypes.length != metaData.getColumnCount() ) {
+        throw new IllegalArgumentException(
+          "Class[] has different number of elements than ResultSet: " + fColumnTypes.length + 
+          " versus " + metaData.getColumnCount () + ", respectively."
+        );
+      }
+      value = getUnformattedObject(aRow, columnIdx);
+      result.put(metaData.getColumnName(columnIdx), value);
+    }
+    return Collections.unmodifiableMap(result);
+  }
+  
+  // PRIVATE //
+
+  /** The types to which ResultSet columns will be converted.  */
+  private Class<?>[] fColumnTypes; 
+  
+  /** Translates ResultSet columns into desired Objects. */
+  private ConvertColumn fColumnToObject;
+  
+  private Class[] defensiveCopy(Class[] aClassArray){
+    Class[] result = new Class[aClassArray.length];
+    System.arraycopy(aClassArray, 0, result, 0, aClassArray.length);
+    return result;
+  }
+  
+  private Object getUnformattedObject(ResultSet aRow, int aColumnIdx) throws SQLException {
+    return fColumnToObject.convert(aRow, aColumnIdx, fColumnTypes[aColumnIdx-1]);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/SqlEditor.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,300 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.logging.Logger;
+
+/**
+ Perform an edit on the database corresponding to a single SQL command, and return 
+ the number of affected records.
+
+ <P>Here, an edit usually corresponds to a single INSERT, UPDATE, or DELETE operation. 
+ (Note that {@link SqlFetcher} is used for SELECT operations.)
+ 
+<P>The convenient utilities in the {@link Db} class should always be considered as a 
+ simpler alternative to this class.
+  
+ <P>This class can use either an internal connection, or an external connection. 
+ This important distinction is made explicit through the static factory methods 
+ provided by this class. It corresponds to whether or not this action takes place 
+ in a transaction. When tying together several operations in a transaction, the 
+ caller will often use {@link TxTemplate}, combined with multiple <tt>SqlEditor</tt> 
+ objects returned by the <tt>forTx</tt> factory methods.
+
+ <P>If an internal {@link Connection} is used, then it will use the isolation 
+ level configured in <tt>web.xml</tt> by default. The level can be overridden
+ by calling {@link #setTxIsolationLevel}.
+
+ <P>Internal <tt>Connection</tt>s are obtained from 
+ {@link hirondelle.web4j.database.ConnectionSource}, using {@link SqlId#getDatabaseName()} 
+ to identify the database. 
+
+ <P><tt>INSERT</tt> operations have a special character, since they often create 
+ identifiers used elsewhere. An <tt>INSERT</tt> can be performed in various ways :
+<ul>
+ <li>one of the two <tt>addRecord</tt> methods, in which auto-generated 
+ keys, if present, are returned to the caller
+ <li><tt>editDatabase</tt>, in which auto-generated keys are NOT returned 
+ to the caller
+</ul>
+
+<P>The user of this class is able to edit the database in a very 
+ compact style, without concern for details regarding 
+<ul>
+ <li>database connections
+ <li>manipulating raw SQL text
+ <li>inserting parameters into SQL statements
+ <li>retrieving auto-generated keys
+ <li>error handling 
+ <li>logging of warnings
+ <li>closing statements and connections
+</ul>
+<P>Example use case taken from a DAO :
+<PRE>
+void add(Message aNewMessage) throws DAOException {
+  List params = Util.asList(
+    aNewMessage.getLoginName(),
+    aNewMessage.getBody(),
+    aNewMessage.getDate()
+  );
+  SqlEditor add = SqlEditor.forSingleOp(ADD_MESSAGE, params);
+  add.editDatabase();
+}
+</PRE>
+ <P>Note that, given the reusable database utility classes in this package,
+ the above feature is implemented using only
+<ul>
+ <li>one entry in an <tt>.sql</tt> text file
+ <li>one <tt>public static final SqlId</tt> constant (<tt>ADD_MESSAGE</tt> in the example)
+ <li>three lines of straight-line code in a data access object (DAO)
+</ul>
+ Given these database utility classes, many DAO implementations 
+ become impressively compact. (The {@link Db} class can often reduce the size of  
+ implementations even further.)
+
+ <P>The <tt>forSingleOp</tt> methods may actually refer to a stored procedure. 
+ In that case, many operations can actually be performed, not just one.
+
+ <P>If the operation throws a {@link SQLException} having the specific error code 
+ configured in <tt>web.xml</tt>, then the {@link DAOException} thrown by 
+ this class will be a {@link DuplicateException}. DAOs for operations which may 
+ have such an exception should declare both <tt>DAOException</tt> and 
+ <tt>DuplicateException</tt> in their <tt>throws</tt> clauses. If the operation cannot 
+ have a duplicate problem, then the DAO must not declare <tt>DuplicateException</tt> 
+ in its <tt>throws</tt> clause.
+
+ @used.By {@link Db}, DAO implementation classes which edit the database in some way, usually with 
+ an <tt>INSERT</tt>, <tt>UPDATE</tt>, or <tt>DELETE</tt> command.
+ @author <a href="http://www.javapractices.com/">javapractices.com</a>
+*/
+final class SqlEditor {
+
+  /**
+   Factory method for a single <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt> 
+   operation having parameters.
+  */
+  static SqlEditor forSingleOp(SqlId aSqlId, Object... aParams){
+    return new SqlEditor(aSqlId, INTERNAL_CONNECTION, aParams);
+  }
+  
+  /**
+   Factory method for a single <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt> 
+   operation having parameters, and participating in a transaction.
+  
+   @param aConnection used by all operations participating in the given transaction; 
+   this connection is owned by the caller.
+  */
+  static SqlEditor forTx(SqlId aSqlId, Connection aConnection, Object... aParams ){
+    return new SqlEditor(aSqlId, aConnection, aParams);
+  }
+  
+  /**
+   Perform a single <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt> operation.
+   Return the number of records edited by this operation.
+  */
+  int editDatabase() throws DAOException {
+    return editDatabase(null, null);
+  }
+  
+  /**
+   Perform an INSERT of a single new record, and return its auto-generated id.
+   Return {@link Consts#EMPTY_STRING} if no record can be added.
+  
+   @param aColumnIdx in range <tt>1..100</tt>, and identifies the auto-generated key; 
+   see {@link Statement#getGeneratedKeys}.
+  */
+  String addRecord(int aColumnIdx) throws DAOException {
+    fLogger.fine("Adding record, and returning autogenerated id : " + fSql);
+    StringBuilder result = new StringBuilder();
+    editDatabase(result, new Integer(aColumnIdx));
+    fLogger.finest("Primary key of item just added : " + result);
+    return result.toString();
+  }
+
+  /**
+   Convenience method calls {@link #addRecord(int)} with a default value of 
+   <tt>1</tt> for its  single argument.
+  */
+  String addRecord() throws DAOException {
+    return addRecord(fDEFAULT_AUTO_GENERATED_COLUMN_IDX);
+  }
+  
+  /**
+   Override the default transaction isolation level specified in <tt>web.xml</tt>.
+  
+    <P>This setting is applied only if this class is using its own internal connection, and 
+   has not received a connection from the caller.
+
+   <P><span class="highlight">If the user passed an external 
+   <tt>Connection</tt> to this class, then this method 
+   cannot be called.</span> Changing the isolation level after a transaction 
+   has started is not permitted - see {@link Connection}.
+  
+   @see TxIsolationLevel
+  */ 
+  void setTxIsolationLevel(TxIsolationLevel aTransactionIsolationLevel) {
+    if ( ! fConnectionIsInternal ) {
+      throw new IllegalStateException (
+        "Cannot set transaction isolation level after transaction has started."
+      );
+    }
+    fLogger.fine("Setting transaction isolation level to " + aTransactionIsolationLevel);
+    fExplicitTxIsolationLevel = aTransactionIsolationLevel;
+  }
+  
+  // PRIVATE
+  
+  private SqlStatement fSql;
+  
+  private Connection fConnection;
+  private boolean fConnectionIsInternal;
+  /** 
+    Isolation level for an internal connection only.
+    If the caller does not customize this setting, then the default level for the 
+    given database name is used. 
+  */
+  private TxIsolationLevel fExplicitTxIsolationLevel;
+  private Config fConfig = new Config();
+  
+  private static final Connection INTERNAL_CONNECTION = null;
+  private static final int fDEFAULT_AUTO_GENERATED_COLUMN_IDX = 1;
+  private static final Logger fLogger = Util.getLogger(SqlEditor.class);  
+
+  private SqlEditor(SqlId aSqlId, Connection aConnection, Object...  aParams){
+    fSql = new SqlStatement(aSqlId, null, aParams);
+    if ( aConnection != null ) {
+      fConnectionIsInternal = false;
+      fConnection = aConnection;
+    }
+    else {
+      fConnectionIsInternal = true;
+    }
+  }
+  
+  private int editDatabase(StringBuilder aOutAutoGeneratedKey, Integer aAutoGeneratedColIdx) throws DAOException {
+    if ( aAutoGeneratedColIdx != null ) {
+      Args.checkForRange(aAutoGeneratedColIdx.intValue(), 1, 100);
+    }
+    if ( 
+      aOutAutoGeneratedKey != null && 
+      Util.textHasContent(aOutAutoGeneratedKey.toString()) 
+    ) {
+      throw new IllegalArgumentException("'Out' param for autogenerated key must be empty");
+    }
+    
+    int result = 0;
+    PreparedStatement statement = null;
+    try {
+      if ( fConnectionIsInternal ){
+        initConnection();
+      }
+      statement = fSql.getPreparedStatement(fConnection);
+      //Not implemented by MySql 3.23, nor in MySql 5.0.
+      //fLogger.fine("Num args : " + statement.getParameterMetaData().getParameterCount());
+      result = statement.executeUpdate();
+      populateAutoGenKey(result, aOutAutoGeneratedKey, aAutoGeneratedColIdx, statement);
+    }
+    catch (SQLException rootCause){
+      String message = 
+        "Cannot execute edit. Error Id code : " +  rootCause.getErrorCode() + Consts.SPACE + 
+        rootCause + Consts.SPACE + fSql
+      ;
+      Integer errorCode = rootCause.getErrorCode();
+      String dbName = fSql.getSqlId().getDatabaseName();
+      if (fConfig.getErrorCodesForDuplicateKey(dbName).contains(errorCode)){
+        throw new DuplicateException(message, rootCause);
+      }
+      else if (fConfig.getErrorCodesForForeignKey(dbName).contains(errorCode)){
+        throw new ForeignKeyException(message, rootCause);
+      }
+      throw new DAOException(message, rootCause);
+    }
+    finally {
+      if ( fConnectionIsInternal ) {
+        DbUtil.close(statement, fConnection);
+      }
+      else {
+        DbUtil.close(statement);
+      }
+    }
+    return result;
+  }
+  
+  private void initConnection() throws DAOException {
+    String dbName = fSql.getSqlId().getDatabaseName();
+    if( Util.textHasContent(dbName) ){
+      fConnection = BuildImpl.forConnectionSource().getConnection(dbName);
+    }
+    else {
+      fConnection = BuildImpl.forConnectionSource().getConnection();
+    }
+    TxIsolationLevel.set(getIsolationLevel(dbName), fConnection);
+  }
+  
+  private TxIsolationLevel getIsolationLevel(String aDbName){
+    TxIsolationLevel result = null;
+    if(fExplicitTxIsolationLevel != null) {
+      result = fExplicitTxIsolationLevel;
+    }
+    else {
+      result = fConfig.getSqlEditorDefaultTxIsolationLevel(aDbName);
+    }
+    return result;
+  }
+  
+  private void populateAutoGenKey(
+    int aNumEdits, StringBuilder aOutAutoGenKey, Integer aAutoGenColIdx, Statement aStatement
+  ) throws SQLException {
+    if ( aOutAutoGenKey != null ){
+      if ( Util.isSuccess(aNumEdits) ) {
+        aOutAutoGenKey.append(getAutoGeneratedKey(aAutoGenColIdx.intValue(), aStatement));
+      }
+      else {
+        aOutAutoGenKey.append(Consts.EMPTY_STRING);
+      }
+    }
+  }
+
+  private String getAutoGeneratedKey(int aColumnIdx, Statement aStatement) throws SQLException {
+    String result = null;
+    ResultSet keys = aStatement.getGeneratedKeys();
+    if ( keys.next() ) {
+      result = keys.getString(aColumnIdx); 
+    }
+    else {
+      throw new IllegalArgumentException(
+        "Invalid column for auto-generated key. Idx: " + aColumnIdx
+      );
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/SqlFetcher.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,359 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.logging.Logger;
+
+/**
+ Perform a single SELECT command.
+
+<P>To perform single INSERT, DELETE, or UPDATE operation, use {@link SqlEditor} instead. 
+
+<P><span class="highlight">The utilities in the {@link Db} class should always 
+ be considered as a simpler alternative to this class.</span> 
+ 
+<P>The user of this class is able to fetch records in a very 
+ compact style, without concern for details regarding 
+<ul>
+ <li>database connections
+ <li>manipulating raw SQL text
+ <li>inserting parameters into SQL statements
+ <li>error handling 
+ <li>logging of warnings
+ <li>closing statements and connections
+</ul>
+<P>Example use case in a Data Access Object(DAO), where a <tt>Person</tt> object 
+is fetched using <tt>aName</tt> as a simple business identifier : 
+<PRE>
+Person fetch(String aName) throws DAOException {
+  Args.checkForContent(aName);
+  SqlFetcher3 fetcher = SqlFetcher3.forSingleOp(FETCH_PERSON, Util.asList(aName));
+  ModelBuilder3 builder = new ModelFromRow(Person.class);
+  return (Person)fetcher.fetchObject( builder );
+}
+</PRE>
+ <P>Note that the above feature is implemented using only
+<ul>
+ <li>one entry in a <tt>.sql</tt> text file, which stores the underlying SQL
+ statement as text, outside compiled code
+ <li>one <tt>public static final</tt> {@link SqlId} field declared in some class 
+ (usually the {@link hirondelle.web4j.action.Action} of the DAO itself) 
+ <li>a short method in a DAO. (In addition, if this method uses 
+ {@link ModelFromRow}, then the length of this method is almost always 3 or 4 lines, 
+ <em>regardless of the number of fields in the underlying Model Object</em>)
+</ul>
+ Given these database utility classes, many DAO implementations 
+ become very compact. (The {@link Db} class can reduce the size of  
+ implementations even further.)
+
+<P> Almost all callers of <tt>SqlFetcher</tt> will also use a {@link ModelBuilder},
+ which is closely related to this class.
+
+ <P><tt>SqlFetcher</tt> can participate in a transaction by passing a
+ {@link Connection} to one of its static factory methods. Otherwise, an internal 
+ <tt>Connection</tt> is obtained from {@link hirondelle.web4j.database.ConnectionSource}, 
+ using {@link SqlId#getDatabaseName()} to identify the database. 
+*/
+final class SqlFetcher {
+
+  /**
+   Factory method for single <tt>SELECT</tt> operation which takes parameters.
+  */
+  static SqlFetcher forSingleOp(SqlId aSqlId, Object... aParams){
+    return new SqlFetcher(aSqlId, INTERNAL_CONNECTION, NO_SEARCH_CRITERIA, aParams);
+  }
+
+  /**
+   Factory method for <tt>SELECT</tt> operation which takes parameters and participates in 
+   a transaction.
+  
+   @param aConnection used by all operations participating in the given transaction; 
+   this connection is owned by the caller.
+  */
+  static SqlFetcher forTx(SqlId aSqlId, Connection aConnection, Object... aParams){
+    return new SqlFetcher(aSqlId, aConnection, NO_SEARCH_CRITERIA, aParams);
+  }
+  
+  /**
+   Factory method for a <tt>SELECT</tt> operation which takes parameters, and whose criteria are dynamic.
+  */
+  static SqlFetcher forSearch(SqlId aSqlId, DynamicSql aSearchCriteria, Object... aParams) {
+    return new SqlFetcher(aSqlId, INTERNAL_CONNECTION, aSearchCriteria, aParams);
+  }
+  
+  /**
+   Perform a SELECT and return the first row as a Model Object.
+    
+   <P>Intended for use with SELECT statements which usually return one row.<br>
+   If <tt>1..N</tt> rows are returned, parse the first row into a Model Object and 
+   return it. Ignore any other rows.<br> If no rows are returned, return <tt>null</tt>.
+   (This behavior can occasionally be used to limit the result set to a single record. 
+   It is a convenient alternative when the database has no robust or simple method of
+   limiting the number of returned rows. However, this technique should be used 
+   only if the number of returned rows is not excessively large.)
+  
+   @param aModelBuilder parses a row into a corresponding Model Object.
+  */
+  <T> T fetchObject(ModelBuilder<T> aModelBuilder) throws DAOException {
+    List<T> list = new ArrayList<T>();
+    fetchObjects(aModelBuilder, list);
+    return list.isEmpty()? null : list.get(0);
+  }
+  
+  /**
+   Perform a SELECT and return a corresponding <tt>Collection</tt> of Model Objects.
+  
+   <P>Intended for use with SELECT statements which usually return multiple rows.<br>
+   If <tt>1..N</tt> rows are found, parse each row into a Model Object and 
+   place it in <tt>aResult</tt>.<br> Items are added to <tt>aResult</tt>
+   in the order returned from the underlying <tt>ResultSet</tt>.<br>
+   If <tt>0</tt> rows are found, then <tt>aResult</tt> will remain empty.
+  
+   @param aModelBuilder parses a row into a corresponding Model Object.
+   @param aResult acts as an "out" parameter, and is initially empty.
+  */
+  <T> void fetchObjects(ModelBuilder<T> aModelBuilder, Collection<T> aResult) throws DAOException {
+    checkIsEmpty(aResult);
+    
+    ResultSet resultSet = fetchRows();
+    RowCycler cycler = new CollectionRowCycler(aResult);
+    cycler.cycleRows(resultSet, aModelBuilder);
+    if (aResult.size() == 0){
+      fLogger.fine("No Results Found for query: " + fSql);
+    }
+  }
+
+  /**
+    Set parameters for limiting returned items to a specific block of rows.
+   
+    <P><em>This method is not usually called</em>. It is intended only for those 
+    cases in which only a subset of the underlying <tt>ResultSet</tt> is to be 
+    returned. Usually, all rows of the underlying result set are of interest, 
+    and none should be eliminated from processing.
+   
+    @param aStartIdx 1 or more
+    @param aPageSize 1 or more, the number of items returned to the caller
+   */
+   void limitRowsToRange(Integer aStartIdx, Integer aPageSize){
+     Args.checkForPositive(aStartIdx.intValue());
+     Args.checkForPositive(aPageSize.intValue());
+     fStartIdx = aStartIdx;
+     fEndIdx = getEndIdx(aStartIdx, aPageSize);
+   }
+
+  /**
+   Override the default transaction isolation level specified in <tt>web.xml</tt>.
+  
+   <P>This setting is applied only if this class is using its own internal connection, and 
+   has not received a connection from the caller.
+   
+   <P><span class="highlight">If the user passed an external 
+   <tt>Connection</tt> to a constructor of this class, then this method 
+   must not be called.</span> (Changing the isolation level after a transaction 
+   has started is not permitted - see {@link Connection}).
+  
+   @see TxIsolationLevel
+  */ 
+  void setTxIsolationLevel(TxIsolationLevel aTxIsolationLevel) {
+    if ( ! fConnectionIsInternal ) {
+      throw new IllegalStateException (
+        "Cannot set transaction isolation level after transaction has started."
+      );
+    }
+    fLogger.fine("Setting transaction isolation level to " + aTxIsolationLevel);
+    fExplicitTxIsolationLevel = aTxIsolationLevel;
+  }
+  
+  // PRIVATE 
+  
+  private SqlStatement fSql;
+  private PreparedStatement fStatement;
+  
+  private Connection fConnection;
+  private boolean fConnectionIsInternal;
+  /** 
+    Explicit Isolation level, for internal connections only.
+    If null, then the isolation level defined by {@link Config} for the given db name will be used. 
+  */
+  private TxIsolationLevel fExplicitTxIsolationLevel;
+  
+  private Integer fStartIdx;
+  private Integer fEndIdx;
+  
+  private static final Connection INTERNAL_CONNECTION = null;
+  private static final DynamicSql NO_SEARCH_CRITERIA = null;
+  
+  private static final Logger fLogger = Util.getLogger(SqlFetcher.class);  
+
+  private SqlFetcher(SqlId aSqlId, Connection aConnection, DynamicSql aSearchCriteria, Object... aParams){
+    fSql = new SqlStatement(aSqlId, aSearchCriteria, aParams);
+    if ( aConnection != null ) {
+      fConnectionIsInternal = false;
+      fConnection = aConnection;
+    }
+    else {
+      fConnectionIsInternal = true;
+    }
+  }
+  
+  private ResultSet fetchRows() throws DAOException {
+    ResultSet result = null;
+    Connection connection = null;
+    try {
+      connection = getConnection();
+      fStatement = fSql.getPreparedStatement(connection);
+      result = fStatement.executeQuery();
+      DbUtil.logWarnings(fStatement);
+    }
+    catch (SQLException rootCause){
+      String message = 
+        "Cannot execute fetch: " + rootCause + Consts.SPACE +  rootCause.getMessage() + 
+        Consts.SPACE + fSql
+      ;
+      fLogger.severe(message);
+      //unusual: the close is not in a finally block, since it must only be closed if there's a problem
+      close(fStatement, connection);
+      throw new DAOException(message, rootCause);
+    }
+    return result;
+  }
+
+  /**
+   Cycles over a <tt>ResultSet</tt>, but what is done to each row is left 
+   up to subclasses.
+  
+   (This private class exists in order to eliminate code repetition between the 
+   Collection and Map styles used above.) 
+  */
+  private abstract class RowCycler<T> {
+    void cycleRows(ResultSet aResultSet, ModelBuilder<T> aModelBuilder) throws DAOException {
+      try {
+        while (aResultSet.next()){
+          if ( ! isLimitingRows() ) {
+            addItemToResult(aModelBuilder, aResultSet);
+          }
+          else {
+            if ( isAfterStart(aResultSet) ){
+              if ( ! isAfterEnd(aResultSet) ) {
+                addItemToResult(aModelBuilder, aResultSet);
+              }
+              else {
+                fLogger.fine(
+                  "Finished processing ResultSet. This row is passed the end :  " + 
+                  aResultSet.getRow()
+                );
+                break;
+              }
+            }
+            else {
+              fLogger.fine(
+                "Skipping row. Index not yet high enough to start processing : " + 
+                aResultSet.getRow()
+              );
+            }
+          }
+        }
+      }
+      catch (SQLException rootCause){
+        String message = 
+          "Cannot execute fetch: " + rootCause + Consts.SPACE + 
+          rootCause.getErrorCode() + Consts.SPACE + fSql
+        ;
+        throw new DAOException(message, rootCause);
+      }
+      finally {
+        close(fStatement, fConnection);
+      }
+    }
+    abstract void addItemToResult(ModelBuilder<T> aModelBuilder, ResultSet aResultSet) throws DAOException, SQLException;
+  }
+  
+  private final class CollectionRowCycler<T> extends RowCycler<T> {
+    CollectionRowCycler(Collection<T> aCollection){
+      fCollection = aCollection;
+    }
+    void addItemToResult(ModelBuilder<T> aModelBuilder, ResultSet aResultSet) throws DAOException {
+      T item = aModelBuilder.buildObject(aResultSet);
+      fCollection.add( item );
+    }
+    private Collection<T> fCollection;
+  }
+  
+  private void checkIsEmpty(Collection aOutParam){
+    if ( ! aOutParam.isEmpty() ) {
+      throw new IllegalArgumentException("Out parameter is not initially empty.");
+    }
+  }
+  
+  /**
+   Return either the external connection, passed by the caller, or an internal one with 
+   the appropriate isolation level.
+  */
+  private Connection getConnection() throws DAOException {
+    if ( fConnectionIsInternal ) {
+      String dbName = fSql.getSqlId().getDatabaseName();
+      if( Util.textHasContent(dbName) ){
+        fConnection = BuildImpl.forConnectionSource().getConnection(dbName);
+      }
+      else{ 
+        fConnection = BuildImpl.forConnectionSource().getConnection();
+      }
+      TxIsolationLevel.set(getIsolationLevel(dbName), fConnection);
+    }
+    else {
+      //use the external connection passed to the constructor
+    }
+    return fConnection;
+  }
+  
+  private TxIsolationLevel getIsolationLevel(String aDbName){
+    TxIsolationLevel result = null;
+    if(fExplicitTxIsolationLevel != null) {
+      result = fExplicitTxIsolationLevel;
+    }
+    else {
+      result = new Config().getSqlFetcherDefaultTxIsolationLevel(aDbName);
+    }
+    return result;
+  }
+  
+  private void close(
+    PreparedStatement aStatement, Connection aConnection
+  ) throws DAOException {
+    if ( fConnectionIsInternal ) {
+      DbUtil.close(aStatement, aConnection);
+    }
+    else {
+      //do not close the connection, since it is owned by the caller, and is
+      //needed by other statements in the transaction
+      DbUtil.close(aStatement);
+    }
+  }
+  
+  private Integer getEndIdx(Integer aStartIdx, Integer aPageSize){
+    return new Integer( aStartIdx.intValue() + aPageSize.intValue() - 1);
+  }
+  
+  private boolean isLimitingRows() {
+    return fStartIdx != null;
+  }
+  
+  private boolean isAfterStart(ResultSet aResultSet) throws SQLException {
+    return aResultSet.getRow() >= fStartIdx.intValue();
+  }
+
+  private boolean isAfterEnd(ResultSet aResultSet) throws SQLException {
+    return aResultSet.getRow() > fEndIdx.intValue();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/SqlId.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,220 @@
+package hirondelle.web4j.database;
+
+import java.util.regex.Pattern;
+import hirondelle.web4j.model.Check;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Regex;
+
+/**
+<span class="highlight">Identifier of an SQL statement block in an <tt>.sql</tt> file.</span>
+(Such identifiers must be unique.)
+ 
+ <P>This class does <em>not</em> contain the text of the underlying SQL statement.
+ Rather, this class allows a code friendly way of <em>referencing</em> SQL statements. 
+ Since <tt>.sql</tt> files are simple text files, there is a need to build a bridge between these text files
+ and java code. This class is that bridge. 
+ 
+ <P> Please see the package summary for important information regarding <tt>.sql</tt> files.
+  
+ <P>Typical use case :
+<PRE>public static final SqlId MEMBER_FETCH = new SqlId("MEMBER_FETCH");</PRE>
+This corresponds to an entry in an <tt>.sql</tt> file :
+<PRE>
+MEMBER_FETCH {
+  SELECT Id, Name, IsActive, DispositionFK 
+  FROM Member WHERE Id=?
+}
+</PRE>
+
+ <P>This class is unusual, since there is only one way to use these objects. 
+ That is, they <span class="highlight">must be declared  
+ as <tt>public static final</tt> fields in a <tt>public</tt> class.</span> 
+ They should never appear <i>only</i> as local objects in the body of a method. (This unusual restriction 
+ exists to allow the framework to find and examine such fields using reflection.)
+ The text passed to the constructor must correspond to the identifier of some SQL 
+ statement block in an <tt>.sql</tt> file. Such identifiers must match a specific 
+ {@link #FORMAT}.
+ 
+ <P><a name="StartupChecks"></a><b>Startup Checks</b><br>
+ To discover simple typographical errors as quickly as possible, 
+ the framework will run diagnostics upon startup : <span class="highlight">there must be an exact, one-to-one 
+ correspondence between the SQL statement identifiers defined in the <tt>.sql</tt> file(s), 
+ and the <tt>public static final SqlId</tt> fields declared by the 
+ application.</span> Any mismatch will result in an error. (Running such diagnostics 
+ upon startup is highly advantageous, since the only alternative is discovery during 
+ actual use, upon the first execution of a particular operation.) 
+ 
+ <P><a name="DeclarationLocation"></a><b>Where To Declare <tt>SqlId</tt> Fields</b><br>
+ Where should <tt>SqlId</tt> fields be declared? The only real restriction is that 
+ they must be declared in a <tt>public</tt> class. With the most recommended first, one may declare 
+ <tt>SqlId</tt> fields in :
+ <ul>
+ <li>a <tt>public</tt> {@link hirondelle.web4j.action.Action}
+ <li>a <tt>public</tt> Data Access Object
+ <li>a <tt>public</tt> constants class, one per package/feature. 
+ <li>a <tt>public</tt> constants class, one per application. If more than one developer at a time 
+ works on the application, then this style will result in a lot of developer contention. It is not recommended. 
+ </ul>
+ 
+ <P><em>Design Note</em>
+ <br>The justification for recommending that <tt>SqlId</tt> fields appear in a 
+ {@link hirondelle.web4j.action.Action} is as follows : 
+ <ul>
+ <li>it is highly satisfying to have mostly <tt>package-private</tt> classes in an application, since it 
+ takes advantage of a principal technique for "information hiding" - one of the guiding principles of 
+ lasting value in object programming. For instance, it is usually possible to have a Data Access Object 
+ (DAO) as package-private. If a <tt>SqlId</tt> is declared in a DAO, however, then that DAO must be 
+ changed to <tt>public</tt>, just to render the <tt>SqlId</tt> fields accessible by reflection, 
+ which is distasteful.
+ <li>the {@link hirondelle.web4j.action.Action} is always <tt>public</tt> anyway, so adding a <tt>SqlId</tt> will 
+ not change its scope.
+ <li>{@link hirondelle.web4j.action.Action} is intended as the <tt>public</tt> face of each feature. Therefore, 
+ all important items related to the feature should be documented there - what it does, when is it called, and 
+ how it shows a response. One can argue with some force that the single most important thing about a 
+ feature is <em>"What does it do?"</em>. In a typical database application, the answer to that 
+ question is usually <em>"these SQL operations"</em>.  
+ </ul>
+*/
+public final class SqlId {
+
+  /**
+   Format of SQL statement identifiers.
+    
+   <P>Matching examples include : 
+  <ul>
+   <li><tt>ADD_MESSAGE</tt>
+   <li><tt>fetch_member</tt>
+   <li><tt>LIST_RESTAURANTS_2</tt>
+  </ul>
+  
+   <P>One or more letters/underscores, with possible trailing digits. 
+   <P>To scope an SQL statement to a particular database, simply prefix the identifier with a second 
+   such identifier to represent the database, separated by a period, 
+   as in <tt>'TRANSLATION_DB.ADD_BASE_TEXT'</tt>. 
+  */
+  public static final String FORMAT = Regex.SIMPLE_IDENTIFIER;
+   
+  /**
+   Constructor for statement against the <em>default</em> database. 
+    
+   @param aStatementName identifier of an SQL statement, satisfies  {@link #FORMAT}, 
+   and matches the name attached to an SQL statement appearing in an <tt>.sql</tt> file.
+  */
+  public SqlId(String aStatementName) { 
+    fStatementName = aStatementName;
+    fDatabaseName = null;
+    validateState();
+  }
+  
+  /**
+   Constructor for statement against a <em>named</em> database.
+   
+   @param aDatabaseName identifier for the target database, 
+   satisfies {@link #FORMAT}, 
+   matches one of the return values of {@link ConnectionSource#getDatabaseNames()},
+   and also matches the prefix for a <tt>aStatementName</tt>. See package overview for more information. 
+   @param aStatementName identifier of an SQL statement, satisfies  {@link #FORMAT}, 
+   and matches the name attached to an SQL statement appearing in an <tt>.sql</tt> file.
+  */
+  public SqlId(String aDatabaseName, String aStatementName) {
+    fStatementName = aStatementName;
+    fDatabaseName = aDatabaseName;
+    validateState();
+  }
+  
+  /**
+   Factory method for building an <tt>SqlId</tt> from a <tt>String</tt> which may or may 
+   not be qualified by the database name. 
+   
+   @param aSqlId which may or may not be qualified by the database name.
+  */
+  public static SqlId fromStringId(String aSqlId){
+    SqlId result = null;
+    String SEPARATOR = ".";
+    if( aSqlId.contains(SEPARATOR) ){
+      String[] parts = aSqlId.split(EscapeChars.forRegex(SEPARATOR));
+      String database = parts[0];
+      String statement = parts[1];
+      result = new SqlId(database, statement);
+    }
+    else {
+      result = new SqlId(aSqlId);
+    }
+    return result;
+  }
+  
+  /**
+   Return <tt>aDatabaseName</tt> passed to the constructor. 
+   
+   <P>If no database name was passed to the constructor, then return an empty {@link String} 
+   (corresponds to the 'default' database).
+   
+  */
+  public String getDatabaseName(){
+    return Util.textHasContent(fDatabaseName) ? fDatabaseName : Consts.EMPTY_STRING;
+  }
+  
+  /** Return <tt>aStatementName</tt> passed to the constructor.  */
+  public String getStatementName(){
+    return fStatementName;
+  }
+  
+  /**
+   Return the SQL statement identifier as it appears in the <tt>.sql</tt> file.
+   
+   <P>Example return values :
+  <ul>
+   <li><tt>MEMBER_FETCH</tt> (against the default database)
+   <li><tt>TRANSLATION.FETCH_ALL_TRANSLATIONS</tt> (against a database named <tt>TRANSLATION</tt>)
+  </ul> 
+  */
+  @Override public String toString() { 
+    return Util.textHasContent(fDatabaseName) ? fDatabaseName + "." + fStatementName : fStatementName;  
+  } 
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if( result == null ) {
+      SqlId that = (SqlId) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;
+  }
+ 
+  @Override public int hashCode(){
+    if(fHashCode == 0){
+      fHashCode =  ModelUtil.hashCodeFor(getSignificantFields()); 
+    }
+    return fHashCode;
+  }
+   
+  // PRIVATE //
+  private final String fStatementName;
+  private final String fDatabaseName;
+  private int fHashCode;
+
+  /**
+   Does NOT throw ModelCtorException, since errors here represent bugs. 
+  */
+  private void validateState(){
+    ModelCtorException ex = new ModelCtorException();
+    Pattern simpleId = Pattern.compile(FORMAT);
+    if ( ! Check.required(fStatementName, Check.pattern(simpleId)) ) {
+      ex.add("Statement Name is required, and must match SqlId.FORMAT.");
+    }
+    if ( ! Check.optional(fDatabaseName, Check.pattern(simpleId)) ) {
+      ex.add("Database Name is optional, and must match SqlId.FORMAT.");
+    }
+    if ( ! ex.isEmpty() ) {
+      throw new IllegalArgumentException(Util.logOnePerLine(ex.getMessages()));
+    }
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[]{fStatementName, fDatabaseName};
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/SqlStatement.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,543 @@
+package hirondelle.web4j.database;
+
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.model.DateTime.Unit;
+import hirondelle.web4j.model.Decimal;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.ConfigReader;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Util;
+
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+  Encapsulates an SQL statement used in the application.
+  
+  <P><span class='highlight'>See package overview for important information</span>.
+  
+  <P>This class hides details regarding all SQL statements used by the application. These items
+  are hidden from the caller of this class :
+  <ul>
+  <li>the retrieval of SQL statements from an underlying textual <tt>.sql</tt> file or
+  files
+  <li>the textual content of SQL statements
+  <li>the details of placing parameters into a {@link PreparedStatement}
+  </ul>
+  
+  <P> Only {@link PreparedStatement} objects are used here, since they <a
+  href="http://www.javapractices.com/Topic212.cjp">are usually preferable</a> to
+  {@link Statement} objects.
+ */
+final class SqlStatement {
+
+  /**
+    Called by the framework upon startup, to read and validate all SQL statements from the
+    underlying <tt>*.sql</tt> text file(s).
+    
+    <P>Verifies that there is no mismatch
+    whatsoever between the <tt>public static final</tt> {@link SqlId} fields used in the
+    application, and the keys of the corresponding <tt>*.sql</tt> file(s). If there is a
+    mismatch, then an exception is thrown when this class loads, to ensure that errors are
+    reported as soon as possible.
+    
+    <P> Upon startup, the framework can optionally
+    attempt a test precompile of each SQL statement, by calling
+    {@link Connection#prepareStatement(String)}. If the SQL text is not syntactically
+    correct, then a call to <tt>Connection.prepareStatement()</tt> 
+    <em>might</em> throw
+    an {@link SQLException}, according to the implementation of the driver/database. (For
+    example, JavaDB/Derby will throw an {@link SQLException}, while MySql and Oracle will not.)
+    If such an error is detected, then it is logged as <tt>SEVERE</tt>.
+    
+    <P>A setting in <tt>web.xml</tt> can disable this pre-compilation, if desired.
+   */
+  static void readSqlFile() {
+    readSqlText();
+    checkStoredProcedures();
+    checkSqlFilesVersusSqlIdFields();
+    precompileAll();
+  }
+  
+  public static void initSqlStatementsManually(Map<String, String> aStatements){
+    if (fSqlProperties == null){
+      fSqlProperties = new Properties();
+    }
+    fSqlProperties.clear();
+    fSqlProperties.putAll(aStatements);
+  }
+
+  /**
+    SQL statement which takes parameters.
+    
+    <P>This class supports the same classes as parameters as {@link ConvertColumnImpl}.
+    That is, only objects of the those classes can be present in the <tt>aSqlParams</tt>
+    list. A parameter may also be <tt>null</tt>.
+    
+    <P> For <tt>Id</tt> objects, in particular, the underlying column must modeled as text,
+    not a number. If the underlying column is numeric, then the caller must convert an
+    {@link Id} into a numeric form using {@link Id#asInteger} or {@link Id#asLong}.
+    
+    @param aSqlId corresponds to a key in the underlying <tt>.sql</tt> file
+    @param aSearchCriteria is possibly <tt>null</tt>, and represents the criteria input
+    by the user during a search operation for a particular record (or records). If present,
+    then {@link DynamicSql#toString()} is appended to the text of the underlying SQL
+    statement from the <tt>.sql</tt> files.
+    @param aSqlParams contains at least one object of the supported classes noted above;
+    <span class="highlight">the number and order of these parameter objects matches the
+    number and order of "?" parameters in the underlying SQL</span>.
+   */
+  SqlStatement(SqlId aSqlId, DynamicSql aSearchCriteria, Object... aSqlParams) {
+    fSqlId = aSqlId;
+    fSqlText = getSqlTextFromId(aSqlId);
+    if (aSearchCriteria != null) {
+      fSqlText = fSqlText + aSearchCriteria.toString();
+    }
+    checkNumParamsMatches(aSqlParams);
+    checkParamsOfSupportedType(aSqlParams);
+    fParams = aSqlParams;
+    fLogger.finest(this.toString());
+  }
+
+  /**
+    Return a {@link PreparedStatement} whose parameters, if any, have been populated using
+    the <tt>aSqlParams</tt> passed to the constructor.
+    
+    <P>If the underlying database auto-generates any keys by executing the returned
+    <tt>PreparedStatement</tt>, they will be available from the returned value using
+    {@link Statement#getGeneratedKeys}.
+    
+    <P>If the returned statement is a <tt>SELECT</tt>, then a limit, as configured in
+    <tt>web.xml</tt>, is placed on the maximum number of rows which can be returned.
+    This is meant as a defensive safety measure, to avoid returning an excessively large
+    number of rows.
+   */
+  PreparedStatement getPreparedStatement(Connection aConnection) throws SQLException {
+    PreparedStatement result = null;
+    result = getPS(fSqlText, aConnection, fSqlId);
+    populateParamsUsingPS(result);
+    result.setMaxRows(fConfig.getMaxRows(fSqlId.getDatabaseName()).intValue());
+    result.setFetchSize(fConfig.getFetchSize(fSqlId.getDatabaseName()).intValue());
+    return result;
+  }
+
+  /** Return the {@link SqlId} passed to the constructor. */
+  public SqlId getSqlId() {
+    return fSqlId;
+  }
+
+  /**
+    Return the number of <tt>'?'</tt> placeholders appearing in the underlying SQL
+    statement.
+   */
+  static int getNumParameters(SqlId aSqlId) {
+    int result = 0;
+    String sqlText = getSqlTextFromId(aSqlId);
+    result = getNumParams(fQUESTION_MARK, sqlText);
+    return result;
+  }
+
+  /** Intended for debugging only. */
+  @Override  public String toString() {
+    StringBuilder result = new StringBuilder();
+    result.append(fSqlId);
+    result.append(" {");
+    result.append(NEW_LINE);
+    result.append(" fSqlText = ").append(fSqlText).append(NEW_LINE);
+    List<Object> params = Arrays.asList(fParams);
+    result.append(" Params = ").append(params).append(NEW_LINE);
+    result.append("}");
+    result.append(NEW_LINE);
+    return result.toString();
+  }
+
+  // PRIVATE
+
+  /** The id of the SQL statement, as named in the underlying .sql file. */
+  private final SqlId fSqlId;
+
+  /** Parameter values to be placed into a SQL statement. */
+  private final Object[] fParams;
+
+  /**
+    The raw text of the SQL statement, as retrieved from the underlying *.sql file(s) (for
+    example "SELECT Name FROM Blah").
+   */
+  private String fSqlText;
+
+  /** Contents of the underlying *.sql file(s). */
+  private static Properties fSqlProperties;
+  private static final Pattern fQUESTION_MARK = Pattern.compile("\\?");
+  private static final Pattern fSQL_PROPERTIES_FILE_NAME_PATTERN = Pattern.compile("(?:.)*\\.sql");
+  private static final String fTESTING_SQL_PROPERTIES = "C:\\johanley\\Projects\\web4j-jar-trunk\\classes\\hirondelle\\web4j\\database\\mysql.sql";
+  private static final String fSTORED_PROC = "{call";
+  private static final Pattern fSELECT_PATTERN = Pattern.compile("^SELECT", Pattern.CASE_INSENSITIVE);
+  private static final String fUNSUPPORTED_STORED_PROC = "{?=";
+  
+  private Config fConfig = new Config();
+  private static final Logger fLogger = Util.getLogger(SqlStatement.class);
+
+  private static PreparedStatement getPS(String aSqlText, Connection aConnection, SqlId aSqlId)  throws SQLException {
+    PreparedStatement result = null;
+    if (isStoredProcedure(aSqlText)) {
+      result = aConnection.prepareCall(aSqlText);
+    }
+    else {
+      if (isSelect(aSqlText)) {
+        // allow scrolling of SELECT result sets; this is needed by ModelFromRow, to create lists with children
+        result = aConnection.prepareStatement(aSqlText, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
+      }
+      else {
+        Config config = new Config();
+        if (config.getHasAutoGeneratedKeys(aSqlId.getDatabaseName())) {
+          result = aConnection.prepareStatement(aSqlText, Statement.RETURN_GENERATED_KEYS);
+        }
+        else {
+          result = aConnection.prepareStatement(aSqlText);
+        }
+      }
+    }
+    return result;
+  }
+
+  private void populateParamsUsingPS(PreparedStatement aStatement) throws SQLException {
+    // parameter indexes are 1-based, not 0-based.
+    for (int idx = 1; idx <= fParams.length; ++idx) {
+      Object param = fParams[idx - 1];
+      if (param == null) {
+        fLogger.finest("Param" + idx + ": null");
+        // is there a better way of doing this?
+        // setNull needs the type of the underlying column, which is not available
+        aStatement.setString(idx, null);
+      }
+      else if (param instanceof String) {
+        fLogger.finest("Param" + idx + ": String");
+        aStatement.setString(idx, (String)param);
+      }
+      else if (param instanceof Integer) {
+        fLogger.finest("Param" + idx + ": Integer");
+        Integer paramVal = (Integer)param;
+        aStatement.setInt(idx, paramVal.intValue());
+      }
+      else if (param instanceof Boolean) {
+        fLogger.finest("Param" + idx + ": Boolean");
+        Boolean paramVal = (Boolean)param;
+        aStatement.setBoolean(idx, paramVal.booleanValue());
+      }
+      else if (param instanceof hirondelle.web4j.model.DateTime) {
+        fLogger.finest("Param" + idx + ": hirondelle.web4j.model.DateTime");
+        setDateTime(param, aStatement, idx);
+      }
+      else if (param instanceof java.util.Date) {
+        fLogger.finest("Param" + idx + ": Date");
+        setDate(param, aStatement, idx);
+      }
+      else if (param instanceof java.math.BigDecimal) {
+        fLogger.finest("Param" + idx + ": BigDecimal");
+        aStatement.setBigDecimal(idx, (BigDecimal)param);
+      }
+      else if (param instanceof Decimal) {
+        fLogger.finest("Param" + idx + ": Decimal");
+        Decimal value = (Decimal)param;
+        aStatement.setBigDecimal(idx, value.getAmount());
+      }
+      else if (param instanceof Long) {
+        fLogger.finest("Param" + idx + ": Long");
+        Long paramVal = (Long)param;
+        aStatement.setLong(idx, paramVal.longValue());
+      }
+      else if (param instanceof Id) {
+        fLogger.finest("Param" + idx + ": Id");
+        Id paramId = (Id)param;
+        aStatement.setString(idx, paramId.getRawString());
+      }
+      else if (param instanceof SafeText) {
+        fLogger.finest("Param" + idx + ": SafeText");
+        SafeText paramText = (SafeText)param;
+        aStatement.setString(idx, paramText.getRawString());
+      }
+      else if (param instanceof Locale){
+        fLogger.finest("Param" + idx + ": Locale");
+        Locale locale = (Locale)param;
+        String nonLocalizedId = locale.toString(); //en_US_south; independent of any JRE locale
+        aStatement.setString(idx, nonLocalizedId);
+      }
+      else if (param instanceof TimeZone){
+        fLogger.finest("Param" + idx + ": TimeZone");
+        TimeZone timeZone = (TimeZone)param;
+        String nonLocalizedId = timeZone.getID(); //America/Montreal  
+        aStatement.setString(idx, nonLocalizedId);
+      }
+      else if (param instanceof InputStream){
+        fLogger.finest("Param" + idx + ": InputStream");
+        InputStream stream = (InputStream)param;
+        aStatement.setBinaryStream(idx, stream); //jdk1.6 ; 1.5 needs an extra int, the stream length
+      }
+      else {
+        throw new IllegalArgumentException("Unsupported type of parameter: " + param.getClass());
+      }
+    }
+  }
+
+  private void setDate(Object aParam, PreparedStatement aStatement, int aIdx) throws SQLException {
+    // java.sql.Date has date only, and java.sql.Time has time only
+    java.util.Date dateUtil = (java.util.Date)aParam;
+    java.sql.Timestamp timestampSql = new java.sql.Timestamp(dateUtil.getTime());
+    if (fConfig.hasTimeZoneHint()) {
+      aStatement.setTimestamp(aIdx, timestampSql, fConfig.getTimeZoneHint());
+    }
+    else {
+      aStatement.setTimestamp(aIdx, timestampSql);
+    }
+  }
+  
+  private void setDateTime(Object aParam, PreparedStatement aStatement, int aIdx) throws SQLException {
+    DateTime dateTime = (DateTime)aParam;
+    String formattedDateTime = "";
+    if (
+      dateTime.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && 
+      dateTime.unitsAllAbsent(Unit.HOUR, Unit.MINUTE, Unit.SECOND)
+    ){
+      fLogger.finest("Treating DateTime as a date (year-month-day).");
+      formattedDateTime = dateTime.format(fConfig.getDateFormat(fSqlId.getDatabaseName()));
+    }
+    else if ( 
+      dateTime.unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY) && 
+      dateTime.unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND)
+    ){
+      fLogger.finest("Treating DateTime as a time (hour-minute-second).");
+      formattedDateTime = dateTime.format(fConfig.getTimeFormat(fSqlId.getDatabaseName()));
+    }
+    else if ( 
+      dateTime.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && 
+      dateTime.unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND)
+    ) {
+      fLogger.finest("Treating DateTime as a date+time (year-month-day-hour-minute-second).");
+      formattedDateTime = dateTime.format(fConfig.getDateTimeFormat(fSqlId.getDatabaseName()));
+    }
+    else {
+      String message = 
+         "Unable to format DateTime using the DateTimeFormatForPassingParamsToDb setting in web.xml." +
+         " The units present in the DateTime object do not match any of the expected combinations. " + 
+         "If needed, you can always format the DateTime manually in your DAO, and pass a String to the database instead of a DateTime."
+       ;
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+    aStatement.setString(aIdx, formattedDateTime);
+  }
+
+  private static String getSqlTextFromId(SqlId aSqlId) {
+    return fSqlProperties.getProperty(aSqlId.toString());
+  }
+
+  private void checkNumParamsMatches(Object[] aSqlParams) {
+    checkNumParams(fQUESTION_MARK, aSqlParams);
+  }
+
+  private static boolean isStoredProcedure(String aSqlText) {
+    return aSqlText.startsWith(fSTORED_PROC);
+  }
+
+  private static boolean isSelect(String aSqlText) {
+    return Util.contains(fSELECT_PATTERN, aSqlText);
+  }
+
+  private void checkNumParams(Pattern aPattern, Object[] aParams) {
+    Matcher matcher = aPattern.matcher(fSqlText);
+    int numParams = 0;
+    while (matcher.find()) {
+      ++numParams;
+    }
+    if (numParams != aParams.length) { 
+      throw new IllegalArgumentException(aParams.length + " params should be " + numParams); 
+    }
+  }
+
+  private static int getNumParams(Pattern aPlaceholderPattern, String aSqlText) {
+    int result = 0;
+    Matcher matcher = aPlaceholderPattern.matcher(aSqlText);
+    while (matcher.find()) {
+      ++result;
+    }
+    return result;
+  }
+
+  private void checkParamsOfSupportedType(Object[] aSqlParams) {
+    for (Object param : aSqlParams) {
+      if (!isSupportedType(param)) {
+        throw new IllegalArgumentException("Unsupported type of SQL parameter: " + param.getClass()); 
+      }
+    }
+  }
+
+  private boolean isSupportedType(Object aParam) {
+    return aParam == null || BuildImpl.forConvertParam().isSupported(aParam.getClass());
+  }
+
+  private static void readSqlText() {
+    if (fSqlProperties != null) {
+      fSqlProperties.clear();
+    }
+    Config config = new Config();
+    if (!config.isTesting()) {
+      fSqlProperties = ConfigReader.fetchMany(fSQL_PROPERTIES_FILE_NAME_PATTERN, ConfigReader.FileType.TEXT_BLOCK);
+    }
+    else {
+      fSqlProperties = ConfigReader.fetchForTesting(fTESTING_SQL_PROPERTIES, ConfigReader.FileType.TEXT_BLOCK);
+    }
+  }
+
+  private static void checkSqlFilesVersusSqlIdFields() {
+    Map sqlIdFields = ConfigReader.fetchPublicStaticFinalFields(SqlId.class);
+    Set<String> sqlIdStrings = convertToSetOfStrings(sqlIdFields);
+    fLogger.config("SqlId fields " + Util.logOnePerLine(sqlIdStrings));
+    AppException mismatches = getMismatches(sqlIdStrings, fSqlProperties.keySet());
+    if (mismatches.isNotEmpty()) {
+      fLogger.severe("MISMATCH found between .sql files and SqlId fields. " + Util.logOnePerLine(mismatches.getMessages()));
+      throw new IllegalStateException(Util.logOnePerLine(mismatches.getMessages()));
+    }
+    fLogger.config("No mismatches found between .sql files and SqlId fields.");
+  }
+
+  /**
+    Map <tt>aSqlIdFields</tt> contains KEY - containing Class VALUE - Set of SqlId Fields
+    <P>
+    In this case, we are interested only in the "global" set of SqlId fields, unrelated to
+    any particular class. This method will doubly iterate through its argument, and return
+    a Set of Strings extracted from the SqlId.toString() method. This is to allow
+    comparison with the identifiers in the .sql files.
+   */
+  private static Set<String> convertToSetOfStrings(Map<Class<?>, Set<SqlId>> aSqlIdFields) {
+    Set<String> result = new LinkedHashSet<String>();
+    Set classes = aSqlIdFields.keySet();
+    Iterator classesIter = classes.iterator();
+    while (classesIter.hasNext()) {
+      Class containingClass = (Class)classesIter.next();
+      Set<SqlId> fields = aSqlIdFields.get(containingClass);
+      result.addAll(getSqlIdFieldsAsStrings(fields));
+    }
+    return result;
+  }
+
+  private static Set<String> getSqlIdFieldsAsStrings(Set<SqlId> aSqlIds) {
+    Set<String> result = new LinkedHashSet<String>();
+    for (SqlId sqlId : aSqlIds) {
+      result.add(sqlId.toString());
+    }
+    return result;
+  }
+
+  private static AppException getMismatches(Set<String> aSqlIdStrings,
+  Collection<Object> aSqlTextFileKeys) {
+    AppException result = new AppException();
+    for (String fieldValue : aSqlIdStrings) {
+      if (!aSqlTextFileKeys.contains(fieldValue)) {
+        result.add("SqlId field " + fieldValue + " is not present in any underlying .sql file.");
+      }
+    }
+    for (Object sqlFileKey : aSqlTextFileKeys) {
+      if (!aSqlIdStrings.contains(sqlFileKey)) {
+        result.add("The key " + sqlFileKey  + " in a .sql file does not match any corresponding public static final SqlId field in any class.");
+      }
+    }
+    return result;
+  }
+
+  private static void checkStoredProcedures() {
+    AppException errors = new AppException();
+
+    Enumeration allSqlIds = fSqlProperties.propertyNames();
+    while (allSqlIds.hasMoreElements()) {
+      String sqlId = (String)allSqlIds.nextElement();
+      String sql = (String)fSqlProperties.get(sqlId);
+      if (sql.startsWith(fUNSUPPORTED_STORED_PROC)) {
+        errors.add(
+          "The stored procedured called " + Util.quote(sqlId) + " has an explict return "
+          + "value since it begins with " + fUNSUPPORTED_STORED_PROC + ". "
+          + "A *.sql file can contain stored procedures, but only if they do not "
+          + "have any OUT or INOUT parameters, including *explicit* return values (which "
+          + "would need registration as an OUT parameter). See hirondelle.web4j.database "
+          + "package overview for more information."
+        );
+      }
+    }
+
+    if (errors.isNotEmpty()) { throw new IllegalStateException(errors.getMessages().toString()); }
+  }
+
+  /**
+    Attempt a precompile of all statements.
+    <P>Precompilation is not supported by some drivers/databases.
+   */
+  private static void precompileAll() {
+    fLogger.config("Attempting precompile of all SQL statements by calling Connection.prepareStatement(String). Precompilation is not supported by all drivers/databases. If not supported, then this checking is not useful. See web.xml.");
+    ConnectionSource connSrc = BuildImpl.forConnectionSource();
+    Connection connection = null;
+    PreparedStatement preparedStatement = null;
+    String sqlText = null;
+    SqlId sqlId = null;
+    String sqlIdString = null;
+    List<String> successIds = new ArrayList<String>();
+    List<String> failIds = new ArrayList<String>();
+    Set<Object> statementIds = fSqlProperties.keySet();
+    Iterator<Object> iter = statementIds.iterator();
+    while (iter.hasNext()) {
+      sqlId = SqlId.fromStringId((String) iter.next());
+      sqlIdString = sqlId.toString();
+      sqlText = fSqlProperties.getProperty(sqlIdString);
+      String dbName = sqlId.getDatabaseName();
+      Config config = new Config();
+      if(config.getIsSQLPrecompilationAttempted(dbName)){
+        try {
+          connection = (Util.textHasContent(dbName)) ? connSrc.getConnection(dbName) : connSrc.getConnection();
+          preparedStatement = getPS(sqlText, connection, sqlId); // SQLException depends on driver/db
+          successIds.add(sqlIdString);
+        }
+        catch (SQLException ex) {
+          failIds.add(sqlIdString);
+          fLogger.severe("SQLException occurs for attempted precompile of " + sqlId + " " + ex.getMessage() + NEW_LINE + sqlText);
+        }
+        catch (DAOException ex) {
+          fLogger.severe("Error encountered during attempts to precompile SQL statements : " + ex);
+        }
+        finally {
+          try {
+            DbUtil.close(preparedStatement, connection);
+          }
+          catch (DAOException ex) {
+            fLogger.severe("Cannot close connection and/or statement : " + ex);
+          }
+        }
+      }
+    }
+    fLogger.config("Attempted SQL precompile, and found no failure for : " + Util.logOnePerLine(successIds));
+    if (!failIds.isEmpty()) {
+      fLogger.config("Attempted SQL precompile, and found *** FAILURE *** for : " + Util.logOnePerLine(failIds));
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/StoredProcedureTemplate.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,254 @@
+package hirondelle.web4j.database;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.CallableStatement;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Util;
+
+/**
+ Template for using <tt>CallableStatement</tt>s.
+
+ <P>The purpose of this class is to reduce code repetition related to 
+ <tt>CallableStatement</tt>s : getting a connection, catching and translating exceptions, 
+ and closing statements and connections. As a second benefit, concrete 
+ implementations of this class have simpler, "straight-line" code, 
+ which is easier to both read and write.
+  
+ <P>This abstract base class is an example of the template design pattern.
+
+ <P>The two constructors of this class correspond to whether or not this task 
+ is being performed as part of a transaction. 
+
+ <P>Use of this class requires creating a subclass. Typically, such a class would likely 
+ be nested within a Data Access Object. If an inner or local class is used, then input parameters
+ defined in the enclosing class (the DAO) can be referenced directly. For example : 
+<PRE>
+ //defined in the body of some enclosing DAO  :
+ class DoPayRun extends StoredProcedureTemplate {
+   DoPayRun(){ 
+     super( "{call do_pay_run(?)}" );
+   }
+   void executeStoredProc(CallableStatement aCallableStatement) throws SQLException {
+     //set param values, register out params, 
+     //get results, etc.
+     //fBlah is defined in the enclosing class:
+     aCallableStatement.setInt(1, fBlah);
+     fResult = aCallableStatement.executeUpdate();
+   }
+   //one way of returning a result, but there are many others :
+   int getResult(){
+     return fResult;
+   }
+   private int fResult;
+ }
+ ...
+ //in the body of a DAO method, use a DoPayRun object
+ DoPayRun doPayRun = new DoPayRun();
+ doPayRun.execute();
+ int result = doPayRun.getResult(); 
+</PRE>
+
+<P>There are many ways to retrieve data from a call to a stored procedure, <em>and 
+ this task is left entirely to subclasses of </em><tt>StoredProcedureTemplate</tt>.
+
+ <P>In the rare cases where the default <tt>ResultSet</tt> properties are not adequate, 
+ the <tt>customizeResultSet</tt> methods may be used to alter them. 
+
+<P><em>Design Note :</em><br>
+ Although this class is still useful, it is not completely satisfactory for two 
+ reasons :
+<ul>
+ <li>there are many different ways to return values from stored procedures :
+ a return value expliclty defined by the stored procedure itself, 
+ <tt>OUT</tt> parameters, <tt>INOUT</tt> parameters, the <tt>executeUpdate</tt> method,
+ and the <tt>executeQuery</tt> method. There is probably no simple technique for 
+ returning all of these possible return values in a generic way. That is, it does not
+ seem possible to create a reasonable method which will return all such values. 
+ <li>although this class does eliminate code repetition, the amount of code which the 
+ caller needs is still a bit large. 
+</ul>
+*/
+public abstract class StoredProcedureTemplate {
+  
+  /**
+   Constructor for case where this task is <em>not</em> part of a transaction.
+  
+   @param aTextForCallingStoredProc text such as <tt>'{call do_this(?,?)}'</tt> (for 
+   more information on valid values, see 
+   <a href="http://java.sun.com/j2se/1.5.0/docs/api/java/sql/CallableStatement.html">
+   CallableStatement</a>)
+  */
+  protected StoredProcedureTemplate(String aTextForCallingStoredProc) {
+    fTextForCallingStoredProc = aTextForCallingStoredProc;
+  }
+
+  /**
+   Constructor for case where this task is part of a transaction.
+  
+   <P>The task performed by {@link #executeStoredProc} will use <tt>aConnection</tt>, 
+   and will thus participate in any associated transaction being used by the caller.
+  
+   @param aTextForCallingStoredProc text such as <tt>'{call do_this(?,?)}'</tt> (for 
+   more information on valid values, see 
+   <a href="http://java.sun.com/j2se/1.5.0/docs/api/java/sql/CallableStatement.html">
+   CallableStatement</a>).
+   @param aSharedConnection pre-existing connection created by the caller for including  
+   multiple operations in the same transaction.
+  */
+  protected StoredProcedureTemplate(String aTextForCallingStoredProc, Connection aSharedConnection) {
+    fTextForCallingStoredProc = aTextForCallingStoredProc;
+    fSharedConnection = aSharedConnection;
+  }
+  
+  /** <b>Template</b> method which calls {@link #executeStoredProc}.  */
+  public void execute() throws DAOException {
+    Connection connection = getConnection();
+    CallableStatement callableStatement = null;
+    try {
+      if ( isUncustomizedRS() ) {
+        callableStatement = connection.prepareCall(fTextForCallingStoredProc);
+      }
+      else if ( isPartiallyCustomizedRS() ) {
+        callableStatement = connection.prepareCall(
+          fTextForCallingStoredProc, 
+          fRSType, 
+          fRSConcurrency
+        );
+      }
+      else {
+        //fully customised ResultSet
+        callableStatement = connection.prepareCall(
+          fTextForCallingStoredProc, 
+          fRSType, 
+          fRSConcurrency, 
+          fRSHoldability
+        );
+      }
+      executeStoredProc(callableStatement);
+    }
+    catch (SQLException ex) {
+      throw new DAOException( getErrorMessage(ex) , ex);
+    }
+    finally {
+      close(callableStatement, connection);
+    }
+  }
+  
+  /**
+   Perform the core task.
+  
+   <P>Implementations of this method do not fetch a connection, catch exceptions, or 
+   call <tt>close</tt> methods. Those tasks are handled by this base class.
+  
+   <P>See class description for an example.
+  */
+  protected abstract void executeStoredProc(CallableStatement aCallableStatement) throws SQLException;
+
+  /**
+   Change to a non-default database.
+    
+   <P>Use this method to force this class to use an 
+   <em>internal</em> connection a non-default database. It does not make sense to call this method when using 
+   an <em>external</em> {@link Connection} - that is, when using {@link StoredProcedureTemplate#StoredProcedureTemplate(String, Connection)}.
+   
+   <P>See {@link ConnectionSource} for more information on database names.
+   
+   @param aDatabaseName one of the values returned by {@link ConnectionSource#getDatabaseNames()}
+  */
+  protected final void setDatabaseName( String aDatabaseName ){
+    Args.checkForContent(aDatabaseName);
+    fDatabaseName = aDatabaseName;
+  }
+   
+  
+  /**
+   Change the properties of the default <tt>ResultSet</tt>, in exactly the same manner as
+   {@link java.sql.Connection#prepareCall(java.lang.String, int, int)}.
+  
+   <P>In the rare cases where this method is used, it must be called before
+   {@link #execute}.
+  */
+  protected final void customizeResultSet(int aResultSetType, int aResultSetConcurrency){
+    fRSType = aResultSetType;
+    fRSConcurrency = aResultSetConcurrency;
+  }
+  
+  /**
+   Change the properties of the default <tt>ResultSet</tt>, in exactly the same manner as
+   {@link java.sql.Connection#prepareCall(java.lang.String, int, int, int)}.
+  
+   <P>In the rare cases where this method is used, it must be called before {@link #execute}.
+  */
+  protected final void customizeResultSet(int aResultSetType, int aResultSetConcurrency, int aResultSetHoldability){
+    fRSType = aResultSetType;
+    fRSConcurrency = aResultSetConcurrency;
+    fRSHoldability = aResultSetHoldability;
+  }
+  
+  // PRIVATE //
+  
+  private final String fTextForCallingStoredProc;
+  private Connection fSharedConnection;
+  
+  /*
+   These three items are passed to the various overloads of Connection.prepareCall, and 
+   carry the same meaning as defined by those methods.
+  
+   A value of 0 for any of these items indicates that they have not been set by the 
+   caller.
+  */
+  private int fRSType;
+  private int fRSConcurrency;
+  private int fRSHoldability;
+  
+  private String fDatabaseName = Consts.EMPTY_STRING;
+  
+  private Connection getConnection() throws DAOException {
+    Connection result = null; 
+    if ( isSharedConnection() ) {
+      result = fSharedConnection;
+    }
+    else {
+      if ( Util.textHasContent(fDatabaseName) ){
+        result = BuildImpl.forConnectionSource().getConnection(fDatabaseName);
+      }
+      else {
+        result = BuildImpl.forConnectionSource().getConnection();
+      }
+    }
+    return result;
+  }
+
+  private boolean isSharedConnection() {
+    return fSharedConnection != null;
+  }
+  
+  private String getErrorMessage(SQLException ex){
+    return 
+      "Cannot execute CallableStatement in the expected manner : " + 
+      fTextForCallingStoredProc + Consts.SPACE + 
+      ex.getMessage()
+    ;
+  }
+  
+  private void close(CallableStatement aStatement, Connection aConnection) throws DAOException {
+    if ( isSharedConnection() ) {
+      DbUtil.close(aStatement);
+    }
+    else {
+      DbUtil.close(aStatement, aConnection);
+    }
+  }
+  
+  private boolean isUncustomizedRS(){
+    return fRSType == 0;
+  }
+
+  private boolean isPartiallyCustomizedRS(){
+    return fRSType != 0 && fRSHoldability == 0;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/Tx.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,15 @@
+package hirondelle.web4j.database;
+
+/**
+ Execute a database transaction.
+
+ <P>Should be applied only to operations involving more than one SQL statement.
+*/
+public interface Tx {
+
+  /**
+   Execute a database transaction, and return the number of edited records.
+  */
+  int executeTx() throws DAOException;
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/TxIsolationLevel.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,183 @@
+package hirondelle.web4j.database;
+
+import java.util.logging.*;
+import java.sql.Connection;
+import java.sql.SQLException;
+
+import hirondelle.web4j.util.Util;
+
+/**
+ Type-safe enumeration for transaction isolation levels.
+ 
+ <P>For more information on transaction isolation levels, see {@link Connection} and the
+ <a href="http://en.wikipedia.org/wiki/Isolation_%28computer_science%29">wikipedia<a/> 
+ article.
+
+ <P>See {@link TxTemplate}, which is closely related to this class. 
+
+ <a name="PermittedValues"></a><h3>Permitted Values</h3>
+ <P>In order of decreasing strictness (and increasing performance), the levels are :
+<ul>
+ <li><tt>SERIALIZABLE</tt> (most strict, slowest)
+ <li><tt>REPEATABLE_READ</tt>
+ <li><tt>READ_COMMITTED</tt>
+ <li><tt>READ_UNCOMMITTED</tt> (least strict, fastest)
+</ul>
+
+<P>In addition, this class includes another item called <tt>DATABASE_DEFAULT</tt>. It 
+ indicates to the WEB4J data layer that, unless instructed otherwise,
+ the default isolation level defined by the database instance is to be used. 
+
+ <h3>Differences In The Top 3 Levels</h3>
+ It is important to understand that the top 3 levels 
+ listed above differ only in one principal respect : behavior for any 
+ <em>re</em>-SELECTs performed in a transaction.  <span class="highlight">If no re-SELECT is 
+ performed in a given transaction, then the there is no difference in the 
+ behavior of the top three levels</span> (except for performance).
+
+<P>If a SELECT is repeated in a given transaction, it may see a different 
+ <tt>ResultSet</tt>, since some second transaction may have committed changes 
+ to the underlying data. Three questions can be asked of the second <tt>ResultSet</tt>, 
+ and each isolation level responds to these three questions in a different way :
+<P><table border=1 cellspacing="0" cellpadding="3"  width="75%">
+ <tr valign="top">
+  <th>Level</th>
+  <th>1. Can a new record appear?</th>
+  <th>2. Can an old record disappear?</th>
+  <th>3. Can an old record change?</th>
+ </tr>
+ <tr><td><tt>SERIALIZABLE</tt></td><td>Never</td><td>Never</td><td>Never</td></tr>
+ <tr><td><tt>REPEATABLE_READ</tt></td><td>Possibly</td><td>Never</td><td>Never</td></tr>
+ <tr><td><tt>READ_COMMITTED</tt></td><td>Possibly</td><td>Possibly</td><td>Possibly</td></tr>
+</table>
+ <P>(Note : 1 is called a <em>phantom read</em>, while both 2 and 3 are called a 
+ <em>non-repeatable read</em>.)
+ 
+ <h3>Configuration In <tt>web.xml</tt></h3>
+ <em>When no external <tt>Connection</tt> is passed by the application</em>, then  
+ the WEB4J data layer will use an internal <tt>Connection</tt>
+ set to the isolation level configured in <tt>web.xml</tt>. 
+
+<h3>General Guidelines</h3>
+<ul>
+ <li>consult both your database administrator and your database documentation 
+ for guidance regarding these levels
+ <li><span class="highlight">since support for these levels is highly variable, 
+ setting the transaction isolation level explicitly has low portability</span> 
+ (see {@link #set} for some help in this regard). The <tt>DATABASE_DEFAULT</tt> 
+ setting is an attempt to hide these variations in support
+ <li>for a WEB4J application, it is likely a good choice to use the 
+ <tt>DATABASE_DEFAULT</tt>, and to alter that level only under special circumstances 
+ <li>selecting a specific level is always a trade-off between level of data 
+ integrity and execution speed
+</ul>
+
+<h3>Support For Some Popular Databases</h3>
+ (Taken from <em>
+ <a href="http://www.amazon.com/exec/obidos/ASIN/0596004818/ref=nosim/javapractices-20">SQL
+ in a Nutshell</a></em>, by Kline, 2004. <span class="highlight">Please confirm with 
+ your database documentation</span>).<P>
+<table border=1 cellspacing="0" cellpadding="3"  width="60%">
+<tr valign="top">
+ <td>&nbsp;</td>
+ <td>DB2</td>
+ <td>MySQL</td>
+ <td>Oracle</td>
+ <td>PostgreSQL</td>
+ <td>SQL Server</td>
+</tr>
+<tr>
+ <td><tt>SERIALIZABLE</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y</td>
+</tr>
+<tr>
+ <td><tt>REPEATABLE_READ</tt></td>
+ <td>Y</td>
+ <td>Y*</td>
+ <td>N</td>
+ <td>N</td>
+ <td>Y</td>
+</tr>
+<tr>
+ <td><tt>READ_COMMITTED</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y*</td>
+ <td>Y*</td>
+ <td>Y*</td>
+</tr>
+<tr>
+ <td><tt>READ_UNCOMMITTED</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>N</td>
+ <td>N</td>
+ <td>Y</td>
+</tr>
+</table>
+ &#8727; Database Default<br>
+*/
+public enum TxIsolationLevel {
+  
+  SERIALIZABLE("SERIALIZABLE", Connection.TRANSACTION_SERIALIZABLE),
+  REPEATABLE_READ("REPEATABLE_READ", Connection.TRANSACTION_REPEATABLE_READ),
+  READ_COMMITTED("READ_COMMITTED", Connection.TRANSACTION_READ_COMMITTED),
+  READ_UNCOMMITTED("READ_UNCOMMITTED", Connection.TRANSACTION_READ_UNCOMMITTED),
+  DATABASE_DEFAULT ("DATABASE_DEFAULT", -1);
+  
+  /**
+   Return the same underlying <tt>int</tt> value used by {@link Connection} to identify the isolation level.
+  
+   <P>For {@link #DATABASE_DEFAULT}, return <tt>-1</tt>.
+  */
+  public int getInt(){
+    return fIntValue;
+  }
+  
+  /**  Return one of the <a href="#PermittedValues">permitted values</a>, including <tt>'DATABASE_DEFAULT'</tt>.  */
+  public String toString(){
+    return fText;
+  }
+  
+  /**
+   Set a particular isolation level for <tt>aConnection</tt>.
+  
+   <P>This method exists because database support for isolation levels varies 
+   widely.<span class="highlight"> If any error occurs because <tt>aLevel</tt> is not supported, then 
+   the error will be logged at a <tt>SEVERE</tt> level, but 
+   the application will continue to run</span>. This policy treats isolation levels 
+   as important, but non-critical. Porting an application to a database which 
+   does not support all levels will not cause an application to fail. The transaction 
+   will simply execute at the database's default isolation level.
+  
+   <P>Passing in the special value {@link #DATABASE_DEFAULT} will cause a no-operation.
+  */
+  public static void set(TxIsolationLevel aLevel, Connection aConnection){
+    if( aLevel != DATABASE_DEFAULT ) {
+      try {
+          aConnection.setTransactionIsolation(aLevel.getInt());
+      }
+      catch (SQLException ex) {
+        fLogger.severe(
+          "Cannot set transaction isolation level. Database does " + 
+          "not apparently support '" + aLevel + 
+          "'. You will likely need to choose a different isolation level. " + 
+          "Please see your database documentation, and the javadoc for TxIsolationLevel."
+        );
+      }
+    }
+  }
+  
+  // PRIVATE //
+  private TxIsolationLevel(String aText, int aIntValue){
+    fText = aText;
+    fIntValue = aIntValue;
+  }
+  private String fText;
+  private int fIntValue;
+  private static final Logger fLogger = Util.getLogger(TxIsolationLevel.class);  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/TxSimple.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,141 @@
+package hirondelle.web4j.database;
+
+import java.util.*;
+import java.util.logging.*;
+import java.sql.Connection;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Args;
+
+/**
+ Perform the simplest kinds of transactions.
+ 
+ <P>Executes a number of SQL statements, in the same order as passed to the constructor. 
+ Each SQL statement is executed once and only once, without any iteration.
+ Each SQL statement must perform an <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt> operation.
+ No <tt>SELECT</tt>s are allowed.
+ 
+ <P><span class="highlight">It is likely that a significant fraction (perhaps as high as 50%) of all 
+ transactions can be implemented with this class. </span>
+ 
+ <P>Example use case:
+ <PRE>
+ SqlId[] sqlIds = {ADD_THIS, DELETE_THAT};
+ Object[] params = {blah.getX(), blah.getY(), blah.getZ(), blah.getQ()}; 
+ Tx doStuff = new TxSimple(sqlIds, params);
+ int numRecordsAffected = doStuff.executeTx();
+ </PRE>
+ 
+ <P>In this example, <tt>blah</tt> will usually represent a Model Object. The data in <tt>params</tt>
+ is divided up among the various <tt>sqlIds</tt>. (This division is performed internally by this class.) 
+ 
+ <P>This class uses strong ordering conventions. <span class="highlight">The order of 
+ items in each array passed to the constructor is very important</span> (see {@link #TxSimple(SqlId[], Object[])}).
+*/
+public final class TxSimple implements Tx {
+  
+  /**
+   Constructor.
+  
+   <P><tt>aAllParams</tt> includes the parameters for all operations, concatenated in a single array. Has at least 
+   one member. 
+   
+   <P>In general, successive pieces of <tt>aAllParams</tt> are used to populate each corresponding 
+   statement, in their order of execution. The order of items is here <em>doubly</em> important: the 
+   first N parameters are used against the first SQL statement, the next M parameters are 
+   used against the second SQL statement, and so on. (This breakdown is deduced internally.)
+    
+   <P>And as usual, within each subset of <tt>aAllParams</tt> 
+   corresponding to an SQL statement, the order of parameters matches the order 
+   of <tt>'?'</tt> placeholders appearing in the underlying SQL statement.  
+   
+   @param aSqlIds identifiers for the operations to be performed, <em>in the order of their intended execution</em>. Has 
+   at least one member. All of the operations are against the same database.
+   @param aAllParams the parameters for all operations, concatenated in a single array. Has at least 
+   one member.  (See above for important requirements on their order.) 
+  */
+  public TxSimple(SqlId[] aSqlIds, Object[] aAllParams){
+    Args.checkForPositive(aSqlIds.length);
+    Args.checkForPositive(aAllParams.length);
+    
+    fSqlIds = aSqlIds;
+    fParams = aAllParams;
+    fParamsForStatement = new LinkedHashMap<SqlId, Object[]>();
+    divideUpParams();
+  }
+  
+  public int executeTx() throws DAOException {
+    Tx transaction = new SimpleTransaction(getDbName());
+    return transaction.executeTx();
+  }
+
+  // PRIVATE //
+  private SqlId[] fSqlIds;
+  private Object[] fParams;
+  private Map<SqlId, Object[]> fParamsForStatement;
+  private static final Object[] EMPTY = new Object[0];
+  private static final Logger fLogger = Util.getLogger(TxSimple.class);
+  
+  private final class SimpleTransaction extends TxTemplate {
+    SimpleTransaction(String aDbName){
+      super(aDbName);
+    }
+    public int executeMultipleSqls(Connection aConnection) throws DAOException {
+      int result = 0;
+      for (SqlId sqlId : fSqlIds){
+        Object[] params = fParamsForStatement.get(sqlId);
+        SqlEditor editor = null;
+        if( params.length == 0 ){
+          editor = SqlEditor.forTx(sqlId, aConnection);
+        }
+        else {
+          editor = SqlEditor.forTx(sqlId, aConnection, params);
+        }
+        result = result + editor.editDatabase();
+      }
+      return result;
+    }
+  }
+  
+  private String getDbName() {
+    return fSqlIds[0].getDatabaseName();
+  }
+  
+  private void divideUpParams(){
+    int startWith = 0;
+    int totalNumExpectedParams = 0;
+    for (SqlId sqlId : fSqlIds){
+      int numExpectedParams = SqlStatement.getNumParameters(sqlId);
+      totalNumExpectedParams = totalNumExpectedParams + numExpectedParams;
+      chunkParams(startWith, numExpectedParams, sqlId);
+      startWith = startWith + numExpectedParams;
+    }
+    if ( totalNumExpectedParams != fParams.length ){
+      throw new IllegalArgumentException(
+        "Size mismatch. Number of parameters passed as data: " + fParams.length + 
+        ".  Number of '?' items appearing in underlying SQL statements: " + totalNumExpectedParams
+      );
+    }
+    fLogger.finest("Chunked params " + Util.logOnePerLine(fParamsForStatement));
+  }
+  
+  private void chunkParams(int aStartWithIdx, int aNumExpectedParams, SqlId aSqlId){
+    int endWithIdx = aStartWithIdx + aNumExpectedParams - 1;
+    int maxIdx = fParams.length - 1;
+    fLogger.fine("Chunking params for " + aSqlId +  ". Start with index " + aStartWithIdx + ", end with index " + endWithIdx);
+    if( aStartWithIdx > maxIdx ){
+      throw new IllegalArgumentException("Error chunking parameter data. Starting-index (" + aStartWithIdx + ") greater than expected maximum (" + maxIdx + "). Mismatch between number of params passed as data, and number of params expected by underlying SQL statements.");
+    }
+    if ( endWithIdx > maxIdx ){
+      throw new IllegalArgumentException("Error chunking parameter data. Ending-index (" + endWithIdx + ") greater than expected maximum (" + maxIdx + "). Mismatch between number of params passed as data, and number of params expected by underlying SQL statements.");
+    }
+    
+    if( aNumExpectedParams == 0 ){
+      fParamsForStatement.put(aSqlId, EMPTY);
+    }
+    else {
+      List list = Arrays.asList(fParams);
+      List sublist = list.subList(aStartWithIdx, endWithIdx + 1);
+      fParamsForStatement.put(aSqlId, sublist.toArray(EMPTY));
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/TxTemplate.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,245 @@
+package hirondelle.web4j.database;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.logging.Logger;
+
+/** 
+ Template for executing a local, non-distributed transaction versus a single database,  
+ using a single connection.
+
+ <P>This abstract base class implements the template method design pattern.  
+ 
+ <P>The {@link TxSimple} class should be the first choice for implementing a transaction.
+ If it is not suitable (for example, if iteration is involved), then this class can always be used.
+ The benefits of using this class to implement transactions is that the caller avoids  
+ repeated code involving connections, commit/rollback, handling exceptions and errors, and so on.
+ 
+ <P>See {@link TxIsolationLevel} for remarks on selection of correct isolation level. The {@link DbTx} class 
+ is often useful for implementors.
+ 
+ <P>Do not use this class in the context of a <tt>UserTransaction</tt>.
+ 
+ <h3>Example Use Case</h3>
+ A DAO method which uses a <tt>TxTemplate</tt> called <tt>AddAllUnknowns</tt> to perform multiple <tt>INSERT</tt> operations :
+<PRE>
+{@code 
+public int addAll(Set<String> aUnknowns) throws DAOException {
+   Tx addTx = new AddAllUnknowns(aUnknowns);
+   return addTx.executeTx();
+}
+  }
+</PRE>
+
+ The <tt>TxTemplate</tt> class itself, defined inside the same DAO, as an inner class :
+<PRE>
+{@code 
+  private static final class AddAllUnknowns extends TxTemplate {
+    AddAllUnknowns(Set<String> aUnknowns){
+      super(ConnectionSrc.TRANSLATION);
+      fUnknowns = aUnknowns; 
+    }
+    &amp;Override public int executeMultipleSqls(Connection aConnection) throws SQLException, DAOException {
+      int result = 0;
+      for(String unknown: fUnknowns){
+        addUnknown(unknown, aConnection);
+        result = result + 1;
+      }
+      return result;
+    }
+    private Set<String> fUnknowns;
+    private void addUnknown(String aUnknown, Connection aConnection) throws DAOException {
+      DbTx.edit(aConnection, UnknownBaseTextEdit.ADD, aUnknown);
+    }
+  }
+ }
+</PRE>
+*/
+public abstract class TxTemplate implements Tx {
+  
+  /**
+   Constructor for a transaction versus the default database, at the 
+   default isolation level.
+    
+   <P>The default transaction isolation level is configured in <tt>web.xml</tt>.
+  */
+  public TxTemplate(){
+    fDatabaseName = DEFAULT_DB;
+    fTxIsolationLevel = fConfig.getSqlEditorDefaultTxIsolationLevel(DEFAULT_DB);
+  }
+  
+  /**
+   Constructor for transaction versus the default database, at a custom 
+   isolation level.
+    
+   <P>The default transaction isolation level is configured in <tt>web.xml</tt>.
+  */
+  public TxTemplate(TxIsolationLevel aTxIsolationLevel){
+    fDatabaseName = DEFAULT_DB;
+    fTxIsolationLevel = aTxIsolationLevel;
+  }
+  
+  /**
+   Constructor for a transaction versus a non-default database, at its
+   isolation level, as configured in <tt>web.xml</tt>. 
+    
+   @param aDatabaseName one of the  return values of {@link ConnectionSource#getDatabaseNames()}
+  */
+  public TxTemplate(String aDatabaseName){
+    fDatabaseName = aDatabaseName;
+    fTxIsolationLevel = fConfig.getSqlEditorDefaultTxIsolationLevel(aDatabaseName);
+  }
+  
+  /**
+   Constructor for a transaction versus a non-default database, at a custom 
+   isolation level. 
+    
+   <P>The default transaction isolation level is configured in <tt>web.xml</tt>.
+   
+   @param aDatabaseName one of the  return values of {@link ConnectionSource#getDatabaseNames()}
+  */
+  public TxTemplate(String aDatabaseName, TxIsolationLevel aTxIsolationLevel){
+    fDatabaseName = aDatabaseName;
+    fTxIsolationLevel = aTxIsolationLevel;
+  }
+  
+  /**
+   <b>Template</b> method calls the abstract method {@link #executeMultipleSqls}.
+   <P>Returns the same value as <tt>executeMultipleSqls</tt>.
+  
+   <P>A <tt>rollback</tt> is performed if <tt>executeMultipleSqls</tt> throws a {@link SQLException} or
+   {@link DAOException}, or if {@link #executeMultipleSqls(Connection)} returns {@link #BUSINESS_RULE_FAILURE}.  
+  */
+  public final int executeTx() throws DAOException {
+    int result = 0;
+    fLogger.fine(
+      "Editing within a local transaction, with isolation level : " + fTxIsolationLevel
+    );
+    ConnectionSource connSource = BuildImpl.forConnectionSource();
+    if(Util.textHasContent(fDatabaseName)){
+      fConnection = connSource.getConnection(fDatabaseName);
+    }
+    else {
+      fConnection = connSource.getConnection();
+    }
+    
+    try {
+      TxIsolationLevel.set(fTxIsolationLevel, fConnection);
+      startTx();
+      result = executeMultipleSqls(fConnection);
+      endTx(result);
+    }
+    catch(SQLException rootCause){
+      //if SqlEditor is used, this branch will not be exercised, since it throws only
+      //DAOExceptions
+      fLogger.fine("Transaction throws SQLException.");
+      rollbackTx();
+      String message = 
+        "Cannot execute edit. Error code : " +  rootCause.getErrorCode() + 
+        Consts.SPACE + rootCause
+      ;
+      Integer errorCode = new Integer(rootCause.getErrorCode());
+      if (fConfig.getErrorCodesForDuplicateKey(fDatabaseName).contains(errorCode)){
+        throw new DuplicateException(message, rootCause);
+      }
+      else if (fConfig.getErrorCodesForForeignKey(fDatabaseName).contains(errorCode)){
+        throw new ForeignKeyException(message, rootCause);
+      }
+      throw new DAOException(message, rootCause);
+    }
+    catch (DAOException ex){
+      //if SqlEditor is used, it will always throw a DAOException, not SQLException
+      fLogger.fine("Transaction throws DAOException.");
+      rollbackTx();
+      throw ex;
+    }
+    finally {
+      DbUtil.logWarnings(fConnection);
+      DbUtil.close(fConnection);
+    }
+    fLogger.fine("Total number of edited records: " + result);
+    return result;
+  }
+
+  /**
+   Execute multiple SQL operations in a single local transaction.
+  
+   <P>This method returns the number of records edited. If a business rule determines that a 
+   rollback should be performed, then it is recommended that the special value
+   {@link #BUSINESS_RULE_FAILURE} be returned by the implementation. This will signal to 
+   {@link #executeTx()} that a rollback must be performed. (Another option for 
+   signalling that a rollback is desired is to throw a checked exception.)  
+  
+   <P><em>Design Note</em>: allowing <tt>SQLException</tt> in the <tt>throws</tt> 
+   clause simplifies the implementor significantly, since no <tt>try-catch</tt> blocks are 
+   needed. Thus, the caller has simple, "straight-line" code.
+  
+   @param aConnection must be used by all SQL statements participating in this transaction
+   @return number of records edited by this operation. Implementations may return 
+   {@link #BUSINESS_RULE_FAILURE} if there is a business rule failure. 
+  */
+  public abstract int executeMultipleSqls(Connection aConnection) throws SQLException, DAOException;
+  
+  /**
+   Value {@value}. Special value returned by {@link #executeMultipleSqls(Connection)} to indicate that  
+   a business rule has been violated. Such a return value indicates to this class that a rollback must be 
+   performed.
+  */
+  public static final int BUSINESS_RULE_FAILURE = -1;
+  
+  // PRIVATE 
+  
+  /**
+   The connection through which all SQL statements attached to this 
+   transaction are executed. This connection may be for the default 
+   database, or any other defined database. See {@link #fDatabaseName}.
+  */
+  private Connection fConnection;
+  
+  /** 
+   Identifier for the database. The connection taken from the default
+   database only if this item has no content. An empty string implies the default
+   database. 
+  */
+  private String fDatabaseName;
+  private static final String DEFAULT_DB = "";
+  
+  /** The transaction isolation level, set only during the constructor. */
+  private final TxIsolationLevel fTxIsolationLevel;
+  
+  private static final boolean fOFF = false;
+  private static final boolean fON = true;
+  private Config fConfig = new Config();
+  private static final Logger fLogger = Util.getLogger(TxTemplate.class);  
+
+  private void startTx() throws SQLException {
+    fConnection.setAutoCommit(fOFF);
+  }
+  
+  private void endTx(int aNumEdits) throws SQLException, DAOException {
+    if ( BUSINESS_RULE_FAILURE == aNumEdits ) {
+      fLogger.severe("Business rule failure occured. Cannot commit transaction.");
+      rollbackTx();
+    }
+    else {
+      fLogger.fine("Commiting transaction.");
+      fConnection.commit();
+      fConnection.setAutoCommit(fON);
+    }
+  }
+  
+  private void rollbackTx() throws DAOException {
+    fLogger.severe("ROLLING BACK TRANSACTION.");
+    try {
+      fConnection.rollback();
+    }
+    catch(SQLException ex){
+      throw new DAOException("Cannot rollback transaction", ex);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/ValueFromRow.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,30 @@
+package hirondelle.web4j.database;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import hirondelle.web4j.BuildImpl;
+
+/**
+ Construct a building block object from the <em>first</em> column of a <tt>ResultSet</tt>. 
+
+ <P>This implementation violates the rule that all columns must be used to construct the 
+ target object.
+*/
+final class ValueFromRow<E> extends ModelBuilder<E>{
+  
+  ValueFromRow(Class<E> aSupportedTargetClass){
+    fClass = aSupportedTargetClass;  
+  }
+  
+  E build(ResultSet aRow) throws SQLException {
+    return fColumnToObject.convert(aRow, FIRST_COLUMN, fClass);
+  }
+
+  // PRIVATE //
+  private final Class<E> fClass;
+  
+  /** Translates column values into desired objects. */
+  private ConvertColumn fColumnToObject = BuildImpl.forConvertColumn();
+  
+  private static final int FIRST_COLUMN = 1;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/mysql.sql	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,366 @@
+-- SQL statements used by this application, other than
+-- CREATE TABLE statements.
+ 
+-- If the underlying database and driver support stored procedures, then 
+-- calls to *simple* stored procedures can also be placed in this file.
+-- As long as the CallableStatement can be treated as a PreparedStatement, 
+-- then they may be placed in this file.
+-- Thus, no items needing registration as an OUT parameter are allowed. 
+-- This restriction disallows EXPLICIT return values, but allows the 
+-- IMPLICIT return values available from CallableStatement.executeUpdate()
+-- and CallableStatement.executeQuery(). That is, this file can include 
+-- stored procedures which implicitly return either a single int or a 
+-- single ResultSet. If more flexibility is needed, (which is very often 
+-- the case) please use StoredProcedureTemplate.java.
+
+-- If a statement has parameters, then the style used by 
+-- PreparedStatement is used, in which question marks acts as 
+-- placeholders; this style avoids the use of vendor-specific 
+-- quoting styles.
+--
+-- If a statement cannot use parameters in the usual way, then
+-- placeholders of the form {i} are used as an alternative (i=0..9).
+-- This case is common for SELECTs with many criteria in the 
+-- WHERE clause. The {i} are used as placeholders for simple 
+-- textual replacement at runtime. This style requires that 
+-- any quotes be stated explicitly. This style is not applicable
+-- to stored procedures.
+
+-- The Mimer validator was used to verify compliance:
+-- http://developer.mimer.se/validator/parser92/index.tml--parser
+-- Keywords which do not comply with the ANSI standard are 
+--  BINARY (used to force case-sensitive string comparison
+
+-- For SELECTs which are used by the ReportBuilder class to build 
+-- reports, it is highly recommended to use the AS keyword, to protect 
+-- the application from changes to column names. This is the only 
+-- area in which web4j uses column names. More typically, the 
+-- column *index* is significant, not the column name. Note that 
+-- Oracle will preserve case when AS is used only if the identifier
+-- is quoted : SELECT Descr AS "Description" FROM SOME_TABLE
+
+-- Constants are intended mainly for any "magic numbers" that appear 
+-- in your SQL statements (especially in WHERE clauses).
+-- They can also be used to define short SQL snippets, if desired.
+-- Each constant must appear on one line only.
+-- No empty lines are permitted in a constants block.
+-- Any number of such blocks can appear in each file.
+-- Constants must be defined before they are referenced as '${blah}'
+-- substitution variables.
+-- Constants are not currently shared between .sql files. (If you 
+-- would like such a feature, please send a refreshingly pleasant 
+-- note of gentle encouragement to javapractices.com.)
+constants {
+  num_neurons_for_smart_people = 1000000000
+  num_neurons_per_replicant = 5000000000
+  -- The following two items define policies for rendering 
+  -- boolean values in web pages. (This is actually better implemented 
+  -- elsewhere. See web.xml).
+  render_false_html = <input type="checkbox" name="blah" value="blah" readonly>
+  render_true_html = <input type="checkbox" name="blah" value="blah" checked readonly>
+}
+
+-- MyUser  
+-- -------------------------
+ADD_USER {
+INSERT INTO MyUser 
+(LoginName, LoginPassword, EmailAddress, StarRating, FavoriteTheory, SendCard, Age, DesiredSalary, BirthDate) 
+VALUES (?,?,?,?,?,?,?,?,?)
+}
+
+-- In this case, ModelBuilder is used to parse the ResultSet.
+-- Since ModelBuilder requires that the order of the columns 
+-- of the ResultSet match the order of arguments passed to the 
+-- corresponding Model Object (MyUser), it seems prudent to explicitly 
+-- specify the column names, instead of using the "SELECT * FROM X" style.
+--
+-- The LEFT JOIN is used to ensure that even NULL values for 
+-- FavoriteTheory are returned.
+FETCH_USER {
+SELECT 
+LoginName, 
+LoginPassword, 
+EmailAddress, 
+StarRating, 
+MyQuantumTheory.Text AS FavoriteTheory, 
+SendCard, 
+Age, 
+DesiredSalary, 
+BirthDate 
+FROM MyUser 
+LEFT JOIN MyQuantumTheory ON 
+MyUser.FavoriteTheory = MyQuantumTheory.Id 
+WHERE LoginName=?
+}
+
+CHANGE_USER {
+UPDATE MyUser SET 
+EmailAddress=?, 
+StarRating=?, 
+FavoriteTheory=?, 
+SendCard=?, 
+Age=?, 
+DesiredSalary=?, 
+BirthDate=? 
+WHERE LoginName=?
+}
+
+DELETE_USER {
+DELETE FROM MyUser WHERE LoginName=?
+}
+
+FETCH_USER_THEORIES {
+SELECT MyQuantumTheory.Text AS TheoryName
+FROM MyQuantumTheory, MyUserTheories 
+WHERE MyUserTheories.QuantumTheory=MyQuantumTheory.Id 
+AND MyUserTheories.LoginName=?
+}
+
+DELETE_USER_THEORIES {
+DELETE FROM MyUserTheories WHERE LoginName=?
+}
+
+ADD_USER_THEORY {
+INSERT INTO MyUserTheories 
+(LoginName, QuantumTheory) 
+VALUES (?,?)
+}
+
+
+-- Pick Lists
+--
+-- See MyPickListDAO for more information.
+--
+-- In this implementation, the structure of the various 
+-- underlying tables does not need to be uniform. 
+-- However, associated *ResultSets* follow the convention
+-- that columns named 'Id' and 'Text' must be present. 
+-- As well, "extended" pick lists also use OrderIdx and 
+-- Description columns. 
+-- If the underlying tables do not use these column names, 
+-- then the AS keyword can always be used to create the 
+-- necessary alias.
+-- -------------------------------------------------------------
+
+-- Pick List : Pizza Toppings
+FETCH_PIZZA_TOPPINGS {
+SELECT Text, Id 
+FROM MyPizzaTopping 
+ORDER BY Text
+}
+
+ADD_PIZZA_TOPPING {
+INSERT INTO MyPizzaTopping (Text) VALUES (?)
+}
+
+-- The BINARY cast serves to force a case-sensitive comparision.
+CHANGE_PIZZA_TOPPING {
+UPDATE MyPizzaTopping SET Text=? WHERE BINARY Text=?
+}
+
+-- Pick List : Artists
+FETCH_ARTISTS {
+SELECT FullName AS Text, Id 
+FROM MyArtist 
+ORDER BY FullName
+}
+
+ADD_ARTIST {
+INSERT INTO MyArtist (FullName) VALUES (?)
+}
+
+-- The BINARY cast serves to force a case-sensitive comparision.
+CHANGE_ARTIST {
+UPDATE MyArtist SET FullName=? WHERE BINARY FullName=?
+}
+
+-- Pick List : Quantum Theories 
+-- This is an "extended" pick list, which includes a Description and 
+-- explicit OrderIdx, in addition to basic Text.
+FETCH_QUANTUM_THEORIES {
+SELECT Text, Description, OrderIdx, Id 
+FROM MyQuantumTheory 
+ORDER BY OrderIdx
+}
+
+ADD_QUANTUM_THEORY {
+INSERT INTO MyQuantumTheory (Text, Description, OrderIdx) VALUES (?,?,?)
+}
+
+-- Pick Lists which include a column devoted to specifying an 
+-- explicit sort order need to allow complete rearrangement  
+-- of the OrderIdx 
+INCREMENT_QUANTUM_THEORY_ORDER_IDX {
+UPDATE MyQuantumTheory SET OrderIdx = OrderIdx + 1 WHERE OrderIdx >= ?
+}
+
+DECREMENT_QUANTUM_THEORY_ORDER_IDX {
+UPDATE MyQuantumTheory SET OrderIdx = OrderIdx - 1 WHERE OrderIdx > ?
+}
+
+CHANGE_QUANTUM_THEORY {
+UPDATE MyQuantumTheory SET Text=?, Description=? WHERE BINARY Text=?
+}
+
+CHANGE_QUANTUM_THEORY_ORDER_IDX {
+UPDATE MyQuantumTheory SET OrderIdx=? WHERE BINARY Text=?
+}
+
+-- MyPerson
+-- This table is very similar to MyUser. It was 
+-- created to allow unfettered access and editing of 
+-- simple toy data.
+-- -----------------------------------------------------
+ADD_PERSON {
+INSERT INTO MyPerson 
+(Name, StarRating, FavoriteTheory, SendCard, Age, DesiredSalary, BirthDate, BodyTemperature, NumNeurons) 
+VALUES (?,?,?,?,?,?,?,?,?)
+}
+
+-- In this case, ModelBuilder is used to parse the ResultSet.
+-- Since ModelBuilder requires that the order of the columns 
+-- of the ResultSet match the order of arguments passed to the 
+-- corresponding Model Object (MyUser), it seems prudent to expicitly 
+-- specify the column names, instead of using the "SELECT * FROM X" style.
+--
+-- The LEFT JOIN is used to ensure that even NULL values for 
+-- FavoriteTheory are returned.
+FETCH_PERSON {
+SELECT 
+Name, 
+StarRating, 
+MyQuantumTheory.Text AS FavoriteTheory, 
+SendCard, 
+Age, 
+DesiredSalary, 
+BirthDate, 
+BodyTemperature, 
+NumNeurons 
+FROM MyPerson 
+LEFT JOIN MyQuantumTheory ON 
+MyPerson.FavoriteTheory = MyQuantumTheory.Id 
+WHERE Name=?
+}
+
+CHANGE_PERSON {
+UPDATE MyPerson SET 
+StarRating=?, 
+FavoriteTheory=?, 
+SendCard=?, 
+Age=?, 
+DesiredSalary=?, 
+BirthDate=?, 
+BodyTemperature=?, 
+NumNeurons=? 
+WHERE Name=?
+}
+
+DELETE_PERSON {
+DELETE FROM MyPerson WHERE Name=?
+}
+
+-- Used to exercise ValueBuilder, which is used to extract simple 
+-- values (often statistics) from the database.
+FETCH_NUM_PERSONS_WITH_SIMILAR_NAME {
+SELECT COUNT(*) AS NUM_PERSONS 
+FROM MyPerson 
+WHERE Name LIKE '{0}%'
+}
+
+-- Used to exercise the rather unusual case of performing a 
+-- database update without any criteria
+UPDATE_NUM_NEURONS {
+UPDATE MyPerson SET NumNeurons=${num_neurons_per_replicant}
+}
+
+-- The following items are used in a report
+-- Note that all columns use the 'AS' keyword. This is important 
+-- here since the column names are used directly in the report.
+-- In other SELECTSs, it is not column name which is significant, 
+-- but column index.
+FETCH_NUM_USERS {
+SELECT COUNT(*) AS NumUsers FROM MyUser;
+}
+
+FETCH_DATE_MOST_RECENT_MESSAGE {
+SELECT MAX(CreationDate) AS MostRecentMessage FROM MyMessage
+}
+
+FETCH_NUM_MSGS_PER_USER {
+SELECT 
+MyUser.LoginName AS userName, 
+COUNT(MyMessage.Id) AS numMessages 
+FROM MyUser, MyMessage 
+WHERE MyMessage.LoginName=MyUser.LoginName 
+GROUP BY MyUser.LoginName
+}
+
+FETCH_SMART_PERSONS {
+SELECT 
+Name as Name, 
+StarRating AS StarRating, 
+MyQuantumTheory.Text AS FavoriteTheory, 
+SendCard AS SendCard, 
+Age AS Age, 
+DesiredSalary AS DesiredSalary, 
+BirthDate AS BirthDate, 
+BodyTemperature AS BodyTemperature, 
+NumNeurons AS NumNeurons 
+FROM MyPerson 
+LEFT JOIN MyQuantumTheory ON 
+MyPerson.FavoriteTheory = MyQuantumTheory.Id 
+WHERE NumNeurons >= ${num_neurons_for_smart_people}
+}
+
+-- Reports often need to state details and corresponding totals.
+-- In particular, the "details" query can often
+-- be re-used directly as a sub-query in the "totals" query.
+-- This is artificial here, since the version of MySQL this is 
+-- running against does not support sub-queries, unfortunately. 
+-- Please see the Oracle version of this query for a demonstration 
+-- of how one query can be used in another *without repeating the query*,
+-- by using a substitution variable. 
+FETCH_NUM_SMART_PERSONS {
+SELECT COUNT(*)
+FROM MyPerson 
+LEFT JOIN MyQuantumTheory ON 
+MyPerson.FavoriteTheory = MyQuantumTheory.Id 
+WHERE NumNeurons >= ${num_neurons_for_smart_people}
+}
+
+-- A repeat of the above query, but with database formatting 
+-- functions applied to the result. Note the rather bizarre CASE
+-- statement used for booleans, for generating an HTML checkbox.
+-- The CASE could be simplified, if desired, to something simpler, 
+-- as in :
+-- CASE SendCard WHEN 0 THEN 'false' ELSE 'true' END
+FETCH_SMART_PERSONS_ADD_FORMATTING {
+SELECT 
+Name as Name, 
+StarRating AS StarRating, 
+MyQuantumTheory.Text AS FavoriteTheory, 
+CASE SendCard 
+ WHEN 0 THEN '${render_false_html}' 
+ ELSE '${render_true_html}' 
+END AS SendCard, 
+Age AS Age, 
+FORMAT(DesiredSalary,2) AS DesiredSalary, 
+DATE_FORMAT(BirthDate, '%m/%d/%Y') AS BirthDate, 
+FORMAT(BodyTemperature,2) AS BodyTemperature, 
+FORMAT(NumNeurons,0) AS NumNeurons 
+FROM MyPerson 
+LEFT JOIN MyQuantumTheory ON 
+MyPerson.FavoriteTheory = MyQuantumTheory.Id 
+WHERE NumNeurons >= ${num_neurons_for_smart_people}
+}
+
+-- Not actually called for MySql, since MySql does not 
+-- implement stored procedures. (The reason it 
+-- appears here, in a file intended only for MySql, is 
+-- merely to keep the items in .sql files
+-- synchronized with the elements of the SqlId enumeration.
+-- See the oracle version for a "real" implementation.)
+UPDATE_NUM_NEURONS_STORED_PROC {
+{call update_num_neurons}
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/database/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,300 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Datastore</title>
+</head>
+<body>
+Interaction with the database(s).
+
+<p>The WEB4J data layer assumes the datastore is one or more relational databases.
+If some other means of persistence is required, then this package will 
+not help the application programmer to implement persistence.
+
+<P>An application is not required to use this package to implement 
+persistence. Other persistence tools can be used instead, if desired.
+
+<p><span class="highlight">Please see the Data Access Object (DAOs) of the 
+example application for effective illustration of how to use the services of 
+this package.</span>
+
+<P>Many DAO methods can be implemented using only two classes :
+<ul>
+<li>{@link hirondelle.web4j.database.Db} - common utility methods
+<li>{@link hirondelle.web4j.database.SqlId} - identifiers for underlying SQL statements in <a href="#SqlFiles">.sql files</a>
+</ul>
+
+
+The WEB4J data layer :
+<ul>
+<li>uses one or more relational databases
+<li>uses simple <a href="#SqlFiles">.sql text files</a>, containing SQL statements
+<li><em>does not use any .xml files</em>
+<li><em>does not use any object-relational mapping</em>
+<li><em>does not use any annotations</em>
+<li>is instructed on how to obtain a <tt>Connection</tt> through the application's implementation of {@link hirondelle.web4j.database.ConnectionSource} 
+<li>uses only <tt>PreparedStatement</tt>s, since they are <a href="http://www.javapractices.com/Topic212.cjp">preferred</a>
+<li>is configured using some items in <tt>web.xml</tt>
+<li>can precompile SQL statements upon startup (if supported by the driver). This allows syntax errors to be found upon startup.
+<li>allows many DAO methods to be implemented in just one or two lines of code
+</ul>
+
+<P>This package does not currently use distributed transactions <em>internally</em>. However, since a 
+{@link hirondelle.web4j.database.ConnectionSource} can return a <tt>Connection</tt> for any database, 
+the caller can still implement operations against multiple databases, including distributed 
+transactions, if desired. 
+
+<h2><a name="OrderingConvention">Ordering Convention</h2>
+When creating Model Objects from a <tt>ResultSet</tt>, WEB4J is unusual since it does <em>not</em> use a 
+naming convention to map columns. Instead, it uses a much more effective <em>ordering</em> 
+convention. This turns out to be a significant advantage for the caller, since trivial column
+mapping is no longer required.
+
+<P>(Ordering conventions seem to be very effective for human understanding. For example, would you rather 
+do arithmetic with roman numerals, which use a <em>naming</em> convention to define magnitudes, 
+or hindu-arabic numerals, which use an <em>ordering</em> convention to define place value?)
+
+<P><span class="highlight">The convention is that the N columns 
+of the <tt>ResultSet</tt> must map, in order, to the N arguments passed to 
+a constructor of the Model Object.</span> This is not as restrictive as it first sounds, 
+since the order of the <tt>ResultSet</tt> columns is controlled by the application's 
+SQL statement, and <em>not</em> by the structure of the underlying table. 
+
+<P>More specifically : when building a Model Object from a <tt>ResultSet</tt>, the number of columns in the 
+<tt>ResultSet</tt> is used to find the appropriate <tt>public</tt> constructor of the 
+target Model Object. (Usually, a Model Object will have only a single constructor, one that 
+takes all fields associated with the object.) Then, the columns are mapped in order, one-to-one, 
+to the constructor arguments. Using the configured implementation of 
+{@link hirondelle.web4j.database.ConvertColumn}, the columns are translated into <tt>Integer</tt>, 
+<tt>Date</tt>, and so on, and passed to the Model Object constructor.
+
+<P>Many authors recommended that the <tt>SELECT * FROM X</tt> style be avoided : it does not explicitly 
+name what columns are returned, so it is unclear to the reader. In addition, the order of the returned columns 
+is not specified, and may change if the definition of the underlying table is altered. 
+In WEB4J, following this recommendation is doubly important, because of the ordering convention mentioned 
+above.
+
+
+<h2><a name="SqlFiles"></a>The <tt>.sql</tt> Files</h2>
+<a href="#BasicIdea">Basic Idea</a><br>
+<a href="#DesignBenefits">Benefits Of This Design</a><br>
+<a href="#NameLocation">File Name And Location</a><br>
+<a href="#StartupProcessing">Startup Processing</a><br>
+<a href="#Portability">Portability</a><br>
+<a href="#PassingParameters">Passing Parameters</a><br>
+<a href="#DetailedSyntax">Detailed Syntax For <tt>.sql</tt> Files</a><br>
+<a href="#StoredProcedures">Stored Procedures</a><br>
+
+<h3><a name="BasicIdea">Basic Idea</a></h3>
+The WEB4J data layer uses text <tt>.sql</tt> files. SQL statements are not placed directly in classes. 
+Instead, they are placed in regular text files (of a certain format), and <em>referenced</em> 
+from code using {@link hirondelle.web4j.database.SqlId} objects.
+
+<P>Here is a quick example.
+
+<P>An entry in an <tt>.sql</tt> file :
+<PRE>
+MEMBER_FETCH {
+  SELECT Id, Name, IsActive FROM Member WHERE Id=?
+}
+</PRE>
+This SQL statement is referenced in code using an {@link hirondelle.web4j.database.SqlId} object, created using a
+corresponding <tt>String</tt> identifier <tt>"MEMBER_FETCH"</tt>. Each <tt>SqlId</tt> must be defined as a 
+<tt>public static final</tt> field:
+<PRE>
+public static final SqlId MEMBER_FETCH = new SqlId("MEMBER_FETCH");
+...
+public Member fetch(Id aMemberId) throws DAOException {
+  return Db.fetch(Member.class, MEMBER_FETCH, aMemberId);
+}
+</PRE>
+
+That is the basic idea.
+
+<P>(When this technique is used, most implementations of DAO methods are compact - usually just 
+one or two lines, as shown above.)
+
+<h3><a name="DesignBenefits">Benefits Of This Design</a></h3>
+This design has many benefits :
+<ul>
+ <li><span class="highlight">it uses no tedious object-relational mapping, no .xml files, and no annotations.</span> 
+ This greatly reduces the effort needed to implement DAOs.
+ <li>SQL statements created and verified in other tools can be easily copied over, 
+ with <em>very few</em> textual edits
+ <li><span class='highlight'>if an application is upgraded to an entirely new implementation, the SQL statements 
+ can be easily reused in the new environment. This is a major benefit when migrating an application.</span>
+ <li>programmers or database administrators unfamiliar with the details of an 
+ application - or even unfamiliar with Java itself - can easily find, read, 
+ and edit the SQL statements used by the application. Thus, a larger pool of maintainers 
+ can contribute meaningfully to an application.
+ <li>SQL statements are not hard-coded into classes. If desired, SQL statements can be edited easily after 
+ deployment, and these edits are pulled in through a simple restart. (In production, this should be used with
+ care, or even avoided altogether. For example, it might be used to change something minor, such as sort 
+ order of a SELECT. It might also be used in an emergency.)
+</ul>
+
+<h3><a name="NameLocation">File Name And Location</a></h3>
+All files under the <tt>WEB-INF</tt> directory whose name matches the regular 
+expression "<tt>(?:.)*&#092;&#092;.sql</tt>"
+will be treated as <tt>.sql</tt> files, and will be read in upon startup. 
+ 
+<P>Examples of valid <tt>*.sql</tt> file names include :
+<ul>
+<li><tt>statements.sql</tt> 
+<li><tt>config.sql</tt>
+<li><tt>REPORTS.sql</tt>
+<li><tt>pick-lists.sql</tt>
+<li><tt>accounts.payable.sql</tt>
+<li><tt>2005-06-15blah0-_$blah.sql</tt>
+</ul> 
+Any other files under <tt>WEB-INF</tt> will be <em>ignored</em> by this mechanism. Note that  
+a file named <tt>something.SQL</tt> (upper case <tt>.SQL</tt>), will <em>not</em> be 
+read by WEB4J upon startup. (This is a quick way of disabling a given file.)
+
+<P>It is recommended to use more than one <tt>*.sql</tt> file, to allow :
+<ul>
+<li>placing <tt>.sql</tt> files in the same package as the Data Access Object
+ that uses it (<a href="http://www.javapractices.com/Topic205.cjp">package-by-feature</a>)
+<li>eliminating or reducing developer contention for commonly needed files
+<li>splitting up a large file into various parts of more reasonable size
+<li>swapping files that target different databases
+</ul>
+
+<P>If you use one <tt>.sql</tt> file per directory/feature, then it's  recommended to consider using a 
+fixed, conventional name such as <tt>statements.sql</tt>.
+ 
+<h3><a name="StartupProcessing">Startup Processing</a> Of <tt>.sql</tt> Files</h3>
+Upon startup, WEB4J will read in all <tt>.sql</tt> files under the WEB-INF directory, and will 
+match entries to {@link hirondelle.web4j.database.SqlId} objects - <a href="SqlId.html#StartupChecks">more info</a>.
+
+<P>In addition, WEB4J can perform a 'test precompile' of each SQL statement, to see if it 
+is syntactically correct. (This is not supported by all databases/drivers.) There is a 
+setting in <tt>web.xml</tt> called <tt>IsSQLPrecompilationAttempted</tt> which 
+turns this behavior on and off.
+
+<h3><a name="Portability">Portability</a></h3>
+To maximize portability, SQL statements should be validated using a tool such as 
+the Mimer SQL-92
+<a href="http://developer.mimer.se/validator/parser92/index.tml#parser">validator</a>.
+
+<h3><a name="PassingParameters">Passing Parameters</a></h3>
+There are constraints on how an application passes parameters to SQL statements.
+They are listed in <a href="Db.html#Parameters">Db</a>.
+
+<h3><a name="DetailedSyntax">Detailed Syntax For .sql Files</a></h3>
+Here is an example of the syntax expected by WEB4J for <tt>.sql</tt> files.
+<PRE>
+-- This is a comment 
+ ADD_MESSAGE   {
+ INSERT INTO MyMessage -- another comment
+  (LoginName, Body, CreationDate)
+  -- another comment
+  VALUES (?,?,?)
+ }
+
+-- Any number of 'constants' blocks can be defined, anywhere 
+-- in the file. Such constants must be defined before being
+-- referenced later in a SQL statement, however.
+constants {
+  num_messages_to_view = 5
+}
+
+-- Example of referring to a constant defined above.
+FETCH_RECENT_MESSAGES {
+ SELECT 
+ LoginName, Body, CreationDate 
+ FROM MyMessage 
+ ORDER BY Id DESC LIMIT ${num_messages_to_view}
+}
+
+FETCH_NUM_MSGS_FOR_USER {
+ SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+}
+
+DELETE_USER_MESSAGES {
+ DELETE FROM MyMessage WHERE LoginName=?
+}
+
+constants {
+  -- Each constant must appear on a *single line* only, which
+  -- is not the best for long SQL statements.
+  -- Typically, if a previously defined *sub-query* is needed, 
+  -- then a SQL statment may simply refer directly to that previously 
+  -- defined SQL statement. 
+  base_query = SELECT Id, LoginName, Body, CreationDate FROM MyMessage 
+}
+
+BROWSE_MESSAGES {
+ ${base_query}
+ ORDER BY 1
+}
+
+-- Some simple stored procedures may be referenced as well
+UPDATE_NUM_NEURONS_STORED_PROC {
+ {call update_num_neurons}
+}
+</PRE>
+
+<P>
+To describe the syntax more precisely, the following terminology is used here :
+<ul>
+ <li>Block - a multiline block of text with within braces, with an associated 
+ identifier (similar to a typical Java block) 
+ <li>Block Name - the identifier of a block, appearing on the first line, before the 
+ opening brace
+ <li>Block Body - the text appearing between the opening and closing braces. 
+</ul>
+
+<P>The format details are as follows :
+<ul>
+  <li>empty lines can appear only outside of blocks
+  <li>the '<tt>--</tt>' character denotes a comment 
+  <li>there are no multiline comments. (Such comments are easier for the writer, 
+  but are much less clear for the reader.) 
+  <li>the body of each item is placed in a named block, bounded by 
+   '<tt>&lt;name&gt; {</tt>' on an initial line, and '<tt>}</tt>' on an end line.
+    The name given to the block (<tt>ADD_MESSAGE</tt> for example) is how 
+    <tt>WEB4J</tt> identifies each block.  The Block Name must correspond 
+    to a <tt>public static final SqlId</tt> field. Upon startup, this allows verification 
+    that all items defined in the text source have a corresponding item in code.
+   <li>'<tt>--</tt>' comments can appear in the Block Body as well
+   <li>if desired, the Block Body may be indented, to make the Block more legible
+   <li>the Block Name of '<tt>constants</tt>' is reserved. A <tt>constants</tt> 
+     block defines one or more simple textual substitution constants, as 
+    <tt>name = value</tt> pairs, one per line, that may be 
+    referenced later on in the file (see example). They are defined only to allow 
+    such substitution to occur <em>later</em> in the file. Any number of <tt>constants</tt> 
+    blocks can appear in a file. 
+   <li>Block Names and the names of constants satisfy 
+    {@link hirondelle.web4j.database.SqlId#FORMAT}. 
+   <li>inside a Block Body, substitutions are denoted by the common 
+     syntax '<tt>${blah}</tt>', where <tt>blah</tt> refers to an item appearing 
+     earlier in the file, either <em>the content 
+     of a previously defined Block</em>, or the value of a constant defined in a 
+     <tt>constants</tt> Block 
+   <li>an item must be defined before it can be used in a substitution ; that is, 
+    it must appear <em>earlier</em> in the file
+   <li>no substitutions are permitted in a <tt>constants</tt> Block
+   <li>Block Names, and the names of constants, should be unique.
+</ul>
+
+<h3><a name="StoredProcedures">Stored Procedures</a></h3>
+For calling stored procedures in general, please see {@link hirondelle.web4j.database.StoredProcedureTemplate}.
+
+<P><em>If the stored procedure is particularly simple in nature</em>, it may also be referenced in an 
+<tt>.sql</tt> file. In short, if the stored procedure can be treated as a {@link java.sql.PreparedStatement}, 
+without using methods specific to {@link java.sql.CallableStatement}, then it may appear in an <tt>.sql</tt> file, 
+and be treated by WEB4J as any other SQL statement.
+ 
+<P>More specifically, <em>such stored procedures have no 
+ <tt>OUT</tt> parameters of any kind, either <tt>OUT</tt>, <tt>INOUT</tt>, or an 
+ explicit return value</em> (which must be registered as an <tt>OUT</tt>). On the 
+ other hand, they <em>must have a single implicit return value</em>. Here, 'implicit 
+ return value' refers to the return values of <tt>executeQuery</tt> and 
+ <tt>executeUpdate</tt> (either a <tt>ResultSet</tt> or an <tt>int</tt> count, 
+ respectively). 
+ 
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/AppException.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,76 @@
+package hirondelle.web4j.model;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ Base class for most exceptions defined by WEB4J.  
+
+ <P>Differs from most exception classes in that multiple error 
+ messages may be used, instead of just one. Used in JSPs to inform the user of 
+ error conditions, usually related to user input validations.
+ 
+ <P>This class is {@link Serializable}, since all {@link Throwable}s are serializable.
+*/
+public class AppException extends Exception implements MessageList { 
+  
+  /** No-argument constructor. */
+  public AppException(){
+    super();
+  }
+  
+  /**
+   Constructor.
+    
+   @param aMessage text describing the problem. Must have content.
+   @param aThrowable root cause underlying the problem.
+  */
+  public AppException(String aMessage, Throwable aThrowable){
+    super(aMessage, aThrowable);
+    add(aMessage);
+    //using instanceof is distasteful, but overloading constructors,
+    //that is, defining a second ctor(String, AppException), does not work
+    if ( aThrowable instanceof AppException ) {
+      add( (AppException)aThrowable );
+    }
+  }
+
+  public final void add(String aErrorMessage){
+    fErrorMessages.add(aErrorMessage);
+  }
+
+  public final void add(String aErrorMessage, Object... aParams){
+    fErrorMessages.add(aErrorMessage, aParams);
+  }
+  
+  public final void add(AppException ex){
+    fErrorMessages.add(ex);
+  }
+  
+  public boolean isEmpty(){
+    return fErrorMessages.isEmpty();
+  }
+  
+  public final boolean isNotEmpty(){
+    return !fErrorMessages.isEmpty();
+  }
+  
+  public final List<AppResponseMessage> getMessages(){
+    return fErrorMessages.getMessages();
+  }
+  
+  /** Intended for debugging only.  */
+  @Override public String toString(){
+    return fErrorMessages.toString();
+  }
+  
+  // PRIVATE 
+  
+  /**
+   List of error messages attached to this exception.
+   <P>Implementation Note :
+   This class is a wrapper of MessageListImpl, and simply forwards
+   related method calls to this field. This avoids code repetition.
+  */
+  private MessageList fErrorMessages = new MessageListImpl();
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/AppResponseMessage.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,287 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.ui.translate.Translator;
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Util;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ Informative message presented to the end user.
+   
+ <P>This class exists in order to hide the difference between <em>simple</em> and 
+ <em>compound</em> messages. 
+ 
+ <P><a name="SimpleMessage"></a><b>Simple Messages</b><br>
+ Simple messages are a single {@link String}, such as <tt>'Item deleted successfully.'</tt>. 
+ They are created using {@link #forSimple(String)}.
+ 
+ <P><a name="CompoundMessage"></a><b>Compound Messages</b><br>
+ Compound messages are made up of several parts, and have parameters. They are created 
+ using {@link #forCompound(String, Object...)}. A compound message
+ is usually implemented in Java using {@link java.text.MessageFormat}. <span class="highlight">
+ However, <tt>MessageFormat</tt> is not used by this class, to avoid the following issues </span>:
+<ul>
+ <li> the dreaded apostrophe problem. In <tt>MessageFormat</tt>, the apostrophe is a special 
+ character, and must be escaped. This is highly unnatural for translators, and has been a 
+ source of continual, bothersome errors. (This is the principal reason for not 
+ using <tt>MessageFormat</tt>.)
+ <li>the <tt>{0}</tt> placeholders start at <tt>0</tt>, not <tt>1</tt>. Again, this is 
+ unnatural for translators.
+ <li>the number of parameters cannot exceed <tt>10</tt>. (Granted, it is not often 
+ that a large number of parameters are needed, but there is no reason why this 
+ restriction should exist.)
+ <li>in general, {@link MessageFormat} is rather complicated in its details.
+</ul> 
+ 
+ <P><a name="CustomFormat"></a><b>Format of Compound Messages</b><br>
+ This class defines an alternative format to that defined by {@link java.text.MessageFormat}. 
+ For example,  
+ <PRE>
+  "At this restaurant, the _1_ meal costs _2_."
+  "On _2_, I am going to Manon's place to see _1_."
+ </PRE>
+ Here, 
+<ul>
+ <li>the placeholders appear as <tt>_1_</tt>, <tt>_2_</tt>, and so on. 
+ They start at <tt>1</tt>, not <tt>0</tt>, and have no upper limit. There is no escaping 
+ mechanism to allow the placeholder text to appear in the message 'as is'. The <tt>_i_</tt>
+ placeholders stand for an <tt>Object</tt>, and carry no format information.
+ <li>apostrophes can appear anywhere, and do not need to be escaped.
+ <li>the formats applied to the various parameters are taken from {@link Formats}. 
+ If the default formatting applied by {@link Formats} is not desired, then the caller 
+ can always manually format the parameter as a {@link String}. (The {@link Translator} may be used when 
+ a different pattern is needed for different Locales.)
+ <li>the number of parameters passed at runtime must match exactly the number of <tt>_i_</tt> 
+ placeholders
+</ul>
+ 
+ <P><b>Multilingual Applications</b><br>
+ Multilingual applications will need to ensure that messages can be successfully translated when 
+ presented in JSPs. In particular, some care must be exercised to <em>not</em> create 
+ a <em>simple</em> message out of various pieces of data when a <em>compound</em> message  
+ should be used instead. See {@link #getMessage(Locale, TimeZone)}.
+ As well, see the <a href="../ui/translate/package-summary.html">hirondelle.web4j.ui.translate</a> 
+ package for more information, in particular the 
+ {@link hirondelle.web4j.ui.translate.Messages} tag used for rendering <tt>AppResponseMessage</tt>s, 
+ even in single language applications.
+ 
+  <P><b>Serialization</b><br>
+  This class implements {@link Serializable} to allow messages stored in session scope to 
+  be transferred over a network, and thus survive a failover operation. 
+  <i>However, this class's implementation of Serializable interface has a minor defect.</i>  
+  This class accepts <tt>Object</tt>s as parameters to messages. These objects almost always represent 
+  data - String, Integer, Id, DateTime, and so on, and all such building block classes are Serializable. 
+  If, however, the caller passes an unusual message parameter object which is not Serializable, then the 
+  serialization of this object (if it occurs), will fail. 
+  
+  <P>The above defect will likely not be fixed since it has large ripple effects, and would seem to cause 
+  more problems than it would solve. In retrospect, this the message parameters passed to 
+  {@link #forCompound(String, Object[])} should likely have been typed as Serializable, not Object. 
+*/
+public final class AppResponseMessage implements Serializable {
+
+  /**
+   <a href="#SimpleMessage">Simple message</a> having no parameters.
+   <tt>aSimpleText</tt> must have content.
+  */
+  public static AppResponseMessage forSimple(String aSimpleText){
+    return new AppResponseMessage(aSimpleText, NO_PARAMS);
+  }
+
+  /**
+   <a href="#CompoundMessage">Compound message</a> having parameters.
+   
+   <P><tt>aPattern</tt> follows the <a href="#CustomFormat">custom format</a> defined by this class.
+   {@link Formats#objectToTextForReport} will be used to format all parameters.
+    
+   @param aPattern must be in the style of the <a href="#CustomFormat">custom format</a>, and 
+   the number of placeholders must match the number of items in <tt>aParams</tt>. 
+   @param aParams must have at least one member; all members must be non-null, but may be empty 
+   {@link String}s.
+  */
+  public static AppResponseMessage forCompound(String aPattern, Object... aParams){
+    if ( aParams.length < 1 ){
+      throw new IllegalArgumentException("Compound messages must have at least one parameter.");
+    }
+    return new AppResponseMessage(aPattern, aParams);
+  }
+  
+  /**
+   Return either the 'simple text' or the <em>formatted</em> pattern with all parameter data rendered, 
+   according to which factory method was called. 
+   
+   <P>The configured {@link Translator} is used to localize 
+   <ul>
+   <li>the text passed to {@link #forSimple(String)} 
+   <li>the pattern passed to {@link #forCompound(String, Object...)}
+   <li>any {@link hirondelle.web4j.request.RequestParameter} parameters passed to {@link #forCompound(String, Object...)}
+   are localized by using {@link Translator} on the return value of {@link RequestParameter#getName()} 
+   (This is intended for displaying localized versions of control names.) 
+   </ul>
+    
+   <P>It is highly recommended that this method be called <em>late</em> in processing, in a JSP.
+   
+   <P>The <tt>Locale</tt> should almost always come from 
+   {@link hirondelle.web4j.BuildImpl#forLocaleSource()}.
+   The <tt>aLocale</tt> parameter is always required, even though there are cases when it 
+   is not actually used to render the result. 
+  */
+  public String getMessage(Locale aLocale, TimeZone aTimeZone){
+    String result = null;
+    Translator translator = BuildImpl.forTranslator();
+    Formats formats = new Formats(aLocale, aTimeZone);
+    if( fParams.isEmpty() ){
+      result = translator.get(fText, aLocale);
+    }
+    else {
+      String localizedPattern = translator.get(fText, aLocale);
+      List<String> formattedParams = new ArrayList<String>();
+      for (Object param : fParams){
+        if ( param instanceof RequestParameter ){
+          RequestParameter reqParam = (RequestParameter)param;
+          String translatedParamName = translator.get(reqParam.getName(), aLocale); 
+          formattedParams.add( translatedParamName ); 
+        }
+        else {
+          //this will escape any special HTML chars in params :
+          formattedParams.add( formats.objectToTextForReport(param).toString() );
+        }
+      }
+      result = populateParamsIntoCustomFormat(localizedPattern, formattedParams);
+    }
+    return result;
+  }
+  
+  /**
+   Return an unmodifiable <tt>List</tt> corresponding to the <tt>aParams</tt> passed to 
+   the constructor.
+   
+   <P>If no parameters are being used, then return an empty list.
+  */
+  public List<Object> getParams(){
+    return Collections.unmodifiableList(fParams);  
+  }
+
+  /**
+   Return either the 'simple text' or the pattern, according to which factory method 
+   was called. Typically, this method is <em>not</em> used to present text to the user (see {@link #getMessage}). 
+  */
+  @Override public String toString(){
+    return fText;
+  }
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      AppResponseMessage that = (AppResponseMessage) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;    
+  }
+  
+  @Override public int hashCode(){
+    return ModelUtil.hashCodeFor(getSignificantFields());
+  }
+  
+  // PRIVATE 
+  
+  /** Holds either the simple text, or the custom pattern.  */
+  private final String fText;
+  
+  /** List of Objects holds the parameters. Empty List if no parameters used.  */
+  private final List<Object> fParams;
+  
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("_(\\d)+_");
+  private static final Object[] NO_PARAMS = new Object[0];
+  private static final Logger fLogger = Util.getLogger(AppResponseMessage.class);
+  
+  private static final long serialVersionUID = 1000L;
+  
+  private AppResponseMessage(String aText, Object... aParams){
+    fText = aText;
+    fParams = Arrays.asList(aParams);
+    validateState();
+  }
+  
+  private void validateState(){
+    Args.checkForContent(fText);
+    if (fParams != null && fParams.size() > 0){
+      for(Object item : fParams){
+        if ( item == null ){
+          throw new IllegalArgumentException("Parameters to compound messages must be non-null.");
+        }
+      }
+    }
+  }
+
+  /**
+   @param aFormattedParams contains Strings ready to be placed in to the pattern. The index <tt>i</tt> of the 
+   List matches the <tt>_i_</tt> placeholder. The size of aFormattedParams must match the number of 
+   placeholders.
+  */
+  private String populateParamsIntoCustomFormat(String aPattern, List<String> aFormattedParams){
+    StringBuffer result = new StringBuffer();
+    fLogger.finest("Populating " + Util.quote(aPattern) + " with params " + Util.logOnePerLine(aFormattedParams));
+    Matcher matcher = PLACEHOLDER_PATTERN.matcher(aPattern);
+    int numMatches = 0;
+    while ( matcher.find() ) {
+      ++numMatches;
+      if(numMatches > aFormattedParams.size()){
+        String message = "The number of placeholders exceeds the number of available parameters (" + aFormattedParams.size() + ")";
+        fLogger.severe(message);
+        throw new IllegalArgumentException(message);
+      }
+      matcher.appendReplacement(result, getReplacement(matcher, aFormattedParams));
+    }
+    if(numMatches < aFormattedParams.size()){
+      String message = "The number of placeholders (" + numMatches + ") is less than the number of available parameters (" + aFormattedParams.size() + ")";
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+    matcher.appendTail(result);
+    return result.toString();    
+  }
+  
+  private String getReplacement(Matcher aMatcher, List<String> aFormattedParams){
+    String result = null;
+    String digit = aMatcher.group(1);
+    int idx = Integer.parseInt(digit);
+    if(idx <= 0){
+      throw new IllegalArgumentException("Placeholder digit should be 1,2,3... but takes value " + idx);
+    }
+    if(idx > aFormattedParams.size()){
+      throw new IllegalArgumentException("Placeholder index for _" + idx + "_ exceeds the number of available parameters (" + aFormattedParams.size() + ")");
+    }
+    result = aFormattedParams.get(idx - 1);
+    return EscapeChars.forReplacementString(result);
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[] {fText, fParams};
+  }
+  
+   /**
+    Always treat de-serialization as a full-blown constructor, by validating the final state of the deserialized object.
+   */
+   private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
+     aInputStream.defaultReadObject();
+     validateState();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/BadRequestException.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,69 @@
+package hirondelle.web4j.model;
+
+/**
+ Thrown by {@link hirondelle.web4j.security.ApplicationFirewall} 
+ when a problem with an incoming HTTP request is detected.
+ 
+ <P><span class="highlight">This class is intended only for bugs and malicious attacks. 
+ It is not intended for normal business logic.</span> If a <tt>BadRequestException</tt> is 
+ thrown by {@link hirondelle.web4j.security.ApplicationFirewall}, then the 
+ {@link hirondelle.web4j.Controller} will reply with a short, unpolished 
+ response (often a default page defined by the server). Under normal operating conditions, 
+ the end user should not see such a response.
+ 
+ <P>See {@link hirondelle.web4j.security.ApplicationFirewall} for more information.
+ 
+ <P><em>Design Note</em>
+ <br>This class is not an {@link AppException}, since it is meant to encapsulate only a 
+ single item at a time.
+*/
+public final class BadRequestException extends Exception {
+
+  /**
+   Construct using a standard HTTP status code.
+   
+   <P>The caller is highly encouraged to pass a field defined in  
+   {@link javax.servlet.http.HttpServletResponse} to this constructor 
+   ({@link javax.servlet.http.HttpServletResponse#SC_NOT_IMPLEMENTED}, and so on).
+   
+   <P>See <a href="http://www.w3.org/Protocols/rfc2616/rfc2616.html">W3C HTTP Specification</a> 
+   for more information on status codes.
+  */
+  public BadRequestException(int aHTTPStatusCode){
+    fStatusCode = aHTTPStatusCode;
+    fErrorMessage = null;
+  }
+  
+  /**
+   Construct using a standard HTTP status code and an error message to be presented to the 
+   user.
+   
+   See {@link #BadRequestException(int)} for more information.
+  */
+  public BadRequestException(int aHTTPStatusCode, String aErrorMessage){
+    fStatusCode = aHTTPStatusCode;
+    fErrorMessage = aErrorMessage;
+  }
+  
+  /** Return the status code passed to the constructor.  */
+  public int getStatusCode(){
+    return fStatusCode;
+  }
+  
+  /**
+   Return the error message passed to the constructor. If constructed without a message, then 
+   return <tt>null</tt>.
+  */
+  public String getErrorMessage(){
+    return fErrorMessage;
+  }
+  
+  /** Intended for debugging only.  */
+  @Override public String toString(){
+    return ToStringUtil.getText(this);
+  }
+  
+  //PRIVATE //
+  private final int fStatusCode;
+  private final String fErrorMessage;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/Check.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,682 @@
+package hirondelle.web4j.model;                    
+   
+import java.util.*;
+import java.util.logging.Logger;
+import java.util.regex.*;
+import java.math.BigDecimal;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.security.SpamDetector;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+import static hirondelle.web4j.util.Consts.PASSES;
+import static hirondelle.web4j.util.Consts.FAILS;
+import hirondelle.web4j.util.Args;
+
+/**
+ <span class="highlight">Returns commonly needed {@link Validator} objects.</span>
+ 
+ <P>In general, the number of possible validations is <em>very</em> large. It is not appropriate 
+ for a framework to attempt to implement <em>all</em> possible validations. Rather, a framework should 
+ provide the most common validations, and allow the application programmer to extend 
+ the validation mechanism as needed.
+ 
+ <P>Validations are important parts of your program's logic. 
+ Using tools such as JUnit to test your validation code is highly recommended. 
+ Since your Model Objects have no dependencies on heavyweight objects, they're almost always easy to test.
+  
+ <P>If a specific validation is not provided here, other options include : 
+<ul>
+ <li>performing the validation directly in the Model Object, without using <tt>Check</tt> or 
+ a {@link Validator}
+ <li>create a new {@link Validator}, and pass it to either {@link #required(Object, Validator...)}
+ or {@link #optional(Object, Validator...)}. This option is especially attractive when it will eliminate 
+ code repetition.  
+ <li>subclassing this class, and adding new <tt>static</tt> methods
+ </ul>
+ 
+ <P>The {@link #range(long, long)}, {@link #min(long)} and {@link #max(long)} methods return {@link Validator}s 
+ that perform checks on a <tt>long</tt> value. <span class='highlight'>The <em>source</em> of the <tt>long</tt> value varies 
+ according to the type of <tt>Object</tt> passed to the {@link Validator}</span>, and is taken as follows 
+ (<tt>int</tt> is internally converted to <tt>long</tt> when necessary) : 
+ <ul>
+ <li>{@link Integer#intValue()}
+ <li>{@link Long#longValue()} 
+ <li>length of a trimmed {@link String} having content; the same is applied to {@link Id#toString()},
+ {@link Code#getText()}, and {@link SafeText#getRawString()}.
+ <li>{@link Collection#size()}
+ <li>{@link Map#size()}
+ <li>{@link Date#getTime()} - underlying millisecond value 
+ <li>{@link Calendar#getTimeInMillis()} - underlying millisecond value 
+ <li>any other class will cause an exception to be thrown by {@link #min(long)} and {@link #max(long)} 
+ </ul>
+
+ <P>The {@link #required(Object)}, {@link #required(Object, Validator...)} and {@link #optional(Object, Validator...)} 
+ methods are important, and are separated out as distinct validations. <span class='highlight'>In addition, the 
+ required/optional character of a field is always the <em>first</em> validation performed</span> (see examples below).
+
+ <P><span class="highlight">In general, it is highly recommended that applications 
+ aggressively perform all possible validations.</span>  
+ 
+ <P> Note that when validation is performed in a Model Object, then it will apply both to objects created from 
+ user input, and to objects created from a database <tt>ResultSet</tt>. 
+ 
+ <P><b>Example 1</b><br>
+ Example of a required field in a Model Object (that is, the field is of any type, and 
+ must be non-<tt>null</tt>) :
+ <PRE>
+if ( ! Check.required(fStartDate) ) {
+  ex.add("Start Date is Required.");
+}
+ </PRE>
+
+  
+ <P><b>Example 2</b><br>
+ Example of a required <em>text</em> field, which must have visible content 
+ (as in {@link Util#textHasContent(String)}) :
+ <PRE>
+if ( ! Check.required(fTitle) ) {
+  ex.add("Title is required, and must have content.");
+}
+ </PRE>
+  
+ <P><b>Example 3</b><br>
+ Example of a required text field, whose length must be in the range <tt>2..50</tt> :
+ <PRE>
+if ( ! Check.required(fTitle, Check.range(2,50)) ) {
+  ex.add("Title is required, and must have between 2 and 50 characters.");
+}
+ </PRE>
+ 
+ <P><b>Example 4</b><br>
+ Example of an optional <tt>String</tt> field that matches the format '<tt>1234-5678</tt>' :
+ <PRE>
+//compile the pattern once, when the class is loaded
+private static final Pattern fID_PATTERN = Pattern.compile("(\\d){4}-(\\d){4}");
+...
+if ( ! Check.optional(fSomeId, Check.pattern(fID_PATTERN)) ) {
+  ex.add("Id is optional, and must have the form '1234-5678'.");
+}
+ </PRE>
+
+ <P><b>Example 5</b><br>
+ The initial <tt>!</tt> negation operator is easy to forget. Many will prefer a more explicit style, which seems 
+ to be more legible :
+ <PRE>
+import static hirondelle.web4j.util.Consts.FAILS;
+...
+if ( FAILS == Check.required(fStartDate) ) {
+  ex.add("Start Date is Required.");
+}
+ </PRE>
+ 
+ <P>Here is one style for implementing custom validations for your application :
+ <PRE>
+//Checks that a person's age is in the range 0..150
+public static Validator ageRange(){
+  class CheckAge implements Validator {
+    public boolean isValid(Object aObject) {
+      Integer age = (Integer)aObject;
+      return 0 <= age <= 150; 
+    }
+  }
+  return new CheckAge();
+}
+ </PRE>
+*/
+public class Check {
+  
+  /**
+   Return <tt>true</tt> only if <tt>aObject</tt> is non-<tt>null</tt>.
+   
+   <P><em><tt>String</tt> and {@link SafeText} objects are a special case</em> : instead of just 
+   being non-<tt>null</tt>, they must have content according to {@link Util#textHasContent(String)}.
+   
+   @param aObject possibly-null field of a Model Object.
+  */
+  public static boolean required(Object aObject){
+    boolean result = FAILS;
+    if ( isText(aObject) ) {
+       result = Util.textHasContent(getText(aObject));
+    }
+    else { 
+      result = (aObject != null);
+    }
+    return result;
+  }
+
+  /**
+   Return <tt>true</tt> only if <tt>aObject</tt> satisfies {@link #required(Object)}, 
+   <em>and</em> it passes all given validations
+    
+   @param aObject possibly-null field of a Model Object.
+  */ 
+  public static boolean required(Object aObject, Validator... aValidators){
+    boolean result = PASSES;
+    if ( ! required(aObject) ) {
+      result = FAILS;
+    } 
+    else { 
+      result = passesValidations(aObject, aValidators);
+    }
+    return result;
+  }
+  
+  /**
+   Return <tt>true</tt> only if <tt>aObject</tt> is <tt>null</tt>, OR if <tt>aObject</tt> is non-<tt>null</tt> 
+   and passes all validations.
+   
+   <P><em><tt>String</tt> and {@link SafeText} objects are a special case</em> : instead of just 
+   being non-<tt>null</tt>, they must have content according to {@link Util#textHasContent(String)}.
+   
+   @param aObject possibly-null field of a Model Object.
+  */
+  public static boolean optional(Object aObject, Validator... aValidators){
+    boolean result = PASSES;
+    if ( aObject != null ){
+      if( isText(aObject) ) {
+        result = Util.textHasContent(getText(aObject)) && passesValidations(aObject, aValidators);
+      }        
+      else {
+        result = passesValidations(aObject, aValidators);
+      }
+    }
+    return result;
+  }
+  
+  /**
+   Return a {@link Validator} to check that all passed booleans are <tt>true</tt>. 
+   Note that the single parameter is a sequence parameter, so you may pass in many booleans, not just one.
+   <P>This is a bizarre method, but it's actually useful. It allows checks on an object's state to be treated as any other check.   
+  */
+  public static Validator isTrue(Boolean... aPredicates){
+    class AllTrue implements Validator{
+      AllTrue(Boolean... aPreds){
+        fPredicates = aPreds;
+      }
+      public boolean isValid(Object aObject) {
+        //aObject is ignored here
+        boolean result = true;
+        for (Boolean predicate: fPredicates){
+          result = result && predicate;
+        }
+        return result;
+      };
+      private Boolean[] fPredicates;
+    }
+    return new AllTrue(aPredicates);
+  }
+  
+  /**
+   Return a {@link Validator} to check that all passed booleans are <tt>false</tt>. 
+   Note that the single parameter is a sequence parameter, so you may pass in many booleans, not just one.
+   <P>This is a bizarre method, but it's actually useful. It allows checks on an object's state to be treated as any other check.   
+  */
+  public static Validator isFalse(Boolean... aPredicates){
+    class AllFalse implements Validator{
+      AllFalse(Boolean... aPreds){
+        fPredicates = aPreds;
+      }
+      public boolean isValid(Object aObject) {
+        //aObject is ignored here
+        boolean result = true;
+        for (Boolean predicate: fPredicates){
+          result = result && !predicate;
+        }
+        return result;
+      };
+      private Boolean[] fPredicates;
+    }
+    return new AllFalse(aPredicates);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a field's value is greater than or equal to <tt>aMinimumValue</tt>. 
+   See class comment for the kind of objects which may be checked by the returned {@link Validator}.
+  */
+  public static Validator min(final long aMinimumValue){
+    class Minimum implements Validator {
+      Minimum(long aMinValue){
+        fMinValue = aMinValue;
+      }
+      public boolean isValid(Object aObject){
+        long value = getValueAsLong(aObject);
+        return value >= fMinValue;
+      }
+      private final long fMinValue;
+    }
+    return new Minimum(aMinimumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a {@link BigDecimal} field is greater than or equal 
+   to <tt>aMinimumValue</tt>. 
+  */
+  public static Validator min(final BigDecimal aMinimumValue){
+    class Minimum implements Validator {
+      Minimum(BigDecimal aMinValue){
+        fMinValue = aMinValue;
+      }
+      public boolean isValid(Object aObject){
+        BigDecimal value = (BigDecimal)aObject;
+        return value.compareTo(fMinValue) >= 0;
+      }
+      private final BigDecimal fMinValue;
+    }
+    return new Minimum(aMinimumValue);
+  }
+
+  /**
+   Return a {@link Validator} to check that a {@link Decimal} amount is greater than or equal 
+   to <tt>aMinimumValue</tt>. This methods allows comparisons between 
+   <tt>Money</tt> objects of different currency.
+  */
+  public static Validator min(final Decimal aMinimumValue){
+    class Minimum implements Validator {
+      Minimum(Decimal aMinValue){
+        fMinValue = aMinValue;
+      }
+      public boolean isValid(Object aObject){
+        Decimal value = (Decimal)aObject;
+        return value.gteq(fMinValue);
+      }
+      private final Decimal fMinValue;
+    }
+    return new Minimum(aMinimumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a {@link DateTime} is greater than or equal 
+   to <tt>aMinimumValue</tt>. 
+  */
+  public static Validator min(final DateTime aMinimumValue){
+    class Minimum implements Validator {
+      Minimum(DateTime aMinValue){
+        fMinValue = aMinValue;
+      }
+      public boolean isValid(Object aObject){
+        DateTime value = (DateTime)aObject;
+        return value.gteq(fMinValue);
+      }
+      private final DateTime fMinValue;
+    }
+    return new Minimum(aMinimumValue);
+  }
+
+  /**
+   Return a {@link Validator} to check that a field's value is less than or equal to <tt>aMaximumValue</tt>. 
+   See class comment for the kind of objects which may be checked by the returned {@link Validator}.
+  */
+  public static Validator max(final long aMaximumValue){
+    class Maximum implements Validator {
+      Maximum(long aMaxValue){
+        fMaxValue = aMaxValue;
+      }
+      public boolean isValid(Object aObject){
+        long value = getValueAsLong(aObject);
+        return value <= fMaxValue;
+      }
+      private final long fMaxValue;
+    }
+    return new Maximum(aMaximumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a {@link BigDecimal} field is less than or equal 
+   to <tt>aMaximumValue</tt>. 
+  */
+  public static Validator max(final BigDecimal aMaximumValue){
+    class Maximum implements Validator {
+      Maximum(BigDecimal aMaxValue){
+        fMaxValue = aMaxValue;
+      }
+      public boolean isValid(Object aObject){
+        BigDecimal value = (BigDecimal)aObject;
+        return value.compareTo(fMaxValue) <= 0;
+      }
+      private final BigDecimal fMaxValue;
+    }
+    return new Maximum(aMaximumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a {@link Decimal} amount is less than or equal 
+   to <tt>aMaximumValue</tt>. This methods allows comparisons between 
+   <tt>Money</tt> objects of different currency.
+  */
+  public static Validator max(final Decimal aMaximumValue){
+    class Maximum implements Validator {
+      Maximum(Decimal aMaxValue){
+        fMaxValue = aMaxValue;
+      }
+      public boolean isValid(Object aObject){
+        Decimal value = (Decimal)aObject;
+        return value.lteq(fMaxValue);
+      }
+      private final Decimal fMaxValue;
+    }
+    return new Maximum(aMaximumValue);
+  }
+
+  /**
+  Return a {@link Validator} to check that a {@link DateTime} is less than or equal 
+  to <tt>aMaximumValue</tt>. 
+  */
+  public static Validator max(final DateTime aMaximumValue){
+    class Maximum implements Validator {
+      Maximum(DateTime aMaxValue){
+        fMaxValue = aMaxValue;
+      }
+      public boolean isValid(Object aObject){
+        DateTime value = (DateTime)aObject;
+        return value.lteq(fMaxValue);
+      }
+      private final DateTime fMaxValue;
+    }
+    return new Maximum(aMaximumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a field's value is in a certain range (inclusive). 
+   See class comment for the kind of objects which may be checked by the returned {@link Validator}.
+  */
+  public static Validator range(final long aMinimumValue, final long aMaximumValue){
+    class Range implements Validator {
+      Range(long aMin, long aMax){
+        fMinValue = aMin;
+        fMaxValue = aMax;
+      }
+      public boolean isValid(Object aObject){
+        long value = getValueAsLong(aObject);
+        return fMinValue <= value && value <= fMaxValue;
+      }
+      private final long fMinValue;
+      private final long fMaxValue;
+    }
+    return new Range(aMinimumValue, aMaximumValue);
+  }
+
+  /**
+   Return a {@link Validator} to check that a {@link BigDecimal} value is in a certain range (inclusive). 
+  */
+  public static Validator range(final BigDecimal aMinimumValue, final BigDecimal aMaximumValue){
+    class Range implements Validator {
+      Range(BigDecimal aMin, BigDecimal aMax){
+        fMinValue = aMin;
+        fMaxValue = aMax;
+      }
+      public boolean isValid(Object aObject){
+        BigDecimal value = (BigDecimal)aObject;
+        return value.compareTo(fMinValue) >= 0 && value.compareTo(fMaxValue) <= 0;
+      }
+      private final BigDecimal fMinValue;
+      private final BigDecimal fMaxValue;
+    }
+    return new Range(aMinimumValue, aMaximumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that a {@link Decimal} amount is in a certain range (inclusive). 
+   This methods allows comparisons between <tt>Money</tt> objects of different currency.
+  */
+  public static Validator range(final Decimal aMinimumValue, final Decimal aMaximumValue){
+    class Range implements Validator {
+      Range(Decimal aMin, Decimal aMax){
+        fMinValue = aMin;
+        fMaxValue = aMax;
+      }
+      public boolean isValid(Object aObject){
+        Decimal value = (Decimal)aObject;
+        return value.gteq(fMinValue) && value.lteq(fMaxValue);
+      }
+      private final Decimal fMinValue;
+      private final Decimal fMaxValue;
+    }
+    return new Range(aMinimumValue, aMaximumValue);
+  }
+
+  /**
+   Return a {@link Validator} to check that a {@link DateTime} is in a certain range (inclusive). 
+  */
+  public static Validator range(final DateTime aMinimumValue, final DateTime aMaximumValue){
+    class Range implements Validator {
+      Range(DateTime aMin, DateTime aMax){
+        fMinValue = aMin;
+        fMaxValue = aMax;
+      }
+      public boolean isValid(Object aObject){
+        DateTime value = (DateTime)aObject;
+        boolean isInRange = value.gt(fMinValue) && value.lt(fMaxValue);
+        boolean isAtAnEndpoint = value.equals(fMinValue) || value.equals(fMaxValue);
+        return isInRange || isAtAnEndpoint;
+      }
+      private final DateTime fMinValue;
+      private final DateTime fMaxValue;
+    }
+    return new Range(aMinimumValue, aMaximumValue);
+  }
+  
+  /**
+   Return a {@link Validator} to check that the number of decimal places of a {@link Decimal} or 
+   {@link BigDecimal} is <em>less than or equal to</em> <tt>aMaxNumberOfDecimalPlaces</tt>. 
+   
+   @param aMaxNumberOfDecimalPlaces is greater than or equal to <tt>1</tt>.
+  */
+  public static Validator numDecimalsMax(final int aMaxNumberOfDecimalPlaces){
+    Args.checkForPositive(aMaxNumberOfDecimalPlaces);
+    class MaxNumPlaces implements Validator {
+      MaxNumPlaces(int aMaxNumberOfDecimals){
+        Args.checkForPositive(aMaxNumberOfDecimals);
+        fMaxNumPlaces = aMaxNumberOfDecimals;
+      }
+      public boolean isValid(Object aObject){
+        return Util.hasMaxDecimals(extractNumber(aObject), fMaxNumPlaces);
+      }
+      private final int fMaxNumPlaces;
+    }
+    return new MaxNumPlaces(aMaxNumberOfDecimalPlaces);
+  }
+  
+  /**
+   Return a {@link Validator} to check that the number of decimal places of a {@link Decimal} 
+   or {@link BigDecimal} is <em>exactly equal to</em> <tt>aNumDecimals</tt>.
+    
+   @param aNumDecimals is 0 or more.
+  */
+  public static Validator numDecimalsAlways(final int aNumDecimals){
+    class NumPlaces implements Validator {
+      NumPlaces(int aNumberOfDecimalPlaces){
+        fNumPlaces = aNumberOfDecimalPlaces;
+      }
+      public boolean isValid(Object aObject){
+        return Util.hasNumDecimals(extractNumber(aObject), fNumPlaces);
+      }
+      private final int fNumPlaces;
+    }
+    return new NumPlaces(aNumDecimals);
+  }
+  
+  /**
+   Return a {@link Validator} that checks a {@link String} or {@link SafeText} 
+   field versus a regular expression {@link Pattern}.
+   
+   <P>This method might be used to validate a zip code, phone number, and so on - any text which has a 
+   well defined format.
+   
+   <P>There must be a complete match versus the whole text, as in {@link Matcher#matches()}.
+   In addition, the returned {@link Validator} will not trim the text before performing the validation.
+   
+   @param aRegex is a {@link Pattern}, which holds a regular expression. 
+  */
+  public static Validator pattern(final Pattern aRegex){
+    class PatternValidator implements Validator {
+      PatternValidator(Pattern aSomeRegex){
+        fRegex = aSomeRegex;
+      }
+      public boolean isValid(Object aObject) {
+          Matcher matcher = fRegex.matcher(getText(aObject));
+          return matcher.matches();
+      }
+      private final Pattern fRegex;
+    }
+    return new PatternValidator(aRegex);
+  }
+  
+  /**
+   Return a {@link Validator} to verify a {@link String} or {@link SafeText} field is a syntactically 
+   valid email address.
+   
+   <P>See {@link WebUtil#isValidEmailAddress(String)}. The text is not trimmed by the returned 
+   {@link Validator}.
+  */
+  public static Validator email(){
+    class EmailValidator implements Validator {
+      public boolean isValid(Object aObject) {
+        return WebUtil.isValidEmailAddress(getText(aObject));
+      }
+    }
+    return new EmailValidator();
+  }
+  
+  /** 
+    Return a {@link Validator} to check that a {@link String} or {@link SafeText} field is not spam, 
+   according to {@link SpamDetector}.   
+  */
+  public static Validator forSpam(){ 
+    class SpamValidator implements Validator {
+      public boolean isValid(Object aObject) {
+        SpamDetector spamDetector = BuildImpl.forSpamDetector();
+        return !spamDetector.isSpam(getText(aObject));
+      }
+    }
+    return new SpamValidator();
+  }
+  
+  /*
+   Note : forURL() method was attempted, but abandoned. 
+   The JDK implementation of the URL constructor seems very flaky.
+  */
+
+  /**
+   This no-argument constructor is empty. 
+   
+   <P>This constructor exists only because of it has <tt>protected</tt> scope. 
+   Having <tt>protected</tt> scope has two desirable effects:
+   <ul>
+   <li>typical callers cannot create <tt>Check</tt> objects. This is appropriate since this class contains 
+   only static methods.
+   <li>if needed, this class may be subclassed. This is useful when you need to add custom validations.
+   </ul>
+  */
+  protected Check(){
+    //empty - prevent construction by caller, but allow it for subclasses
+  }
+  
+  // PRIVATE //
+
+  private static final Logger fLogger = Util.getLogger(Check.class);
+  
+  private static boolean passesValidations(Object aObject, Validator... aValidators) {
+    boolean result = PASSES;
+    for(Validator validator: aValidators){
+      if ( ! validator.isValid(aObject) ) {
+        result = FAILS;
+        fLogger.fine("Failed a validation.");
+        break;
+      }
+    }
+    return result;
+  }
+
+  private static long getValueAsLong(Object aObject){
+    long result = 0;
+    if ( aObject instanceof Integer){
+      Integer value = (Integer)aObject;
+      result = value.intValue();
+    }
+    else if (aObject instanceof Long) {
+      Long value = (Long)aObject;
+      result = value.longValue();
+    }
+    else if (aObject instanceof String){
+      String text = (String)aObject;
+      if ( Util.textHasContent(text) ) {
+        result = text.trim().length();
+      }
+    }
+    else if (aObject instanceof Id){
+      Id id = (Id)aObject;
+      if ( Util.textHasContent(id.toString()) ) {
+        result = id.toString().trim().length();
+      }
+    }
+    else if (aObject instanceof SafeText){
+      SafeText text = (SafeText)aObject;
+      if ( Util.textHasContent(text.getRawString()) ) {
+        result = text.getRawString().trim().length();
+      }
+    }
+    else if (aObject instanceof Code){
+      Code code = (Code)aObject;
+      if ( Util.textHasContent(code.getText()) ) {
+        result = code.getText().getRawString().trim().length();
+      }
+    }
+    else if (aObject instanceof Collection) {
+      Collection collection = (Collection)aObject;
+      result = collection.size();
+    }
+    else if (aObject instanceof Map) {
+      Map map = (Map)aObject;
+      result = map.size();
+    }
+    else if (aObject instanceof Date) {
+      Date date = (Date)aObject;
+      result = date.getTime(); 
+    }
+    else if (aObject instanceof Calendar){
+      Calendar calendar = (Calendar)aObject;
+      result = calendar.getTimeInMillis();
+    }
+    else {
+      String message = "Unexpected type of Object: " + aObject.getClass().getName();
+      fLogger.severe(message);
+      throw new AssertionError(message);
+    }
+    return result;
+  }
+  
+  private static boolean isText(Object aObject){
+    return (aObject instanceof String) || (aObject instanceof SafeText);
+  }
+  
+  private static String getText(Object aObject){
+    String result = null;
+    if ( aObject instanceof String ){
+      String text = (String) aObject;
+      result = text.toString();
+    }
+    else if (aObject instanceof SafeText ){
+      SafeText text = (SafeText)aObject;
+      result = text.getRawString();
+    }
+    return result;
+  }
+  
+  /** aObject must be a BigDecimal or a Money object.  */
+  private static BigDecimal extractNumber(Object aObject){
+    BigDecimal result = null;
+    if( aObject instanceof BigDecimal){
+      result = (BigDecimal)aObject;
+    }
+    else if (aObject instanceof Decimal) {
+      Decimal decimal = (Decimal)aObject;
+      result = decimal.getAmount();
+    }
+    else {
+      throw new IllegalArgumentException("Unexpected class: " + aObject.getClass());
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/Code.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,230 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.model.Check;
+import hirondelle.web4j.security.SafeText;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+
+/**
+ An item in a code table.
+ 
+ <P>Here, a value in a code table is modeled with one required item (text), and four optional items 
+ (id, short form, long form, and order index). The id is optional, since for 'add' operations it is not yet 
+ specified. This class is offered as a convenience for implementing Code Tables. 
+ Applications are not required to use it. 
+ 
+ <P>Please see the example application for an example of one way of implementing code tables.
+ 
+ <h3>Code Tables</h3>
+ <P>A code table is an informal term describing a set of related values. It resembles  
+ an enumeration. Simple examples :
+<ul>
+ <li>geographical divisions - countries, states or provinces
+ <li>list of accepted credit cards - Mastercard, Visa, and so on
+ <li>the account types offered by a bank - chequing, savings, and so on
+</ul>
+
+ Other important aspects of code tables :
+ <ul>
+ <li>most applications use them.
+ <li>they often appear in the user interface as drop-down <tt>SELECT</tt> controls.
+ <li>they usually don't change very often.
+ <li>they may have a specific sort order, unrelated to alphabetical ordering. 
+ <li>it is almost always desirable that the user-visible text attached to a code be unique (in a given code table), 
+ since the user will usually have no means to distinguish the identical items. 
+ <li>it is often desirable to present the same code in different ways, according to context.
+ For example, report listings may present codes in an abbreviated style, while a tool-tips may present a 
+ codes in a more verbose style, to explain their meaning.
+ <li>they are usually closely related to various foreign keys in the database.
+ </ul>
+ 
+ <h3>Underlying Data</h3> 
+ Code tables may be implemented in various ways, including 
+ <ul>
+ <li>database tables constructed explicitly for that purpose 
+ <li>database tables that have already been constructed to hold problem domain items. That is, 
+ data from an existing table may be extracted in a shortened form, to be used as a code table.
+ <li>simple in-memory data. For example, a <tt>1..10</tt> rating system might use simple <tt>Integer</tt>
+ objects created upon startup. 
+ </ul>
+ 
+ <h3>Avoiding Double-Escaping</h3>
+ This class uses {@link SafeText}, which escapes special characters. 
+ When rendering a <tt>Code</tt> object in a JSP, some care must be taken to ensure that
+ special characters are not mistakenly escaped <em>twice</em>. 
+ 
+ <P>In a single language app, it's usually safe to render a <tt>Code</tt> by simply using <tt>${code}</tt>. This 
+ calls {@link #toString()}, which returns escaped text, safe for direct rendering in a JSP.
+ 
+ <P>In a multilingual app, however, the various translation tags 
+ (<tt>&lt;w:txt&gt;, &lt;w:txtFlow&gt;, &lt;w:tooltip&gt;</tt>) <em>already escape special characters</em>.
+ So, if a translation tag encounters a <tt>Code</tt> somewhere its body, the <tt>Code</tt> must be in 
+ an <em>unescaped</em> form, otherwise it wil be escaped <em>twice</em>, which undesirable.  
+ <span class='highlight'>In a multilingual app, you should usually render a <tt>Code</tt> using <tt>${code.text.rawString}</tt>.</span> 
+ 
+ <P>This asymmetry between single-language and many-language apps is somewhat displeasing, and 
+ constitutes a pitfall of using this class. If desired, you could define an alternate <tt>Code</tt> class 
+ whose <tt>toString</tt> returns a <tt>String</tt> instead of {@link SafeText}. 
+*/
+public final class Code implements Serializable {
+
+  /**
+   Full constructor.
+   
+   @param aId underlying database identifier for the code value. Optional, 1..50 characters.
+   @param aText default text for presentation to the end user. Required, 1..50 characters.
+   @param aShortText short form for text presented to the end user. This item is useful for terse 
+   presentation in reports, often as an abbreviation of one or two letters. Optional, 1..10 characters.
+   @param aLongText long form for text presented to the user. For example, this may be a description 
+   or definition of the meaning of the code. Optional, 1..200 characters.
+   @param aOrderIdx defines the order of appearance of this item in a listing. Optional, range 1..1000.
+   This item is used to provide explicit control over the order of appearance of items as presented to 
+   the user, and will often be related to an <tt>ORDER BY</tt> clause in an SQL statement.
+  */
+  public Code(Id aId, SafeText aText, SafeText aShortText, SafeText aLongText, Integer aOrderIdx) throws ModelCtorException {
+    fId = aId;
+    fText = aText;
+    fShortText = aShortText;
+    fLongText = aLongText;
+    fOrderIdx = aOrderIdx;
+    validateState();
+  }
+   
+  /** As in the full constructor, but without a short description, long description, or an order index.  */
+  public Code(Id aId, SafeText aText) throws ModelCtorException {
+    fId = aId;
+    fText = aText;
+    fShortText = null;
+    fLongText = null;
+    fOrderIdx = null;
+    validateState();
+  }
+  
+  /** As in the full constructor, but without a long description or order index.  */
+  public Code(Id aId, SafeText aText, SafeText aShortText) throws ModelCtorException {
+    fId = aId;
+    fText = aText;
+    fShortText = aShortText;
+    fLongText = null;
+    fOrderIdx = null;
+    validateState();
+  }
+  
+  /** As in the full constructor, but without an order index.  */
+  public Code(Id aId, SafeText aText, SafeText aShortText, SafeText aLongText) throws ModelCtorException {
+    fId = aId;
+    fText = aText;
+    fShortText = aShortText;
+    fLongText = aLongText;
+    fOrderIdx = null;
+    validateState();
+  }
+  
+  /** Return the Id passed to the constructor. */
+  public Id getId() { return fId;  }
+  /** Return the Text passed to the constructor. */
+  public SafeText getText() {  return fText;  }
+  /** Return the Short Text passed to the constructor. */
+  public SafeText getShortText() {  return fShortText;  }
+  /** Return the Long Text passed to the constructor. */
+  public SafeText getLongText() {  return fLongText;  }
+  /** Return the Order Index passed to the constructor. */
+  public Integer getOrderIdx() {  return fOrderIdx; }
+  
+  /** 
+   Returns {@link #getText()}.toString().
+   
+   <P>This is the most user-friendly form of a code, and is useful for rendering in JSPs.
+  */
+  @Override public String toString(){
+    return fText.toString();
+  }
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      Code that = (Code) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;    
+  }
+  
+  @Override public int hashCode() {
+    if ( fHashCode == 0 ) {
+      fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
+    }
+    return fHashCode;
+  }
+  
+  // PRIVATE 
+  
+  /** @serial */
+  private final Id fId;
+  /** @serial */
+  private final SafeText fText;
+  /** @serial */
+  private final SafeText fShortText;
+  /** @serial */
+  private final SafeText fLongText;
+  /** @serial */
+  private final Integer fOrderIdx;
+  /** @serial */
+  private int fHashCode;
+  
+  /**
+   For evolution of this class, see Sun guidelines : 
+   http://java.sun.com/j2se/1.5.0/docs/guide/serialization/spec/version.html#6678 
+  */
+  private static final long serialVersionUID = 8856876119383545215L;
+
+  private Object[] getSignificantFields(){
+    return new Object[] {fId, fText, fShortText, fLongText, fOrderIdx};
+  }
+  
+  private void validateState() throws ModelCtorException {
+    ModelCtorException ex = new ModelCtorException();
+    if ( ! Check.optional(fId, Check.min(1), Check.max(50))) {
+      ex.add("Code Id is optional, 1..50 chars.");
+    }
+    if ( ! Check.required(fText, Check.range(1,50))) {
+      ex.add("Text is required, 1..50 chars.");
+    }
+    if ( ! Check.optional(fShortText, Check.range(1,10))) {
+      ex.add("Short Text is optional, 1..10 chars.");
+    }
+    if ( ! Check.optional(fLongText, Check.range(1,200))) {
+      ex.add("Long Text is optional, 1..200 chars.");
+    }
+    if ( ! Check.optional(fOrderIdx, Check.range(1,1000))) {
+      ex.add("Order Idx is optional, 1..1000.");
+    }
+    if ( ! ex.isEmpty() ) throw ex;
+  }
+
+  /**
+   Always treat de-serialization as a full-blown constructor, by
+   validating the final state of the de-serialized object.
+  */
+  private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException, ModelCtorException {
+    //always perform the default de-serialization first
+    aInputStream.defaultReadObject();
+    //make defensive copy of mutable fields (none here)
+    //ensure that object state has not been corrupted or tampered with maliciously
+    validateState();
+ }
+
+  /**
+   This is the default implementation of writeObject.
+   Customise if necessary.
+  */
+  private void writeObject(ObjectOutputStream aOutputStream) throws IOException {
+    //perform the default serialization for all non-transient, non-static fields
+    aOutputStream.defaultWriteObject();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ConvertParam.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,83 @@
+package hirondelle.web4j.model;
+
+import java.util.Locale;
+import java.util.TimeZone;
+
+import hirondelle.web4j.model.ModelCtorException;
+
+/**
+ Convert request parameters into common 'building block' objects.
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forConvertParam()} 
+ returns the configured implementation of this interface.
+ 
+<h3> <a name="BuildingBlock"></a>Building Block Classes</h3>
+ Here, a <em>building block</em> class is one of the 'base' objects from which Model
+ Objects can in turn be built - <tt>Integer</tt>, <tt>BigDecimal</tt>, <tt>Date</tt>,
+ and so on. {@link #isSupported(Class)} defines which classes are supported as 
+ building block classes, and {@link #convert(String, Class, Locale, TimeZone)} defines 
+ how to construct them from request parameters.
+ 
+ <P>{@link ConvertParamImpl} is provided as a default implementation, and is likely
+ suitable for many applications.
+ 
+ <P>
+ In addition, implementations may optionally choose to apply <em>universal pre-processing</em> 
+ for Strings. For example, an implementation may decide to trim all Strings, or to force capitalization for 
+ all Strings. Such policies may be centralized here, by including them in the implementation of 
+ {@link #filter(String)} or {@link #convert(String, Class, Locale, TimeZone)}.
+*/
+public interface ConvertParam {
+
+  /**
+   Determine if <tt>aTargetClass</tt> is a supported <a href="#BuildingBlock">building block</a> class.
+   
+   <P>If <tt>aTargetClass</tt> is supported, then an underlying request parameter can be converted into 
+   an object of that class.
+   
+   <P>The framework will always call this method first, before calling the other methods in
+   this interface. 
+  */
+  boolean isSupported(Class<?> aTargetClass);
+
+  /**
+   Apply a policy for parameter values which are <tt>null</tt>, empty, or equal to
+   <tt>IgnorableParamValue</tt> in <tt>web.xml</tt>.
+   <P>
+   When a 'blank' form is submitted, items are not treated in a
+   uniform manner. For example, a popular browser exhibits this behavior :
+   <ul>
+   <li>blank text area : param submitted, empty <tt>String</tt>
+   <li>radio button, with none selected : no param submitted (<tt>null</tt>)
+   <li>checkbox, unselected : no param submitted (<tt>null</tt>)
+   <li>drop-down selection, with first pre-selected by browser : param submitted, with value
+   </ul>
+   <P>
+   Moreover, the W3C <a href=http://www.w3.org/TR/html4/interact/forms.html#successful-controls>spec</a> seems
+   to allow for some ambiguity in what exactly is posted, so the above behavior may not be
+   seen for all browsers.
+   
+   <P>This method is used to impose uniformity upon all such 'blank' items.
+   
+   <P><span class="highlight">This method can return <tt>null</tt>. Any non-<tt>null</tt> 
+   values returned by this method must have content.</span> (That is, an implementation cannot return a 
+   <tt>String</tt> which, when trimmed, is empty.)
+    
+   @param aRawParamValue the raw, unchanged parameter value, as submitted in the underlying request.
+  */
+  String filter(String aRawParamValue);
+
+  /**
+   Convert a request parameter value into a <a href="#BuildingBlock">building block</a> object.
+   <P>
+   The value passed to this method is first <em>filtered</em>, using {@link #filter(String)}.
+   <span class="highlight">Implementations must throw a {@link ModelCtorException} when a parsing problem occurs</span>.
+   <P>
+   @param aFilteredParamValue parameter value as returned by {@link #filter(String)}
+   @param aSupportedTargetClass supported target building block class in a Model Object constructor
+   @param aLocale {@link Locale} returned by the implementation of {@link hirondelle.web4j.request.LocaleSource}
+   @param aTimeZone {@link TimeZone} returned by the implementation of {@link hirondelle.web4j.request.TimeZoneSource}
+  */
+  <T> T convert(String aFilteredParamValue, Class<T> aSupportedTargetClass, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ConvertParamError.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,48 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+
+/**
+ Instructs WEB4J how to respond to any errors found during parsing of raw user input into 
+ 'base' types such as <tt>Integer</tt>, <tt>Date</tt>, and so on.
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forConvertParamError()} 
+ returns the configured implementation of this interface.
+ 
+ <P>On behalf of the application, {@link RequestParser} and related classes will parse 
+ user input into various target types such as <tt>Integer</tt>, <tt>Date</tt> and so on, using 
+ the configured implementation of {@link hirondelle.web4j.model.ConvertParam}.
+ This centralizes such repetitive parsing into the framework, and allows the application programmer to 
+ mostly ignore the possibility of such errors - the <tt>try..catch</tt> block has 
+ already been written.
+ 
+ <P>WEB4J performs such parsing as a <a href="../request/ApplicationFirewall.html#SoftValidation">soft validation</a>.
+ For example, if user input should be an {@link Integer} in the range <tt>0..150</tt>, then 
+ WEB4J will attempt to parse the raw user input (a <tt>String</tt>) first into an 
+ {@link Integer}. If that parse <em>succeeds</em>, 
+ then WEB4J will pass the <tt>Integer</tt> on to an {@link Action}, which will then perform further 
+ "business validation" using a business domain Model Object - that is, it will check that its value is in the range <tt>0..150</tt>. 
+ So, an {@link Action} and its Model Object can almost always assume that an object of the correct 'base' type already exists, 
+ without worrying about low level parsing problems. 
+  
+ <P>However, if user input <em>cannot be parsed</em> by WEB4J into the desired class, then an error message must 
+ be displayed to the user. Implementations of this interface allow an application 
+ programmer to specify the content of that response message.
+   
+ <P>Please see the example application for an example implementation.
+*/
+public interface ConvertParamError {
+  
+  /**
+   Return a {@link ModelCtorException} for a given parsing error, suitable for presentation to the end user.
+   
+   @param aSupportedTargetClass identifies the class into which the user input cannot be successfully parsed
+   @param aUserInputValue value input by the user
+   @param aRequestParameter the request parameter 
+  */
+  public ModelCtorException get(Class<?> aSupportedTargetClass, String aUserInputValue, RequestParameter aRequestParameter);
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ConvertParamImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,327 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.request.DateConverter;
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Util;
+
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+/** Default implementation of {@link ConvertParam}.*/
+public class ConvertParamImpl implements ConvertParam {
+  
+  /**
+   Return <tt>true</tt> only if <tt>aTargetClass</tt> is supported by this implementation. 
+   <P>
+   The following classes are supported by this implementation as building block classes: 
+   <ul>
+   <li><tt>{@link SafeText}</tt>
+   <li><tt>String</tt> (conditionally, see below)
+   <li><tt>Integer</tt>
+   <li><tt>Long</tt>
+   <li><tt>Boolean</tt>
+   <li><tt>BigDecimal</tt>
+   <li><tt>{@link Decimal}</tt>
+   <li><tt>{@link Id}</tt>
+   <li><tt>{@link DateTime}</tt>
+   <li><tt>java.util.Date</tt>
+   <li><tt>Locale</tt> 
+   <li><tt>TimeZone</tt> 
+   <li><tt>InputStream</tt>
+   </ul>
+   
+   <P><i>You are not obliged to use this class to model Locale and TimeZone. 
+   Many will choose to implement them as just another 
+   <a href='http://www.web4j.com/UserGuide.jsp#StartupTasksAndCodeTables'>code table</a>
+    instead.</i> In this case, your model object constructors would usually take an {@link Id} parameter for these 
+    items, and translate them into a {@link Code}. See the example apps for a demonstration of this technique. 
+   
+   <P><b>String is supported only when explicitly allowed.</b> 
+   The <tt>AllowStringAsBuildingBlock</tt> setting in <tt>web.xml</tt>
+   controls whether or not this class allows <tt>String</tt> as a supported class.
+   By default, its value is <tt>FALSE</tt>, since {@link SafeText} is the recommended 
+   replacement for <tt>String</tt>.  
+  */
+  public final boolean isSupported(Class<?> aTargetClass){
+    boolean result = false;
+    if (String.class.equals(aTargetClass)){
+      result = fConfig.getAllowStringAsBuildingBlock();
+    }
+    else {
+      for (Class standardClass : STANDARD_CLASSES){
+        if (standardClass.isAssignableFrom(aTargetClass)){
+          result = true;
+          break;
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   Coerce all parameters with no visible content to <tt>null</tt>.
+   
+   <P>In addition, any raw input value that matches <tt>IgnorableParamValue</tt> in <tt>web.xml</tt> is 
+   also coerced to <tt>null</tt>. See <tt>web.xml</tt> for more information.
+   
+   <P>Any non-<tt>null</tt> result is trimmed. 
+   This method can be overridden, if desired.
+  */
+  public String filter(String aRawInputValue){
+    String result = aRawInputValue;
+    if ( ! Util.textHasContent(aRawInputValue) || aRawInputValue.equals(getIgnorableParamValue()) ){
+      result = null;
+    }
+    return Util.trimPossiblyNull(result); //some apps may elect to trim elsewhere 
+  }
+
+  /**
+   Apply reasonable parsing policies, suitable for most applications.
+   
+   <P>Roughly, the policies are: 
+   <ul>
+   <li><tt>SafeText</tt> uses {@link SafeText#SafeText(String)}
+   <li><tt>String</tt> just return the filtered value as is
+   <li><tt>Integer</tt> uses {@link Integer#Integer(String)}
+   <li><tt>BigDecimal</tt> uses {@link Formats#getDecimalInputFormat()}
+   <li><tt>Decimal</tt> uses {@link Formats#getDecimalInputFormat()}
+   <li><tt>Boolean</tt> uses {@link Util#parseBoolean(String)}
+   <li><tt>DateTime</tt> uses {@link DateConverter#parseEyeFriendlyDateTime(String, Locale)} 
+   and {@link DateConverter#parseHandFriendlyDateTime(String, Locale)}
+   <li><tt>Date</tt> uses {@link DateConverter#parseEyeFriendly(String, Locale, TimeZone)} 
+   and {@link DateConverter#parseHandFriendly(String, Locale, TimeZone)}
+   <li><tt>Long</tt> uses {@link Long#Long(String)}
+   <li><tt>Id</tt> uses {@link Id#Id(String)}
+   <li><tt>Locale</tt> uses {@link Locale#getAvailableLocales()} and {@link Locale#toString()}, case sensitive.
+   <li><tt>TimeZone</tt> uses {@link TimeZone#getAvailableIDs()}, case sensitive.
+  </ul>
+  <tt>InputStream</tt>s are not converted by this class, and need to be handled separately by the caller.
+  */
+  public final <T> T convert(String aFilteredInputValue, Class<T> aSupportedTargetClass, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException {
+    // Defensive : this check should have already been performed by the calling framework class.
+    if( ! isSupported(aSupportedTargetClass) ) {
+      throw new AssertionError("Unsupported type cannot be translated to an object: " + aSupportedTargetClass + ". If you're trying to use String, consider using SafeText instead. Otherwise, change the AllowStringAsBuildingBlock setting in web.xml.");
+    }
+    
+    Object result = null;
+    if (aSupportedTargetClass == SafeText.class){
+      //no translation needed; some impl's might trim here, or force CAPS
+      result = parseSafeText(aFilteredInputValue);
+    }
+    else if (aSupportedTargetClass == String.class) {
+      result = aFilteredInputValue; //no translation needed; some impl's might trim here, or force CAPS
+    }
+    else if (aSupportedTargetClass == Integer.class || aSupportedTargetClass == int.class){
+      result = parseInteger(aFilteredInputValue);
+    }
+    else if (aSupportedTargetClass == Boolean.class || aSupportedTargetClass == boolean.class){
+      result = Util.parseBoolean(aFilteredInputValue);
+    }
+    else if (aSupportedTargetClass == BigDecimal.class){
+      result = parseBigDecimal(aFilteredInputValue, aLocale, aTimeZone);
+    }
+    else if (aSupportedTargetClass == Decimal.class){
+      result = parseDecimal(aFilteredInputValue, aLocale, aTimeZone);
+    }
+    else if (aSupportedTargetClass == java.util.Date.class){
+      result = parseDate(aFilteredInputValue, aLocale, aTimeZone);
+    }
+    else if (aSupportedTargetClass == DateTime.class){
+      result = parseDateTime(aFilteredInputValue, aLocale);
+    }
+    else if (aSupportedTargetClass == Long.class || aSupportedTargetClass == long.class){
+      result = parseLong(aFilteredInputValue);
+    }
+    else if (aSupportedTargetClass == Id.class){
+      result = new Id(aFilteredInputValue.trim());
+    }
+    else if (aSupportedTargetClass == Locale.class){
+      result = parseLocale(aFilteredInputValue);
+    }
+    else if (TimeZone.class.isAssignableFrom(aSupportedTargetClass)){
+      //the above style is needed since TimeZone is abstract
+      result = parseTimeZone(aFilteredInputValue);
+    }
+    else {
+       throw new AssertionError("Failed to build object for ostensibly supported class: " + aSupportedTargetClass);
+    }
+    fLogger.finer("Converted request param into a " + aSupportedTargetClass.getName());
+    return (T)result; //this cast is unavoidable, and safe.
+  }
+  
+  /**
+   Return the <tt>IgnorableParamValue</tt> configured in <tt>web.xml</tt>.
+   See <tt>web.xml</tt> for more information.
+  */
+  public final String getIgnorableParamValue(){
+    return fConfig.getIgnorableParamValue();
+  }
+  
+  // PRIVATE 
+  
+  private Config fConfig = new Config();
+  private static List<Class<?>> STANDARD_CLASSES; //always the same 
+  private static final ModelCtorException PROBLEM_FOUND = new ModelCtorException();
+  private static final Logger fLogger = Util.getLogger(ConvertParamImpl.class);
+  static {  
+    STANDARD_CLASSES = new ArrayList<Class<?>>();
+    STANDARD_CLASSES.add(Integer.class);
+    STANDARD_CLASSES.add(int.class);
+    STANDARD_CLASSES.add(Boolean.class);
+    STANDARD_CLASSES.add(boolean.class);
+    STANDARD_CLASSES.add(BigDecimal.class);
+    STANDARD_CLASSES.add(java.util.Date.class);
+    STANDARD_CLASSES.add(Long.class);
+    STANDARD_CLASSES.add(long.class);
+    STANDARD_CLASSES.add(Id.class);
+    STANDARD_CLASSES.add(SafeText.class);
+    STANDARD_CLASSES.add(Locale.class);
+    STANDARD_CLASSES.add(TimeZone.class); 
+    STANDARD_CLASSES.add(Decimal.class);
+    STANDARD_CLASSES.add(DateTime.class);
+    STANDARD_CLASSES.add(InputStream.class);
+  }
+  
+  private Integer parseInteger(String aUserInputValue) throws ModelCtorException {
+    try {
+      return new Integer(aUserInputValue);
+    }
+    catch (NumberFormatException ex){
+      throw PROBLEM_FOUND;
+    }
+  }
+  
+  private BigDecimal parseBigDecimal(String aUserInputValue, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException {
+    BigDecimal result = null;
+    Formats formats = new Formats(aLocale, aTimeZone);
+    Pattern pattern = formats.getDecimalInputFormat();
+    if ( Util.matches(pattern, aUserInputValue)) {
+      //BigDecimal ctor only takes '.' as decimal sign, never ','          
+      result = new BigDecimal(aUserInputValue.replace(',', '.'));
+    }
+    else {
+      throw PROBLEM_FOUND;
+    }
+    return result;
+  }
+  
+  private Decimal parseDecimal(String aUserInputValue, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException {
+    Decimal result = null;
+    BigDecimal amount = null;
+    Formats formats = new Formats(aLocale, aTimeZone);
+    Pattern pattern = formats.getDecimalInputFormat();
+    if ( Util.matches(pattern, aUserInputValue)) {
+      //BigDecimal ctor only takes '.' as decimal sign, never ','          
+      amount = new BigDecimal(aUserInputValue.replace(',', '.'));
+      try {
+         result = new Decimal(amount);
+      }
+      catch(IllegalArgumentException ex){
+        throw PROBLEM_FOUND;
+      }
+    }
+    else {
+      throw PROBLEM_FOUND;
+    }
+    return result;
+  }
+  
+  private Date parseDate(String aUserInputValue, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException {
+    Date result = null;
+    DateConverter dateConverter = BuildImpl.forDateConverter();
+    result = dateConverter.parseHandFriendly(aUserInputValue, aLocale, aTimeZone);
+    if ( result == null ){
+      result = dateConverter.parseEyeFriendly(aUserInputValue, aLocale, aTimeZone);
+    }
+    if ( result == null ) {
+      throw PROBLEM_FOUND;
+    }
+    return result;
+  }
+  
+  private DateTime parseDateTime(String aUserInputValue, Locale aLocale) throws ModelCtorException {
+    DateTime result = null;
+    DateConverter dateConverter = BuildImpl.forDateConverter();
+    result = dateConverter.parseHandFriendlyDateTime(aUserInputValue, aLocale);
+    if ( result == null ){
+      result = dateConverter.parseEyeFriendlyDateTime(aUserInputValue, aLocale);
+    }
+    if ( result == null ) {
+      throw PROBLEM_FOUND;
+    }
+    return result;
+  }
+  
+  private Long parseLong(String aUserInputValue) throws ModelCtorException {
+    Long result = null;
+    if ( Util.textHasContent(aUserInputValue) ){
+      try {
+        result = new Long(aUserInputValue);
+      }
+      catch (NumberFormatException ex){
+        throw PROBLEM_FOUND;
+      }
+    }
+    return result;
+  }
+  
+  private SafeText parseSafeText(String aUserInputValue) throws ModelCtorException {
+    SafeText result = null;
+    if( Util.textHasContent(aUserInputValue) ) {
+      try {
+        result = new SafeText(aUserInputValue);
+      }
+      catch(IllegalArgumentException ex){
+        throw PROBLEM_FOUND;
+      }
+    }
+    return result;
+  }
+
+  /** Translate user input into a known time zone id. Case sensitive. */
+  private TimeZone parseTimeZone(String aUserInputValue) throws ModelCtorException {
+    TimeZone result = null;
+    if ( Util.textHasContent(aUserInputValue) ){
+      List<String> allTimeZoneIds = Arrays.asList(TimeZone.getAvailableIDs());
+      for(String id : allTimeZoneIds){
+        if (id.equals(aUserInputValue)){
+          result = TimeZone.getTimeZone(id);
+          break;
+        }
+      }
+      if(result == null){ //has content, but no match found
+        throw PROBLEM_FOUND;
+      }
+    }
+    return result;
+  }
+  
+  /** Translate user input into a known Locale id. Case sensitive. */
+  private Locale parseLocale(String aUserInputValue) throws ModelCtorException {
+    Locale result = null;
+    if ( Util.textHasContent(aUserInputValue) ){
+      List<Locale> allLocales = Arrays.asList(Locale.getAvailableLocales());
+      for(Locale locale: allLocales){
+        if (locale.toString().equals(aUserInputValue)){
+          result = locale;
+          break;
+        }
+      }
+      if(result == null){ //has content, but no match found
+        throw PROBLEM_FOUND;
+      }
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/DateTime.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,1692 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.action.ActionImpl;
+import hirondelle.web4j.model.ModelUtil.NullsGo;
+import hirondelle.web4j.request.TimeZoneSource;
+import hirondelle.web4j.util.TimeSource;
+import hirondelle.web4j.util.Util;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ Building block class for an immutable date-time, with no time zone. 
+ 
+ <P>
+ This class is provided as an alternative to java.util.{@link java.util.Date}. 
+ You're strongly encouraged to use this class in your WEB4J applications, but you can still use java.util.{@link java.util.Date} if you wish.
+
+<P>This class can hold :
+<ul>
   <li>a date-and-time : <tt>1958-03-31 18:59:56.123456789</tt>
   <li>a date only : <tt>1958-03-31</tt>
   <li>a time only : <tt>18:59:56.123456789</tt>
+</ul>
+
+ <P>
+ <a href='#Examples'>Examples</a><br>
+ <a href='#JustificationForThisClass'>Justification For This Class</a><br>
+ <a href='#DatesAndTimesInGeneral'>Dates and Times In General</a><br>
+ <a href='#TheApproachUsedByThisClass'>The Approach Used By This Class</a><br>
+ <a href='#TwoSetsOfOperations'>Two Sets Of Operations</a><br>
+ <a href='#ParsingDateTimeAcceptedFormats'>Parsing DateTime - Accepted Formats</a><br>
+ <a href='#FormattingLanguage'>Mini-Language for Formatting</a><br>
+ <a href='#InteractionWithTimeSource'>Interaction with {@link TimeSource}</a><br>
+ <a href='#PassingDateTimeToTheDatabase'>Passing DateTime Objects to the Database</a>
+
+ <a name='Examples'></a>
+ <h3> Examples</h3>
+ Some quick examples of using this class :
+ <PRE>
+  DateTime dateAndTime = new DateTime("2010-01-19 23:59:59");
+  //highest precision is nanosecond, not millisecond:
+  DateTime dateAndTime = new DateTime("2010-01-19 23:59:59.123456789");
+  
+  DateTime dateOnly = new DateTime("2010-01-19");
+  DateTime timeOnly = new DateTime("23:59:59");
+  
+  DateTime dateOnly = DateTime.forDateOnly(2010,01,19);
+  DateTime timeOnly = DateTime.forTimeOnly(23,59,59,0);
+  
+  DateTime dt = new DateTime("2010-01-15 13:59:15");
+  boolean leap = dt.isLeapYear(); //false
+  dt.getNumDaysInMonth(); //31
+  dt.getStartOfMonth(); //2010-01-01, 00:00:00
+  dt.getEndOfDay(); //2010-01-15, 23:59:59
+  dt.format("YYYY-MM-DD"); //formats as '2010-01-15'
+  dt.plusDays(30); //30 days after Jan 15
+  dt.numDaysFrom(someDate); //returns an int
+  dueDate.lt(someDate); //less-than
+  dueDate.lteq(someDate); //less-than-or-equal-to
+  
+  //{@link ActionImpl#getTimeZone()} is readily available in most Actions
+  DateTime.now(getTimeZone());
+  DateTime.today(getTimeZone());
+  DateTime fromMilliseconds = DateTime.forInstant(31313121L, getTimeZone());
+  birthday.isInFuture(getTimeZone());
+ </PRE>
+ 
+ <a name='JustificationForThisClass'></a>
+ <h3> Justification For This Class</h3>
+ The fundamental reasons why this class exists are :
+ <ul>
+ <li>to avoid the embarrassing number of distasteful inadequacies in the JDK's date classes
+ <li>to oppose the very "mental model" of the JDK's date-time classes with something significantly simpler 
+ </ul>
+ 
+ <a name='MentalModels'></a>
+ <P><b>There are 2 distinct mental models for date-times, and they don't play well together</b> :
+ <ul>
+ <li><b>timeline</b> - an instant on the timeline, as a physicist would picture it, representing the number of 
+ seconds from some epoch. In this picture, such a date-time can have many, many different 
+ representations according to calendar and time zone. That is, the date-time, <i> as seen and understood by 
+ the end user</i>, can change according to "who's looking at it". It's important to understand that a timeline instant, 
+ before being presented to the user, <i>must always have an associated time zone - even in the case of 
+ a date only, with no time.</i>
+ <li><b>everyday</b> - a date-time in the Gregorian calendar, such as '2009-05-25 18:25:00', 
+ which never changes according to "who's looking at it". Here, <i>the time zone is always both implicit and immutable</i>.
+ </ul>
+ 
+ <P>The problem is that java.util.{@link java.util.Date} uses <i>only</i> the timeline style, while <i>most</i> users, <i>most</i> 
+ of the time, think in terms of the <i>other</i> mental model - the 'everday' style. 
+
+ In particular, there are a large number of applications which experience 
+ <a href='http://martinfowler.com/bliki/TimeZoneUncertainty.html'>problems with time zones</a>, because the timeline model 
+ is used instead of the everday model.
+ <i>Such problems are often seen by end users as serious bugs, because telling people the wrong date or time is often a serious issue.</i>
+ <b>These problems make you look stupid.</b> 
+ 
+ <a name='JDKDatesMediocre'></a>
+ <h4>Date Classes in the JDK are Mediocre</h4>
+ The JDK's classes related to dates are widely regarded as frustrating to work with, for various reasons:
+ <ul>
+ <li>mistakes regarding time zones are very common
+ <li>month indexes are 0-based, leading to off-by-one errors
+ <li>difficulty of calculating simple time intervals 
+ <li><tt>java.util.Date</tt> is mutable, but 'building block' classes should be
+ immutable
+ <li>numerous other minor nuisances
+ </ul>
+ 
+ <a name='JodaTimeDrawbacks'></a>
+ <h4>Joda Time Has Drawbacks As Well</h4>
+ The <a href='http://joda-time.sourceforge.net/'>Joda Time</a> library is used by some programmers as an alternative 
+ to the JDK classes. Joda Time has the following drawbacks :
+ <ul>
+ <li>it limits precision to milliseconds. Database timestamp values almost always have a precision of microseconds 
+ or even nanoseconds. This is a serious defect: <b>a library should never truncate your data, for any reason.</b>
+ <li>it's large, with well over 100 items in its <a href='http://joda-time.sourceforge.net/api-release/index.html'>javadoc</a>
+ <li>in order to stay current, it needs to be manually updated occasionally with fresh time zone data 
+ <li>it has mutable versions of classes
+ <li>it always coerces March 31 + 1 Month to April 30 (for example), without giving you any choice in the matter
+ <li>some databases allow invalid date values such as '0000-00-00', but Joda Time doesn't seem to be able to handle them   
+ </ul>
+ 
+ 
+ <a name='DatesAndTimesInGeneral'></a>
+ <h3>Dates and Times in General</h3>
+ 
+ <h4>Civil Timekeeping Is Complex</h4>
+ Civil timekeeping is a byzantine hodge-podge of arcane and arbitrary rules. Consider the following :
+ <ul>
+ <li>months have varying numbers of days
+ <li>one month (February) has a length which depends on the year
+ <li>not all years have the same number of days
+ <li>time zone rules spring forth arbitrarily from the fecund imaginations of legislators 
+ <li>summer hours mean that an hour is 'lost' in the spring, while another hour must
+ repeat itself in the autumn, during the switch back to normal time
+ <li>summer hour logic varies widely across various jurisdictions
+ <li>the cutover from the Julian calendar to the Gregorian calendar happened at different times in
+ different places, which causes a varying number of days to be 'lost' during the cutover
+ <li>occasional insertion of leap seconds are used to ensure synchronization with the
+ rotating Earth (whose speed of rotation is gradually slowing down, in an irregular way)
+ <li>there is no year 0 (1 BC is followed by 1 AD), except in the reckoning used by
+ astronomers
+ </ul>
+ 
+ <h4>How Databases Treat Dates</h4>
+ <b>Most databases model dates and times using the Gregorian Calendar in an aggressively simplified form</b>,
+ in which :
+ <ul>
+ <li>the Gregorian calendar is extended back in time as if it was in use previous to its
+ inception (the 'proleptic' Gregorian calendar)
+ <li>the transition between Julian and Gregorian calendars is entirely ignored
+ <li>leap seconds are entirely ignored
+ <li>summer hours are entirely ignored
+ <li>often, even time zones are ignored, in the sense that <i>the underlying database
+ column doesn't usually explicitly store any time zone information</i>. 
+ </ul>
+ 
+ <P><a name='NoTimeZoneInDb'></a>The final point requires elaboration.
+ Some may doubt its veracity, since they have seen date-time information "change time zone" when 
+ retrieved from a database. But this sort of change is usually applied using logic which is <i>external</i> to the data 
+ stored in the particular column.  
+ 
+ <P> For example, the following items might be used in the calculation of a time zone difference :
+ <ul>
+ <li>time zone setting for the client (or JDBC driver) 
+ <li>time zone setting for the client's connection to the database server
+ <li>time zone setting of the database server
+ <li>time zone setting of the host where the database server resides
+ </ul>
+
+ <P>(Note as well what's <i>missing</i> from the above list: your own application's logic, and the user's time zone preference.) 
+
+ <P>When an end user sees such changes to a date-time, all they will say to you is 
+ <i>"Why did you change it? That's not what I entered"</i> - and this is a completely valid question. 
+ Why <i>did</i> you change it? Because you're using the timeline model instead of the everyday model. 
+ Perhaps you're using a inappropriate abstraction for what the user really wants. 
+ 
+<a name='TheApproachUsedByThisClass'></a>
+ <h3>The Approach Used By This Class</h3>
+ 
+ This class takes the following design approach :
+ <ul>
+ <li>it models time in the "everyday" style, not in the "timeline" style (see <a href='#MentalModels'>above</a>)
+ <li>its precision matches the highest precision used by databases (nanosecond)
+ <li>it uses only the proleptic Gregorian Calendar, over the years <tt>1..9999</tt> 
+ <li><i>it ignores all non-linearities</i>: summer-hours, leap seconds, and the cutover
+ from Julian to Gregorian calendars
+ <li><i>it ignores time zones</i>. Most date-times are stored in columns whose type
+ does <i>not</i> include time zone information (see note <a href='#NoTimeZoneInDb'>above</a>).
+ <li>it has (very basic) support for wonky dates, such as the magic value <tt>0000-00-00</tt> used by MySQL
+ <li>it's immutable
+ <li>it lets you choose among 4 policies for 'day overflow' conditions during calculations
+ <li>it talks to your {@link TimeSource} implementation when returning the current moment, allowing you to customise dates during testing
+ </ul>
+ 
+ <P>Even though the above list may appear restrictive, it's very likely true that
+ <tt>DateTime</tt> can handle the dates and times you're currently storing in your database.
+ 
+<a name='TwoSetsOfOperations'></a>
+ <h3>Two Sets Of Operations</h3>
+ This class allows for 2 sets of operations: a few "basic" operations, and many "computational" ones.
+ 
+ <P><b>Basic operations</b> model the date-time as a simple, dumb String, with absolutely no parsing or substructure. 
+ This will always allow your application to reflect exactly what is in a <tt>ResultSet</tt>, with
+ absolutely no modification for time zone, locale, or for anything else. 
+ 
+ <P>This is meant as a back-up, to ensure that <i>your application will always be able
+ to, at the very least, display a date-time exactly as it appears in your
+ <tt>ResultSet</tt> from the database</i>. This style is particularly useful for handling invalid
+ dates such as <tt>2009-00-00</tt>, which can in fact be stored by some databases (MySQL, for
+ example). It can also be used to handle unusual items, such as MySQL's 
+ <a href='http://dev.mysql.com/doc/refman/5.1/en/time.html'>TIME</a> datatype.
+ 
+ <P>The basic operations are represented by {@link #DateTime(String)}, {@link #toString()}, and {@link #getRawDateString()}.
+ 
+ <P><b>Computational operations</b> allow for calculations and formatting. 
+ If a computational operation is performed by this class (for example, if the caller asks for the month), 
+ then any underlying date-time String must be parseable by this class into its components - year, month, day, and so on. 
+ Computational operations require such parsing, while the basic operations do not. Almost all methods in this class 
+ are categorized as computational operations.
+ 
+ <a name="ParsingDateTimeAcceptedFormats"></a>
+ <h3>Parsing DateTime - Accepted Formats</h3>
+  The {@link #DateTime(String)} constructor accepts a <tt>String</tt> representation of a date-time.
+ The format of the String can take a number of forms. When retrieving date-times from a database, the 
+ majority of cases will have little problem in conforming to these formats. If necessary, your SQL statements 
+ can almost always use database formatting functions to generate a String whose format conforms to one of the 
+ many formats accepted by the {@link #DateTime(String)} constructor.  
+ 
+ <a name="FormattingLanguage"></a>
+ <h3>Mini-Language for Formatting</h3>
+ This class defines a simple mini-language for formatting a <tt>DateTime</tt>, used by the various <tt>format</tt> methods. 
+ 
+ <P>The following table defines the symbols used by this mini-language, and the corresponding text they 
+ would generate given the date:
+ <PRE>1958-04-09 Wednesday, 03:05:06.123456789 AM</PRE>
+ in an English Locale. (Items related to date are in upper case, and items related to time are in lower case.)
+ 
+ <P><table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th>Format</th><th>Output</th> <th>Description</th><th>Needs Locale?</th></tr>
+ <tr><td>YYYY</td> <td>1958</td> <td>Year</td><td>...</td></tr>
+ <tr><td>YY</td> <td>58</td> <td>Year without century</td><td>...</td></tr>
+ <tr><td>M</td> <td>4</td> <td>Month 1..12</td><td>...</td></tr>
+ <tr><td>MM</td> <td>04</td> <td>Month 01..12</td><td>...</td></tr>
+ <tr><td>MMM</td> <td>Apr</td> <td>Month Jan..Dec</td><td>Yes</td></tr>
+ <tr><td>MMMM</td> <td>April</td> <td>Month January..December</td><td>Yes</td></tr>
+ <tr><td>DD</td> <td>09</td> <td>Day 01..31</td><td>...</td></tr>
+ <tr><td>D</td> <td>9</td> <td>Day 1..31</td><td>...</td></tr>
+ <tr><td>WWWW</td> <td>Wednesday</td> <td>Weekday Sunday..Saturday</td><td>Yes</td></tr>
+ <tr><td>WWW</td> <td>Wed</td> <td>Weekday Sun..Sat</td><td>Yes</td></tr>
+ <tr><td>hh</td> <td>03</td> <td>Hour 01..23</td><td>...</td></tr>
+ <tr><td>h</td> <td>3</td> <td>Hour 1..23</td><td>...</td></tr>
+ <tr><td>hh12</td> <td>03</td> <td>Hour 01..12</td><td>...</td></tr>
+ <tr><td>h12</td> <td>3</td> <td>Hour 1..12</td><td>...</td></tr>
+ <tr><td>a</td> <td>AM</td> <td>AM/PM Indicator</td><td>Yes</td></tr>
+ <tr><td>mm</td> <td>05</td> <td>Minutes 01..59</td><td>...</td></tr>
+ <tr><td>m</td> <td>5</td> <td>Minutes 1..59</td><td>...</td></tr>
+ <tr><td>ss</td> <td>06</td> <td>Seconds 01..59</td><td>...</td></tr>
+ <tr><td>s</td> <td>6</td> <td>Seconds 1..59</td><td>...</td></tr>
+ <tr><td>f</td> <td>1</td> <td>Fractional Seconds, 1 decimal</td><td>...</td></tr>
+ <tr><td>ff</td> <td>12</td> <td>Fractional Seconds, 2 decimals</td><td>...</td></tr>
+ <tr><td>fff</td> <td>123</td> <td>Fractional Seconds, 3 decimals</td><td>...</td></tr>
+ <tr><td>ffff</td> <td>1234</td> <td>Fractional Seconds, 4 decimals</td><td>...</td></tr>
+ <tr><td>fffff</td> <td>12345</td> <td>Fractional Seconds, 5 decimals</td><td>...</td></tr>
+ <tr><td>ffffff</td> <td>123456</td> <td>Fractional Seconds, 6 decimals</td><td>...</td></tr>
+ <tr><td>fffffff</td> <td>1234567</td> <td>Fractional Seconds, 7 decimals</td><td>...</td></tr>
+ <tr><td>ffffffff</td> <td>12345678</td> <td>Fractional Seconds, 8 decimals</td><td>...</td></tr>
+ <tr><td>fffffffff</td> <td>123456789</td> <td>Fractional Seconds, 9 decimals</td><td>...</td></tr>
+ <tr><td>|</td> <td>(no example)</td> <td>Escape character</td><td>...</td></tr>
+ </table>
+
+ <P>As indicated above, some of these symbols can only be used with an accompanying <tt>Locale</tt>.
+ In general, if the output is text, not a number, then a <tt>Locale</tt> will be needed. 
+ For example, 'September' is localizable text, while '09' is a numeric representation, which doesn't require a <tt>Locale</tt>.
+ Thus, the symbol 'MM' can be used without a <tt>Locale</tt>, while 'MMMM' and 'MMM' both require a <tt>Locale</tt>, since they 
+ generate text, not a number. 
+
+ <P>The fractional seconds 'f' does not perform any rounding. 
+ 
+<P> The escape character '|' allows you to insert arbitrary text. 
+ The escape character always appears in pairs; these pairs define a range of characters over which 
+ the text will not be interpreted using the special format symbols defined above. 
+ 
+ <P>Examples :
+ <table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th>Format</th><th>Output</th></tr>
+ <tr><td>YYYY-MM-DD hh:mm:ss.fffffffff a</td> <td>1958-04-09 03:05:06.123456789 AM</td></tr>
+ <tr><td>YYYY-MM-DD hh:mm:ss.fff a</td> <td>1958-04-09 03:05:06.123 AM</td></tr>
+ <tr><td>YYYY-MM-DD</td> <td>1958-04-09</td></tr>
+ <tr><td>hh:mm:ss.fffffffff</td> <td>03:05:06.123456789</td></tr>
+ <tr><td>hh:mm:ss</td> <td>03:05:06</td></tr>
+ <tr><td>YYYY-M-D h:m:s</td> <td>1958-4-9 3:5:6</td></tr>
+ <tr><td>WWWW, MMMM D, YYYY</td> <td>Wednesday, April 9, 1958</td></tr>
+ <tr><td>WWWW, MMMM D, YYYY |at| D a</td> <td>Wednesday, April 9, 1958 at 3 AM</td></tr>
+ </table>
+ 
+ <P>In the last example, the escape characters are needed only because 'a', the formating symbol for am/pm, appears in the text. 
+ 
+ <a name='InteractionWithTimeSource'></a>
+ <h3>Interaction with {@link TimeSource}</h3>
+ The methods of this class related to the current date interact with your configured implementation 
+ of {@link hirondelle.web4j.util.TimeSource}. That is, {@link #now(TimeZone)} and {@link #today(TimeZone)}
+ will return values derived from {@link TimeSource}. Thus, callers of this class automatically use any
+ 'fake' system clock that you may want to define.
+ 
+ <a name='PassingDateTimeToTheDatabase'></a>
+ <h3>Passing DateTime Objects to the Database</h3>
+ When a <tt>DateTime</tt> is passed as a parameter to an SQL statement, the <tt>DateTime</tt> is 
+ formatted into a <tt>String</tt> of a form accepted by the database. There are two mechanisms to 
+ accomplish this
+ <ul>
   <li>in your DAO code, format the <tt>DateTime</tt> explicitly as a String, using one of the <tt>format</tt> methods, 
+   and pass the <tt>String</tt> as the parameter to the SQL statement, and not the actual <tt>DateTime</tt>
   <li>pass the <tt>DateTime</tt> itself. In this case, WEB4J will use the setting in <tt>web.xml</tt> named
+   <tt>DateTimeFormatForPassingParamsToDb</tt> to perform the formatting for you. If the formats defined by this 
+   setting are not appropriate for a given case, you will need to format the <tt>DateTime</tt> as a String explicitly instead. 
 </ul>
+ */
+public final class DateTime implements Comparable<DateTime>, Serializable {
+
+  /** The seven parts of a <tt>DateTime</tt> object. The <tt>DAY</tt> represents the day of the month (1..31), not the weekday. */
+  public enum Unit {
+    YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, NANOSECONDS;
+  }
+
+  /**
+   Policy for treating 'day-of-the-month overflow' conditions encountered during some date calculations.
+   
+   <P>Months are different from other units of time, since the length of a month is not fixed, but rather varies with 
+   both month and year. This leads to problems. Take the following simple calculation, for example :
+   
+   <PRE>May 31 + 1 month = ?</PRE>
+   
+   <P>What's the answer? Since there is no such thing as June 31, the result of this operation is inherently ambiguous. 
+   This  <tt>DayOverflow</tt> enumeration lists the various policies for treating such situations, as supported by 
+   <tt>DateTime</tt>.
+   
+   <P>This table illustrates how the policies behave :
+   <P><table BORDER="1" CELLPADDING="3" CELLSPACING="0">
+   <tr>
+   <th>Date</th>
+   <th>DayOverflow</th>
+   <th>Result</th>
+   </tr>    
+   <tr>
+   <td>May 31 + 1 Month</td>
+   <td>LastDay</td>
+   <td>June 30</td>
+   </tr>    
+   <tr>
+   <td>May 31 + 1 Month</td>
+   <td>FirstDay</td>
+   <td>July 1</td>
+   </tr>    
+   <tr>
+   <td>December 31, 2001 + 2 Months</td>
+   <td>Spillover</td>
+   <td>March 3</td>
+   </tr>    
+   <tr>
+   <td>May 31 + 1 Month</td>
+   <td>Abort</td>
+   <td>RuntimeException</td>
+   </tr>    
+   </table> 
+   */
+  public enum DayOverflow {
+    /** Coerce the day to the last day of the month. */
+    LastDay,
+    /** Coerce the day to the first day of the next month. */
+    FirstDay,
+    /** Spillover the day into the next month. */
+    Spillover,
+    /** Throw a RuntimeException. */
+    Abort;
+  }
+
+  /**
+   Constructor taking a date-time as a String. 
+   
+   <P>This constructor is called when WEB4J's data layer needs to translate a column in a <tt>ResultSet</tt>
+   into a <tt>DateTime</tt>.
+   
+   <P> When this constructor is called, the underlying text can be in an absolutely arbitrary
+   form, since it will not, initially, be parsed in any way. This policy of extreme
+   leniency allows you to use dates in an arbitrary format, without concern over possible
+   transformations of the date (time zone in particular), and without concerns over possibly bizarre content, such 
+   as '2005-00-00', as seen in some databases, such as MySQL.
+   
+   <P><i>However</i>, the moment you attempt to call <a href='#TwoSetsOfOperations'>almost any method</a>
+   in this class, an attempt will be made to parse 
+   the given date-time string into its constituent parts. Then, if the date-time string does not match one of the 
+   example formats listed below, a <tt>RuntimeException</tt> will be thrown.
+   
+   <P>Before calling this constructor, you may wish to call {@link #isParseable(String)} to explicitly test whether a 
+   given String is parseable by this class.
+   
+   <P>The full date format expected by this class is <tt>'YYYY-MM-YY hh:mm:ss.fffffffff'</tt>. 
+   All fields except for the fraction of a second have a fixed width.
+   In addition, various portions of this format are also accepted by this class.
+   
+   <P>All of the following dates can be parsed by this class to make a <tt>DateTime</tt> :
+   <ul>
+   <li><tt>2009-12-31 00:00:00.123456789</tt>
+   <li><tt>2009-12-31T00:00:00.123456789</tt>
+   <li><tt>2009-12-31 00:00:00.12345678</tt>
+   <li><tt>2009-12-31 00:00:00.1234567</tt>
+   <li><tt>2009-12-31 00:00:00.123456</tt>
+   <li><tt>2009-12-31 23:59:59.12345</tt>
+   <li><tt>2009-01-31 16:01:01.1234</tt>
+   <li><tt>2009-01-01 16:59:00.123</tt>
+   <li><tt>2009-01-01 16:00:01.12</tt>
+   <li><tt>2009-02-28 16:25:17.1</tt>
+   <li><tt>2009-01-01 00:01:01</tt>
+   <li><tt>2009-01-01 16:01</tt>
+   <li><tt>2009-01-01 16</tt>
+   <li><tt>2009-01-01</tt>
+   <li><tt>2009-01</tt>
+   <li><tt>2009</tt>
+   <li><tt>0009</tt>
+   <li><tt>9</tt>
+   <li><tt>00:00:00.123456789</tt>
+   <li><tt>00:00:00.12345678</tt>
+   <li><tt>00:00:00.1234567</tt>
+   <li><tt>00:00:00.123456</tt>
+   <li><tt>23:59:59.12345</tt>
+   <li><tt>01:59:59.1234</tt>
+   <li><tt>23:01:59.123</tt>
+   <li><tt>00:00:00.12</tt>
+   <li><tt>00:59:59.1</tt>
+   <li><tt>23:59:00</tt>
+   <li><tt>23:00:10</tt>
+   <li><tt>00:59</tt>
+   </ul>
+   
+   <P>The range of each field is :
+   <ul>
+   <li>year: 1..9999 (leading zeroes are optional)
+   <li>month: 01..12
+   <li>day: 01..31
+   <li>hour: 00..23
+   <li>minute: 00..59
+   <li>second: 00..59
+   <li>nanosecond: 0..999999999
+   </ul>
+   
+   <P>Note that <b>database format functions</b> are an option when dealing with date formats. 
+   Since your application is always in control of the SQL used to talk to the database, you can, if needed, usually
+    use database format functions to alter the format of dates returned in a <tt>ResultSet</tt>.
+   */
+  public DateTime(String aDateTime) {
+    fIsAlreadyParsed = false;
+    if (aDateTime == null) {
+      throw new IllegalArgumentException("String passed to DateTime constructor is null. You can use an empty string, but not a null reference.");
+    }
+    fDateTime = aDateTime;
+  }
+  
+  /**
+   Return <tt>true</tt> only if the given String follows one of the formats documented by {@link #DateTime(String)}.
+   <P>If the text is not from a trusted source, then the caller may use this method to validate whether the text 
+   is in a form that's parseable by this class.    
+  */
+  public static boolean isParseable(String aCandidateDateTime){
+    boolean result = true;
+    try {
+      DateTime dt = new DateTime(aCandidateDateTime);
+      dt.ensureParsed();
+    }
+    catch (RuntimeException ex){
+      result = false;
+    }
+    return result;
+  }
+  
+
+  /**
+   Constructor taking each time unit explicitly.
+   
+   <P>Although all parameters are optional, many operations on this class require year-month-day to be 
+   present. 
+   
+   @param aYear 1..9999, optional 
+   @param aMonth 1..12 , optional
+   @param aDay 1..31, cannot exceed the number of days in the given month/year, optional
+   @param aHour 0..23, optional
+   @param aMinute 0..59, optional
+   @param aSecond 0..59, optional
+   @param aNanoseconds 0..999,999,999, optional (allows for databases that store timestamps up to
+   nanosecond precision).
+   */
+  public DateTime(Integer aYear, Integer aMonth, Integer aDay, Integer aHour, Integer aMinute, Integer aSecond, Integer aNanoseconds) {
+    fIsAlreadyParsed = true;
+    fYear = aYear;
+    fMonth = aMonth;
+    fDay = aDay;
+    fHour = aHour;
+    fMinute = aMinute;
+    fSecond = aSecond;
+    fNanosecond = aNanoseconds;
+    validateState();
+  }
+
+  /**
+   Factory method returns a <tt>DateTime</tt> having year-month-day only, with no time portion.
+   <P>See {@link #DateTime(Integer, Integer, Integer, Integer, Integer, Integer, Integer)} for constraints on the parameters.
+   */
+  public static DateTime forDateOnly(Integer aYear, Integer aMonth, Integer aDay) {
+    return new DateTime(aYear, aMonth, aDay, null, null, null, null);
+  }
+
+  /**
+   Factory method returns a <tt>DateTime</tt> having hour-minute-second-nanosecond only, with no date portion.
+   <P>See {@link #DateTime(Integer, Integer, Integer, Integer, Integer, Integer, Integer)} for constraints on the parameters.
+   */
+  public static DateTime forTimeOnly(Integer aHour, Integer aMinute, Integer aSecond, Integer aNanoseconds) {
+    return new DateTime(null, null, null, aHour, aMinute, aSecond, aNanoseconds);
+  }
+
+  /** 
+   Constructor taking a millisecond value and a {@link TimeZone}.
+   This constructor may be use to convert a <tt>java.util.Date</tt> into a <tt>DateTime</tt>.
+   
+   <P>To use nanosecond precision, please use {@link #forInstantNanos(long, TimeZone)} instead.
+   
+   @param aMilliseconds must be in the range corresponding to the range of dates supported by this class (year 1..9999); corresponds 
+   to a millisecond instant on the timeline, measured from the epoch used by {@link java.util.Date}.
+   */
+  public static DateTime forInstant(long aMilliseconds, TimeZone aTimeZone) {
+    Calendar calendar = new GregorianCalendar(aTimeZone);
+    calendar.setTimeInMillis(aMilliseconds);
+    int year = calendar.get(Calendar.YEAR);
+    int month = calendar.get(Calendar.MONTH) + 1; // 0-based
+    int day = calendar.get(Calendar.DAY_OF_MONTH);
+    int hour = calendar.get(Calendar.HOUR_OF_DAY); // 0..23
+    int minute = calendar.get(Calendar.MINUTE);
+    int second = calendar.get(Calendar.SECOND);
+    int milliseconds = calendar.get(Calendar.MILLISECOND);
+    int nanoseconds = milliseconds * 1000 * 1000;
+    return new DateTime(year, month, day, hour, minute, second, nanoseconds);
+  }
+  
+  /**
+    For the given time zone,  return the corresponding time in milliseconds-since-epoch for this <tt>DateTime</tt>.
+    
+    <P>This method is meant to help you convert between a <tt>DateTime</tt> and the 
+    JDK's date-time classes, which are based on the combination of a time zone and a 
+    millisecond value from the Java epoch.
+    <P>Since <tt>DateTime</tt> can go to nanosecond accuracy, the return value can 
+    lose precision. The nanosecond value is truncated to milliseconds, not rounded.
+    To retain nanosecond accuracy, please use {@link #getNanosecondsInstant(TimeZone)} instead.
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+  */
+  public long getMilliseconds(TimeZone aTimeZone){
+    Integer year = getYear();
+    Integer month = getMonth();
+    Integer day = getDay();
+    //coerce missing times to 0:
+    Integer hour = getHour() == null ? 0 : getHour();
+    Integer minute = getMinute() == null ? 0 : getMinute();
+    Integer second = getSecond() == null ? 0 : getSecond();
+    Integer nanos = getNanoseconds() == null ? 0 : getNanoseconds();
+    
+    Calendar calendar = new GregorianCalendar(aTimeZone);
+    calendar.set(Calendar.YEAR, year);
+    calendar.set(Calendar.MONTH, month-1); // 0-based
+    calendar.set(Calendar.DAY_OF_MONTH, day);
+    calendar.set(Calendar.HOUR_OF_DAY, hour); // 0..23
+    calendar.set(Calendar.MINUTE, minute);
+    calendar.set(Calendar.SECOND, second);
+    calendar.set(Calendar.MILLISECOND, nanos/1000000);
+    
+    return calendar.getTimeInMillis();
+  }
+  
+  /** 
+  Constructor taking a nanosecond value and a {@link TimeZone}.
+ 
+  <P>To use milliseconds instead of nanoseconds, please use {@link #forInstant(long, TimeZone)}.
+ 
+  @param aNanoseconds must be in the range corresponding to the range of dates supported by this class (year 1..9999); corresponds 
+  to a nanosecond instant on the time-line, measured from the epoch used by {@link java.util.Date}.
+ */
+  public static DateTime forInstantNanos(long aNanoseconds, TimeZone aTimeZone) {
+    //these items can be of either sign
+    long millis = aNanoseconds / MILLION; //integer division truncates towards 0, doesn't round
+    long nanosRemaining = aNanoseconds % MILLION; //size 0..999,999
+    //when negative: go to the previous millis, and take the complement of nanosRemaining
+    if(aNanoseconds < 0){
+      millis = millis - 1;
+      nanosRemaining = MILLION + nanosRemaining; //-1 remaining coerced to 999,999
+    }
+    
+    //base calculation in millis
+    Calendar calendar = new GregorianCalendar(aTimeZone);
+    calendar.setTimeInMillis(millis);
+    int year = calendar.get(Calendar.YEAR);
+    int month = calendar.get(Calendar.MONTH) + 1; // 0-based
+    int day = calendar.get(Calendar.DAY_OF_MONTH);
+    int hour = calendar.get(Calendar.HOUR_OF_DAY); // 0..23
+    int minute = calendar.get(Calendar.MINUTE);
+    int second = calendar.get(Calendar.SECOND);
+    int milliseconds = calendar.get(Calendar.MILLISECOND);
+    
+    DateTime withoutNanos = new DateTime(year, month, day, hour, minute, second, milliseconds * MILLION);
+    //adjust for nanos - this cast is acceptable, because the value's range is 0..999,999:
+    DateTime withNanos = withoutNanos.plus(0, 0,  0, 0, 0, 0, (int)nanosRemaining, DayOverflow.Spillover);
+    return withNanos;
+  }
+  
+  /**
+   For the given time zone,  return the corresponding time in nanoseconds-since-epoch for this <tt>DateTime</tt>.
+  
+   <P>For conversion between a <tt>DateTime</tt> and the JDK's date-time classes, 
+   you should likely use {@link #getMilliseconds(TimeZone)} instead. 
+  <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+ */
+  public long getNanosecondsInstant(TimeZone aTimeZone){
+    // these are always positive:
+    Integer year = getYear();
+    Integer month = getMonth();
+    Integer day = getDay();
+    //coerce missing times to 0:
+    Integer hour = getHour() == null ? 0 : getHour();
+    Integer minute = getMinute() == null ? 0 : getMinute();
+    Integer second = getSecond() == null ? 0 : getSecond();
+    Integer nanos = getNanoseconds() == null ? 0 : getNanoseconds();
+    
+    int millis = nanos / MILLION; //integer division truncates, doesn't round
+    int nanosRemaining = nanos % MILLION; //0..999,999 - always positive
+    
+    //base calculation in millis
+    Calendar calendar = new GregorianCalendar(aTimeZone);
+    calendar.set(Calendar.YEAR, year);
+    calendar.set(Calendar.MONTH, month-1); // 0-based
+    calendar.set(Calendar.DAY_OF_MONTH, day);
+    calendar.set(Calendar.HOUR_OF_DAY, hour); // 0..23
+    calendar.set(Calendar.MINUTE, minute);
+    calendar.set(Calendar.SECOND, second);
+    calendar.set(Calendar.MILLISECOND, millis);
+    
+    long baseResult = calendar.getTimeInMillis() * MILLION; // either sign
+    //the adjustment for nanos is always positive, toward the future:
+    return baseResult + nanosRemaining;
+  }
+  
+  /**
+   Return the raw date-time String passed to the {@link #DateTime(String)} constructor.
+   Returns <tt>null</tt> if that constructor was not called. See {@link #toString()} as well.
+   */
+  public String getRawDateString() {
+    return fDateTime;
+  }
+
+  /** Return the year, 1..9999. */
+  public Integer getYear() {
+    ensureParsed();
+    return fYear;
+  }
+
+  /** Return the Month, 1..12. */
+  public Integer getMonth() {
+    ensureParsed();
+    return fMonth;
+  }
+
+  /** Return the Day of the Month, 1..31. */
+  public Integer getDay() {
+    ensureParsed();
+    return fDay;
+  }
+
+  /** Return the Hour, 0..23. */
+  public Integer getHour() {
+    ensureParsed();
+    return fHour;
+  }
+
+  /** Return the Minute, 0..59. */
+  public Integer getMinute() {
+    ensureParsed();
+    return fMinute;
+  }
+
+  /** Return the Second, 0..59. */
+  public Integer getSecond() {
+    ensureParsed();
+    return fSecond;
+  }
+
+  /** Return the Nanosecond, 0..999999999. */
+  public Integer getNanoseconds() {
+    ensureParsed();
+    return fNanosecond;
+  }
+
+  /**
+   Return the Modified Julian Day Number. 
+   <P>The Modified Julian Day Number is defined by astronomers for simplifying the calculation of the number of days between 2 dates. 
+   Returns a monotonically increasing sequence number. 
+   Day 0 is November 17, 1858 00:00:00 (whose Julian Date was 2400000.5).
+   
+   <P>Using the Modified Julian Day Number instead of the Julian Date has 2 advantages:
+   <ul>
+   <li>it's a smaller number
+   <li>it starts at midnight, not noon (Julian Date starts at noon)
+   </ul>
+   
+   <P>Does not reflect any time portion, if present. 
+   
+   <P>(In spite of its name, this method, like all other methods in this class, uses the 
+   proleptic Gregorian calendar - not the Julian calendar.)
+   
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public Integer getModifiedJulianDayNumber() {
+    ensureHasYearMonthDay();
+    int result = calculateJulianDayNumberAtNoon() - 1 - EPOCH_MODIFIED_JD;
+    return result;
+  }
+
+  /**
+   Return an index for the weekday for this <tt>DateTime</tt>.
+   Returns 1..7 for Sunday..Saturday.
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public Integer getWeekDay() {
+    ensureHasYearMonthDay();
+    int dayNumber = calculateJulianDayNumberAtNoon() + 1;
+    int index = dayNumber % 7;
+    return index + 1;
+  }
+
+  /**
+   Return an integer in the range 1..366, representing a count of the number of days from the start of the year.
+   January 1 is counted as day 1.
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public Integer getDayOfYear() {
+    ensureHasYearMonthDay();
+    int k = isLeapYear() ? 1 : 2;
+    Integer result = ((275 * fMonth) / 9) - k * ((fMonth + 9) / 12) + fDay - 30; // integer division
+    return result;
+  }
+
+  /**
+   Returns true only if the year is a leap year. 
+   <P>Requires year to be present; if not, a runtime exception is thrown.
+   */
+  public Boolean isLeapYear() {
+    ensureParsed();
+    Boolean result = null;
+    if (isPresent(fYear)) {
+      result = isLeapYear(fYear);
+    }
+    else {
+      throw new MissingItem("Year is absent. Cannot determine if leap year.");
+    }
+    return result;
+  }
+
+  /** 
+   Return the number of days in the month which holds this <tt>DateTime</tt>. 
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public int getNumDaysInMonth() {
+    ensureHasYearMonthDay();
+    return getNumDaysInMonth(fYear, fMonth);
+  }
+
+  /**
+   Return The week index of this <tt>DateTime</tt> with respect to a given starting <tt>DateTime</tt>.
+   <P>The single parameter to this method defines first day of week number 1.
+   See {@link #getWeekIndex()} as well. 
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public Integer getWeekIndex(DateTime aStartingFromDate) {
+    ensureHasYearMonthDay();
+    aStartingFromDate.ensureHasYearMonthDay();
+    int diff = getModifiedJulianDayNumber() - aStartingFromDate.getModifiedJulianDayNumber();
+    return (diff / 7) + 1; // integer division
+  }
+
+  /**
+   Return The week index of this <tt>DateTime</tt>, taking day 1 of week 1 as Sunday, January 2, 2000. 
+   <P>See {@link #getWeekIndex(DateTime)} as well, which takes an arbitrary date to define 
+   day 1 of week 1. 
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public Integer getWeekIndex() {
+    DateTime start = DateTime.forDateOnly(2000, 1, 2);
+    return getWeekIndex(start);
+  }
+
+  /**
+   Return <tt>true</tt> only if this <tt>DateTime</tt> has the same year-month-day as the given parameter.
+   Time is ignored by this method.
+   <P> Requires year-month-day to be present, both for this <tt>DateTime</tt> and for
+   <tt>aThat</tt>; if not, a runtime exception is thrown.
+   */
+  public boolean isSameDayAs(DateTime aThat) {
+    boolean result = false;
+    ensureHasYearMonthDay();
+    aThat.ensureHasYearMonthDay();
+    result = (fYear.equals(aThat.fYear) && fMonth.equals(aThat.fMonth) && fDay.equals(aThat.fDay));
+    return result;
+  }
+
+  /**  
+   'Less than' comparison.
+   Return <tt>true</tt> only if this <tt>DateTime</tt> comes before the given parameter, according to {@link #compareTo(DateTime)}.  
+  */
+  public boolean lt(DateTime aThat) {
+    return compareTo(aThat) < EQUAL;
+  }
+
+  /**  
+   'Less than or equal to' comparison.
+   Return <tt>true</tt> only if this <tt>DateTime</tt> comes before the given parameter, according to {@link #compareTo(DateTime)}, 
+   or this <tt>DateTime</tt> equals the given parameter.  
+  */
+  public boolean lteq(DateTime aThat) {
+    return compareTo(aThat) < EQUAL || equals(aThat);
+  }
+
+  /**
+   'Greater than' comparison.  
+   Return <tt>true</tt> only if this <tt>DateTime</tt> comes after the given parameter, according to {@link #compareTo(DateTime)}. 
+  */
+  public boolean gt(DateTime aThat) {
+    return compareTo(aThat) > EQUAL;
+  }
+  
+  /**  
+   'Greater than or equal to' comparison.
+   Return <tt>true</tt> only if this <tt>DateTime</tt> comes after the given parameter, according to {@link #compareTo(DateTime)}, 
+   or this <tt>DateTime</tt> equals the given parameter.  
+  */
+  public boolean gteq(DateTime aThat) {
+    return compareTo(aThat) > EQUAL || equals(aThat);
+  }
+
+  /** Return the smallest non-null time unit encapsulated by this <tt>DateTime</tt>. */
+  public Unit getPrecision() {
+    ensureParsed();
+    Unit result = null;
+    if (isPresent(fNanosecond)) {
+      result = Unit.NANOSECONDS;
+    }
+    else if (isPresent(fSecond)) {
+      result = Unit.SECOND;
+    }
+    else if (isPresent(fMinute)) {
+      result = Unit.MINUTE;
+    }
+    else if (isPresent(fHour)) {
+      result = Unit.HOUR;
+    }
+    else if (isPresent(fDay)) {
+      result = Unit.DAY;
+    }
+    else if (isPresent(fMonth)) {
+      result = Unit.MONTH;
+    }
+    else if (isPresent(fYear)) {
+      result = Unit.YEAR;
+    }
+    return result;
+  }
+
+  /**
+   Truncate this <tt>DateTime</tt> to the given precision.
+   <P>The return value will have all items lower than the given precision simply set to
+   <tt>null</tt>. In addition, the return value will not include any date-time String passed to the 
+   {@link #DateTime(String)} constructor.
+   
+   @param aPrecision takes any value <i>except</i> {@link Unit#NANOSECONDS} (since it makes no sense to truncate to the highest
+   available precision).
+   */
+  public DateTime truncate(Unit aPrecision) {
+    ensureParsed();
+    DateTime result = null;
+    if (Unit.NANOSECONDS == aPrecision) {
+      throw new IllegalArgumentException("It makes no sense to truncate to nanosecond precision, since that's the highest precision available.");
+    }
+    else if (Unit.SECOND == aPrecision) {
+      result = new DateTime(fYear, fMonth, fDay, fHour, fMinute, fSecond, null);
+    }
+    else if (Unit.MINUTE == aPrecision) {
+      result = new DateTime(fYear, fMonth, fDay, fHour, fMinute, null, null);
+    }
+    else if (Unit.HOUR == aPrecision) {
+      result = new DateTime(fYear, fMonth, fDay, fHour, null, null, null);
+    }
+    else if (Unit.DAY == aPrecision) {
+      result = new DateTime(fYear, fMonth, fDay, null, null, null, null);
+    }
+    else if (Unit.MONTH == aPrecision) {
+      result = new DateTime(fYear, fMonth, null, null, null, null, null);
+    }
+    else if (Unit.YEAR == aPrecision) {
+      result = new DateTime(fYear, null, null, null, null, null, null);
+    }
+    return result;
+  }
+
+  /**
+   Return <tt>true</tt> only if all of the given units are present in this <tt>DateTime</tt>.
+   If a unit is <i>not</i> included in the argument list, then no test is made for its presence or absence
+   in this <tt>DateTime</tt> by this method.
+   */
+  public boolean unitsAllPresent(Unit... aUnits) {
+    boolean result = true;
+    ensureParsed();
+    for (Unit unit : aUnits) {
+      if (Unit.NANOSECONDS == unit) {
+        result = result && fNanosecond != null;
+      }
+      else if (Unit.SECOND == unit) {
+        result = result && fSecond != null;
+      }
+      else if (Unit.MINUTE == unit) {
+        result = result && fMinute != null;
+      }
+      else if (Unit.HOUR == unit) {
+        result = result && fHour != null;
+      }
+      else if (Unit.DAY == unit) {
+        result = result && fDay != null;
+      }
+      else if (Unit.MONTH == unit) {
+        result = result && fMonth != null;
+      }
+      else if (Unit.YEAR == unit) {
+        result = result && fYear != null;
+      }
+    }
+    return result;
+  }
+
+  /**
+   Return <tt>true</tt> only if this <tt>DateTime</tt> has a non-null values for year, month, and day.
+  */
+  public boolean hasYearMonthDay() {
+    return unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY);
+  }
+
+  /**
+   Return <tt>true</tt> only if this <tt>DateTime</tt> has a non-null values for hour, minute, and second.
+  */
+  public boolean hasHourMinuteSecond() {
+    return unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND);
+  }
+
+  /**
+   Return <tt>true</tt> only if all of the given units are absent from this <tt>DateTime</tt>.
+   If a unit is <i>not</i> included in the argument list, then no test is made for its presence or absence
+   in this <tt>DateTime</tt> by this method.
+   */
+  public boolean unitsAllAbsent(Unit... aUnits) {
+    boolean result = true;
+    ensureParsed();
+    for (Unit unit : aUnits) {
+      if (Unit.NANOSECONDS == unit) {
+        result = result && fNanosecond == null;
+      }
+      else if (Unit.SECOND == unit) {
+        result = result && fSecond == null;
+      }
+      else if (Unit.MINUTE == unit) {
+        result = result && fMinute == null;
+      }
+      else if (Unit.HOUR == unit) {
+        result = result && fHour == null;
+      }
+      else if (Unit.DAY == unit) {
+        result = result && fDay == null;
+      }
+      else if (Unit.MONTH == unit) {
+        result = result && fMonth == null;
+      }
+      else if (Unit.YEAR == unit) {
+        result = result && fYear == null;
+      }
+    }
+    return result;
+  }
+
+  /**
+   Return this <tt>DateTime</tt> with the time portion coerced to '00:00:00.000000000'.
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public DateTime getStartOfDay() {
+    ensureHasYearMonthDay();
+    return getStartEndDateTime(fDay, 0, 0, 0, 0);
+  }
+
+  /**
+   Return this <tt>DateTime</tt> with the time portion coerced to '23:59:59.999999999'. 
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public DateTime getEndOfDay() {
+    ensureHasYearMonthDay();
+    return getStartEndDateTime(fDay, 23, 59, 59, 999999999);
+  }
+
+  /**
+   Return this <tt>DateTime</tt> with the time portion coerced to '00:00:00.000000000', 
+   and the day coerced to 1.
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public DateTime getStartOfMonth() {
+    ensureHasYearMonthDay();
+    return getStartEndDateTime(1, 0, 0, 0, 0);
+  }
+
+  /**
+   Return this <tt>DateTime</tt> with the time portion coerced to '23:59:59.999999999', 
+   and the day coerced to the end of the month.
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   */
+  public DateTime getEndOfMonth() {
+    ensureHasYearMonthDay();
+    return getStartEndDateTime(getNumDaysInMonth(), 23, 59, 59, 999999999);
+  }
+
+  /**
+   Create a new <tt>DateTime</tt> by adding an interval to this one.
+   
+   <P>See {@link #plusDays(Integer)} as well. 
+   
+   <P>Changes are always applied by this class <i>in order of decreasing units of time</i>: 
+   years first, then months, and so on. After changing both the year and month, a check on the month-day combination is made before 
+   any change is made to the day. If the day exceeds the number of days in the given month/year, then 
+   (and only then) the given {@link DayOverflow} policy applied, and the day-of-the-month is adusted accordingly.
+   
+   <P>Afterwards, the day is then changed in the usual way, followed by the remaining items (hour, minute, second, and nanosecond). 
+ 
+   <P><em>The mental model for this method is very similar to that of a car's odometer.</em> When a limit is reach for one unit of time, 
+   then a rollover occurs for a neighbouring unit of time. 
+   
+   <P>The returned value cannot come after <tt>9999-12-13 23:59:59</tt>.
+   
+   <P>This class works with <tt>DateTime</tt>'s having the following items present :
+   <ul>
+   <li>year-month-day and hour-minute-second (and optional nanoseconds)
+   <li>year-month-day only. In this case, if a calculation with a time part is performed, that time part 
+   will be initialized by this class to 00:00:00.0, and the <tt>DateTime</tt> returned by this class will include a time part.
+   <li>hour-minute-second (and optional nanoseconds) only. In this case, the calculation is done starting with the   
+   the arbitrary date <tt>0001-01-01</tt> (in order to remain within a valid state space of <tt>DateTime</tt>). 
+   </ul>
+   
+   @param aNumYears positive, required, in range 0...9999 
+   @param aNumMonths positive, required, in range 0...9999 
+   @param aNumDays positive, required, in range 0...9999 
+   @param aNumHours positive, required, in range 0...9999 
+   @param aNumMinutes positive, required, in range 0...9999 
+   @param aNumSeconds positive, required, in range 0...9999 
+   @param aNumNanoseconds positive, required, in range 0...999999999 
+   */
+  public DateTime plus(Integer aNumYears, Integer aNumMonths, Integer aNumDays, Integer aNumHours, Integer aNumMinutes, Integer aNumSeconds, Integer aNumNanoseconds,  DayOverflow aDayOverflow) {
+    DateTimeInterval interval = new DateTimeInterval(this, aDayOverflow);
+    return interval.plus(aNumYears, aNumMonths, aNumDays, aNumHours, aNumMinutes, aNumSeconds, aNumNanoseconds);
+  }
+
+  /**
+   Create a new <tt>DateTime</tt> by subtracting an interval to this one.
+   
+   <P>See {@link #minusDays(Integer)} as well. 
+   <P>This method has nearly the same behavior as {@link #plus(Integer, Integer, Integer, Integer, Integer, Integer, Integer, DayOverflow)},
+   except that the return value cannot come before <tt>0001-01-01 00:00:00</tt>.
+   */
+  public DateTime minus(Integer aNumYears, Integer aNumMonths, Integer aNumDays, Integer aNumHours, Integer aNumMinutes, Integer aNumSeconds, Integer aNumNanoseconds, DayOverflow aDayOverflow) {
+    DateTimeInterval interval = new DateTimeInterval(this, aDayOverflow);
+    return interval.minus(aNumYears, aNumMonths, aNumDays, aNumHours, aNumMinutes, aNumSeconds, aNumNanoseconds);
+  }
+
+  /**
+   Return a new <tt>DateTime</tt> by adding an integral number of days to this one.
+   
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   @param aNumDays can be either sign; if negative, then the days are subtracted. 
+   */
+  public DateTime plusDays(Integer aNumDays) {
+    ensureHasYearMonthDay();
+    int thisJDAtNoon = getModifiedJulianDayNumber() + 1 + EPOCH_MODIFIED_JD;
+    int resultJD = thisJDAtNoon + aNumDays;
+    DateTime datePortion = fromJulianDayNumberAtNoon(resultJD);
+    return new DateTime(datePortion.getYear(), datePortion.getMonth(), datePortion.getDay(), fHour, fMinute, fSecond, fNanosecond);
+  }
+
+  /**
+   Return a new <tt>DateTime</tt> by subtracting an integral number of days from this one.
+   
+   <P>Requires year-month-day to be present; if not, a runtime exception is thrown.
+   @param aNumDays can be either sign; if negative, then the days are added.
+   */
+  public DateTime minusDays(Integer aNumDays) {
+    return plusDays(-1 * aNumDays);
+  }
+
+  /**  
+   The whole number of days between this <tt>DateTime</tt> and the given parameter. 
+   <P>Requires year-month-day to be present, both for this <tt>DateTime</tt> and for the <tt>aThat</tt> 
+   parameter; if not, a runtime exception is thrown.
+  */
+  public int numDaysFrom(DateTime aThat) {
+    return aThat.getModifiedJulianDayNumber() - this.getModifiedJulianDayNumber();
+  }
+
+  /** 
+    The number of seconds between this <tt>DateTime</tt> and the given argument.
+    <P>If only time information is present in both this <tt>DateTime</tt> and <tt>aThat</tt>, then there are 
+    no restrictions on the values of the time units. 
+    <P>If any date information is present, in either this <tt>DateTime</tt> or <tt>aThat</tt>, 
+    then full year-month-day must be present in both; if not, then the date portion will be ignored, and only the 
+    time portion will contribute to the calculation.
+  */
+  public long numSecondsFrom(DateTime aThat) {
+    long result = 0;
+    if(hasYearMonthDay() && aThat.hasYearMonthDay()){
+      result = numDaysFrom(aThat) * 86400; //just the day portion
+    }
+    result = result - this.numSecondsInTimePortion() + aThat.numSecondsInTimePortion();
+    return result;
+  }
+
+  /**
+   Output this <tt>DateTime</tt> as a formatted String using numbers, with no localizable text.
+   
+   <P>Example:
+   <PRE>dt.format("YYYY-MM-DD hh:mm:ss");</PRE>
+   would generate text of the form
+   <PRE>2009-09-09 18:23:59</PRE>
+   
+   <P>If months, weekdays, or AM/PM indicators are output as localizable text, you must use {@link #format(String, Locale)}.
+   @param aFormat uses the <a href="#FormattingLanguage">formatting mini-language</a> defined in the class comment.
+   */
+  public String format(String aFormat) {
+    DateTimeFormatter format = new DateTimeFormatter(aFormat);
+    return format.format(this);
+  }
+
+  /**
+   Output this <tt>DateTime</tt> as a formatted String using numbers and/or localizable text.
+   
+   <P>This method is intended for alphanumeric output, such as '<tt>Sunday, November 14, 1858 10:00 AM</tt>'.
+   <P>If months and weekdays are output as numbers, you are encouraged to use {@link #format(String)} instead.
+   
+   @param aFormat uses the <a href="#FormattingLanguage">formatting mini-language</a> defined in the class comment.
+   @param aLocale used to generate text for Month, Weekday and AM/PM indicator; required only by patterns which return localized 
+   text, instead of numeric forms.
+   */
+  public String format(String aFormat, Locale aLocale) {
+    DateTimeFormatter format = new DateTimeFormatter(aFormat, aLocale);
+    return format.format(this);
+  }
+
+  /**
+   Output this <tt>DateTime</tt> as a formatted String using numbers and explicit text for months, weekdays, and AM/PM indicator. 
+
+   <P>Use of this method is likely relatively rare; it should be used only if the output of {@link #format(String, Locale)}  is 
+   inadequate. 
+   
+   @param aFormat uses the <a href="#FormattingLanguage">formatting mini-language</a> defined in the class comment.
+   @param aMonths contains text for all 12 months, starting with January; size must be 12. 
+   @param aWeekdays contains text for all 7 weekdays, starting with Sunday; size must be 7. 
+   @param aAmPmIndicators contains text for A.M and P.M. indicators (in that order); size must be 2. 
+   */
+  public String format(String aFormat, List<String> aMonths, List<String> aWeekdays, List<String> aAmPmIndicators) {
+    DateTimeFormatter format = new DateTimeFormatter(aFormat, aMonths, aWeekdays, aAmPmIndicators);
+    return format.format(this);
+  }
+
+  /**
+   Return the current date-time.
+   <P>Combines the configured implementation of {@link TimeSource} with the given {@link TimeZone}.
+   The <tt>TimeZone</tt> will typically come from your implementation of {@link TimeZoneSource}. 
+   
+   <P>In an Action, the current date-time date can be referenced using 
+   <PRE>DateTime.now(getTimeZone())</PRE>
+   See {@link ActionImpl#getTimeZone()}.
+   
+   <P>Only millisecond precision is possible for this method.
+   */
+  public static DateTime now(TimeZone aTimeZone) {
+    TimeSource timesource = BuildImpl.forTimeSource();
+    return forInstant(timesource.currentTimeMillis(), aTimeZone);
+  }
+
+  /** 
+   Return the current date.
+   <P>As in {@link #now(TimeZone)}, but truncates the time portion, leaving only year-month-day.
+   <P>In an Action, today's date can be referenced using 
+   <PRE>DateTime.today(getTimeZone())</PRE>
+   See {@link ActionImpl#getTimeZone()}. 
+   */
+  public static DateTime today(TimeZone aTimeZone) {
+    DateTime result = now(aTimeZone);
+    return result.truncate(Unit.DAY);
+  }
+
+  /** Return <tt>true</tt> only if this date is in the future, with respect to {@link #now(TimeZone)}. */
+  public boolean isInTheFuture(TimeZone aTimeZone) {
+    return now(aTimeZone).lt(this);
+  }
+
+  /** Return <tt>true</tt> only if this date is in the past, with respect to {@link #now(TimeZone)}. */
+  public boolean isInThePast(TimeZone aTimeZone) {
+    return now(aTimeZone).gt(this);
+  }
+  
+  /**
+    Return a <tt>DateTime</tt> corresponding to a change from one {@link TimeZone} to another.
+    
+    <P>A <tt>DateTime</tt> object has an implicit and immutable time zone. 
+    If you need to change the implicit time zone, you can use this method to do so.
+    
+    <P>Example :
+    <PRE>
+TimeZone fromUK = TimeZone.getTimeZone("Europe/London");
+TimeZone toIndonesia = TimeZone.getTimeZone("Asia/Jakarta");
+DateTime newDt = oldDt.changeTimeZone(fromUK, toIndonesia);
+    </PRE>
+     
+   <P>Requires year-month-day-hour to be present; if not, a runtime exception is thrown.
+   @param aFromTimeZone the implicit time zone of this object.
+   @param aToTimeZone the implicit time zone of the <tt>DateTime</tt> returned by this method.
+   @return aDateTime corresponding to the change of time zone implied by the 2 parameters.
+   */
+  public DateTime changeTimeZone(TimeZone aFromTimeZone, TimeZone aToTimeZone){
+    DateTime result = null;
+    ensureHasYearMonthDay();
+    if (unitsAllAbsent(Unit.HOUR)){
+      throw new IllegalArgumentException("DateTime does not include the hour. Cannot change the time zone if no hour is present.");
+    }
+    Calendar fromDate = new GregorianCalendar(aFromTimeZone);
+    fromDate.set(Calendar.YEAR, getYear());
+    fromDate.set(Calendar.MONTH, getMonth()-1);
+    fromDate.set(Calendar.DAY_OF_MONTH, getDay());
+    fromDate.set(Calendar.HOUR_OF_DAY, getHour());
+    if(getMinute() != null) {
+      fromDate.set(Calendar.MINUTE, getMinute());
+    }
+    else {
+      fromDate.set(Calendar.MINUTE, 0);
+    }
+    //other items zeroed out here, since they don't matter for time zone calculations
+    fromDate.set(Calendar.SECOND, 0);
+    fromDate.set(Calendar.MILLISECOND, 0);
+
+    //millisecond precision is OK here, since the seconds/nanoseconds are not part of the calc
+    Calendar toDate = new GregorianCalendar(aToTimeZone);
+    toDate.setTimeInMillis(fromDate.getTimeInMillis());
+    //needed if this date has hour, but no minute (bit of an oddball case) :
+    Integer minute = getMinute() != null ? toDate.get(Calendar.MINUTE) : null;
+    result = new DateTime(
+      toDate.get(Calendar.YEAR), toDate.get(Calendar.MONTH) + 1, toDate.get(Calendar.DAY_OF_MONTH), 
+      toDate.get(Calendar.HOUR_OF_DAY), minute, getSecond(), getNanoseconds() 
+    );
+    return result;
+  }
+
+  /**
+   Compare this object to another, for ordering purposes.
+   <P> Uses the 7 date-time elements (year..nanosecond). The Year is considered the most
+   significant item, and the Nanosecond the least significant item. Null items are placed first in this comparison.
+   */
+  public int compareTo(DateTime aThat) {
+    if (this == aThat) return EQUAL;
+    ensureParsed();
+    aThat.ensureParsed();
+
+    NullsGo nullsGo = NullsGo.FIRST;
+    int comparison = ModelUtil.comparePossiblyNull(this.fYear, aThat.fYear, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    comparison = ModelUtil.comparePossiblyNull(this.fMonth, aThat.fMonth, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    comparison = ModelUtil.comparePossiblyNull(this.fDay, aThat.fDay, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    comparison = ModelUtil.comparePossiblyNull(this.fHour, aThat.fHour, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    comparison = ModelUtil.comparePossiblyNull(this.fMinute, aThat.fMinute, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    comparison = ModelUtil.comparePossiblyNull(this.fSecond, aThat.fSecond, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    comparison = ModelUtil.comparePossiblyNull(this.fNanosecond, aThat.fNanosecond, nullsGo);
+    if (comparison != EQUAL)  return comparison;
+
+    return EQUAL;
+  }
+
+  /**
+   Equals method for this object.
+   
+   <P>Equality is determined by the 7 date-time elements (year..nanosecond).
+   */
+  @Override public boolean equals(Object aThat) {
+    /*
+     * Implementation note: it was considered branching this method, according to whether
+     * the objects are already parsed. That was rejected, since maintaining 'synchronicity'
+     * with hashCode would not then be possible, since hashCode is based only on one object,
+     * not two.
+     */
+    ensureParsed();
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if (result == null) {
+      DateTime that = (DateTime)aThat;
+      that.ensureParsed();
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;
+  }
+
+  /**
+   Hash code for this object.
+   
+   <P> Uses the same 7 date-time elements (year..nanosecond) as used by
+   {@link #equals(Object)}.
+   */
+  @Override public int hashCode() {
+    if (fHashCode == 0) {
+      ensureParsed();
+      fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
+    }
+    return fHashCode;
+  }
+
+  /**
+  Intended for <i>debugging and logging</i> only.
+  
+  <P><b>To format this <tt>DateTime</tt> for presentation to the user, see the various <tt>format</tt> methods.</b>
+  
+  <P>If the {@link #DateTime(String)} constructor was called, then return that String. 
+  
+  <P>Otherwise, the return value is constructed from each date-time element, in a fixed format, depending 
+  on which time units are present. Example values :
+  <ul>
+   <li>2011-04-30 13:59:59.123456789
+   <li>2011-04-30 13:59:59
+   <li>2011-04-30
+   <li>2011-04-30 13:59
+   <li>13:59:59.123456789
+   <li>13:59:59
+   <li>and so on...
+  </ul>
+  
+  <P>In the great majority of cases, this will give reasonable output for debugging and logging statements.
+  
+  <P>In cases where a bizarre combinations of time units is present, the return value is presented in a verbose form.
+  For example, if all time units are present <i>except</i> for minutes, the return value has this form:
+  <PRE>Y:2001 M:1 D:31 h:13 m:null s:59 f:123456789</PRE> 
+ */
+  @Override public String toString() {
+    String result = "";
+    if (Util.textHasContent(fDateTime)) {
+      result = fDateTime;
+    }
+    else {
+      String format = calcToStringFormat();
+      if(format != null){
+        result = format(calcToStringFormat());
+      }
+      else {
+        StringBuilder builder = new StringBuilder();
+        addToString("Y", fYear, builder);
+        addToString("M", fMonth, builder);
+        addToString("D", fDay, builder);
+        addToString("h", fHour, builder);
+        addToString("m", fMinute, builder);
+        addToString("s", fSecond, builder);
+        addToString("f", fNanosecond, builder);
+        result = builder.toString().trim();
+      }
+    }
+    return result;
+  }
+
+  // PACKAGE-PRIVATE (for unit testing, mostly)
+
+  static final class ItemOutOfRange extends RuntimeException {
+    ItemOutOfRange(String aMessage) {
+      super(aMessage);
+    }
+  }
+
+  static final class MissingItem extends RuntimeException {
+    MissingItem(String aMessage) {
+      super(aMessage);
+    }
+  }
+
+  /** Intended as internal tool, for testing only. Not scope is not public! */
+  void ensureParsed() {
+    if (!fIsAlreadyParsed) {
+      parseDateTimeText();
+    }
+  }
+
+  /**
+   Return the number of days in the given month. The returned value depends on the year as
+   well, because of leap years. Returns <tt>null</tt> if either year or month are
+   absent. WRONG - should be public??
+   Package-private, needed for interval calcs.
+   */
+  static Integer getNumDaysInMonth(Integer aYear, Integer aMonth) {
+    Integer result = null;
+    if (aYear != null && aMonth != null) {
+      if (aMonth == 1) {
+        result = 31;
+      }
+      else if (aMonth == 2) {
+        result = isLeapYear(aYear) ? 29 : 28;
+      }
+      else if (aMonth == 3) {
+        result = 31;
+      }
+      else if (aMonth == 4) {
+        result = 30;
+      }
+      else if (aMonth == 5) {
+        result = 31;
+      }
+      else if (aMonth == 6) {
+        result = 30;
+      }
+      else if (aMonth == 7) {
+        result = 31;
+      }
+      else if (aMonth == 8) {
+        result = 31;
+      }
+      else if (aMonth == 9) {
+        result = 30;
+      }
+      else if (aMonth == 10) {
+        result = 31;
+      }
+      else if (aMonth == 11) {
+        result = 30;
+      }
+      else if (aMonth == 12) {
+        result = 31;
+      }
+      else {
+        throw new AssertionError("Month is out of range 1..12:" + aMonth);
+      }
+    }
+    return result;
+  }
+
+  static DateTime fromJulianDayNumberAtNoon(int aJDAtNoon) {
+    //http://www.hermetic.ch/cal_stud/jdn.htm
+    int l = aJDAtNoon + 68569;
+    int n = (4 * l) / 146097;
+    l = l - (146097 * n + 3) / 4;
+    int i = (4000 * (l + 1)) / 1461001;
+    l = l - (1461 * i) / 4 + 31;
+    int j = (80 * l) / 2447;
+    int d = l - (2447 * j) / 80;
+    l = j / 11;
+    int m = j + 2 - (12 * l);
+    int y = 100 * (n - 49) + i + l;
+    return DateTime.forDateOnly(y, m, d);
+  }
+
+  // PRIVATE
+
+  /*
+   There are 2 representations of a date - a text form, and a 'parsed' form, in which all
+   of the elements of the date are separated out. A DateTime starts out with one of these
+   forms, and may need to generate the other.
+   */
+
+  /** The text form of a date. @serial */
+  private String fDateTime;
+
+  /* The following 7 items represent the parsed form of a DateTime. */
+  /**  @serial */
+  private Integer fYear;
+  /**  @serial */
+  private Integer fMonth;
+  /**  @serial */
+  private Integer fDay;
+  /**  @serial */
+  private Integer fHour;
+  /**  @serial */
+  private Integer fMinute;
+  /**  @serial */
+  private Integer fSecond;
+  /**  @serial */
+  private Integer fNanosecond;
+
+  /** Indicates if this DateTime has been parsed into its 7 constituents. @serial */
+  private boolean fIsAlreadyParsed;
+
+  /** @serial */
+  private int fHashCode;
+  
+  private static final int EQUAL = 0;
+  
+  private static int EPOCH_MODIFIED_JD = 2400000;
+  
+  private static final int MILLION = 1000000;
+  
+  private static final long serialVersionUID =  -1300068157085493891L; 
+    
+  /**
+   Return a the whole number, with no fraction.
+   The JD at noon is 1 more than the JD at midnight. 
+   */
+  private int calculateJulianDayNumberAtNoon() {
+    //http://www.hermetic.ch/cal_stud/jdn.htm
+    int y = fYear;
+    int m = fMonth;
+    int d = fDay;
+    int result = (1461 * (y + 4800 + (m - 14) / 12)) / 4 + (367 * (m - 2 - 12 * ((m - 14) / 12))) / 12 - (3 * ((y + 4900 + (m - 14) / 12) / 100)) / 4 + d - 32075;
+    return result;
+  }
+
+  private void ensureHasYearMonthDay() {
+    ensureParsed();
+    if (!hasYearMonthDay()) {
+      throw new MissingItem("DateTime does not include year/month/day.");
+    }
+  }
+
+  /** Return the number of seconds in any existing time portion of the date. */
+  private int numSecondsInTimePortion() {
+    int result = 0;
+    if (fSecond != null) {
+      result = result + fSecond;
+    }
+    if (fMinute != null) {
+      result = result + 60 * fMinute;
+    }
+    if (fHour != null) {
+      result = result + 3600 * fHour;
+    }
+    return result;
+  }
+
+  private void validateState() {
+    checkRange(fYear, 1, 9999, "Year");
+    checkRange(fMonth, 1, 12, "Month");
+    checkRange(fDay, 1, 31, "Day");
+    checkRange(fHour, 0, 23, "Hour");
+    checkRange(fMinute, 0, 59, "Minute");
+    checkRange(fSecond, 0, 59, "Second");
+    checkRange(fNanosecond, 0, 999999999, "Nanosecond");
+    checkNumDaysInMonth(fYear, fMonth, fDay);
+  }
+
+  private void checkRange(Integer aValue, int aMin, int aMax, String aName) {
+    if (!Check.optional(aValue, Check.range(aMin, aMax))) {
+      throw new ItemOutOfRange(aName + " is not in the range " + aMin + ".." + aMax + ". Value is:" + aValue);
+    }
+  }
+
+  private void checkNumDaysInMonth(Integer aYear, Integer aMonth, Integer aDay) {
+    if (hasYearMonthDay(aYear, aMonth, aDay) && aDay > getNumDaysInMonth(aYear, aMonth)) {
+      throw new ItemOutOfRange("The day-of-the-month value '" + aDay + "' exceeds the number of days in the month: " + getNumDaysInMonth(aYear, aMonth));
+    }
+  }
+
+  private void parseDateTimeText() {
+    DateTimeParser parser = new DateTimeParser();
+    DateTime dateTime = parser.parse(fDateTime);
+    /*
+     * This is unusual - we essentially copy from one object to another. This could be
+     * avoided by building another interface, But defining a top-level interface for this
+     * simple task is too high a price.
+     */
+    fYear = dateTime.fYear;
+    fMonth = dateTime.fMonth;
+    fDay = dateTime.fDay;
+    fHour = dateTime.fHour;
+    fMinute = dateTime.fMinute;
+    fSecond = dateTime.fSecond;
+    fNanosecond = dateTime.fNanosecond;
+    validateState();
+  }
+
+  private boolean hasYearMonthDay(Integer aYear, Integer aMonth, Integer aDay) {
+    return isPresent(aYear, aMonth, aDay);
+  }
+
+  private static boolean isLeapYear(Integer aYear) {
+    boolean result = false;
+    if (aYear % 100 == 0) {
+      // this is a century year
+      if (aYear % 400 == 0) {
+        result = true;
+      }
+    }
+    else if (aYear % 4 == 0) {
+      result = true;
+    }
+    return result;
+  }
+
+  private Object[] getSignificantFields() {
+    return new Object[]{fYear, fMonth, fDay, fHour, fMinute, fSecond, fNanosecond};
+  }
+
+  private void addToString(String aName, Object aValue, StringBuilder aBuilder) {
+    aBuilder.append(aName + ":" + String.valueOf(aValue) + " ");
+  }
+
+  /** Return true only if all the given arguments are non-null. */
+  private boolean isPresent(Object... aItems) {
+    boolean result = true;
+    for (Object item : aItems) {
+      if (item == null) {
+        result = false;
+        break;
+      }
+    }
+    return result;
+  }
+
+  private DateTime getStartEndDateTime(Integer aDay, Integer aHour, Integer aMinute, Integer aSecond, Integer aNanosecond) {
+    ensureHasYearMonthDay();
+    return new DateTime(fYear, fMonth, aDay, aHour, aMinute, aSecond, aNanosecond);
+  }
+  
+  private String calcToStringFormat(){
+    String result = null; //caller will check for this; null means the set of units is bizarre
+    if(unitsAllPresent(Unit.YEAR) && unitsAllAbsent(Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){
+      result = "YYYY";
+    }
+    else if (unitsAllPresent(Unit.YEAR, Unit.MONTH) && unitsAllAbsent(Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){
+      result = "YYYY-MM";
+    }
+    else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && unitsAllAbsent(Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){
+      result = "YYYY-MM-DD";
+    }
+    else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR) && unitsAllAbsent(Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){
+      result = "YYYY-MM-DD hh";
+    }
+    else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE) && unitsAllAbsent(Unit.SECOND, Unit.NANOSECONDS)){
+      result = "YYYY-MM-DD hh:mm";
+    }
+    else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND) && unitsAllAbsent(Unit.NANOSECONDS)){
+      result = "YYYY-MM-DD hh:mm:ss";
+    }
+    else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){
+      result = "YYYY-MM-DD hh:mm:ss.fffffffff";
+    }
+    else if (unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY) && unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){
+      result = "hh:mm:ss.fffffffff";
+    }
+    else if (unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.NANOSECONDS) && unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND)){
+      result = "hh:mm:ss";
+    }
+    else if (unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.SECOND, Unit.NANOSECONDS) && unitsAllPresent(Unit.HOUR, Unit.MINUTE)){
+      result = "hh:mm";
+    }
+    return result;
+  }
+  
+  /**
+    Always treat de-serialization as a full-blown constructor, by
+    validating the final state of the de-serialized object.
+  */
+  private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
+    //always perform the default de-serialization first
+    aInputStream.defaultReadObject();
+    //no mutable fields in this case
+    validateState();
+  }
+
+   /**
+    This is the default implementation of writeObject.
+    Customise if necessary.
+  */
+  private void writeObject(ObjectOutputStream aOutputStream) throws IOException {
+    //perform the default serialization for all non-transient, non-static fields
+    aOutputStream.defaultWriteObject();
+  }
+  
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/DateTimeFormatter.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,611 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.util.Util;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.GregorianCalendar;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ Formats a {@link DateTime}, and implements {@link DateTime#format(String)}.
+ 
+ <P>This class defines a mini-language for defining how a {@link DateTime} is formatted.
+ See {@link DateTime#format(String)} for details regarding the formatting mini-language.
+
+ <P>The DateFormatSymbols class might be used to grab the locale-specific text, but the arrays it 
+ returns are wonky and weird, so I have avoided it.
+*/
+final class DateTimeFormatter {
+
+  /**
+   Constructor used for patterns that represent date-time elements using only numbers, and no localizable text. 
+   @param aFormat uses the syntax described by {@link DateTime#format(String)}.
+   */
+  DateTimeFormatter(String aFormat){
+    fFormat = aFormat;
+    fLocale = null;
+    fCustomLocalization = null;
+    validateState();
+  }
+
+  /**
+   Constructor used for patterns that represent date-time elements using not only numbers, but text as well. 
+   The text needs to be localizable.
+   @param aFormat uses the syntax described by {@link DateTime#format(String)}.
+   @param aLocale used to generate text for Month, Weekday, and AM-PM indicator; required only by patterns which return localized 
+   text, instead of numeric forms for date-time elements.
+   */
+  DateTimeFormatter(String aFormat, Locale aLocale){
+    fFormat = aFormat;
+    fLocale = aLocale;
+    fCustomLocalization = null;
+    validateState();
+  }
+
+  /**
+   Constructor used for patterns that represent using not only numbers, but customized text as well. 
+   
+   <P>This constructor exists mostly since SimpleDateFormat doesn't support all locales, and it has a 
+   policy of N letters for text, where N != 3. 
+
+   @param aFormat must match the syntax described by {@link DateTime#format(String)}.
+   @param aMonths contains text for all 12 months, starting with January; size must be 12. 
+   @param aWeekdays contains text for all 7 weekdays, starting with Sunday; size must be 7. 
+   @param aAmPmIndicators contains text for A.M and P.M. indicators (in that order); size must be 2. 
+   */
+  DateTimeFormatter(String aFormat, List<String> aMonths, List<String> aWeekdays, List<String> aAmPmIndicators){
+    fFormat = aFormat;
+    fLocale = null;
+    fCustomLocalization = new CustomLocalization(aMonths, aWeekdays, aAmPmIndicators);
+    validateState();
+  }
+  
+  /**  Format a {@link DateTime}.  */
+  String format(DateTime aDateTime){
+    fEscapedRanges = new ArrayList<EscapedRange>();
+    fInterpretedRanges = new ArrayList<InterpretedRange>();
+    findEscapedRanges();
+    interpretInput(aDateTime);
+    return produceFinalOutput();
+  }
+  
+  // PRIVATE 
+  private final String fFormat;
+  private final Locale fLocale;
+  private Collection<InterpretedRange> fInterpretedRanges;
+  private Collection<EscapedRange> fEscapedRanges;
+  
+   /** 
+    Table mapping a Locale to the names of the months.
+    Initially empty, populated only when a specific Locale is needed for presenting such text. 
+    Used for MMMM and MMM tokens.  
+  */
+  private final Map<Locale, List<String>> fMonths = new LinkedHashMap<Locale, List<String>>();
+   
+   /** 
+   Table mapping a Locale to the names of the weekdays.
+   Initially empty, populated only when a specific Locale is needed for presenting such text. 
+   Used for WWWW and WWW tokens.  
+  */
+  private final Map<Locale, List<String>> fWeekdays = new LinkedHashMap<Locale, List<String>>();
+   
+   /**
+    Table mapping a Locale to the text used to indicate a.m. and p.m. 
+    Initially empty, populated only when a specific Locale is needed for presenting such text. 
+    Used for the 'a' token.  
+  */
+  private final Map<Locale, List<String>> fAmPm = new LinkedHashMap<Locale, List<String>>();
+  
+  private final CustomLocalization fCustomLocalization;
+  
+  private final class CustomLocalization{
+    CustomLocalization(List<String> aMonths, List<String> aWeekdays, List<String> aAmPm){
+      if(aMonths.size() != 12){
+        throw new IllegalArgumentException("Your List of custom months must have size 12, but its size is " + aMonths.size());
+      }
+      if(aWeekdays.size() != 7){
+        throw new IllegalArgumentException("Your List of custom weekdays must have size 7, but its size is " + aWeekdays.size());
+      }
+      if(aAmPm.size() != 2){
+        throw new IllegalArgumentException("Your List of custom a.m./p.m. indicators must have size 2, but its size is " + aAmPm.size());
+      }
+      Months = aMonths;
+      Weekdays = aWeekdays;
+      AmPmIndicators = aAmPm;
+    }
+    List<String> Months;
+    List<String> Weekdays;
+    List<String> AmPmIndicators;
+  }
+
+  /** A section of fFormat containing a token that must be interpreted. */ 
+  private static final class InterpretedRange {
+    int Start; 
+    int End;
+    String Text;
+    @Override public String toString(){ return "Start:" + Start + " End:" + End + " '" + Text + "'";};
+  }
+  
+  /** A section of fFormat bounded by a pair of escape characters; such ranges contain uninterpreted text. */
+  private static final class EscapedRange {
+    int Start;
+    int End;
+  }
+
+  /** Special character used to escape the interpretation of parts of fFormat. */
+  private static final String ESCAPE_CHAR = "|";
+  private static final Pattern ESCAPED_RANGE = Pattern.compile("\\|[^\\|]*\\|");
+  
+  /* Here, 'token' means an item in the mini-language, having special meaning (defined below). */ 
+  
+  //all date-related tokens are in upper case
+  private static final String YYYY = "YYYY";
+  private static final String YY = "YY";
+  private static final String M = "M";
+  private static final String MM = "MM";
+  private static final String MMM = "MMM";
+  private static final String MMMM = "MMMM";
+  private static final String D = "D";
+  private static final String DD = "DD";
+  private static final String WWW = "WWW";
+  private static final String WWWW = "WWWW";
+  
+  //all time-related tokens are in lower case
+  private static final String hh = "hh";
+  private static final String h = "h";
+  private static final String m = "m";
+  private static final String mm = "mm";
+  private static final String s = "s";
+  private static final String ss = "ss";
+  
+  /**
+   The 12-hour clock style.
+    
+   12:00 am is midnight, 12:30am is 30 minutes past midnight, 12:00 pm is 12 noon.
+   This item is almost always used with 'a' to indicate am/pm.  
+  */
+  private static final String h12 = "h12";
+  
+  /** As {@link #h12}, but with leading zero. */ 
+  private static final String hh12 = "hh12";
+
+  private static final int AM = 0; //a.m. comes first in lists used by this class 
+  private static final int PM = 1;
+  
+  /**
+   A.M./P.M. text is sensitive to Locale, in the same way that names of months and weekdays are 
+   sensitive to Locale.  
+  */
+  private static final String a = "a"; 
+  
+  private static final Pattern FRACTIONALS = Pattern.compile("f{1,9}");
+  
+  private static final String EMPTY_STRING = "";
+  
+  /** 
+   The order of these items is significant, and is critical for how fFormat is interpreted.
+   The 'longer' tokens must come first, in any group of related tokens. 
+  */
+  private static final List<String> TOKENS = new ArrayList<String>();
+  static {
+    TOKENS.add(YYYY);
+    TOKENS.add(YY);
+    TOKENS.add(MMMM);
+    TOKENS.add(MMM);
+    TOKENS.add(MM);
+    TOKENS.add(M);
+    TOKENS.add(DD);
+    TOKENS.add(D);
+    TOKENS.add(WWWW);
+    TOKENS.add(WWW);
+    TOKENS.add(hh12);
+    TOKENS.add(h12);
+    TOKENS.add(hh);
+    TOKENS.add(h);
+    TOKENS.add(mm);
+    TOKENS.add(m);
+    TOKENS.add(ss);
+    TOKENS.add(s);
+    TOKENS.add(a);
+    //should these be constants too?
+    TOKENS.add("fffffffff");
+    TOKENS.add("ffffffff");
+    TOKENS.add("fffffff");
+    TOKENS.add("ffffff");
+    TOKENS.add("fffff");
+    TOKENS.add("ffff");
+    TOKENS.add("fff");
+    TOKENS.add("ff");
+    TOKENS.add("f");
+  }
+
+  /** Escaped ranges are bounded by a PAIR of {@link #ESCAPE_CHAR} characters. */
+  private void findEscapedRanges(){
+    Matcher matcher = ESCAPED_RANGE.matcher(fFormat);
+    while (matcher.find()){
+      EscapedRange escapedRange = new EscapedRange();
+      escapedRange.Start = matcher.start(); //first pipe
+      escapedRange.End = matcher.end() - 1; //second pipe
+      fEscapedRanges.add(escapedRange);
+    }
+  }
+  
+  /** Return true only if the start of the interpreted range is in an escaped range. */ 
+  private boolean isInEscapedRange(InterpretedRange aInterpretedRange){
+    boolean result = false; //innocent till shown guilty
+    for(EscapedRange escapedRange : fEscapedRanges){
+      //checking only the start is sufficient, because the tokens never contain the escape char
+      if(escapedRange.Start <= aInterpretedRange.Start && aInterpretedRange.Start <= escapedRange.End ){
+        result = true;
+        break;
+      }
+    }
+    return result;
+  }
+  
+  /** 
+   Scan fFormat for all tokens, in a specific order, and interpret them with the given DateTime. 
+   The interpreted tokens are saved for output later. 
+  */
+  private void interpretInput(DateTime aDateTime){
+    String format = fFormat;
+    for(String token : TOKENS){
+      Pattern pattern = Pattern.compile(token);
+      Matcher matcher = pattern.matcher(format);
+      while(matcher.find()){
+        InterpretedRange interpretedRange = new InterpretedRange();
+        interpretedRange.Start = matcher.start();
+        interpretedRange.End = matcher.end() - 1;
+        if(! isInEscapedRange(interpretedRange)){
+          interpretedRange.Text = interpretThe(matcher.group(), aDateTime);
+          fInterpretedRanges.add(interpretedRange);
+        }
+      }
+      format = format.replace(token, withCharDenotingAlreadyInterpreted(token));
+    }
+  }
+  
+  /** 
+   Return a temp placeholder string used to identify sections of fFormat that have already been interpreted.
+   The returned string is a list of "@" characters, whose length is the same as aToken. 
+  */
+  private String withCharDenotingAlreadyInterpreted(String aToken){
+    StringBuilder result = new StringBuilder();
+    for(int idx = 1; idx <= aToken.length(); ++idx){
+      //any character that isn't interpreted, or a special regex char, will do here
+      //the fact that it's interpreted at location x is stored elsewhere;
+      //this is meant only to prevent multiple interpretations of the same text
+      result.append("@");
+    }
+    return result.toString();
+  }
+
+  /** Render the final output returned to the caller. */
+  private String produceFinalOutput(){
+    StringBuilder result = new StringBuilder();
+    int idx = 0;
+    while ( idx < fFormat.length() ) {
+      String letter = nextLetter(idx);
+      InterpretedRange interpretation = getInterpretation(idx);
+      if  (interpretation != null){
+        result.append(interpretation.Text);
+        idx = interpretation.End;
+      }
+      else {
+        if(!ESCAPE_CHAR.equals(letter)){
+          result.append(letter);
+        }
+      }
+      ++idx;
+    }
+    return result.toString();
+  }
+
+  private InterpretedRange getInterpretation(int aIdx){
+    InterpretedRange result = null;
+    for(InterpretedRange interpretedRange : fInterpretedRanges){
+      if(interpretedRange.Start == aIdx ){
+        result = interpretedRange;
+      }
+    }
+    return result;
+  }
+
+  private String nextLetter(int aIdx){
+    return fFormat.substring(aIdx, aIdx+1); 
+  }
+  
+  private String  interpretThe(String aCurrentToken, DateTime aDateTime){
+    String result = EMPTY_STRING;
+    if(YYYY.equals(aCurrentToken)) {
+      result =  valueStr(aDateTime.getYear());
+    }
+    else if (YY.equals(aCurrentToken)){
+      result = noCentury(valueStr(aDateTime.getYear()));
+    }
+    else if (MMMM.equals(aCurrentToken)){
+      int month = aDateTime.getMonth();
+      result = fullMonth(month);
+    }
+    else if (MMM.equals(aCurrentToken)){
+      int month = aDateTime.getMonth();
+      result = firstThreeChars(fullMonth(month));
+    }
+    else if (MM.equals(aCurrentToken)){
+      result = addLeadingZero(valueStr(aDateTime.getMonth()));
+    }
+    else if (M.equals(aCurrentToken)){
+      result = valueStr(aDateTime.getMonth());
+    }
+    else if(DD.equals(aCurrentToken)){
+      result = addLeadingZero(valueStr(aDateTime.getDay()));
+    }
+    else if(D.equals(aCurrentToken)){
+      result = valueStr(aDateTime.getDay());
+    }
+    else if(WWWW.equals(aCurrentToken)){
+      int weekday = aDateTime.getWeekDay();
+      result = fullWeekday(weekday);
+    }
+    else if(WWW.equals(aCurrentToken)){
+      int weekday = aDateTime.getWeekDay();
+      result = firstThreeChars(fullWeekday(weekday));
+    }
+    else if(hh.equals(aCurrentToken)){
+      result = addLeadingZero(valueStr(aDateTime.getHour()));
+    }
+    else if(h.equals(aCurrentToken)){
+      result = valueStr(aDateTime.getHour());
+    }
+    else if (h12.equals(aCurrentToken)){
+      result = valueStr(twelveHourStyle(aDateTime.getHour()));
+    }
+    else if (hh12.equals(aCurrentToken)){
+      result = addLeadingZero(valueStr(twelveHourStyle(aDateTime.getHour())));
+    }
+    else if (a.equals(aCurrentToken)){
+      int hour = aDateTime.getHour();
+      result = amPmIndicator(hour);
+    }
+    else if(mm.equals(aCurrentToken)){
+      result = addLeadingZero(valueStr(aDateTime.getMinute()));
+    }
+    else if(m.equals(aCurrentToken)){
+      result = valueStr(aDateTime.getMinute());
+    }
+    else if(ss.equals(aCurrentToken)){
+      result = addLeadingZero(valueStr(aDateTime.getSecond()));
+    }
+    else if(s.equals(aCurrentToken)){
+      result = valueStr(aDateTime.getSecond());
+    }
+    else if(aCurrentToken.startsWith("f")){
+      Matcher matcher = FRACTIONALS.matcher(aCurrentToken);
+      if ( matcher.matches() ) {
+        String nanos = nanosWithLeadingZeroes(aDateTime.getNanoseconds());
+        int numDecimalsToShow = aCurrentToken.length();
+        result = firstNChars(nanos, numDecimalsToShow);
+      }
+      else {
+        throw new IllegalArgumentException("Unknown token in date formatting pattern: " + aCurrentToken);
+      }
+    }
+    else {
+      throw new IllegalArgumentException("Unknown token in date formatting pattern: " + aCurrentToken);
+    }
+    
+    return result;
+  }
+  
+  private String valueStr(Object aItem){
+    String result = EMPTY_STRING;
+    if(aItem != null){
+      result = String.valueOf(aItem);
+    }
+    return result;
+  }
+
+  private String noCentury(String aItem){
+    String result = EMPTY_STRING;
+    if(Util.textHasContent(aItem)){
+      result = aItem.substring(2);
+    }
+    return result;
+  }
+  
+  private String nanosWithLeadingZeroes(Integer aNanos){
+    String result = valueStr(aNanos);
+    while(result.length() < 9){
+      result = "0" + result;
+    }
+    return result;
+  }
+  
+  
+  /** Pad 0..9 with a leading zero. */
+  private String addLeadingZero(String aTimePart){
+    String result = aTimePart;
+    if(Util.textHasContent(aTimePart) && aTimePart.length() ==1){
+      result = "0" + result;
+    }
+    return result;
+  }
+  
+  private String firstThreeChars(String aText){
+    String result = aText;
+    if(Util.textHasContent(aText) && aText.length()>=3){
+      result = aText.substring(0,3);
+    }
+    return result;
+  }
+  
+  private String fullMonth(Integer aMonth){
+    String result = "";
+    if(aMonth != null){
+      if(fCustomLocalization != null){
+        result = lookupCustomMonthFor(aMonth);
+      }
+      else if (fLocale != null){
+        result = lookupMonthFor(aMonth);
+      }
+      else {
+        throw new IllegalArgumentException("Your date pattern requires either a Locale, or your own custom localizations for text:" + Util.quote(fFormat)) ;
+      }
+    }
+    return result;
+  }
+
+  private String lookupCustomMonthFor(Integer aMonth){
+    return fCustomLocalization.Months.get(aMonth-1);
+  }
+  
+  private String lookupMonthFor(Integer aMonth){
+    String result = EMPTY_STRING;
+    if (! fMonths.containsKey(fLocale) ){
+      List<String> months = new ArrayList<String>();
+      SimpleDateFormat format = new SimpleDateFormat("MMMM", fLocale);
+      for(int idx = Calendar.JANUARY; idx <= Calendar.DECEMBER; ++idx){
+        Calendar firstDayOfMonth = new GregorianCalendar();
+        firstDayOfMonth.set(Calendar.YEAR, 2000);
+        firstDayOfMonth.set(Calendar.MONTH, idx);
+        firstDayOfMonth.set(Calendar.DAY_OF_MONTH, 15);
+        String monthText = format.format(firstDayOfMonth.getTime());
+        months.add(monthText);
+      }
+      fMonths.put(fLocale, months);
+    }
+    result = fMonths.get(fLocale).get(aMonth-1); //list is 0-based
+    return result;
+  }
+  
+  private String fullWeekday(Integer aWeekday){
+    String result = "";
+    if(aWeekday != null){
+      if (fCustomLocalization != null){
+        result = lookupCustomWeekdayFor(aWeekday);
+      }
+      else if(fLocale != null ){
+        result = lookupWeekdayFor(aWeekday);
+      }
+      else {
+        throw new IllegalArgumentException("Your date pattern requires either a Locale, or your own custom localizations for text:" + Util.quote(fFormat)) ;
+      }
+    }
+    return result;
+  }
+
+  private String lookupCustomWeekdayFor(Integer aWeekday){
+    return fCustomLocalization.Weekdays.get(aWeekday-1);
+  }
+  
+  private String lookupWeekdayFor(Integer aWeekday){
+    String result = EMPTY_STRING;
+    if (! fWeekdays.containsKey(fLocale) ){
+      List<String> weekdays = new ArrayList<String>();
+      SimpleDateFormat format = new SimpleDateFormat("EEEE", fLocale);
+      //Feb 8, 2009..Feb 14, 2009 runs Sun..Sat
+      for(int idx = 8; idx <= 14; ++idx){
+        Calendar firstDayOfWeek = new GregorianCalendar();
+        firstDayOfWeek.set(Calendar.YEAR, 2009);
+        firstDayOfWeek.set(Calendar.MONTH, 1); //month is 0-based
+        firstDayOfWeek.set(Calendar.DAY_OF_MONTH, idx);
+        String weekdayText = format.format(firstDayOfWeek.getTime());
+        weekdays.add(weekdayText);
+      }
+      fWeekdays.put(fLocale, weekdays);
+    }
+    result = fWeekdays.get(fLocale).get(aWeekday-1); //list is 0-based
+    return result;
+  }
+
+  private String firstNChars(String aText, int aN){
+    String result = aText;
+    if(Util.textHasContent(aText) && aText.length()>=aN){
+      result = aText.substring(0,aN);
+    }
+    return result;
+  }
+
+  /**  Coerce the hour to match the number used in the 12-hour style. */
+  private Integer twelveHourStyle(Integer aHour){
+    Integer result = aHour;
+    if(aHour != null){
+      if (aHour == 0) {
+        result = 12; //eg 12:30 am
+      }
+      else if (aHour > 12){
+        result = aHour - 12; //eg 14:00 -> 2:00
+      }
+    }
+    return result;
+  }
+  
+  private String amPmIndicator(Integer aHour){
+    String result = "";
+    if(aHour != null){
+      if(fCustomLocalization != null){
+        result = lookupCustomAmPmFor(aHour);
+      }
+      else if (fLocale != null) {
+        result = lookupAmPmFor(aHour);
+      }
+      else {
+        throw new IllegalArgumentException(
+          "Your date pattern requires either a Locale, or your own custom localizations for text:" + Util.quote(fFormat)
+        ) ;
+      }
+    }
+    return result;
+  }
+    
+  private String lookupCustomAmPmFor(Integer aHour){
+    String result = EMPTY_STRING;
+    if(aHour < 12 ){
+      result = fCustomLocalization.AmPmIndicators.get(AM);
+    }
+    else {
+      result = fCustomLocalization.AmPmIndicators.get(PM);
+    }
+    return result;
+  }
+    
+  private String lookupAmPmFor(Integer aHour){
+    String result = EMPTY_STRING;
+    if (! fAmPm.containsKey(fLocale) ){
+      List<String> indicators = new ArrayList<String>();
+      indicators.add(getAmPmTextFor(6));
+      indicators.add(getAmPmTextFor(18));
+      fAmPm.put(fLocale, indicators);
+    }
+    if (aHour < 12 ){
+      result = fAmPm.get(fLocale).get(AM); 
+    }
+    else {
+      result = fAmPm.get(fLocale).get(PM); 
+    }
+    return result;
+  }
+  
+  private String getAmPmTextFor(Integer aHour){
+    SimpleDateFormat format = new SimpleDateFormat("a", fLocale);
+    Calendar someDay = new GregorianCalendar();
+    someDay.set(Calendar.YEAR, 2000);
+    someDay.set(Calendar.MONTH, 6);
+    someDay.set(Calendar.DAY_OF_MONTH, 15);
+    someDay.set(Calendar.HOUR_OF_DAY, aHour);
+    return format.format(someDay.getTime());
+  }
+  
+  private void validateState(){
+    if(! Util.textHasContent(fFormat)){  
+      throw new IllegalArgumentException("DateTime format has no content.");
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/DateTimeInterval.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,335 @@
+package hirondelle.web4j.model;
+
+import static hirondelle.web4j.model.DateTime.DayOverflow;
+import static hirondelle.web4j.model.DateTime.Unit;
+
+/**
+ Helper class for adding intervals of time. 
+ The mental model of this class is similar to that of a car's odometer, except
+ in reverse. 
+ */
+final class DateTimeInterval {
+
+  /**  Constructor.  */
+  DateTimeInterval(DateTime aFrom, DayOverflow aMonthOverflow){
+    fFrom = aFrom;
+    checkUnits();
+    fYear = fFrom.getYear() == null ? 1 : fFrom.getYear();
+    fMonth = fFrom.getMonth() == null ? 1 : fFrom.getMonth();
+    fDay = fFrom.getDay() == null ? 1 : fFrom.getDay();
+    fHour = fFrom.getHour() == null ? 0 : fFrom.getHour();
+    fMinute = fFrom.getMinute() == null ? 0 : fFrom.getMinute();
+    fSecond = fFrom.getSecond() == null ? 0 : fFrom.getSecond();
+    fNanosecond = fFrom.getNanoseconds() == null ? 0 : fFrom.getNanoseconds();
+    fDayOverflow = aMonthOverflow;
+  }
+  
+  DateTime plus(int aYear, int aMonth, int aDay, int aHour, int aMinute, int aSecond, int aNanosecond){
+    return plusOrMinus(PLUS, aYear, aMonth, aDay, aHour, aMinute, aSecond, aNanosecond);
+  }
+  
+  DateTime minus(int aYear, int aMonth, int aDay, int aHour, int aMinute, int aSecond, int aNanosecond){
+    return plusOrMinus(MINUS, aYear, aMonth, aDay, aHour, aMinute, aSecond, aNanosecond);
+  }
+  
+  // PRIVATE 
+  
+  //the base date to which the interval is calculated
+  private final DateTime fFrom;
+  
+  private boolean fIsPlus;
+  private DateTime.DayOverflow fDayOverflow;
+  
+  //the various increments
+  private int fYearIncr;
+  private int fMonthIncr;
+  private int  fDayIncr;
+  private int fHourIncr;
+  private int fMinuteIncr;
+  private int fSecondIncr;
+  private int fNanosecondIncr;
+
+  //work area for the final result - starts off with values from base date fFrom
+  private Integer fYear;
+  private Integer fMonth;
+  private Integer fDay;
+  private Integer fHour;
+  private Integer fMinute;
+  private Integer fSecond;
+  private Integer fNanosecond;
+
+  private static final int MIN = 0;
+  private static final int MAX = 9999;
+  private static final int MIN_NANOS = 0;
+  private static final int MAX_NANOS = 999999999;
+  private static final boolean PLUS = true;
+  private static final boolean MINUS = false;
+
+  private void checkUnits(){
+    boolean success = false;
+    if(fFrom.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND) ){
+      success = true;
+    }
+    else if( fFrom.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) &&  fFrom.unitsAllAbsent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) ){
+      success = true;
+    }
+    else if ( fFrom.unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY) && fFrom.unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) ){
+      success = true;
+    }
+    else {
+      success = false;
+    }
+    if(! success ){
+      throw new IllegalArgumentException("For interval calculations, DateTime must have year-month-day, or hour-minute-second, or both.");
+    }
+  }
+  
+  private DateTime plusOrMinus(boolean aIsPlus, Integer aYear, Integer aMonth, Integer aDay, Integer aHour, Integer aMinute, Integer aSecond, Integer aNanosecond){
+    fIsPlus = aIsPlus;
+    fYearIncr = aYear;
+    fMonthIncr = aMonth;
+    fDayIncr = aDay;
+    fHourIncr = aHour;
+    fMinuteIncr = aMinute;
+    fSecondIncr = aSecond;
+    fNanosecondIncr = aNanosecond;
+    
+    checkRange(fYearIncr, "Year");
+    checkRange(fMonthIncr, "Month");
+    checkRange(fDayIncr, "Day");
+    checkRange(fHourIncr, "Hour");
+    checkRange(fMinuteIncr, "Minute");
+    checkRange(fSecondIncr, "Second");
+    checkRangeNanos(fNanosecondIncr);
+    
+    changeYear();
+    changeMonth();
+    handleMonthOverflow();
+    changeDay();
+    changeHour();
+    changeMinute();
+    changeSecond();
+    changeNanosecond();
+    
+    return new DateTime(fYear, fMonth, fDay, fHour, fMinute, fSecond, fNanosecond);
+  }
+
+  private void checkRange(Integer aValue, String aName) {
+    if ( aValue <  MIN || aValue > MAX ) { 
+      throw new IllegalArgumentException(aName + " is not in the range " + MIN + ".." + MAX); 
+    }
+  }
+  
+  private void checkRangeNanos(Integer aValue) {
+    if ( aValue <  MIN_NANOS || aValue > MAX_NANOS ) { 
+      throw new IllegalArgumentException("Nanosecond interval is not in the range " + MIN_NANOS + ".." + MAX_NANOS); 
+    }
+  }
+  
+  private void changeYear(){
+    if(fIsPlus){
+      fYear = fYear + fYearIncr;
+    }
+    else {
+      fYear = fFrom.getYear() - fYearIncr;
+    }
+    //the DateTime ctor will check the range of the year 
+  }
+  
+  private void changeMonth(){
+    int count = 0;
+    while (count < fMonthIncr){
+      stepMonth();
+      count++;
+    }
+  }
+
+  private void  changeDay(){
+    int count = 0;
+    while (count < fDayIncr){
+      stepDay();
+      count++;
+    }
+  }
+
+  private  void changeHour(){
+    int count = 0;
+    while (count < fHourIncr){
+      stepHour();
+      count++;
+    }
+  }
+
+  private void changeMinute(){
+    int count = 0;
+    while (count < fMinuteIncr){
+      stepMinute();
+      count++;
+    }
+  }
+  
+  private void changeSecond(){
+    int count = 0;
+    while (count < fSecondIncr){
+      stepSecond();
+      count++;
+    }
+  }
+
+  /** 
+   Nanos are different from other items. They don't cycle one step at a time.
+   They are just added. If they under/over flow, then extra math is performed.
+   They don't over/under by more than 1 second, since the size of the increment is limited.
+  */ 
+  private void changeNanosecond(){
+    if (fIsPlus){
+      fNanosecond = fNanosecond + fNanosecondIncr;      
+    }
+    else {
+      fNanosecond = fNanosecond - fNanosecondIncr;      
+    }
+    if(fNanosecond > MAX_NANOS){
+      stepSecond();
+      fNanosecond = fNanosecond - MAX_NANOS - 1;
+    }
+    else if (fNanosecond < MIN_NANOS){
+      stepSecond();
+      fNanosecond =  MAX_NANOS + fNanosecond + 1;
+    }
+  }
+  
+  private void stepYear() {
+    if(fIsPlus) {
+      fYear = fYear + 1;
+    }
+    else {
+      fYear = fYear - 1;
+    }
+  }
+  
+  private void stepMonth() {
+    if(fIsPlus){
+      fMonth = fMonth + 1;
+    }
+    else {
+      fMonth = fMonth - 1;
+    }
+    if(fMonth > 12) { 
+      fMonth = 1;
+      stepYear();
+    }
+    else if(fMonth < 1){
+      fMonth = 12;
+      stepYear();
+    }
+  }
+
+  private void stepDay() {
+    if(fIsPlus){
+      fDay = fDay + 1;
+    }
+    else {
+      fDay = fDay - 1;
+    }
+    if(fDay > numDaysInMonth()){
+      fDay = 1;
+      stepMonth();
+    }
+    else if (fDay < 1){
+      fDay = numDaysInPreviousMonth();
+      stepMonth();
+    }
+  }
+  
+  private int numDaysInMonth(){
+    return DateTime.getNumDaysInMonth(fYear, fMonth);
+  }
+  
+  private int numDaysInPreviousMonth(){
+    int result = 0;
+    if(fMonth > 1) {
+      result = DateTime.getNumDaysInMonth(fYear, fMonth - 1);
+    }
+    else {
+      result = DateTime.getNumDaysInMonth(fYear - 1 , 12);
+    }
+    return result;
+  }
+  
+  private void stepHour() {
+    if(fIsPlus){
+      fHour = fHour + 1;      
+    }
+    else {
+      fHour = fHour - 1;      
+    }
+    if(fHour > 23){
+      fHour = 0;
+      stepDay();
+    }
+    else if (fHour < 0){
+      fHour = 23;
+      stepDay();
+    }
+  }
+  
+  private void stepMinute() {
+    if(fIsPlus){
+      fMinute = fMinute + 1;      
+    }
+    else {
+      fMinute = fMinute - 1;      
+    }
+    if(fMinute > 59){
+      fMinute = 0;
+      stepHour();
+    }
+    else if (fMinute < 0){
+      fMinute = 59;
+      stepHour();
+    }
+  }
+  
+  private void stepSecond() {
+    if(fIsPlus){
+      fSecond = fSecond + 1;        
+    }
+    else {
+      fSecond = fSecond - 1;        
+    }
+    if (fSecond > 59){
+      fSecond = 0;
+      stepMinute();
+    }
+    else if (fSecond < 0){
+      fSecond = 59;
+      stepMinute();
+    }
+  }
+  
+  private void handleMonthOverflow(){
+    int daysInMonth = numDaysInMonth();
+    if( fDay > daysInMonth ){
+      if(DayOverflow.Abort == fDayOverflow) {
+        throw new RuntimeException(
+          "Day Overflow: Year:" + fYear + " Month:" + fMonth + " has " + daysInMonth + " days, but day has value:" + fDay + 
+          " To avoid these exceptions, please specify a different DayOverflow policy."
+        );
+      }
+      else if (DayOverflow.FirstDay == fDayOverflow) {
+        fDay = 1;
+        stepMonth();
+      }
+      else if (DayOverflow.LastDay == fDayOverflow) {
+        fDay = daysInMonth;
+      }
+      else if (DayOverflow.Spillover == fDayOverflow) {
+        int overflowAmount = fDay - daysInMonth;
+        fDay = overflowAmount;
+        stepMonth();
+      }
+    }
+  }
+
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/DateTimeParser.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,204 @@
+package hirondelle.web4j.model;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import static hirondelle.web4j.util.Consts.SPACE;
+
+/**
+ Convert a date-time from a string into a  {@link DateTime}.
+ The primary use case for this class is converting date-times from a database <tt>ResultSet</tt>
+ into a {@link DateTime}. It can also parse an ISO date-time containing a 'T' separator.
+*/
+final class DateTimeParser  {
+
+  /** 
+   Thrown when the given string cannot be converted into a <tt>DateTime</tt>, since it doesn't 
+   have a format allowed by this class. 
+   An unchecked exception.
+  */
+  static final class UnknownDateTimeFormat extends RuntimeException {
+    UnknownDateTimeFormat(String aMessage){   super(aMessage);   }
+    UnknownDateTimeFormat(String aMessage, Throwable aEx){   super(aMessage, aEx);   }
+  }
+  
+  DateTime parse(String aDateTime) {
+    if(aDateTime == null){
+      throw new NullPointerException("DateTime string is null");
+    }
+    String dateTime = aDateTime.trim();
+    Parts parts = splitIntoDateAndTime(dateTime);
+    if (parts.hasTwoParts()) {
+      parseDate(parts.datePart);
+      parseTime(parts.timePart);
+    }
+    else if (parts.hasDateOnly()){
+      parseDate(parts.datePart);
+    }
+    else if (parts.hasTimeOnly()){
+      parseTime(parts.timePart);
+    }
+    DateTime result = new DateTime(fYear, fMonth, fDay, fHour, fMinute, fSecond, fNanosecond);
+    return result;
+  }
+  
+  // PRIVATE
+  
+  /** 
+   Gross pattern for dates. 
+   Detailed validation is done by DateTime.
+   The Group index VARIES for y-m-d according to which option is selected
+   Year: Group 1, 4, 6
+   Month: Group 2, 5
+   Day: Group 3 
+  */
+  private static final Pattern DATE = Pattern.compile("(\\d{1,4})-(\\d\\d)-(\\d\\d)|(\\d{1,4})-(\\d\\d)|(\\d{1,4})");
+  
+  /** 
+   Gross pattern for times. 
+   Detailed validation is done by DateTime.   
+   The Group index VARIES for h-m-s-f according to which option is selected
+   Hour: Group 1, 5, 8, 10
+   Minute: Group 2, 6, 9
+   Second: Group 3, 7
+   Microsecond:  Group 4
+  */
+  private static final String CL = "\\:"; //colon is a special character
+  private static final String TT = "(\\d\\d)"; //colon is a special character
+  private static final String NUM_DIGITS_FOR_FRACTIONAL_SECONDS = "9";
+  private static final Integer NUM_DIGITS = Integer.valueOf(NUM_DIGITS_FOR_FRACTIONAL_SECONDS);
+  private static final Pattern TIME = Pattern.compile("" +
+      TT+CL+TT+CL+TT+ "\\." + "(\\d{1," + NUM_DIGITS_FOR_FRACTIONAL_SECONDS + "})" + "|" + 
+      TT+CL+TT+CL+TT+ "|" + 
+      TT+CL+TT+ "|" +
+      TT
+  );
+  
+  private static final String COLON = ":";
+  private static final int THIRD_POSITION = 2;
+  
+  private Integer fYear;
+  private Integer fMonth;
+  private Integer fDay;
+  private Integer fHour;
+  private Integer fMinute;
+  private Integer fSecond;
+  private Integer fNanosecond;
+  
+  private class Parts {
+    String datePart;
+    String timePart;
+    boolean hasTwoParts(){
+      return datePart != null && timePart != null;
+    }
+    boolean hasDateOnly(){
+      return timePart == null;
+    }
+    boolean hasTimeOnly(){
+      return datePart == null;
+    }
+  }
+  
+  /** Date and time can be separated with a single space, or with a 'T' character (case-sensitive). */
+  private Parts splitIntoDateAndTime(String aDateTime){
+    Parts result = new Parts();
+    int dateTimeSeparator = getDateTimeSeparator(aDateTime);
+    boolean hasDateTimeSeparator = 0 < dateTimeSeparator  && dateTimeSeparator < aDateTime.length();
+    if (hasDateTimeSeparator){
+      result.datePart = aDateTime.substring(0, dateTimeSeparator);
+      result.timePart = aDateTime.substring(dateTimeSeparator+1);
+    }
+    else if(hasColonInThirdPlace(aDateTime)){
+      result.timePart = aDateTime;
+    }
+    else {
+      result.datePart = aDateTime;
+    }
+    return result;
+  }
+  
+  /** Return the index of a space character, or of a 'T' character. If not found, return -1.*/
+  int getDateTimeSeparator(String aDateTime){
+    int NOT_FOUND = -1;
+    int result = NOT_FOUND;
+    result = aDateTime.indexOf(SPACE);
+    if(result == NOT_FOUND){
+      result = aDateTime.indexOf("T");
+    }
+    return result;
+  }
+  
+  private boolean hasColonInThirdPlace(String aDateTime){
+    boolean result = false;
+    if(aDateTime.length() >= THIRD_POSITION){
+      result = COLON.equals(aDateTime.substring(THIRD_POSITION,THIRD_POSITION+1));
+    }
+    return result;
+  }
+  
+  private void parseDate(String aDate) {
+    Matcher matcher = DATE.matcher(aDate);
+    if (matcher.matches()){
+      String year = getGroup(matcher, 1, 4, 6);
+      if(year !=null ){
+        fYear = Integer.valueOf(year);
+      }
+      String month = getGroup(matcher, 2, 5);
+      if(month !=null ){
+        fMonth = Integer.valueOf(month);
+      }
+      String day = getGroup(matcher, 3);
+      if(day !=null ){
+        fDay = Integer.valueOf(day);
+      }
+    }
+    else {
+      throw new DateTimeParser.UnknownDateTimeFormat("Unexpected format for date:" + aDate);
+    }
+  }
+
+  private String getGroup(Matcher aMatcher, int... aGroupIds){
+    String result = null;
+    for(int id: aGroupIds){
+      result = aMatcher.group(id);
+      if(result!=null) break;
+    }
+    return result;
+  }
+
+  private void parseTime(String aTime) {
+    Matcher matcher = TIME.matcher(aTime);
+    if (matcher.matches()){
+      String hour = getGroup(matcher, 1, 5, 8, 10);
+      if(hour !=null ){
+        fHour = Integer.valueOf(hour);
+      }
+      String minute = getGroup(matcher, 2, 6, 9);
+      if(minute !=null ){
+        fMinute = Integer.valueOf(minute);
+      }
+      String second = getGroup(matcher, 3, 7);
+      if(second !=null ){
+        fSecond = Integer.valueOf(second);
+      }
+      String decimalSeconds = getGroup(matcher, 4);
+      if(decimalSeconds !=null ){
+        fNanosecond = Integer.valueOf(convertToNanoseconds(decimalSeconds));
+      }
+    }
+    else {
+      throw new DateTimeParser.UnknownDateTimeFormat("Unexpected format for time:" + aTime);
+    }
+  }
+  
+  /**
+   Convert any number of decimals (1..9) into the form it would have taken if nanos had been used, 
+   by adding any 0's to the right side.  
+  */
+  private String convertToNanoseconds(String aDecimalSeconds){
+    StringBuilder result = new StringBuilder(aDecimalSeconds);
+    while( result.length( ) < NUM_DIGITS ){
+      result.append("0");
+    }
+    return result.toString();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/Decimal.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,629 @@
+package hirondelle.web4j.model;
+
+import java.util.*;
+import java.io.Serializable;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ Represent an immutable number, using a natural, compact syntax. 
+ The number may have a decimal portion, or it may not. 
+
+<P>Decimal amounts are typically used to represent two kinds of items :
+<ul>
+ <li>monetary amounts 
+ <li>measurements such as temperature, distance, and so on
+ </ul>
+ 
+ <P>Your applications are not obliged to use this class to represent decimal amounts. 
+ You may choose to use {@link BigDecimal} instead (perhaps along with an <tt>Id</tt>
+ to store a currency, if needed).
+ 
+<P>This class exists for these reasons:
+<ul>
+ <li>to simplify calculations, and build on top of what's available from the {@link BigDecimal} class
+ <li>to allow your code to read at a higher level
+ <li>to define a more natural, pleasing syntax
+ <li>to help you avoid floating-point types, 
+ which have many <a href='http://www.ibm.com/developerworks/java/library/j-jtp0114/'>pitfalls</a> 
+</ul>
+
+ <P><tt>Decimal</tt> objects are immutable.  
+ Many operations return new <tt>Decimal</tt> objects. 
+ 
+ <h3>Currency Is Unspecified</h3>
+ This class can be used to model amounts of money. 
+<P><em>Many will be surprised that this class does not make any reference to currency.</em> 
+ The reason for this is adding currency would render this class a poor <em>building block</em>.  
+ Building block objects such as <tt>Date</tt>, <tt>Integer</tt>, and so on, are 
+ <em>atomic</em>, in the sense of representing a <em>single</em> piece of data.   
+ They correspond to a single column in a table, or a single form control. If the currency 
+ were included in this class, then it would no longer be atomic, and it could not be 
+ treated by WEB4J as any other building block class.
+ However, allowing this class to be treated like any other building block class is 
+ highly advantageous. 
+ 
+ <P>If a feature needs to explicitly distinguish between <em>multiple</em> currencies 
+ such as US Dollars and Japanese Yen, then a <tt>Decimal</tt> object 
+ will need to be paired by the caller with a <em>second</em> item representing the 
+ underlying currency (perhaps modeled as an <tt>Id</tt>). 
+ See the {@link Currency} class for more information.  
+ 
+ <h3>Number of Decimal Places</h3>
+ To validate the number of decimals in your Model Objects, 
+ call the {@link Check#numDecimalsAlways(int)} or {@link Check#numDecimalsMax(int)} methods.
+ 
+ <h3>Different Numbers of Decimals</h3>
+ <P>As usual, operations can be performed on two items having a different number of decimal places. 
+ For example, these  operations are valid (using an informal, <em>ad hoc</em> notation) : 
+ <PRE>10 + 1.23 = 11.23
+10.00 + 1.23 = 11.23
+10 - 1.23 = 8.77
+(10 > 1.23) => true </PRE> 
+ This corresponds to typical user expectations.
+  
+ <P>The {@link #eq(Decimal)} is usually to be preferred over the {@link #equals(Object)} method. 
+ The {@link #equals(Object)} is unusual, in that it's the only method sensitive to the exact 
+ number of decimal places, while {@link #eq(Decimal)} is not. That is,  
+ <PRE>10.equals(10.00) => false
+10.eq(10.00) => true</PRE>
+   
+ <h3>Terse Method Names</h3>
+ Various methods in this class have unusually terse names, such as 
+ <tt>lt</tt> for 'less than',  and <tt>gt</tt> for 'greater than', and so on. 
+ The intent of such names is to improve the legibility of mathematical 
+ expressions.
+  
+ <P>Example: 
+<PRE>if (amount.lt(hundred)) {
+  cost = amount.times(price); 
+}</PRE>
+ 
+ <h3>Prefer Decimal forms</h3>
+ <P>Many methods in this class are overloaded to perform the same operation with various types:
+<ul>
+ <li>Decimal
+ <li>long (which will also accept an int)
+ <li>double  (which will also accept a float)
+</ul>
+  Usually, you should prefer the Decimal form. The long and double forms are usually convenience methods, which simply call the Decimal 
+  version as part of their implementations; they're intended for cases when you simply wish to specify a hard-coded constant value. 
+ 
+ <h3>Extends Number</h3>
+ <P>This class extends {@link Number}. This allows other parts of the JDK to treat a <tt>Decimal</tt> just like any other 
+ <tt>Number</tt>.
+*/
+public final class Decimal extends Number implements Comparable<Decimal>, Serializable {
+  
+  /**
+  The default rounding mode used by this class (<tt>HALF_EVEN</tt>). 
+  This rounding style results in the least bias.  
+ */ 
+ public static final RoundingMode ROUNDING = RoundingMode.HALF_EVEN;
+ 
+ /**
+  Constructor.
+  @param aAmount required, any number of decimals. 
+ */
+ public Decimal(BigDecimal aAmount){
+   fAmount = aAmount;
+   validateState();
+ }
+ 
+ /**
+  Convenience factory method. Leading zeroes are allowed.
+  <P>Instead of  : 
+  <PRE>Decimal decimal = new Decimal(new BigDecimal("100"));</PRE>
+  one may instead use this more compact form: 
+  <PRE>Decimal decimal = Decimal.from("100");</PRE>
+  which is a bit more legible. 
+ */
+ public static Decimal from(String aAmount){
+   return new Decimal(new BigDecimal(aAmount));
+ }
+ 
+ /** Convenience factory method.  */ 
+ public static Decimal from(long aAmount){
+   return new Decimal(new BigDecimal(aAmount));
+ }
+ 
+ /** Convenience factory method.  */ 
+ public static Decimal from(double aAmount){
+   return new Decimal(BigDecimal.valueOf(aAmount));
+ }
+
+ /**
+  Renders this <tt>Decimal</tt> in a style suitable for debugging. 
+  <em>Intended for debugging only.</em>
+  
+  <P>Returns the amount in the format defined by {@link BigDecimal#toPlainString()}. 
+*/
+ public String toString(){
+   return fAmount.toPlainString();
+ }
+
+ /**
+  Equals, sensitive to scale of the underlying BigDecimal. 
+ 
+  <P>That is, <tt>10</tt> and <tt>10.00</tt> are <em>not</em> 
+  considered equal by this method. <b>Such behavior is often undesired; in most 
+  practical cases, it's likely best to use the {@link #eq(Decimal)} method instead.</b>, 
+  which has no such monkey business.
+ 
+  <P>This implementation imitates {@link BigDecimal#equals(java.lang.Object)}, 
+  which is also sensitive to the number of decimals (or 'scale'). 
+ */
+ public boolean equals(Object aThat){
+   if (this == aThat) return true;
+   if (! (aThat instanceof Decimal) ) return false;
+   Decimal that = (Decimal)aThat;
+   //the object fields are never null :
+   boolean result = (this.fAmount.equals(that.fAmount) );
+   return result;
+ }
+
+ public int hashCode(){
+   if ( fHashCode == 0 ) {
+     fHashCode = HASH_SEED;
+     fHashCode = HASH_FACTOR * fHashCode + fAmount.hashCode(); 
+   }
+   return fHashCode;
+ }
+
+ /**
+  Implements the {@link Comparable} interface. 
+ 
+  <P>It's possible to use this method as a general replacement for a large number of methods which compare numbers: 
+  lt, eq, lteq, and so on. However, it's recommended that you use those other methods, since they have greater clarity 
+  and concision.
+ */
+ public int compareTo(Decimal aThat) {
+   final int EQUAL = 0;
+   if ( this == aThat ) return EQUAL;
+   //the object field is never null 
+   int comparison = this.fAmount.compareTo(aThat.fAmount);
+   if ( comparison != EQUAL ) return comparison;
+   return EQUAL;
+ }
+ 
+ /** Return the amount as a BigDecimal. */
+ public BigDecimal getAmount() { return fAmount; }
+ 
+ /** The suffix is needed to distinguish from the public field.  Declared 'early' since compiler complains.*/
+ private static final BigDecimal ZERO_BD = BigDecimal.ZERO;
+ private static final BigDecimal ONE_BD = BigDecimal.ONE;
+ private static final BigDecimal MINUS_ONE_BD = new BigDecimal("-1");
+
+ /** 
+  Zero <tt>Decimal</tt> amount, a simple convenience constant.
+  
+  <P>Like {@link BigDecimal#ZERO}, this item has no explicit decimal. 
+  In most cases that will not matter, since only the {@link #equals(Object)} method is sensitive to 
+  exact decimals. All other methods, including {@link #eq(Decimal)}, are not sensitive to exact decimals.
+ */
+ public static final Decimal ZERO = new Decimal(ZERO_BD);
+ 
+ /** Convenience constant. */
+ public static final Decimal ONE = new Decimal(ONE_BD);
+ 
+ /** Convenience constant. */
+ public static final Decimal MINUS_ONE = new Decimal(MINUS_ONE_BD);
+ 
+ /**
+   An approximation to the number pi, to 15 decimal places.
+   Pi is the ratio of the circumference of a circle to its radius.
+   It's also rumoured to <a href='http://en.wikipedia.org/wiki/Pi_Day'>taste good</a> as well.
+  */
+ public static final Decimal PI = new Decimal(new BigDecimal("3.141592653589793"));
+ 
+ /** 
+  An approximation to Euler's number, to 15 decimal places.
+  Euler's number is the base of the natural logarithms. 
+ */
+ public static final Decimal E = new Decimal(new BigDecimal( "2.718281828459045"));
+ 
+ /**
+  Return the number of decimals in this value. More accurately, this returns the 'scale' of the 
+  underlying BigDecimal. Negative scales are possible; they represent the number of zeros to be
+  adding on to the end of an integer. 
+ */
+ public int getNumDecimals(){
+   return fAmount.scale();
+ }
+
+ /**
+  Return <tt>true</tt> only if this Decimal is an integer.
+  For example, 2 and 2.00 are integers, but 2.01 is not.
+ */
+ public boolean isInteger(){
+   return round().minus(this).eq(ZERO);
+ }
+ 
+ /** Return <tt>true</tt> only if the amount is positive. */
+ public boolean isPlus(){
+   return fAmount.compareTo(ZERO_BD) > 0;
+ }
+ 
+ /** Return <tt>true</tt> only if the amount is negative. */
+ public boolean isMinus(){
+   return fAmount.compareTo(ZERO_BD) <  0;
+ }
+ 
+ /** Return <tt>true</tt> only if the amount is zero. */
+ public boolean isZero(){
+   return fAmount.compareTo(ZERO_BD) ==  0;
+ }
+ 
+ /** 
+  Equals (insensitive to number of decimals).
+  That is, <tt>10</tt> and <tt>10.00</tt> are considered equal by this method.
+  
+  <P>Return <tt>true</tt> only if the amounts are equal.
+  This method is <em>not</em> synonymous with the <tt>equals</tt> method, 
+  since the {@link #equals(Object)} method is sensitive to the exact number of decimal places (or, more 
+  precisely, the scale of the underlying BigDecimal.)
+ */
+ public boolean eq(Decimal aThat) {
+   return compareAmount(aThat) == 0;
+ }
+ public boolean eq(long aThat) {
+   return eq(Decimal.from(aThat));
+ }
+ public boolean eq(double aThat) {
+   return eq(Decimal.from(aThat));
+ }
+
+ /** 
+  Greater than.
+  <P>Return <tt>true</tt> only if  'this' amount is greater than
+  'that' amount. 
+ */
+ public boolean gt(Decimal aThat) { 
+   return compareAmount(aThat) > 0;  
+ }
+ public boolean gt(long aThat) { 
+   return gt(Decimal.from(aThat));  
+ }
+ public boolean gt(double aThat) { 
+   return gt(Decimal.from(aThat));  
+ }
+ 
+ /** 
+  Greater than or equal to.
+  <P>Return <tt>true</tt> only if 'this' amount is 
+  greater than or equal to 'that' amount. 
+ */
+ public boolean gteq(Decimal aThat) { 
+   return compareAmount(aThat) >= 0;  
+ }
+ public boolean gteq(long aThat) { 
+   return gteq(Decimal.from(aThat));  
+ }
+ public boolean gteq(double aThat) { 
+   return gteq(Decimal.from(aThat));  
+ }
+ 
+ /** 
+  Less than.
+  <P>Return <tt>true</tt> only if 'this' amount is less than
+  'that' amount. 
+ */
+ public boolean lt(Decimal aThat) { 
+   return compareAmount(aThat) < 0;  
+ }
+ public boolean lt(long aThat) { 
+   return lt(Decimal.from(aThat));  
+ }
+ public boolean lt(double aThat) { 
+   return lt(Decimal.from(aThat));  
+ }
+ 
+ /** 
+  Less than or equal to.
+  <P>Return <tt>true</tt> only if 'this' amount is less than or equal to
+  'that' amount.  
+ */
+ public boolean lteq(Decimal aThat) { 
+   return compareAmount(aThat) <= 0;  
+ }
+ public boolean lteq(long aThat) { 
+   return lteq(Decimal.from(aThat));  
+ }
+ public boolean lteq(double aThat) { 
+   return lteq(Decimal.from(aThat));  
+ }
+ 
+ /** 
+  Add <tt>aThat</tt> <tt>Decimal</tt> to this <tt>Decimal</tt>.
+ */
+ public Decimal plus(Decimal aThat){
+   return new Decimal(fAmount.add(aThat.fAmount));
+ }
+ public Decimal plus(long aThat){
+   return plus(Decimal.from(aThat));
+ }
+ public Decimal plus(double aThat){
+   return plus(Decimal.from(aThat));
+ }
+
+ /** 
+  Subtract <tt>aThat</tt> <tt>Decimal</tt> from this <tt>Decimal</tt>. 
+ */
+ public Decimal minus(Decimal aThat){
+   return new Decimal(fAmount.subtract(aThat.fAmount));
+ }
+ public Decimal minus(long aThat){
+   return minus(Decimal.from(aThat));
+ }
+ public Decimal minus(double aThat){
+   return minus(Decimal.from(aThat));
+ }
+
+ /**
+  Sum a collection of <tt>Decimal</tt> objects.
+  
+  @param aDecimals collection of <tt>Decimal</tt> objects.
+  If the collection is empty, then a zero value is returned.
+ */
+ public static Decimal sum(Collection<Decimal> aDecimals){
+   Decimal sum = new Decimal(ZERO_BD);
+   for(Decimal decimal : aDecimals){
+     sum = sum.plus(decimal);
+   }
+   return sum;
+ }
+
+ /**  Multiply this <tt>Decimal</tt> by a factor.  */
+ public Decimal times(Decimal aFactor){
+   BigDecimal newAmount = fAmount.multiply(aFactor.getAmount());
+   return  new Decimal(newAmount);
+ }
+ public Decimal times(long aFactor){  
+   return times(Decimal.from(aFactor));
+ }
+ public Decimal times(double aFactor){
+   return times(Decimal.from(aFactor));
+ }
+
+ /**  
+  Divide this <tt>Decimal</tt> by a divisor.
+  <p>If the division results in a number which will never terminate, then this method 
+  will round the result to 20 decimal places, using the default {@link #ROUNDING}.  
+ */
+ public Decimal div(Decimal aDivisor){
+   BigDecimal newAmount = null;
+   try {
+     newAmount = fAmount.divide(aDivisor.fAmount);
+   }
+   catch(ArithmeticException  ex){
+     // non-terminating decimal
+     // need to apply a policy for where and how to round
+     newAmount = fAmount.divide(aDivisor.fAmount, DECIMALS, ROUNDING);
+   }
+   return new Decimal(newAmount);
+ }
+ public Decimal div(long aDivisor){
+   return div(Decimal.from(aDivisor));
+ }
+ public Decimal div(double aDivisor){  
+   return div(Decimal.from(aDivisor));
+ }
+
+ /** Return the absolute value of the amount. */
+ public Decimal abs(){
+   return isPlus() ? this : times(-1);
+ }
+ 
+ /** Return this amount x (-1). */
+ public Decimal negate(){ 
+   return times(-1); 
+ }
+ 
+ /**  Round to an integer value, using the default {@link #ROUNDING} style.  */
+ public Decimal round(){
+   BigDecimal amount = fAmount.setScale(0, ROUNDING);
+   return new Decimal(amount);
+ }
+ 
+ /** 
+  Round to 0 or more decimal places, using the default {@link #ROUNDING} style.
+  @param aNumberOfDecimals must 0 or more.  
+ */
+ public Decimal round(int aNumberOfDecimals){
+   if( aNumberOfDecimals < 0 ){
+     throw new IllegalArgumentException("Number of decimals is negative: " + quote(aNumberOfDecimals));
+   }
+   BigDecimal amount = fAmount.setScale(aNumberOfDecimals, ROUNDING);
+   return new Decimal(amount);
+ }
+ 
+ /**  
+  Round to 0 or more decimal places, using the given rounding style. 
+  @param aNumberOfDecimals must 0 or more.  
+ */
+ public Decimal round(int aNumberOfDecimals, RoundingMode aRoundingMode){
+   if( aNumberOfDecimals < 0 ){
+     throw new IllegalArgumentException("Number of decimals is negative: " + quote(aNumberOfDecimals));
+   }
+   BigDecimal amount = fAmount.setScale(aNumberOfDecimals, aRoundingMode);
+   return new Decimal(amount);
+ }
+
+ /**
+  Round a number to the nearest multiple of the given interval.
+  For example:
+  <tt>
+    Decimal amount = Decimal.from("1710.12");
+    amount.round2(0.05); // 1710.10
+    amount.round2(100);  // 1700
+  </tt>
+  @param aInterval must be greater than zero
+ */
+ public Decimal round2(Decimal aInterval){
+   if( ! aInterval.isPlus() ){
+     throw new IllegalArgumentException("Interval is negative or zero : " + quote(aInterval));
+   }
+   BigDecimal result = fAmount.divide(aInterval.fAmount).setScale(0, ROUNDING).multiply(aInterval.fAmount);
+   return new Decimal(result);
+ }
+ public Decimal round2(long aInterval){
+   return round2(Decimal.from(aInterval));    
+ }
+ public Decimal round2(double aInterval){
+   return round2(Decimal.from(aInterval));    
+ }
+
+ /**
+  Raise this number to an <b>integral</b> power; the power can be of either sign.
+  
+  <P>Special cases regarding 0:
+  <ul>
+    <li> <tt>0^-n</tt> is undefined (<tt>n > 0</tt>).
+    <li> <tt>x^0<tt/> always returns 1, even for <tt>x = 0</tt>.
+  </ul>
+  
+  @param aPower is in the range -999,999,999..999,999,999, inclusive. (This reflects a restriction on 
+  the underlying {@link BigDecimal#pow(int)} method.
+ */
+ public Decimal pow(int aPower){
+   BigDecimal newAmount = null;
+   if (aPower == 0){
+     newAmount = ONE_BD;
+   }
+   else if (aPower == 1){
+     newAmount = fAmount;
+   }
+   else if (aPower > 0){
+     newAmount = fAmount.pow(aPower);
+   }
+   else if (aPower < 0 && this.eq(ZERO)){
+     throw new RuntimeException("Raising 0 to a negative power is undefined.");
+   }
+   else if (aPower < 0){
+     newAmount = fAmount.pow(-1 * aPower);
+     newAmount = ONE_BD.divide(newAmount);
+   }
+   return new Decimal(newAmount);  
+ }
+
+ /** This implementation uses {@link Math#pow(double, double)}.   */
+ public Decimal pow(double aPower){
+   double value = Math.pow(fAmount.doubleValue(), aPower);
+   return Decimal.from(value);
+ }
+ 
+ /**
+  Raise this Decimal to a Decimal power.
+  <P>This method calls either {@link #pow(int)} or {@link #pow(double)}, according to the return value of 
+  {@link #isInteger()}.
+ */
+ public Decimal pow(Decimal aPower){
+   Decimal result = ZERO;
+   if (aPower.isInteger()){
+     result = pow(aPower.intValue());
+   }
+   else {
+     result = pow(aPower.doubleValue());
+   }
+   return result;
+ }
+ 
+ /** 
+  Required by {@link Number}.
+     
+  <P><em>Use of floating point data is highly discouraged.</em> 
+  This method is provided only because it's required by <tt>Number</tt>. 
+ */
+ @Override public double doubleValue() {
+   return fAmount.doubleValue();
+ }
+ 
+ /** 
+  Required by {@link Number}.
+     
+  <P><em>Use of floating point data is highly discouraged.</em> 
+  This method is provided only because it's required by <tt>Number</tt>. 
+ */
+ @Override public float floatValue() {
+   return fAmount.floatValue();
+ }
+
+ /** Required by {@link Number}. */
+ @Override  public int intValue() {
+   return fAmount.intValue();
+ }
+ 
+ /** Required by {@link Number}. */
+ @Override public long longValue() {
+   return fAmount.longValue();
+ }
+ 
+ // PRIVATE 
+ 
+ /** 
+  The decimal amount. 
+  Never null. 
+  @serial 
+ */
+ private BigDecimal fAmount;
+ 
+ /** Number of decimals to use when a division operation blows up into a non-terminating decimal.  */
+ private static final int DECIMALS = 20;
+ 
+ /** @serial */
+ private int fHashCode;
+ private static final int HASH_SEED = 23;
+ private static final int HASH_FACTOR = 37;
+
+ /**
+  Determines if a deserialized file is compatible with this class.
+ 
+  Maintainers must change this value if and only if the new version
+  of this class is not compatible with old versions. See Sun docs
+  for <a href=http://java.sun.com/products/jdk/1.1/docs/guide
+  /serialization/spec/version.doc.html> details. </a>
+ 
+  Not necessary to include in first version of the class, but
+  included here as a reminder of its importance.
+ */
+ private static final long serialVersionUID = 7526471155622776147L;
+
+ /**
+  Always treat de-serialization as a full-blown constructor, by
+  validating the final state of the de-serialized object.
+ */  
+ private void readObject(
+   ObjectInputStream aInputStream
+ ) throws ClassNotFoundException, IOException {
+   //always perform the default de-serialization first
+   aInputStream.defaultReadObject();
+   //defensive copy for mutable date field
+   //BigDecimal is not technically immutable, since its non-final
+   fAmount = new BigDecimal( fAmount.toPlainString() );
+   //ensure that object state has not been corrupted or tampered with maliciously
+   validateState();
+ }
+
+ private void writeObject(ObjectOutputStream aOutputStream) throws IOException {
+   //perform the default serialization for all non-transient, non-static fields
+   aOutputStream.defaultWriteObject();
+ }  
+
+ private void validateState(){
+   if( fAmount == null ) {
+     throw new IllegalArgumentException("Amount cannot be null");
+   }
+ }
+ 
+ /** Ignores scale: 0 same as 0.00 */
+ private int compareAmount(Decimal aThat){
+   return this.fAmount.compareTo(aThat.fAmount);
+ }
+ 
+ private static String quote(Object aText){
+   return "'" + String.valueOf(aText) + "'";
+ }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/Id.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,171 @@
+package hirondelle.web4j.model;
+
+import java.io.Serializable;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.IOException;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.security.SafeText;
+
+/**
+ Building block class for identifiers. 
+ 
+ <P>Identifiers are both common and important. Unfortunately, there is no class in the 
+ JDK specifically for identifiers. 
+ 
+ <P>An <tt>Id</tt> class is useful for these reasons :
+<ul>
+ <li>it allows model classes to read at a higher level of abstraction. Identifiers are 
+ labeled as such, and stand out very clearly from other items
+ <li>it avoids a common <a href="http://www.javapractices.com/Topic192.cjp">problem</a> 
+ in modeling identifiers as numbers.
+</ul>
+
+ <P>The underlying database column may be modeled as either text or as a number.
+ If the underlying column is of a numeric type, however, then a Data Access Object
+ will need to pass <tt>Id</tt> parameters to {@link hirondelle.web4j.database.Db} 
+ using {@link #asInteger} or {@link #asLong}.
+
+ <P><em>Design Note :</em><br>
+ This class is <tt>final</tt>, immutable, {@link Serializable}, 
+ and {@link Comparable}, in imitation of the other building block classes 
+ such as {@link String}, {@link Integer}, and so on.
+*/
+public final class Id implements Serializable, Comparable<Id> {
+
+  /**
+   Construct an identifier using an arbitrary {@link String}.
+    
+   This class uses a {@link SafeText} object internally.
+   @param aText is non-null, and contains characters that are allowed 
+   by {@link hirondelle.web4j.security.PermittedCharacters}.
+  */
+  public Id(String aText) {
+    fId = new SafeText(aText);
+    validateState();
+  }
+  
+  /**
+   Factory method.
+   
+   Simply a slightly more compact way of building an object, as opposed to 'new'.
+  */
+  public static Id from(String aText){
+    return new Id(aText);
+  }
+
+  /**
+   Return this id as an {@link Integer}, if possible.
+   
+   <P>See class comment.
+   
+   <P>If this <tt>Id</tt> is not convertible to an {@link Integer}, then a {@link RuntimeException} is 
+   thrown.
+  */
+  public Integer asInteger(){
+    return new Integer(fId.getRawString());
+  }
+
+  /**
+   Return this id as a {@link Long}, if possible.
+   
+   <P>See class comment.
+   
+   <P>If this <tt>Id</tt> is not convertible to a {@link Long}, 
+   then a {@link RuntimeException} is thrown.
+  */
+  public Long asLong(){
+    return new Long(fId.getRawString());
+  }
+  
+  /**
+   Return the id, with special characters escaped. 
+   
+   <P>The return value either has content (with no leading or trailing spaces), 
+   or is empty.
+   See {@link hirondelle.web4j.util.EscapeChars#forHTML(String)} for a list of escaped
+   characters.
+  */
+  @Override public String toString(){
+    return fId.toString();
+  }
+  
+  /** Return the text passed to the constructor. */
+  public String getRawString(){
+    return fId.getRawString();
+  }
+  
+  /** Return the text with special  XML characters esacped. See {@link SafeText#getXmlSafe()}.  */
+  public String getXmlSafe() {
+    return fId.getXmlSafe();
+  }
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      Id that = (Id) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;    
+  }
+
+  @Override public int hashCode(){
+    return ModelUtil.hashCodeFor(getSignificantFields());
+  }
+  
+  public int compareTo(Id aThat) {
+    final int EQUAL = 0;
+    if ( this == aThat ) return EQUAL;
+    int comparison = this.fId.compareTo(aThat.fId);
+    if ( comparison != EQUAL ) return comparison;
+    return EQUAL;
+  }
+  
+  // PRIVATE 
+  
+  /** @serial  */
+  private SafeText fId;
+  
+  /**
+   For evolution of this class, see Sun guidelines : 
+   http://java.sun.com/j2se/1.5.0/docs/guide/serialization/spec/version.html#6678 
+  */
+  private static final long serialVersionUID = 7526472295633676147L;
+  
+  /**
+   Always treat de-serialization as a full-blown constructor, by
+   validating the final state of the de-serialized object.
+  */
+  private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
+     //always perform the default de-serialization first
+     aInputStream.defaultReadObject();
+     //make defensive copy of mutable fields (none here)
+     //ensure that object state has not been corrupted or tampered with maliciously
+     validateState();
+  }
+
+  /**
+   This is the default implementation of writeObject.
+   Customise if necessary.
+  */
+  private void writeObject(ObjectOutputStream aOutputStream) throws IOException {
+    //perform the default serialization for all non-transient, non-static fields
+    aOutputStream.defaultWriteObject();
+  }
+  
+  private void validateState() {
+    if( ! Util.textHasContent(fId) ) {
+      if ( ! Consts.EMPTY_STRING.equals(fId.getRawString()) ) {
+        throw new IllegalArgumentException(
+          "Id must have content, or be the empty String. Erroneous Value : " + Util.quote(fId)
+        );
+      }
+    }
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[] {fId};
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/MessageList.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,69 @@
+package hirondelle.web4j.model;
+
+import java.util.List;
+
+/**
+ List of {@link AppResponseMessage} objects to be shown to the user.
+
+<P>Used for error messages, success messages, or any such item communicated 
+ to the user. See <tt>displayMessages.tag</tt> in the example application 
+ for an illustration of rendering a <tt>MessageList</tt>.
+
+ <P>If a message needs to survive a redirect, it must be placed in 
+ session scope, not request scope. Typically, successful edits to the 
+ database use a redirect (to avoid the 
+ <a href='http://www.javapractices.com/Topic181.cjp'>duplicate-upon-browser-reload</a>
+ problem), so success messages will almost always be placed in session 
+ scope. Conversely, failure messages will usually be placed in request
+ scope.
+
+ <P><em>Design Note</em><br>
+ The forces which WEB4J resolves are :
+ <ul>
+ <li>messages can be shown to the user upon both success and failure 
+ <li>redirect/forward behavior can occur for either success or failure
+ <li>rendering may be the same for both success and failure messages
+ <li>rendering may differ between success and failure messages
+ <li>the message may or may not include parameters 
+</ul>
+
+ <P>This item is provided as an interface because 
+ {@link AppException} needs to be both an <tt>Exception</tt>
+ and a <tt>MessageList</tt>.
+*/
+public interface MessageList {
+
+  /**
+   Add a simple {@link AppResponseMessage} to this list.
+  
+   <P>The argument satisfies the same conditions as {@link AppResponseMessage#forSimple}.
+  */
+  void add(String aErrorMessage);
+   
+  /**
+   Add a compound {@link AppResponseMessage} to this list.
+    
+   <P>The arguments satisfy the same conditions as {@link AppResponseMessage#forCompound}.
+  */
+  void add(String aErrorMessage, Object... aParams);
+  
+  /** Add all {@link AppResponseMessage}s attached to <tt>aAppEx</tt> to this list.  */
+  void add(AppException aAppEx);
+  
+  /**
+   Return <tt>true</tt> only if there are no messages in this list.
+  
+   <P>Note that this method name conflicts with the <tt>empty</tt> keyword 
+   of JSTL. Thus, {@link #isNotEmpty} is supplied as an alternative.
+  */
+  boolean isEmpty();
+  
+  /** Return the negation of {@link #isEmpty}.  */
+  boolean isNotEmpty();
+  
+  /**
+   Return an unmodifiable <tt>List</tt> of {@link AppResponseMessage}s.
+  */
+  List<AppResponseMessage> getMessages();
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/MessageListImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,83 @@
+package hirondelle.web4j.model;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** General implementation of {@link MessageList}. */
+public class MessageListImpl implements MessageList, Serializable {
+  
+  /** Create an empty {@link MessageList}.   */
+  public MessageListImpl(){
+    //empty 
+  }
+
+  /**
+   Create a {@link MessageList} having one simple {@link AppResponseMessage}.
+    
+   <P>The argument satisfies the same conditions as {@link AppResponseMessage#forSimple}.
+  */
+  public MessageListImpl(String aMessage) {
+    add(aMessage);
+  }
+  
+  /**
+   Create a {@link MessageList} having one compound {@link AppResponseMessage}.
+   
+   <P>The arguments satisfy the same conditions as 
+   {@link AppResponseMessage#forCompound}.
+  */
+  public MessageListImpl(String aMessage, Object... aParams) {
+    add(aMessage, aParams);
+  }
+  
+  public final void add(String aMessage){
+    fAppResponseMessages.add(AppResponseMessage.forSimple(aMessage));
+  }
+  
+  public final void add(String aMessage, Object... aParams){
+    fAppResponseMessages.add(AppResponseMessage.forCompound(aMessage, aParams));
+  }
+  
+  public final void add (AppException ex) {
+    fAppResponseMessages.addAll( ex.getMessages() );
+  }
+  
+  public final List<AppResponseMessage> getMessages () {
+    return Collections.unmodifiableList(fAppResponseMessages);
+  }
+  
+  public final boolean isNotEmpty () {
+    return !isEmpty();
+  }
+  
+  public final boolean isEmpty () {
+    return fAppResponseMessages.isEmpty();
+  }
+  
+  /** Intended for debugging only.  */
+  @Override public String toString(){
+    return 
+      "Messages : + " + fAppResponseMessages.toString() + 
+      " Has Been Displayed  : " +  fHasBeenDisplayed
+    ;
+  }
+  
+  // PRIVATE 
+  
+  /** List of {@link AppResponseMessage}s attached to this exception.  */
+  private final List<AppResponseMessage> fAppResponseMessages = new ArrayList<AppResponseMessage>();
+  
+  /** Controls the display-once-only behavior needed for JSPs.  */
+  private boolean fHasBeenDisplayed = false;
+  
+  private static final long serialVersionUID = 1000L;
+  
+   /** Always treat de-serialization as a full-blown constructor, by validating the final state of the deserialized object. */
+  private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
+    aInputStream.defaultReadObject();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ModelCtorException.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,43 @@
+package hirondelle.web4j.model;
+
+/**
+ Thrown when a Model Object (MO) cannot be constructed because of invalid 
+ constructor arguments.
+
+ <P>Arguments to a MO constructor have two sources: the end user and the 
+ the database. In both cases, errors in the values of these arguments 
+ are outside the immediate control of the application. Hence, MO constructors 
+ should throw a checked exception - (<tt>ModelCtorException</tt>).
+
+ <P>Using a checked exception has the advantage that
+ it cannot be ignored by the caller.
+
+ Example use case:<br>
+<PRE>
+  //a Model Object constructor 
+  Blah(String aText, int aID) throws ModelCtorException {
+    //check validity of all params.
+    //if one or more params is invalid, throw a ModelCtorException.
+    //for each invalid param, add a corresponding error message to 
+    //ModelCtorException. 
+  }
+
+  //call the Model Object constructor 
+  try {
+    Blah blah = new Blah(text, id);
+  }
+  catch(ModelCtorException ex){
+    //place the exception in scope, for subsequent 
+    //display to the user in a JSP
+  }
+</PRE>
+
+ <P>In the case of an error, the problem arises of how to redisplay the original, 
+ erroneous user input. The {@link hirondelle.web4j.ui.tag.Populate} tag 
+ accomplishes this in an elegant manner, simply by recycling the original 
+ request parameters.
+*/
+public final class ModelCtorException extends AppException {
+  //empty
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ModelCtorUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,152 @@
+package hirondelle.web4j.model;
+
+import java.util.*;
+import java.lang.reflect.*;
+import hirondelle.web4j.util.Args;
+
+/**
+ <b>UNPUBLISHED</b> - Utilities for constructing Model Objects.
+
+ <P>Instead of using this class directly, the application programmer will
+ use {@link hirondelle.web4j.model.ModelFromRequest} instead. 
+*/
+public final class ModelCtorUtil {
+  
+  /**
+   Return the {@link java.lang.reflect.Constructor} having the given number of arguments.
+  
+   <P>In WEB4J, Model Objects have <tt>public</tt> constructors. 
+   Any no-argument constructors are ignored by WEB4J. Constructors which take arguments
+   are used by WEB4J to build Model Objects out of both user input and database
+   {@link java.sql.ResultSet}s.
+    
+   <P>In WEB4J, the recommended style is to define Model Objects as immutable,
+   with a single constructor taking all necessary data.
+  
+  @param aMOClass has a public constructor which takes <tt>aNumArguments</tt>
+  @param aNumArguments number of arguments taken by a constructor, must be greater 
+   than <tt>0</tt>
+  */
+  public static <T> Constructor<T> getConstructor(Class<T> aMOClass, int aNumArguments) {
+    Args.checkForPositive(aNumArguments);
+    Constructor<T> result = null;
+    //Original line did not compile under JDK 6    Constructor<T>[] allCtors = aMOClass.getConstructors(); 
+    Constructor<T>[] allCtors = (Constructor<T>[]) aMOClass.getConstructors();
+    if (allCtors.length == 0) {
+      throw new IllegalArgumentException(
+        "Model Object class has no public constructor : " + aMOClass.getName()
+      );   
+    }
+    //cycle thru, and take the first constructor with the matching number of arguments
+    for ( Constructor<T> ctor : allCtors) {
+      if (ctor.getParameterTypes().length == aNumArguments) {
+        result = ctor;
+        break;
+      }
+    }
+    if (result == null){
+      throw new IllegalArgumentException(
+        "Model Object class " + aMOClass.getName() + " does not have a public " +
+        "constructor having " + aNumArguments + " arguments."
+      );
+    }
+    return result;
+  }
+  
+  /**
+   Return the <tt>public</tt>  {@link java.lang.reflect.Constructor} whose 
+   <em>exact</em> argument types are listed, in order, in <tt>aArgClasses</tt>. 
+   
+   <P><em>This method is intended only for cases in which specifying the number of 
+   arguments would not uniquely identify the desired <tt>public</tt> constructor.</em>
+  
+   <P>Example values for <tt>aArgClasses</tt> : <P>
+   <tt>
+   <table cellpadding="3" cellspacing="0" border="1">
+   <tr><th>public Constructor</th><th>aArgClasses array</th></tr>
+   <tr><td>Account(String, int)</td><td>{String.class, int.class}</td></tr>
+   <tr><td>Account(String, BigDecimal)</td><td>{String.class, BigDecimal.class}</td></tr>
+   </table>
+   </tt>
+   @param aArgClasses has the same restrictions as the arguments to 
+   {@link Class#getConstructor(Class[])}, and must have at least one element.
+  */
+  public static <T> Constructor<T> getExactConstructor(Class<T> aMOClass, Class<?>[] aArgClasses) {
+    Args.checkForPositive(aArgClasses.length);
+    Constructor<T> result = null;
+    try {
+      result = aMOClass.getConstructor(aArgClasses);
+    }
+    catch (NoSuchMethodException ex){
+      throw new IllegalArgumentException(
+        "Model Object class " + aMOClass.getName() + " does not have a public " +
+        "constructor having as arguments " + getClassNames(aArgClasses)
+      );
+    }
+    return result;
+  }
+  
+  /**
+   Build and return a Model Object, by passing the given argument values
+   to the given constructor.
+   
+   @param aMOCtor Model Object constructor having at least one argument
+   @param aArgValues contains possibly-null Objects, to be passed to <tt>aMOCtor</tt> ; 
+   if the Model Object constructor takes primitives, and not objects, then the primitives 
+   are transparently "unwrapped" from corresponding wrapper objects. 
+   See {@link Constructor#newInstance}.
+  */
+  public static <T> T buildModelObject(Constructor<T> aMOCtor, List<Object> aArgValues) throws ModelCtorException {
+    T result = null;
+    try { 
+      result =  aMOCtor.newInstance( aArgValues.toArray() );
+    }
+    catch (InstantiationException ex){
+      vomit(aMOCtor, aArgValues, ex);
+    }
+    catch (IllegalAccessException ex){
+      vomit(aMOCtor, aArgValues, ex);
+    }
+    catch(IllegalArgumentException ex){
+      vomit(aMOCtor, aArgValues, ex);
+    }
+    catch (InvocationTargetException ex){
+      //If the underlying MO ctor throws an exception, then this branch is exercised.
+      //Curiously, it is appropriate here to unwrap the underlying ModelCtorException, 
+      //just to re-throw it!
+      Throwable cause = ex.getCause();
+      if ( cause instanceof ModelCtorException ) {
+        //the MO cannot accept the given arguments
+        throw (ModelCtorException)cause;
+      }
+      //a bug has occurred
+      throw (RuntimeException)cause;
+    }
+    return result;
+  }
+  
+  // PRIVATE //
+  
+  private ModelCtorUtil(){
+    //empty - prevent construction by the caller
+  }
+  
+  /**
+   This will not be called during normal operation of the program, even 
+   when the user has input invalid values. That is, a call to this method 
+   means a bug in the program or its environment, and not a user input error.
+  */
+  private static void vomit(Constructor<?> aMOCtor, List<Object> aArgValues, Exception aEx) {
+    throw new RuntimeException(
+      "Cannot reflectively construct Model Object of class " 
+      + aMOCtor.getDeclaringClass().getName() + 
+      ", using arguments " + aArgValues + ". " + aEx.toString(),
+      aEx
+    );
+  }
+  
+  private static String getClassNames(Class<?>[] aClasses){
+    List names = Arrays.asList(aClasses);
+    return names.toString(); 
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ModelFromRequest.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,205 @@
+package hirondelle.web4j.model;
+
+import java.util.*;
+import java.util.logging.*;
+import java.lang.reflect.Constructor;
+
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.model.ModelCtorException;
+
+/**
+ <span class="highlight">Parse a set of request parameters into a Model Object.</span>
+ 
+ <P>Since HTTP is entirely textual, the problem always arises in a web application of 
+ building Model Objects (whose constructors may take arguments of any type) out of 
+ the text taken from HTTP request parameters. (See the <tt>hirondelle.web4j.database</tt> 
+ package for the similar problem of translating rows of a <tt>ResultSet</tt> into a Model Object.)
+
+ <P>Somewhat surprisingly, some web application frameworks do not assist the programmer
+ in this regard. That is, they leave the programmer to always translate raw HTTP request 
+ parameters (<tt>String</tt>s) into target types (<tt>Integer</tt>, <tt>Boolean</tt>,
+ etc), and then to in turn build complete Model Objects. This usually results in 
+ much code repetition.
+
+ <P>This class, along with implementations of {@link ConvertParam} and {@link RequestParser}, 
+ help an {@link Action} build a Model Object by defining such "type translation" 
+ policies in one place. 
+
+ <P>Example use case of building a <tt>'Visit'</tt> Model Object out of four
+ {@link hirondelle.web4j.request.RequestParameter} objects (ID, RESTAURANT, etc.):
+ <PRE>
+protected void validateUserInput() {
+  try {
+    ModelFromRequest builder = new ModelFromRequest(getRequestParser());
+    //pass RequestParameters (or any object) using a sequence (varargs)
+    fVisit = builder.build(Visit.class, ID, RESTAURANT, LUNCH_DATE, MESSAGE);
+  }
+  catch (ModelCtorException ex){
+    addError(ex);
+  }    
+}
+ </PRE>
+
+ <P><span class="highlight">The order of the sequence params passed to {@link #build(Class, Object...)} 
+ must match the order of arguments passed to the Model Object constructor</span>. 
+ This mechanism is quite effective and compact.
+
+ <P>The sequence parameters passed to {@link #build(Class, Object...)} need not be a {@link RequestParameter}. 
+ They can be any object whatsoever. Before calling the Model Object constructor, the sequence 
+ parameters are examined and treated as follows :
+ <PRE>
+ if the item is not an instance of RequestParameter
+    - do not alter it in any way
+    - it will be passed to the MO ctor 'as is'
+ else 
+   - fetch the corresponding param value from the request
+   - attempt to translate its text to the target type required
+     by the corresponding MO ctor argument, using policies 
+     defined by RequestParser and ConvertParam
+     if the translation attempt fails
+      - create a ModelCtorException 
+ </PRE>
+
+ <P> If no {@link ModelCtorException} has been constructed, then the MO constructor is 
+ called using reflection. Note that the MO constructor may itself in turn throw 
+ a <tt>ModelCtorException</tt>.
+ In fact, in order for this class to be well-behaved, <span class="highlight">the MO 
+ constructor cannot throw anything other than a <tt>ModelCtorException</tt> as part of
+ its contract. This includes
+ <tt>RuntimeException</tt>s</span>. For example, if a <tt>null</tt> is not permitted 
+ by a MO constructor, it should not throw a <tt>NullPointerException</tt> (unchecked). 
+ Rather, it should throw a <tt>ModelCtorException</tt> (checked). This allows the caller to 
+ be notified of all faulty user input in a uniform manner. It also makes MO constructors
+ simpler, since all irregular input will result in a <tt>ModelCtorException</tt>, instead 
+ of a mixture of checked and unchecked exceptions.
+
+ <P>This unusual policy is related to the unusual character of Model Objects, 
+ which attempt to build an object out of arbitrary user input. 
+ Unchecked exceptions should be thrown only if a bug is present. 
+ <em>However, irregular user input is not a bug</em>. 
+
+ <P>When converting from a {@link hirondelle.web4j.request.RequestParameter} into a building block class, 
+ this class supports only the types supported by the implementation of {@link ConvertParam}.
+
+<P>In summary, to work with this class, a Model Object must :
+<ul>
+ <li>be <tt>public</tt>
+ <li>have a <tt>public</tt> constructor, whose number of arguments matches the number of <tt>Object[]</tt> params 
+ passed to {@link #build(Class, Object...)}
+ <li>the constructor is allowed to throw only {@link hirondelle.web4j.model.ModelCtorException} - no 
+ unchecked exceptions should be (knowingly) permitted
+</ul>
+*/
+public final class ModelFromRequest {
+
+  /*<em>Design Note (for background only)</em> :
+    The design of this mechanism is a result of the following issues :
+    <ul>
+     <li>model objects (MO's) need to be constructed out of a textual source
+     <li>that textual source (the HTTP request) is not necessarily the <em>sole</em> 
+     source of data; that is, a MO may be constructed entirely out of the parameters in 
+     a request, or may also be constructed out of an arbitrary combination of both 
+     request params and java objects. For example, a time-stamp may be passed to a 
+     MO constructor alongside other information extracted from the request.
+     <li>the HTTP request may lack explicit data needed to create a MO. For example, an
+     unchecked checkbox will not cause a request param to be sent to the server.
+     <li>users do not always have to make an explicit selection for every field in a form.
+     This corresponds to a MO constructor having optional arguments, and to absent or empty 
+     request parameters.
+     <li>error messages should use names meaningful to the user; for example 
+     <tt>'Number of planetoids is not an integer'</tt> is preferred over the more 
+     generic <tt>'Item is not an integer'</tt>.
+     <li>since construction of MOs is tedious and repetitive, this class should make
+     the caller's task as simple as possible. This class should not force the caller to 
+     select particular methods based on the target type of a constructor argument.
+     <li>error messages should be gathered for all erroneous fields, and presented to the 
+     user in a single listing. This gives the user the chance to make all corrections at once, 
+     instead of in sequence. This class is not completely successful in this regard, since 
+     it is possible, in a few cases, to not see all possible error messages after the first 
+     submission : a <tt>ModelCtorException</tt> can be thrown first by this class after 
+     a failure to translate into a target type, and then subsequently by the MO 
+     constructor itself. Thus, there are thus two flavours of error message : 
+     'bad translation from text to type x', and 'bad call to a MO constructor'.
+    </ul>
+    */
+  
+  /**
+   Constructor.
+    
+   @param aRequestParser translates parameter values into <tt>Integer</tt>, 
+   <tt>Date</tt>, and so on, using the implementation of {@link ConvertParam}.
+  */
+  public ModelFromRequest(RequestParser aRequestParser){
+    fRequestParser = aRequestParser;
+    fModelCtorException = new ModelCtorException();
+  }
+  
+  /**
+   Return a Model Object constructed out of request parameters (and possibly 
+   other Java objects).
+  
+   @param aMOClass class of the target Model Object to be built.
+   @param aCandidateArgs represents the <em>ordered</em> list of items to be passed 
+   to the Model Object's constructor, and can contain <tt>null</tt> elements. Usually contains {@link RequestParameter} 
+   objects, but may contain objects of any type, as long as they are expected by the target Model Object constructor.
+   @throws ModelCtorException if either an element of <tt>aCandidateArgs</tt> 
+   cannot be translated into the target type, or if all such translations succeed,  
+   but the call to the MO constructor itself fails.
+  */
+  public <T> T build(Class<T> aMOClass, Object... aCandidateArgs) throws ModelCtorException {
+    fLogger.finest("Constructing a Model Object using request param values.");
+    Constructor<T> ctor = ModelCtorUtil.getConstructor(aMOClass, aCandidateArgs.length);
+    Class<?>[] targetClasses = ctor.getParameterTypes();
+    
+    List<Object> argValues = new ArrayList<Object>(); //may contain nulls!
+    int argIdx = 0;
+    for( Class<?> targetClass : targetClasses ){
+      argValues.add( convertCandidateArg(aCandidateArgs[argIdx], targetClass) );
+      ++argIdx;
+    }
+    fLogger.finest("Candidate args: " + argValues); 
+    if ( fModelCtorException.isNotEmpty() ) {
+      fLogger.finest("Failed to convert request param(s) into types expected by ctor.");
+      throw fModelCtorException;
+    }
+    return ModelCtorUtil.buildModelObject(ctor, argValues);
+  }
+  
+  // PRIVATE //
+
+  /** Provides access to the underlying request.  */
+  private final RequestParser fRequestParser;
+  
+  /**
+   Holds all error messages, for either failed translation of a param into an Object, 
+   or for a failed call to a constructor.
+  */
+  private final ModelCtorException fModelCtorException;
+  private static final Logger fLogger = Util.getLogger(ModelFromRequest.class); 
+  
+  private Object convertCandidateArg(Object aCandidateArg, Class<?> aTargetClass){
+    Object result = null;
+    if ( ! (aCandidateArg instanceof RequestParameter) ) {
+      result = aCandidateArg;
+    }
+    else {
+      RequestParameter reqParam = (RequestParameter)aCandidateArg;
+      result = translateParam(reqParam, aTargetClass);
+    }
+    return result;
+  }
+  
+  private Object translateParam(RequestParameter aReqParam, Class<?> aTargetClass){
+    Object result = null;
+    try {
+      result = fRequestParser.toSupportedObject(aReqParam, aTargetClass);  
+    }
+    catch (ModelCtorException ex){
+      fModelCtorException.add(ex);
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ModelUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,554 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.util.Util;
+
+import java.lang.reflect.Array;
+
+/**
+ Collected utilities for overriding {@link Object#toString}, {@link Object#equals}, 
+ and {@link Object#hashCode}, and implementing {@link Comparable}.
+ 
+ <P>All Model Objects should override the above {@link Object} methods. 
+ All Model Objects that are being sorted in code should implement {@link Comparable}. 
+ 
+ <P>In general, it is easier to use this class with <em>object</em> fields (<tt>String</tt>, <tt>Date</tt>, 
+ <tt>BigDecimal</tt>, and so on), instead of <em>primitive</em> fields (<tt>int</tt>, <tt>boolean</tt>, and so on).
+ 
+ <P>See below for example implementations of :
+ <ul> 
+ <li><a href="#ToString">toString()</a>
+ <li><a href="#HashCode">hashCode()</a>
+ <li><a href="#Equals">equals()</a>
+ <li><a href="#Comparable">compareTo()</a>
+ </ul>
+ 
+ <a name="ToString"><P><b>toString()</b><br>
+ This class is intended for the most common case, where <tt>toString</tt> is used in
+ an <em>informal</em> manner (usually for logging and stack traces). That is, <span class="highlight">
+ the caller should not rely on the <tt>toString()</tt> text returned by this class to define program logic.</span> 
+ 
+ <P>Typical example :
+<PRE>
+  &#064;Override public String toString() {
+    return ModelUtil.toStringFor(this);
+  }
+</PRE>
+ 
+ <P>There is one <em>occasional</em> variation, used only when two model objects reference each other. To avoid 
+ a problem with cyclic references and infinite looping, implement as : 
+ <PRE> 
+  &#064;Override public String toString() {
+    return ModelUtil.toStringAvoidCyclicRefs(this, Product.class, "getId");
+  }
+ </PRE>
+ 
+ Here, the usual behavior is overridden for any method in 'this' object 
+ which returns a <tt>Product</tt> : instead of calling <tt>Product.toString()</tt>, 
+ the return value of <tt>Product.getId()</tt> is used instead. 
+ 
+ <a name="HashCode"><P><b>hashCode()</b><br>
+ Example of the simplest style :
+ <pre>
+  &#064;Override public int hashCode() {
+    return ModelUtil.hashFor(getSignificantFields());
+  }
+  ...
+  private String fName;
+  private Boolean fIsActive;
+  private Object[] getSignificantFields(){
+    //any primitive fields can be placed in a wrapper Object
+    return new Object[]{fName, fIsActive};
+  }
+ </pre>
+ 
+ <P><a name="GetSignificantFields"></a><span class="highlight">Since the {@link Object#equals} and 
+ {@link Object#hashCode} methods are so closely related, and should always refer to the same fields, 
+ defining a <tt>private</tt> method to return the <tt>Object[]</tt> of significant fields is highly 
+ recommended.</span> Such a method would be called by <em>both</em> <tt>equals</tt> and <tt>hashCode</tt>. 
+ 
+ <P>If an object is <a href="http://www.javapractices.com/Topic29.cjp">immutable</a>, 
+ then the result may be calculated once, and then cached, as a small performance 
+ optimization :
+ <pre>
+  &#064;Override public int hashCode() {
+    if ( fHashCode == 0 ) {
+      fHashCode = ModelUtil.hashFor(getSignificantFields());
+    }
+    return fHashCode;
+  }
+  ...
+  private String fName;
+  private Boolean fIsActive;
+  private int fHashCode;
+  private Object[] getSignificantFields(){
+    return new Object[]{fName, fIsActive};
+  }
+ </pre>
+
+ The most verbose style does not require wrapping primitives in an <tt>Object</tt> array:
+ <pre>
+  &#064;Override public int hashCode(){
+    int result = ModelUtil.HASH_SEED;
+    //collect the contributions of various fields
+    result = ModelUtil.hash(result, fPrimitive);
+    result = ModelUtil.hash(result, fObject);
+    result = ModelUtil.hash(result, fArray);
+    return result;
+  }
+ </pre>
+ 
+ <a name="Equals"><P><b>equals()</b><br>
+ Simplest example, in a class called <tt>Visit</tt> (this is the recommended style):
+ <PRE>
+  &#064;Override public boolean equals(Object aThat) {
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      Visit that = (Visit) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;
+  }
+  ...
+  private final Code fRestaurantCode;
+  private final Date fLunchDate;
+  private final String fMessage;
+  private Object[] getSignificantFields(){
+    return new Object[] {fRestaurantCode, fLunchDate, fMessage};
+  }
+ </PRE>
+ 
+ Second example, in a class called <tt>Member</tt> :
+ <PRE>
+  &#064;Override public boolean equals( Object aThat ) {
+    if ( this == aThat ) return true;
+    if ( !(aThat instanceof Member) ) return false;
+    Member that = (Member)aThat;
+    return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+  }
+  ...
+  private final String fName;
+  private final Boolean fIsActive;
+  private final Code fDisposition;
+  private Object[] getSignificantFields(){
+    return new Object[]{fName, fIsActive, fDisposition};
+  }
+ </PRE>
+ See note above regarding <a href="#GetSignificantFields">getSignificantFields()</a>.
+ 
+ <P>More verbose example, in a class called <tt>Planet</tt> :
+ <PRE> 
+  &#064;Override public boolean equals(Object aThat){
+    if ( this == aThat ) return true;
+    if ( !(aThat instanceof Planet) ) return false;
+    Planet that = (Planet)aThat;
+    return 
+      EqualsUtil.areEqual(this.fPossiblyNullObject, that.fPossiblyNullObject) &&
+      EqualsUtil.areEqual(this.fCollection, that.fCollection) &&
+      EqualsUtil.areEqual(this.fPrimitive, that.fPrimitive) &&
+      Arrays.equals(this.fArray, that.fArray); //arrays are different!
+  }
+ </PRE>
+ 
+ <a name="Comparable"><P><b>compareTo()</b><br>
+ The {@link Comparable} interface is distinct, since it is not an overridable method of the
+ {@link Object} class. 
+ 
+ <P>Example use case of using <a href='#comparePossiblyNull(T, T, hirondelle.web4j.model.ModelUtil.NullsGo)'>comparePossiblyNull</a>, 
+ (where <tt>EQUAL</tt> takes the value <tt>0</tt>) :
+ <PRE>
+  public int compareTo(Movie aThat) {
+    if ( this == aThat ) return EQUAL;
+    
+    int comparison = ModelUtil.comparePossiblyNull(this.fDateViewed, aThat.fDateViewed, NullsGo.LAST);
+    if ( comparison != EQUAL ) return comparison;
+
+    //this field is never null
+    comparison = this.fTitle.compareTo(aThat.fTitle);
+    if ( comparison != EQUAL ) return comparison;
+    
+    comparison = ModelUtil.comparePossiblyNull(this.fRating, aThat.fRating, NullsGo.LAST);
+    if ( comparison != EQUAL ) return comparison;
+   
+    comparison = ModelUtil.comparePossiblyNull(this.fComment, aThat.fComment, NullsGo.LAST);
+    if ( comparison != EQUAL ) return comparison;
+    
+    return EQUAL;
+  }
+ </PRE>
+ 
+ @author Hirondelle Systems
+ @author with a contribution by an anonymous user of javapractices.com
+*/
+public final class ModelUtil {
+
+  // TO STRING // 
+  
+  /**
+   Implements an override of <tt>Object.toString()</tt> (see class comment).
+  
+  <P>Example output format, for an <tt>Rsvp</tt> object with 4 fields :
+   <PRE>
+  hirondelle.fish.main.rsvp.Rsvp {
+  Response: null
+  MemberId: 4
+  MemberName: Tom Thumb
+  VisitId: 13
+  }
+   </PRE>
+   (There is no indentation since it causes problems when there is nesting.)
+   
+   <P>The only items which contribute to the result are : 
+  <ul>
+   <li>the full class name
+   <li>all no-argument <tt>public</tt> methods which return a value
+  </ul>
+   
+  <P>These items are excluded from the result : 
+  <ul>
+   <li>methods defined in {@link Object}
+   <li>factory methods which return an object of the native class ("<tt>getInstance()</tt>" methods)
+  </ul> 
+    
+   <P>Reflection is used to access field values. Items are converted to a <tt>String</tt> simply by calling 
+   their <tt>toString method</tt>, with the following exceptions : 
+   <ul>
+   <li>for arrays, the {@link Util#getArrayAsString(Object)} is used
+   <li>for methods whose name contains the text <tt>"password"</tt> (case-insensitive),  
+   their return values hard-coded to '<tt>****</tt>'. 
+   </ul>
+  
+   <P>If the method name follows the pattern '<tt>getXXX</tt>', then the word '<tt>get</tt>'
+   is removed from the result.
+  
+   <P><span class="highlight">WARNING</span>: If two classes have cyclic references 
+   (that is, each has a reference to the other), then infinite looping will result 
+   if <em>both</em> call this method! To avoid this problem, use <tt>toStringFor</tt>
+   for one of the classes, and {@link #toStringAvoidCyclicRefs} for the other class.
+  
+   @param aObject the object for which a <tt>toString()</tt> result is required.
+  */
+  public static String toStringFor(Object aObject) {
+    return ToStringUtil.getText(aObject);
+  }
+  
+  /**
+   As in {@link #toStringFor}, but avoid problems with cyclic references.
+   
+   <P>Cyclic references occur when one Model Object references another, and both Model Objects have 
+   their <tt>toString()</tt> methods implemented with this utility class.
+   
+   <P>Behaves as in {@link #toStringFor}, with one exception: for methods of <tt>aObject</tt> that 
+   return instances of <tt>aSpecialClass</tt>, then call <tt>aMethodName</tt> on such instances, 
+   instead of <tt>toString()</tt>.
+  */
+  public static String toStringAvoidCyclicRefs(Object aObject, Class aSpecialClass, String aMethodName) {
+    return ToStringUtil.getTextAvoidCyclicRefs(aObject, aSpecialClass, aMethodName);
+  }  
+  
+  // HASH CODE //
+
+  /**
+   Return the hash code in a single step, using all significant fields passed in an {@link Object} sequence parameter.
+   
+   <P>(This is the recommended way of implementing <tt>hashCode</tt>.)
+   
+   <P>Each element of <tt>aFields</tt> must be an {@link Object}, or an array containing 
+   possibly-null <tt>Object</tt>s. These items will each contribute to the 
+   result. (It is not a requirement to use <em>all</em> fields related to an object.)
+   
+   <P>If the caller is using a <em>primitive</em> field, then it must be converted to a corresponding 
+   wrapper object to be included in <tt>aFields</tt>. For example, an <tt>int</tt> field would need 
+   conversion to an {@link Integer} before being passed to this method.
+  */
+  public static final int hashCodeFor(Object... aFields){
+    int result = HASH_SEED;
+    for(Object field: aFields){
+      result = hash(result, field);
+    }
+    return result;
+  }
+
+  /**
+   Initial seed value for a <tt>hashCode</tt>. 
+   
+   Contributions from individual fields are 'added' to this initial value.
+   (Using a non-zero value decreases collisons of <tt>hashCode</tt> values.)
+  */
+  public static final int HASH_SEED = 23;
+  
+  /** Hash code for <tt>boolean</tt> primitives. */
+  public static int hash( int aSeed, boolean aBoolean ) {
+    return firstTerm( aSeed ) + ( aBoolean ? 1 : 0 );
+  }
+
+  /** Hash code for <tt>char</tt> primitives. */
+  public static int hash( int aSeed, char aChar ) {
+    return firstTerm( aSeed ) + aChar;
+  }
+    
+  /** 
+   Hash code for <tt>int</tt> primitives.
+   <P>Note that <tt>byte</tt> and <tt>short</tt> are also handled by this method, through implicit conversion.  
+  */
+  public static int hash( int aSeed , int aInt ) {
+    return firstTerm( aSeed ) + aInt;
+  }
+
+  /** Hash code for <tt>long</tt> primitives.  */
+  public static int hash( int aSeed , long aLong ) {
+    return firstTerm(aSeed)  + (int)( aLong ^ (aLong >>> 32) );
+  }
+
+  /** Hash code for <tt>float</tt> primitives.  */
+  public static int hash( int aSeed , float aFloat ) {
+    return hash( aSeed, Float.floatToIntBits(aFloat) );
+  }
+
+  /** Hash code for <tt>double</tt> primitives.  */
+  public static int hash( int aSeed , double aDouble ) {
+    return hash( aSeed, Double.doubleToLongBits(aDouble) );
+  }
+
+  /**
+   Hash code for an Object.
+    
+   <P><tt>aObject</tt> is a possibly-null object field, and possibly an array.
+   
+   <P>Arrays can contain possibly-null objects or primitives; no circular references are permitted.
+  */
+  public static int hash(int aSeed , Object aObject) {
+    int result = aSeed;
+    if (aObject == null) {
+      result = hash(result, 0);
+    }
+    else if ( ! isArray(aObject) ) {
+      //ordinary objects
+      result = hash(result, aObject.hashCode());
+    }
+    else {
+      int length = Array.getLength(aObject);
+      for ( int idx = 0; idx < length; ++idx ) {
+        Object item = Array.get(aObject, idx); //wraps any primitives! 
+        //recursive call - no circular object graphs allowed
+        result = hash(result, item);
+      }        
+    }
+    return result;
+  }
+  
+  // EQUALS //
+  
+  /**
+   Quick checks for <em>possibly</em> determining equality of two objects.
+   
+   <P>This method exists to make <tt>equals</tt> implementations read more legibly, 
+   and to avoid multiple <tt>return</tt> statements.
+    
+   <P><em>It cannot be used by itself to fully implement <tt>equals</tt>. </em> 
+   It uses <tt>==</tt> and <tt>instanceof</tt> to determine if equality can be 
+   found cheaply, without the need to examine field values in detail. It is 
+   <em>always</em> paired with some other method 
+   (usually {@link #equalsFor(Object[], Object[])}), as in the following example :
+   <PRE>
+   public boolean equals(Object aThat){
+     Boolean result = ModelUtil.quickEquals(this, aThat);
+     <b>if ( result == null ){</b>
+       //quick checks not sufficient to determine equality,
+       //so a full field-by-field check is needed :
+       This this = (This) aThat; //will not fail 
+       result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+     }
+     return result;
+   }
+   </PRE>
+   
+   <P>This method is unusual since it returns a <tt>Boolean</tt> that takes 
+   <em>3</em> values : <tt>true</tt>, <tt>false</tt>, and <tt>null</tt>. Here, 
+   <tt>true</tt> and <tt>false</tt> mean that a simple quick check was able to 
+   determine equality. <span class='highlight'>The <tt>null</tt> case means that the 
+   quick checks were not able to determine if the objects are equal or not, and that 
+   further field-by-field examination is necessary. The caller must always perform a 
+   check-for-null on the return value.</span>
+  */
+  static public Boolean quickEquals(Object aThis, Object aThat){
+    Boolean result = null;
+    if ( aThis == aThat ) {
+      result = Boolean.TRUE;
+    }
+    else {
+      Class<?> thisClass = aThis.getClass();
+      if ( ! thisClass.isInstance(aThat) ) {
+        result = Boolean.FALSE;
+      }
+    }
+    return result;
+  }
+  
+  /**
+   Return the result of comparing all significant fields.
+   
+   <P>Both <tt>Object[]</tt> parameters are the same size. Each includes all fields that have been 
+   deemed by the caller to contribute to the <tt>equals</tt> method. <em>None of those fields are 
+   array fields.</em> The order is the same in both arrays, in the sense that the Nth item 
+   in each array corresponds to the same underlying field. The caller controls the order in which fields are 
+   compared simply through the iteration order of these two arguments. 
+   
+   <P>If a primitive field is significant, then it must be converted to a corresponding 
+   wrapper <tt>Object</tt> by the caller. 
+  */
+  static public boolean equalsFor(Object[] aThisSignificantFields, Object[] aThatSignificantFields){
+    //(varargs can be used for final arg only)
+    if (aThisSignificantFields.length != aThatSignificantFields.length) {
+      throw new IllegalArgumentException(
+        "Array lengths do not match. 'This' length is " + aThisSignificantFields.length + 
+        ", while 'That' length is " + aThatSignificantFields.length + "."
+      );
+    }
+    
+    boolean result = true;
+    for(int idx=0; idx < aThisSignificantFields.length; ++idx){
+      if ( ! areEqual(aThisSignificantFields[idx], aThatSignificantFields[idx]) ){
+        result = false;
+        break;
+      }
+    }
+    return result;
+  }
+  
+  /** Equals for <tt>boolean</tt> fields. */
+  static public boolean areEqual(boolean aThis, boolean aThat){
+    return aThis == aThat;
+  }
+  
+  /** Equals for <tt>char</tt> fields. */
+  static public boolean areEqual(char aThis, char aThat){
+    return aThis == aThat;
+  }
+
+  /**
+   Equals for <tt>long</tt> fields.
+    
+   <P>Note that <tt>byte</tt>, <tt>short</tt>, and <tt>int</tt> are handled by this method, through
+   implicit conversion.
+  */
+  static public boolean areEqual(long aThis, long aThat){
+    return aThis == aThat;
+  }
+  
+  /** Equals for <tt>float</tt> fields. */
+  static public boolean areEqual(float aThis, float aThat){
+    return Float.floatToIntBits(aThis) == Float.floatToIntBits(aThat);
+  }
+  
+  /** Equals for <tt>double</tt> fields. */
+  static public boolean areEqual(double aThis, double aThat){
+    return Double.doubleToLongBits(aThis) == Double.doubleToLongBits(aThat);
+  }
+  
+  /**
+   Equals for an Object.
+   <P>The objects are possibly-null, and possibly an array.
+   <P>Arrays can contain possibly-null objects or primitives; no circular references are permitted.
+  */
+  static public boolean areEqual(Object aThis, Object aThat){
+    if (aThis == aThat){
+      return true;
+    }
+    if (aThis == null || aThat == null){
+      return (aThis == null && aThat == null);
+    }
+    
+    boolean result = false;
+    if (! isArray(aThis)){
+      //ordinary objects
+      result = aThis.equals(aThat);
+    }
+    else {
+      //two arrays, with possible sub-structure
+      int length = Array.getLength(aThis);
+      for (int idx = 0; idx < length; ++idx) {
+        Object thisItem = Array.get(aThis, idx); //wraps any primitives! 
+        Object thatItem = Array.get(aThat, idx); //wraps any primitives! 
+        //recursive call - no circular object graphs allowed
+        result = areEqual(thisItem, thatItem);
+      }        
+    }
+    return result;
+  }
+  
+
+  //Comparable<T>
+
+  /**
+   Define hows <tt>null</tt> items are treated in a comparison. Controls if <tt>null</tt>
+   items appear first or last.
+   
+   <P>See <a href='#comparePossiblyNull(T, T, hirondelle.web4j.model.ModelUtil.NullsGo)'>comparePossiblyNull</a>. 
+  */
+  public enum NullsGo {FIRST,LAST}
+   
+  /**
+   Utility for implementing {@link Comparable}. See <a href='#Comparable'>class example</a> 
+   for illustration.
+   
+   <P>The {@link Comparable} interface specifies that 
+   <PRE>
+   blah.compareTo(null)
+   </PRE> should throw a {@link NullPointerException}. You should follow that 
+   guideline. Note that this utility method itself  
+   accepts nulls <em>without</em> throwing a {@link NullPointerException}. 
+   In this way, this method can handle nullable fields just like any other field.
+   
+   <P>There are 
+   <a href='http://www.javapractices.com/topic/TopicAction.do?Id=207'>special issues</a> 
+   for sorting {@link String}s regarding case, {@link java.util.Locale}, 
+   and accented characters. 
+   
+   @param aThis an object that implements {@link Comparable}
+   @param aThat an object of the same type as <tt>aThis</tt>
+   @param aNullsGo defines if <tt>null</tt> items should be placed first or last
+  */
+  static public <T extends Comparable<T>> int comparePossiblyNull(T aThis, T aThat, NullsGo aNullsGo){
+    int EQUAL = 0;
+    int BEFORE = -1;
+    int AFTER = 1;
+    int result = EQUAL;
+    
+    if(aThis != null && aThat != null){ 
+      result = aThis.compareTo(aThat);
+    }
+    else {
+      //at least one reference is null - special handling
+      if(aThis == null && aThat == null) {
+        //not distinguishable, so treat as equal 
+      }
+      else if(aThis == null && aThat != null) {
+        result = BEFORE;
+      }
+      else if( aThis != null && aThat == null) {
+        result = AFTER;
+      }
+      if(NullsGo.LAST == aNullsGo){
+        result = (-1) * result;
+      }
+    }
+    return result;
+  }
+  
+  // PRIVATE //
+  
+  private ModelUtil(){
+    //prevent object construction  
+  }
+  
+  private static final int fODD_PRIME_NUMBER = 37;
+
+  private static int firstTerm( int aSeed ){
+    return fODD_PRIME_NUMBER * aSeed;
+  }
+
+  private static boolean isArray(Object aObject){
+    return aObject != null && aObject.getClass().isArray();
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTCheck.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,286 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.security.SafeText;
+import java.util.*;
+import java.math.*;
+import java.util.regex.*;
+import junit.framework.*;
+import java.io.FileNotFoundException;
+
+public final class TESTCheck extends TestCase {
+
+  /** Run the test cases. */
+  public static void main(String args[]) throws AppException, FileNotFoundException {
+    String[] testCaseName = { TESTCheck.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTCheck( String aName) {
+    super(aName);
+  }
+
+  // TEST CASES //
+  
+  public void testRequired(){
+    Object thing = null;
+    assertFalse(Check.required(thing));
+    thing = new Object();
+    assertTrue(Check.required(thing));
+  }
+  
+  public void testIntegerRange(){
+    Validator[] validators = {Check.min(1), Check.max(10)};
+    assertTrue(Check.required(new Integer(1), validators));
+    assertTrue(Check.required(new Integer(2), validators));
+    assertTrue(Check.required(new Integer(10), validators));
+    
+    assertFalse(Check.required(new Integer(0), validators));
+    assertFalse(Check.required(new Integer(-1), validators));
+    assertFalse(Check.required(new Integer(-50), validators));
+    assertFalse(Check.required(new Integer(11), validators));
+    assertFalse(Check.required(new Integer(50), validators));
+  }
+  
+  public void testLongRange(){
+    Validator[] validators = {Check.min(1L), Check.max(10L)};
+    assertTrue(Check.required(new Long(1L), validators));
+    assertTrue(Check.required(new Long(2L), validators));
+    assertTrue(Check.required(new Long(10L), validators));
+    assertFalse(Check.required(new Long(0L), validators));
+    assertFalse(Check.required(new Long(-1L), validators));
+    assertFalse(Check.required(new Long(-50L), validators));
+    assertFalse(Check.required(new Long(11L), validators));
+    assertFalse(Check.required(new Long(50L), validators));
+  }
+  
+  public void testString(){
+    Validator[] validators = {Check.min(1), Check.max(10)};
+    assertTrue(Check.required("1", validators));
+    assertTrue(Check.required("123", validators));
+    assertTrue(Check.required("123456789", validators));
+    assertTrue(Check.required("1234567890", validators));
+    assertTrue(Check.required("This is th", validators));
+    assertTrue(Check.required("This is", validators));
+    
+    assertFalse(Check.required(null, validators));
+    assertFalse(Check.required("", validators));
+    assertFalse(Check.required(" ", validators));
+    assertFalse(Check.required("  ", validators));
+  }
+  
+  public void testId(){
+    Validator[] validators = {Check.min(1), Check.max(10)};
+    assertTrue(Check.required(new Id("1"), validators));
+    assertTrue(Check.required(new Id("123"), validators));
+    assertTrue(Check.required(new Id("123456789"), validators));
+    assertTrue(Check.required(new Id("1234567890"), validators));
+    assertTrue(Check.required(new Id("Yes What"), validators));
+    
+    assertFalse(Check.required(null, validators));
+    assertFalse(Check.required(new Id("12345678901"), validators));
+    assertFalse(Check.required(new Id(""), validators));
+  }
+
+  public void testFreeFormText(){
+    Validator[] validators = {Check.min(1), Check.max(10)};
+    assertTrue(Check.required(new SafeText("1"), validators));
+    assertTrue(Check.required(new SafeText("123"), validators));
+    assertTrue(Check.required(new SafeText("123456789"), validators));
+    assertTrue(Check.required(new SafeText("1234567890"), validators));
+    assertTrue(Check.required(new SafeText("Yes What"), validators));
+    assertTrue(Check.required(new SafeText("Yes"), validators));
+    assertTrue(Check.required(new SafeText("That's it."), validators));
+    assertTrue(Check.required(new SafeText("& co."), validators));
+    
+    assertFalse(Check.required(null, validators));
+    assertFalse(Check.required(new SafeText("12345678901"), validators));
+    assertFalse(Check.required(new SafeText(""), validators));
+  }
+  
+  
+  public void testCollection() {
+    Validator[] validators = {Check.min(1), Check.max(3)};
+    assertTrue(Check.required(getList("1"), validators));
+    assertTrue(Check.required(getList("1", "2"), validators));
+    assertTrue(Check.required(getList("1", "2", "3"), validators));
+    
+    assertFalse(Check.required(null, validators));
+    assertFalse(Check.required(Collections.EMPTY_LIST, validators));
+    assertFalse(Check.required(getList("1", "2", "3", "4"), validators));
+  }
+  
+  public void testMap() {
+    Validator[] validators = {Check.min(1), Check.max(3)};
+    Map<String, String> map = new HashMap<String, String>();
+    map.put("A", "1");
+    assertTrue(Check.required(map, validators));
+    map.put("B", "2");
+    assertTrue(Check.required(map, validators));
+    map.put("C", "3");
+    assertTrue(Check.required(map, validators));
+    
+    assertFalse(Check.required(null, validators));
+    assertFalse(Check.required(Collections.EMPTY_MAP, validators));
+    map.put("D", "4");
+    assertFalse(Check.required(map, validators));
+  }
+  
+  public void testDate() {
+    Date start = new Date(62,2,1);
+    Date end = new Date(62,2,3);
+    Validator[] validators = {Check.min(start.getTime()), Check.max(end.getTime())};
+    Date test = new Date(62,2,1);
+    assertTrue(Check.required(test, validators));
+    test = new Date(62,2,2);
+    assertTrue(Check.required(test, validators));
+    test = new Date(62,2,3);
+    assertTrue(Check.required(test, validators));
+    
+    assertFalse(Check.required(null, validators));
+    test = new Date(62,1,28);
+    assertFalse(Check.required(test, validators));
+    test = new Date(62,2,4);
+    assertFalse(Check.required(test, validators));
+  }
+  
+  public void testCalendar() {
+    Calendar start = new GregorianCalendar();
+    start.set(2006, 8, 9); 
+    Calendar end = new GregorianCalendar();
+    end.set(2006, 8, 11); 
+    
+    Validator[] validators = {Check.min(start.getTimeInMillis()), Check.max(end.getTimeInMillis())};
+    Calendar test = new GregorianCalendar();
+    test.set(2006,8,9);
+    assertTrue(test.toString(), Check.required(test, validators));
+    test.set(2006,8,10);
+    assertTrue(test.toString(), Check.required(test, validators));
+    test.set(2006,8,11);
+    assertTrue(test.toString(), Check.required(test, validators));
+    
+    assertFalse("Versus null", Check.required(null, validators));
+    test.set(2006,8,8);
+    assertFalse(test.toString(), Check.required(test, validators));
+    test.set(2006,8,12);
+    assertFalse(test.toString(), Check.required(test, validators));
+  }
+  
+  public void testOptional(){
+    Validator[] validators = {Check.min(1), Check.max(10)};
+    assertTrue(Check.optional(null, validators));
+    assertTrue(Check.optional(new Integer(1), validators));
+    assertTrue(Check.optional(new Integer(2), validators));
+    assertTrue(Check.optional(new Integer(10), validators));
+    
+    assertFalse(Check.optional(new Integer(0), validators));
+    assertFalse(Check.optional(new Integer(-1), validators));
+    assertFalse(Check.optional(new Integer(-50), validators));
+    assertFalse(Check.optional(new Integer(11), validators));
+    assertFalse(Check.optional(new Integer(50), validators));
+  }
+  
+  public void testBigDecimal(){
+    Validator[] validators = {Check.min(new BigDecimal("1.25")), Check.max(new BigDecimal("10.75")) };
+    assertTrue(Check.required(new BigDecimal("1.25"), validators));
+    assertTrue(Check.required(new BigDecimal("10.75"), validators));
+    assertTrue(Check.required(new BigDecimal("1.26"), validators));
+    assertTrue(Check.required(new BigDecimal("10.74"), validators));
+    assertTrue(Check.required(new BigDecimal("9"), validators));
+    
+    assertFalse(Check.required(new BigDecimal("1.24"), validators));
+    assertFalse(Check.required(new BigDecimal("10.750001"), validators));
+    assertFalse(Check.required(new BigDecimal("1.249999"), validators));
+    assertFalse(Check.required(new BigDecimal("1"), validators));
+    assertFalse(Check.required(new BigDecimal("10.76"), validators));
+    assertFalse(Check.required(new BigDecimal("-1"), validators));
+    assertFalse(Check.required(new BigDecimal("-1.25"), validators));
+    assertFalse(Check.required(new BigDecimal("1000"), validators));
+  }
+  
+  public void testPattern(){
+    Pattern idPattern = Pattern.compile("(\\d){4}-(\\d){4}");
+    Validator validator = Check.pattern(idPattern);
+    assertTrue(Check.required("1234-1234", validator));
+    assertTrue(Check.required("9845-9852", validator));
+    assertTrue(Check.required("0000-0000", validator));
+    assertTrue(Check.required("9999-9999", validator));
+    
+    assertFalse(Check.required(null, validator));
+    assertFalse(Check.required("12345678", validator));
+    assertFalse(Check.required("", validator));
+    assertFalse(Check.required(" ", validator));
+    assertFalse(Check.required("A", validator));
+    assertFalse(Check.required("A234-9955", validator));
+    assertFalse(Check.required("Blah", validator));
+    assertFalse(Check.required("1234-5678 ", validator));
+    assertFalse(Check.required(" 1234-5678", validator));
+    assertFalse(Check.required("1 234-5678", validator));
+    assertFalse(Check.required("1234- 5678", validator));
+    assertFalse(Check.required("1234 - 5678", validator));
+    assertFalse(Check.required("1234 -5678", validator));
+  }
+  
+  public void testCheckEmail(){
+    Validator validator = Check.email();
+    assertTrue(Check.required("john@blah.com", validator));
+    assertTrue(Check.required("x@y.c", validator));
+    assertTrue(Check.required("john@blah", validator));
+    assertTrue(Check.required("john@blah.com ", validator)); 
+   assertTrue(Check.required(" john@blah.com", validator));
+    
+    assertFalse(Check.required("john", validator));
+    assertFalse(Check.required(null, validator));
+    assertFalse(Check.required(" ", validator));
+    assertFalse(Check.required("", validator));
+    assertFalse(Check.required("john@", validator));
+    //These are actually OK :
+    assertFalse(Check.required("john @blah.com", validator));
+    assertFalse(Check.required("john@ blah.com", validator));
+    assertFalse(Check.required("john@blah. com", validator));
+    assertFalse(Check.required("john@blah .com", validator));
+  }
+  
+  public void testLongRange2(){
+    Validator validator = Check.range(1,10);
+    assertTrue( validator.isValid(new Long(1)));
+    assertTrue( validator.isValid(new Long(2)));
+    assertTrue( validator.isValid(new Long(9)));
+    assertTrue( validator.isValid(new Long(10)));
+    assertTrue( Check.required(new Long(5), validator));
+    
+    assertFalse( validator.isValid(new Long(0)));
+    assertFalse( validator.isValid(new Long(11)));
+    assertFalse( Check.required(new Long(0), validator));
+  }
+  
+  public void testRangeBigDecimal(){
+    Validator validator = Check.range(new BigDecimal("0.01"), new BigDecimal("10.00"));
+    assertTrue( Check.required( new BigDecimal("0.01"), validator));
+    assertTrue( Check.required( new BigDecimal("0.02"), validator));
+    assertTrue( Check.required( new BigDecimal("1.00"), validator));
+    assertTrue( Check.required( new BigDecimal("5.00"), validator));
+    assertTrue( Check.required( new BigDecimal("9.99"), validator));
+    assertTrue( Check.required( new BigDecimal("10.00"), validator));
+    
+    assertFalse( Check.required( new BigDecimal("0.00"), validator));
+    assertFalse( Check.required( new BigDecimal("10.01"), validator));
+    assertFalse( Check.required( new BigDecimal("1000.00"), validator));
+    assertFalse( Check.required( new BigDecimal("-0.00"), validator));
+    assertFalse( Check.required( new BigDecimal("-0.01"), validator));
+  }
+  
+  
+  // FIXTURE //
+  
+  protected void setUp(){
+  }
+
+  protected void tearDown() {
+  }
+
+  // PRIVATE  //
+  
+  private List<String> getList(String... aStrings){
+    return Arrays.asList(aStrings);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTComparePossiblyNull.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,194 @@
+package hirondelle.web4j.model;
+
+import java.util.*;
+import java.io.FileNotFoundException;
+import java.math.*;
+import java.util.regex.*;
+import junit.framework.*;
+import static hirondelle.web4j.model.ModelUtil.NullsGo;
+
+public class TESTComparePossiblyNull extends TestCase {
+  
+  /** Run the test cases. */
+  public static void main(String args[]) throws AppException, FileNotFoundException {
+    String[] testCaseName = { TESTComparePossiblyNull.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTComparePossiblyNull( String aName) {
+    super(aName);
+  }
+  
+
+  // TEST CASES //
+  
+   public void testStrings(){
+     assertTrue(compareStringsNullsLast("aaa", "bbb", BEFORE));
+     assertTrue(compareStringsNullsLast("ccc", "bbb", AFTER));
+     assertTrue(compareStringsNullsLast("ccc", null , BEFORE));
+     assertTrue(compareStringsNullsLast(null, "ccc" , AFTER));
+     assertTrue(compareStringsNullsLast("ccc", "ccc", EQUAL));
+     assertTrue(compareStringsNullsLast("ccc", "CCC", AFTER));
+     
+     assertTrue(compareStringsNullsFirst("aaa", "bbb", BEFORE));
+     assertTrue(compareStringsNullsFirst("ccc", "bbb", AFTER));
+     assertTrue(compareStringsNullsFirst("ccc", null , AFTER));
+     assertTrue(compareStringsNullsFirst(null, "ccc" , BEFORE));
+     assertTrue(compareStringsNullsFirst("ccc", "ccc", EQUAL));
+     assertTrue(compareStringsNullsFirst("ccc", "CCC", AFTER));
+   }
+   
+   public void testTypicalObject(){
+     Person bob = new Person();
+     assertTrue(compareObjectsNullsLast(bob, bob, EQUAL));
+     
+     Person peter = new Person();
+     peter.setName("Peter");
+     assertTrue(compareObjectsNullsLast(bob, peter, BEFORE));
+     
+     Person andrea = new Person();
+     andrea.setName("Andrea");
+     assertTrue(compareObjectsNullsLast(bob, andrea, AFTER));
+     assertTrue(compareObjectsNullsLast(andrea, bob, BEFORE));
+
+     assertTrue(compareObjectsNullsLast(bob, null, BEFORE));
+     assertTrue(compareObjectsNullsLast(null, bob, AFTER));
+     
+     Person noName = new Person();
+     noName.setName(null);
+     assertTrue(compareObjectsNullsLast(bob, noName, BEFORE));
+     assertTrue(compareObjectsNullsLast(noName, bob, AFTER));
+     
+     Person bobTwo = new Person();
+     bobTwo.setName("Bob");
+     assertTrue(compareObjectsNullsLast(bob, bobTwo, EQUAL));
+     assertTrue(compareObjectsNullsLast(bobTwo, bob, EQUAL));
+     
+     Person bobOld = new Person();
+     bobOld.setBirthDate(new Date(22,2,1));
+     assertTrue(compareObjectsNullsLast(bob, bobOld, AFTER));
+     assertTrue(compareObjectsNullsLast(bobOld, bob, BEFORE));
+
+     Person bobSame = new Person();
+     bobSame.setBirthDate(new Date(45,3,3));
+     assertTrue(compareObjectsNullsLast(bob, bobSame, EQUAL));
+     assertTrue(compareObjectsNullsLast(bobSame, bob, EQUAL));
+     
+     Person bobYoung = new Person();
+     bobYoung.setBirthDate(new Date(63,2,1));
+     assertTrue(compareObjectsNullsLast(bob, bobYoung, BEFORE));
+
+     Person bobYoung2 = new Person();
+     bobYoung2.setAge(10);
+     assertTrue(compareObjectsNullsLast(bob, bobYoung2, AFTER));
+     
+     Person bobOld2 = new Person();
+     bobOld2.setAge(100);
+     assertTrue(compareObjectsNullsLast(bob, bobOld2, BEFORE));
+     
+     Person poor = new Person();
+     poor.setSalary(new BigDecimal("10"));
+     assertTrue(compareObjectsNullsLast(bob, poor, AFTER));
+     
+     Person rich = new Person();
+     rich.setSalary(new BigDecimal("1000"));
+     assertTrue(compareObjectsNullsLast(bob, rich, BEFORE));
+   }
+   
+   //test how a collection will sort items? worth it...
+   public void testCollection(){
+     Set<Person> items = new TreeSet<Person>(); //sorts the items 
+     
+     Person bob = new Person();
+     
+     Person andrea = new Person();
+     andrea.setName("Andrea");
+     
+     Person noName = new Person();
+     noName.setName("");
+     
+     Person nobody = new Person();
+     nobody.setName(null);
+     
+     items.add(bob);
+     items.add(andrea);
+     items.add(noName);
+     items.add(nobody);
+     
+     //Informal : just print it out
+     //System.out.println(items);
+   }
+   
+   // PRIVATE //
+   private static final int BEFORE = -1;
+   private static final int AFTER = 1;
+   private static final int EQUAL = 0;
+   
+   private boolean compareStringsNullsLast(String aThis, String aThat, int aExpectedResult) {
+     int result = ModelUtil.comparePossiblyNull(aThis, aThat, NullsGo.LAST);
+     return sign(result) == aExpectedResult;
+   }
+   
+   private boolean compareStringsNullsFirst(String aThis, String aThat, int aExpectedResult) {
+     int result = ModelUtil.comparePossiblyNull(aThis, aThat, NullsGo.FIRST);
+     return sign(result) == aExpectedResult;
+   }
+   
+   private boolean compareObjectsNullsLast(Comparable aThis, Comparable aThat, int aExpectedResult) {
+     int result = ModelUtil.comparePossiblyNull(aThis, aThat, NullsGo.LAST);
+     return sign(result) == aExpectedResult;
+   }
+   
+   private int sign(int aNumber){
+     int result = 0;
+     if (aNumber > 0){ 
+       result = 1;
+     }
+     else if (aNumber < 0){
+       result = -1;
+     }
+     return result;
+   }
+   
+   private static final class Person implements Comparable<Person> {
+      Person(String aName, Integer aAge, Date aBirthDate, BigDecimal aSalary){
+        fName = aName;
+        fAge = aAge;
+        fBirthDate = aBirthDate;
+        fSalary = aSalary;
+      }
+      Person(){
+        fName = "Bob";
+        fAge = new Integer(65);
+        fBirthDate = new Date(45,3,3);
+        fSalary = new BigDecimal("123.45");
+      }
+      void setName(String aName){ fName = aName; }
+      void setAge(Integer aAge){ fAge = aAge; }
+      void setBirthDate(Date aBirthDate){ fBirthDate = aBirthDate; }
+      void setSalary(BigDecimal aSalary){ fSalary = aSalary; }
+      public String toString() { return fName; }
+      public int compareTo(Person aThat) {
+        if ( this == aThat ) return EQUAL;
+        
+        int comparison = ModelUtil.comparePossiblyNull(this.fName, aThat.fName, NullsGo.LAST);
+        if ( comparison != EQUAL ) return comparison;
+
+        comparison = ModelUtil.comparePossiblyNull(this.fAge, aThat.fAge, NullsGo.LAST);
+        if ( comparison != EQUAL ) return comparison;
+       
+        comparison = ModelUtil.comparePossiblyNull(this.fBirthDate,  aThat.fBirthDate, NullsGo.LAST);
+        if ( comparison != EQUAL ) return comparison;
+        
+        comparison = ModelUtil.comparePossiblyNull(this.fSalary,  aThat.fSalary, NullsGo.LAST);
+        if ( comparison != EQUAL ) return comparison;
+        
+        return EQUAL;
+      }
+      //assume all these can be null
+      private String fName;
+      private Integer fAge;
+      private Date fBirthDate;
+      private BigDecimal fSalary;
+   }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTDateTime.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,1157 @@
+package hirondelle.web4j.model;
+
+import hirondelle.web4j.util.Util;
+import java.util.TimeZone;
+import junit.framework.TestCase;
+
+/** JUnit tests. */
+public final class TESTDateTime  extends TestCase  {
+
+  /** Run the test cases.  */
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTDateTime.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTDateTime( String aName) {
+    super(aName);
+  }
+
+  // TEST CASES 
+  
+  public void testCtorWithStringStandardFormat(){
+    //note that you can pass just about anything to the constructor - except null
+    testStandardFormatCtorSuccess("A");
+    testStandardFormatCtorSuccess("ABC");
+    testStandardFormatCtorSuccess("");
+    testStandardFormatCtorSuccess("  ");
+    testStandardFormatCtorSuccess("2009");
+    testStandardFormatCtorSuccess("2009-01");
+    testStandardFormatCtorSuccess("2009-01-01");
+    testStandardFormatCtorSuccess("2009-01-01 23");
+    testStandardFormatCtorSuccess("2009-01-01 23:40");
+    testStandardFormatCtorSuccess("2009-01-01 23:40:19");
+    testStandardFormatCtorSuccess("2009-01-01 23:40:19.123456789");
+    testStandardFormatCtorFail(null);
+    
+    //accepts with (ISO-8601) AND without (human hand) leading zeroes.
+    testStandardFormatCtorPlusParseSuccess("0009-01-01 23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ;
+    testStandardFormatCtorPlusParseSuccess("9-01-01 23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ;
+    
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.123456789", 2009, 1, 1, 23, 40, 19, 123456789) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.12345678", 2009, 1, 1, 23, 40, 19, 123456780) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.1234567", 2009, 1, 1, 23, 40, 19, 123456700) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.123456", 2009, 1, 1, 23, 40, 19, 123456000) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.12345", 2009, 1, 1, 23, 40,19,123450000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.1234", 2009, 1, 1, 23, 40,19,123400000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.123", 2009, 1, 1, 23, 40,19,123000000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.12", 2009, 1, 1, 23, 40,19,120000000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.1", 2009, 1, 1, 23, 40,19,100000000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19", 2009, 1, 1, 23, 40,19,null);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40", 2009, 1, 1, 23, 40,null,null);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01 23", 2009, 1, 1, 23, null,null,null);
+    
+    testStandardFormatCtorPlusParseSuccess("2009-01-01", 2009, 1, 1, null, null,null,null);
+    testStandardFormatCtorPlusParseSuccess("2009-01", 2009, 1, null, null,null,null,null);
+    testStandardFormatCtorPlusParseSuccess("2009", 2009, null, null, null, null,null,null);
+    testStandardFormatCtorPlusParseSuccess("1", 1, null, null, null, null,null,null);
+    testStandardFormatCtorPlusParseSuccess("0001", 1, null, null, null, null,null,null);
+    
+    testStandardFormatCtorPlusParseSuccess("23:40:19.123456789", null, null, null, 23, 40, 19, 123456789) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.12345678", null, null, null, 23, 40, 19, 123456780) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.1234567", null, null, null, 23, 40, 19, 123456700) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.123456", null, null, null, 23, 40, 19, 123456000) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.12345", null, null, null, 23, 40, 19, 123450000) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.1234", null, null, null, 23, 40, 19, 123400000) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.123", null, null, null, 23, 40, 19, 123000000) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.12", null, null, null, 23, 40, 19, 120000000) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19.1", null, null, null, 23, 40, 19, 100000000) ;
+    testStandardFormatCtorPlusParseSuccess("23:40:19", null, null, null, 23, 40, 19, null) ;
+    testStandardFormatCtorPlusParseSuccess("23:40", null, null, null, 23, 40, null, null) ;
+    
+    testStandardFormatCtorPlusParseFail("A") ;
+    testStandardFormatCtorPlusParseFail(" ") ;
+    testStandardFormatCtorPlusParseFail("") ;
+    testStandardFormatCtorPlusParseFail("12345-01-01 23:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("0-01-01 23:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01 12h:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01 12 pm") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01 45:40:60") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01 16:40:19.1234567890") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01 24:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01 16:60:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-13-01 16:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-32 16:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("-1-01-01 16:40:19.123456") ;
+    
+   testStandardFormatCtorPlusParseFail("2009-1-01 16:40:19.123456") ;
+   testStandardFormatCtorPlusParseFail("2009-01-1 16:40:19.123456") ;
+   testStandardFormatCtorPlusParseFail("2009-01-01 6:40:19.123456") ;
+   testStandardFormatCtorPlusParseFail("2009-01-01 16:0:19.123456") ;
+   testStandardFormatCtorPlusParseFail("2009-01-01 16:40:1.123456") ;
+    
+  }
+  
+  public void testCtorWithStringStandardFormatWithT(){
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.123456789", 2009, 1, 1, 23, 40, 19, 123456789) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.12345678", 2009, 1, 1, 23, 40, 19, 123456780) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.1234567", 2009, 1, 1, 23, 40, 19, 123456700) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.123456", 2009, 1, 1, 23, 40, 19, 123456000) ;
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.12345", 2009, 1, 1, 23, 40,19,123450000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.1234", 2009, 1, 1, 23, 40,19,123400000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.123", 2009, 1, 1, 23, 40,19,123000000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.12", 2009, 1, 1, 23, 40,19,120000000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.1", 2009, 1, 1, 23, 40,19,100000000);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19", 2009, 1, 1, 23, 40,19,null);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40", 2009, 1, 1, 23, 40,null,null);
+    testStandardFormatCtorPlusParseSuccess("2009-01-01T23", 2009, 1, 1, 23, null,null,null);
+
+    testStandardFormatCtorPlusParseSuccess("0009-01-01T23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ;
+    testStandardFormatCtorPlusParseSuccess("9-01-01T23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ;
+    
+    testStandardFormatCtorPlusParseFail("2009-01-01A23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("12345-01-01T23:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("0-01-01T23:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T12h:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T12 pm") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T45:40:60") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T16:40:19.1234567890") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T24:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T16:60:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-13-01T16:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-32T16:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("-1-01-01T16:40:19.123456") ;
+    
+    testStandardFormatCtorPlusParseFail("2009-1-01T16:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-1T16:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T6:40:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T16:0:19.123456") ;
+    testStandardFormatCtorPlusParseFail("2009-01-01T16:40:1.123456") ;
+  }
+  
+  public void testParseable(){
+    testParseable(SUCCESS, "2000-01-01 01:01:01.000000000");
+    testParseable(SUCCESS, "2000-01-01");
+    testParseable(SUCCESS, "01:01:01.000000000");
+    
+
+    testParseable(SUCCESS, "2009-12-31 00:00:00.123456789");
+    testParseable(SUCCESS, "2009-12-31T00:00:00.123456789");
+    testParseable(SUCCESS, "2009-12-31 00:00:00.12345678");
+    testParseable(SUCCESS, "2009-12-31 00:00:00.1234567");
+    testParseable(SUCCESS, "2009-12-31 00:00:00.123456");
+    testParseable(SUCCESS, "2009-12-31 23:59:59.12345");
+    testParseable(SUCCESS, "2009-01-31 16:01:01.1234");
+    testParseable(SUCCESS, "2009-01-01 16:59:00.123");
+    testParseable(SUCCESS, "2009-01-01 16:00:01.12");
+    testParseable(SUCCESS, "2009-02-28 16:25:17.1");
+    testParseable(SUCCESS, "2009-01-01 00:01:01");
+    testParseable(SUCCESS, "2009-01-01T00:01:01");
+    testParseable(SUCCESS, "2009-01-01 16:01");
+    testParseable(SUCCESS, "2009-01-01 16");
+    testParseable(SUCCESS, "2009-01-01");
+    testParseable(SUCCESS, "2009-01");
+    testParseable(SUCCESS, "2009");
+    testParseable(SUCCESS, "0009");
+    testParseable(SUCCESS, "9");
+    testParseable(SUCCESS, "00:00:00.123456789");
+    testParseable(SUCCESS, "00:00:00.12345678");
+    testParseable(SUCCESS, "00:00:00.1234567");
+    testParseable(SUCCESS, "00:00:00.123456");
+    testParseable(SUCCESS, "23:59:59.12345");
+    testParseable(SUCCESS, "01:59:59.1234");
+    testParseable(SUCCESS, "23:01:59.123");
+    testParseable(SUCCESS, "00:00:00.12");
+    testParseable(SUCCESS, "00:59:59.1");
+    testParseable(SUCCESS, "23:59:00");
+    testParseable(SUCCESS, "23:00:10");
+    testParseable(SUCCESS, "00:59"); 
+    testParseable(SUCCESS, "9999-12-31 23:59:59.999999999");
+    testParseable(SUCCESS, "0001-01-01 00:00:00.000000000");
+    testParseable(SUCCESS, "0001-01-01 00:00:00.000000000 "); // extra trailing/leading spaces
+    testParseable(SUCCESS, " 0001-01-01 00:00:00.000000000 "); 
+    testParseable(SUCCESS, " 0001-01-01 00:00:00.000000000");
+    testParseable(SUCCESS, "              0001-01-01 00:00:00.000000000");
+    
+    testParseable(FAIL, "0000-01-01 00:00:00.000000000");
+    testParseable(FAIL, "19999-01-01 00:00:00.000000000");
+    testParseable(FAIL, "10000-01-01 00:00:00.000000000");
+    
+    testParseable(FAIL, "02000-01-01 01:01:01.000000000");
+    testParseable(FAIL, "");
+    testParseable(FAIL, " ");
+    testParseable(FAIL, "blah");
+    testParseable(FAIL, "A2000-01-01 01:01:01.000000000");
+    testParseable(FAIL, "2000-01-01 01:01:01.000000000A");
+    testParseable(FAIL, "2000-01-01A");
+    testParseable(FAIL, "A2000-01-01");
+  }
+  
+  public void testRangeYear(){
+    testRange(SUCCESS, "2009-01-01");
+    testRange(SUCCESS, "1-01-01");
+    testRange(SUCCESS, "01-01-01");
+    testRange(SUCCESS, "0001-01-01");
+    testRange(SUCCESS, "0010-01-01");
+    testRange(SUCCESS, "10-01-01");
+    testRange(SUCCESS, "100-01-01");
+    testRange(SUCCESS, "0100-01-01");
+    testRange(SUCCESS, "9999-01-01");
+    testRange(SUCCESS, "2300-01-01");
+    testRange(SUCCESS, "7998-01-01");
+    
+    testRange(FAIL, "99999-01-01");
+    testRange(FAIL, "0-01-01");
+    testRange(FAIL, "10000-01-01");
+    testRange(FAIL, "10001-01-01");
+  }
+  
+  public void testRangeMonth(){
+    testRange(SUCCESS, "2009-01-01");
+    testRange(SUCCESS, "2009-02-01");
+    testRange(SUCCESS, "2009-10-01");
+    testRange(SUCCESS, "2009-11-01");
+    testRange(SUCCESS, "2009-12-01");
+    
+    testRange(FAIL, "2009-00-01");
+    testRange(FAIL, "2009-13-01");
+    testRange(FAIL, "2009-99-01");
+  }
+
+  public void testRangeDay(){
+    testRange(SUCCESS, "2009-01-01");
+    testRange(SUCCESS, "2009-02-02");
+    testRange(SUCCESS, "2009-10-10");
+    testRange(SUCCESS, "2009-11-30");
+    testRange(SUCCESS, "2009-12-31");
+    
+    testRange(FAIL, "2009-10-00");
+    testRange(FAIL, "2009-10-32");
+  }
+  
+  public void testRangeHour(){
+    testRange(SUCCESS, "2009-01-01 00:01:01");
+    testRange(SUCCESS, "2009-01-01 01:01:01");
+    testRange(SUCCESS, "2009-01-01 23:01:01");
+    
+    testRange(FAIL, "2009-01-01 24:01:01");
+    testRange(FAIL, "2009-01-01 99:01:01");
+  }
+  
+  public void testRangeMinute(){
+    testRange(SUCCESS, "2009-01-01 00:00:01");
+    testRange(SUCCESS, "2009-01-01 01:01:01");
+    testRange(SUCCESS, "2009-01-01 23:59:01");
+    
+    testRange(FAIL, "2009-01-01 23:60:01");
+    testRange(FAIL, "2009-01-01 23:99:01");
+  }
+
+  public void testRangeSecond(){
+    testRange(SUCCESS, "2009-01-01 00:00:00");
+    testRange(SUCCESS, "2009-01-01 01:01:01");
+    testRange(SUCCESS, "2009-01-01 23:59:59");
+    
+    testRange(FAIL, "2009-01-01 23:59:60");
+    testRange(FAIL, "2009-01-01 23:59:99");
+  }
+  
+  public void testRangeNanosecond(){
+    testRange(SUCCESS, "2009-01-01 00:00:00.000000000");
+    testRange(SUCCESS, "2009-01-01 01:01:01.000000001");
+    testRange(SUCCESS, "2009-01-01 01:01:01.000001000");
+    testRange(SUCCESS, "2009-01-01 23:59:59.123456789");
+    testRange(SUCCESS, "2009-01-01 23:59:59.999999999");
+    
+    testRange(FAIL, "2009-01-01 23:59:59.9999999999");
+    testRange(FAIL, "2009-01-01 23:59:59.0000000000");
+    testRange(FAIL, "2009-01-01 23:59:59.0000010000");
+    testRange(FAIL, "2009-01-01 23:59:59.1234567890");
+  }
+  
+  public void testMonthOverflow(){
+    testStandardFormatCtorPlusParseFail("2009-01-32 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-02-29 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-03-32 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-04-31 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-05-32 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-06-31 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-07-32 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-08-32 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-09-31 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-10-32 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-11-31 23:40:19.123456789") ;
+    testStandardFormatCtorPlusParseFail("2009-12-32 23:40:19.123456789") ;
+    
+    testStandardFormatCtorPlusParseFail("2008-02-30 23:40:19.123456789") ; //leap year
+  }
+  
+  public void testLeapYear(){
+    testLeapYear(SUCCESS, "2008-01-01") ;
+    testLeapYear(SUCCESS, "2004-01-01") ;
+    testLeapYear(SUCCESS, "1996-01-01") ;
+    testLeapYear(SUCCESS, "2000-01-01") ;
+    testLeapYear(SUCCESS, "1600-01-01") ;
+    testLeapYear(SUCCESS, "1200-01-01") ;
+    testLeapYear(SUCCESS, "800-01-01") ;
+    testLeapYear(SUCCESS, "400-01-01") ;
+    
+    testLeapYear(FAIL, "2009-01-01") ;
+    testLeapYear(FAIL, "1900-01-01") ;
+    testLeapYear(FAIL, "1800-01-01") ;
+    testLeapYear(FAIL, "1700-01-01") ;
+    testLeapYear(FAIL, "1900-01-01") ;
+    testLeapYear(FAIL, "1500-01-01") ;
+    testLeapYear(FAIL, "1400-01-01") ;
+    testLeapYear(FAIL, "1300-01-01") ;
+    testLeapYear(FAIL, "1100-01-01") ;
+    testLeapYear(FAIL, "1000-01-01") ;
+    testLeapYear(FAIL, "900-01-01") ;
+    testLeapYear(FAIL, "700-01-01") ;
+    testLeapYear(FAIL, "600-01-01") ;
+    testLeapYear(FAIL, "500-01-01") ;
+    testLeapYear(FAIL, "300-01-01") ;
+    testLeapYear(FAIL, "200-01-01") ;
+    testLeapYear(FAIL, "100-01-01") ;
+    
+    testLeapYearFails("11:18:.16.23");
+  }
+  
+  public void testToString(){
+    testToString(SUCCESS, "2001-01-01 12:34:56.123456789", "2001-01-01 12:34:56.123456789");
+    testToString(SUCCESS, "2001-01-01 12:34:56.12345678", "2001-01-01 12:34:56.12345678");
+    testToString(SUCCESS, "2001-01-01 12:34:56.1234567", "2001-01-01 12:34:56.1234567");
+    testToString(SUCCESS, "2001-01-01 12:34:56.123456", "2001-01-01 12:34:56.123456");
+    testToString(SUCCESS, "2001-01-01 12:34:56.12345", "2001-01-01 12:34:56.12345");
+    testToString(SUCCESS, "2001-01-01 12:34:56.1234", "2001-01-01 12:34:56.1234");
+    testToString(SUCCESS, "2001-01-01 12:34:56.123", "2001-01-01 12:34:56.123");
+    testToString(SUCCESS, "2001-01-01 12:34:56.12", "2001-01-01 12:34:56.12");
+    testToString(SUCCESS, "2001-01-01 12:34:56.1", "2001-01-01 12:34:56.1");
+    
+    testToString(SUCCESS, "2001-01-01 12:34:56", "2001-01-01 12:34:56");
+    testToString(SUCCESS, "2001-01-01 12:34", "2001-01-01 12:34");
+    testToString(SUCCESS, "2001-01-01 12", "2001-01-01 12");
+    testToString(SUCCESS, "2001-01-01", "2001-01-01");
+    testToString(SUCCESS, "2001-12", "2001-12");
+    testToString(SUCCESS, "2001", "2001");
+    
+    // The String constructor is lenient in the extreme:
+    testToString(SUCCESS, "BLAH", "BLAH");
+    testToString(SUCCESS, "Humpday Jan 1, 2001 12:34:56.1234567890123456", "Humpday Jan 1, 2001 12:34:56.1234567890123456");
+    
+    testToString(FAIL, "2001-01-01", "2001-01-01 ");
+    testToString(FAIL, "2001-12-01", " 2001-12-01 ");
+    
+    //date only
+    testToString(SUCCESS, 2001, 1, 1, null, null, null, null, "2001-01-01");
+    testToString(SUCCESS, 2001, 1, 31, null, null, null, null, "2001-01-31");
+    testToString(SUCCESS, 2001, 11, 30, null, null, null, null, "2001-11-30");
+    testToString(SUCCESS, 1, 11, 30, null, null, null, null, "1-11-30");
+    testToString(SUCCESS, 2001, 1, null, null, null, null, null, "2001-01");
+    testToString(SUCCESS, 2001, null, null, null, null, null, null, "2001");
+    //date and time
+    testToString(SUCCESS, 2001, 1, 31, 13, 30, 59, 123456789, "2001-01-31 13:30:59.123456789");
+    testToString(SUCCESS, 2001, 1, 31, 13, 30, 59, 12, "2001-01-31 13:30:59.000000012");
+    testToString(SUCCESS, 2001, 1, 31, 13, 30, 59, null, "2001-01-31 13:30:59");
+    testToString(SUCCESS, 2001, 1, 31, 13, 30, null, null, "2001-01-31 13:30");
+    testToString(SUCCESS, 2001, 1, 31, 13, null, null, null, "2001-01-31 13");
+    //time only
+    testToString(SUCCESS, null, null, null, 13, 30, 59, 123456789, "13:30:59.123456789");
+    testToString(SUCCESS, null, null, null, 13, 30, 59, null, "13:30:59");
+    
+    //bizarre combination of formats
+    testToString(SUCCESS, 2001, 1, 31, 13, null, 59, 123456789, "Y:2001 M:1 D:31 h:13 m:null s:59 f:123456789");
+
+    //extra trailing space
+    testToString(FAIL, 2001, 1, 31, 13, 30, 59, 123456789, "2001-01-31 13:30:59.123456789 ");
+  }
+  
+  public void testDayOfWeek(){
+    testDayOfWeek(SUCCESS, "2009-01-01", 5);
+    testDayOfWeek(SUCCESS, "2009-01-02", 6);
+    testDayOfWeek(SUCCESS, "2009-01-03", 7);
+    testDayOfWeek(SUCCESS, "2009-01-04", 1);
+    testDayOfWeek(SUCCESS, "2009-01-05", 2);
+    testDayOfWeek(SUCCESS, "2009-01-06", 3);
+    testDayOfWeek(SUCCESS, "2012-02-28", 3);
+    testDayOfWeek(SUCCESS, "2012-02-29", 4);
+    testDayOfWeek(SUCCESS, "1921-11-03", 5);
+    testDayOfWeek(SUCCESS, "1928-05-29", 3);
+    testDayOfWeek(SUCCESS, "1954-06-30", 4);
+    testDayOfWeek(FAIL, "1954-06", 4);
+    testDayOfWeek(FAIL, "1954", 4);
+    testDayOfWeek(FAIL, "11:05:13", 4);
+  }
+  
+  public void testDayOfYear(){
+    testDayOfYear(SUCCESS, "1978-11-14", 318);
+    testDayOfYear(SUCCESS, "1988-04-22", 113);
+    testDayOfYear(SUCCESS, "1978-01-01", 1);
+    testDayOfYear(SUCCESS, "1962-12-31", 365);
+    testDayOfYear(SUCCESS, "1960-12-31", 366);
+    testDayOfYear(FAIL, "1988-04", 113);
+    testDayOfYear(FAIL, "1988", 113);
+    testDayOfYear(FAIL, "11:13:59.123", 113);
+  }
+  
+  public void testSameDayAs(){
+    testSameDayAs(SUCCESS, "1955-04-30", "1955-04-30");
+    testSameDayAs(SUCCESS, "1955-04-30 01:23:15", "1955-04-30");
+    testSameDayAs(SUCCESS, "1955-04-30 01:23:15", "1955-04-30 16:56:07");
+    testSameDayAs(SUCCESS, "1955-04-30 01:23", "1955-04-30 16:56:07");
+    testSameDayAs(SUCCESS, "1955-04-30 01", "1955-04-30 16:56:07");
+    testSameDayAs(SUCCESS, "1955-04-30 01:23:15", "1955-04-30 16:56:07.23");
+    testSameDayAs(SUCCESS, "1955-04-30 16:56:07.23321", "1955-04-30 16:56:07.23");
+    
+    testSameDayAs(FAIL, "1955-04-30", "1955-05-30");
+    testSameDayAs(FAIL, "1955-04-30", "1955-04-03");
+  }
+
+  public void testCompare(){
+    testCompare("1959-05-08", "1965-08-01", LESS);  
+    testCompare("1965-08-01", "1965-08-02", LESS);  
+    testCompare("1965-08-01", "1965-08-30", LESS);  
+    testCompare("1965-08-01", "1965-12-01", LESS);  
+    testCompare("1965-08-01", "1966-08-01", LESS);
+    testCompare("1959-05-08 13:13:59", "1965-08-01", LESS);  
+    testCompare("1959-05-08", "1959-05-08 13:13:59", LESS);  
+    testCompare("1959-05-08", "1959-05-08 00:00:00", LESS);  
+    testCompare("1959-05-08", "1959-05-08 00:00", LESS);  
+    testCompare("1959-05-08", "1959-05-08 00", LESS);  
+    
+    testCompare("1965-08-01", "1959-05-08", MORE);
+    testCompare("900-12-31", "1-01-01", MORE); 
+    testCompare("1900-12-31", "1801-01-01", MORE); 
+    testCompare("5000-12-31", "1801-01-01", MORE); 
+  }
+  
+  public void testBefore(){
+    testBefore("1579-05-31", "5856-03-01");
+    testBefore("1579-05-31", "1579-06-01");
+    testBefore("900-05-01", "1578-05-01");
+  }
+
+  public void testAfter(){
+    testAfter("5856-03-01", "1579-05-31" );
+    testAfter("1579-06-01", "1579-05-31");
+    testAfter("1901-05-01", "1579-05-01");
+  }
+  
+  public void testTruncate(){
+    testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15 11:34:19", DateTime.Unit.SECOND);
+    testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15 11:34", DateTime.Unit.MINUTE);
+    testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15 11", DateTime.Unit.HOUR);
+    testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15", DateTime.Unit.DAY);
+    testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05", DateTime.Unit.MONTH);
+    testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999", DateTime.Unit.YEAR);
+  }
+  
+  public void testNumDaysInMonth(){
+    testNumDaysInMonth("2001-01-01", 31);
+    testNumDaysInMonth("2001-02-01", 28);
+    testNumDaysInMonth("2001-03-01", 31);
+    testNumDaysInMonth("2001-04-01", 30);
+    testNumDaysInMonth("2001-05-01", 31);
+    testNumDaysInMonth("2001-06-01", 30);
+    testNumDaysInMonth("2001-07-01", 31);
+    testNumDaysInMonth("2001-08-01", 31);
+    testNumDaysInMonth("2001-09-01", 30);
+    testNumDaysInMonth("2001-10-01", 31);
+    testNumDaysInMonth("2001-11-01", 30);
+    testNumDaysInMonth("2001-12-01", 31);
+    
+    testNumDaysInMonth("2000-01-01", 31);
+    testNumDaysInMonth("2000-02-01", 29);
+    testNumDaysInMonth("2000-03-01", 31);
+    testNumDaysInMonth("2000-04-01", 30);
+    testNumDaysInMonth("2000-05-01", 31);
+    testNumDaysInMonth("2000-06-01", 30);
+    testNumDaysInMonth("2000-07-01", 31);
+    testNumDaysInMonth("2000-08-01", 31);
+    testNumDaysInMonth("2000-09-01", 30);
+    testNumDaysInMonth("2000-10-01", 31);
+    testNumDaysInMonth("2000-11-01", 30);
+    testNumDaysInMonth("2000-12-01", 31);
+    
+    testNumDaysInMonth("1960-01-01", 31);
+    testNumDaysInMonth("1960-02-01", 29);
+    testNumDaysInMonth("1960-03-01", 31);
+    testNumDaysInMonth("1960-04-01", 30);
+    testNumDaysInMonth("1960-05-01", 31);
+    testNumDaysInMonth("1960-06-01", 30);
+    testNumDaysInMonth("1960-07-01", 31);
+    testNumDaysInMonth("1960-08-01", 31);
+    testNumDaysInMonth("1960-09-01", 30);
+    testNumDaysInMonth("1960-10-01", 31);
+    testNumDaysInMonth("1960-11-01", 30);
+    testNumDaysInMonth("1960-12-01", 31);
+  }
+
+  public void testModifiedJulianDate(){
+    testModifiedJD("1858-11-17", 0 );
+    testModifiedJD("2004-01-01", 53005);
+    testModifiedJD("1900-12-15", 15368);
+    testModifiedJD("1995-09-27", 49987 );
+    testModifiedJD("1858-11-17", 0 );
+    testModifiedJD("1858-11-18", 1 );
+    testModifiedJD("1858-11-16", -1 );
+    testModifiedJD("1858-11-01", -16 );
+    testModifiedJD("2005-05-24", 53514);
+    testModifiedJD("2006-12-19", 54088);
+    testModifiedJD("1834-12-15", -8738); 
+    testModifiedJD("1957-10-05", 36116);
+    testModifiedJD("2000-01-01", 51544);
+    testModifiedJD("1987-01-27", 46822);
+    testModifiedJD("1987-06-19", 46965);
+    testModifiedJD("1988-01-27", 47187);
+    testModifiedJD("1988-06-19", 47331);
+    testModifiedJD("1900-01-01", 15020);
+    testModifiedJD("1600-01-01", -94553);
+    testModifiedJD("1600-12-31", -94188);
+    testModifiedJD("837-04-10", -373133); //Proleptic Gregorian (B=-4), not Julian (B=0)
+  }
+  
+  public void testStartOfDay(){
+    testStartOfDay("2005-08-13 13:15:58", "2005-08-13 00:00:00.0");
+    testStartOfDay("2005-08-13 00:00:00", "2005-08-13 00:00:00.0");
+    testStartOfDay("2005-08-13 00:00:00.0", "2005-08-13 00:00:00.0");
+    testStartOfDay("2005-08-13 00:00:00.000000000", "2005-08-13 00:00:00.0");
+    testStartOfDay("2005-08-13", "2005-08-13 00:00:00.0");
+  }
+  public void testEndOfDay(){
+    testEndOfDay("2005-08-13 13:15:58", "2005-08-13 23:59:59.999999999");
+    testEndOfDay("2005-08-13", "2005-08-13 23:59:59.999999999");
+  }
+  
+  public void testStartOfMonth(){
+    testStartOfMonth("2005-08-13 13:15:58", "2005-08-01 00:00:00.0");
+    testStartOfMonth("2005-08-13", "2005-08-01 00:00:00.0");
+  }
+  
+  public void testEndOfMonth(){
+    testEndOfMonth("2005-08-13 13:15:58", "2005-08-31 23:59:59.999999999");
+    testEndOfMonth("2005-01-13 13:15:58", "2005-01-31 23:59:59.999999999");
+    testEndOfMonth("2005-02-13 13:15:58", "2005-02-28 23:59:59.999999999");
+    testEndOfMonth("2005-02-13", "2005-02-28 23:59:59.999999999");
+    testEndOfMonth("2005-02-13 13:15", "2005-02-28 23:59:59.999999999");
+    testEndOfMonth("2005-02-13 13:15:12.23", "2005-02-28 23:59:59.999999999");
+  }
+  
+  public void testDateFromJD(){
+    //http://isotropic.org/cgi-bin/date.pl?date=333-01-27
+    
+    testDateFromJD("2000-01-01", 2451545);
+    testDateFromJD("1957-10-04", 2436116);
+    testDateFromJD("1987-06-19", 2446966);
+    testDateFromJD("1988-06-19", 2447332);
+    testDateFromJD("1988-06-19", 2447332);
+    testDateFromJD("2009-12-23", 2455189);
+    testDateFromJD("1987-01-27", 2446823);
+    
+    testDateFromJD("1582-10-16", 2299162);
+    testDateFromJD("1582-10-15", 2299161);
+    testDateFromJD("1582-10-14", 2299160);
+    testDateFromJD("1582-10-13", 2299159);
+       
+    testDateFromJD("333-01-27", 1842712); 
+  }
+  
+  public void testFutureAndPast(){
+    TimeZone tz = TimeZone.getTimeZone("America/Halifax");
+    //DateTime now = DateTime.now(tz);
+    //log(now);
+
+    //DateTime today = DateTime.today(tz);
+    //log(today);
+    
+    DateTime future = new DateTime("2500-01-31");
+    assertTrue(future.isInTheFuture(tz));
+    
+    DateTime past = new DateTime("1900-01-31");
+    assertTrue(past.isInThePast(tz));
+  }
+  
+  public void testEquals(){
+    testEquals("1938-01-31", "1938-01-31", SUCCESS);
+    testEquals("1938-01-31 18:13:15", "1938-01-31 18:13:15", SUCCESS);
+    testEquals("1938-01-31 18:13:15.0", "1938-01-31 18:13:15.0", SUCCESS);
+    testEquals("1938-01-31 18:13:15.123", "1938-01-31 18:13:15.123", SUCCESS);
+    testEquals("1938-01-31 18:13:15.123456", "1938-01-31 18:13:15.123456", SUCCESS);
+    testEquals("1938-01-31 18:13:15.123456789", "1938-01-31 18:13:15.123456789", SUCCESS);
+    testEquals("18:13:15.123456789", "18:13:15.123456789", SUCCESS);
+    testEquals("18:13:15.1", "18:13:15.1", SUCCESS);
+    testEquals("18:13:15", "18:13:15", SUCCESS);
+    testEquals("18:13", "18:13", SUCCESS);
+    
+    testEquals("1938-01-31 00:00:00", "1938-01-31", FAIL);
+    testEquals("1938-01-31 18:31:25", "1938-01-31", FAIL);
+    
+    testEquals(new DateTime(2156, 1, 3, 18,31,25,0), new DateTime(2156, 1,3,18,31,25,0), SUCCESS);
+    testEquals(new DateTime(2156, 1, 3, 18,31,25,null), new DateTime(2156, 1,3,18,31,25,null), SUCCESS);
+    testEquals(new DateTime(2156, 1, 3, 18,31,null,null), new DateTime(2156, 1,3,18,31,null,null), SUCCESS);
+    testEquals(new DateTime(2156, 1, 3, null,null,null,null), new DateTime(2156, 1,3,null,null,null,null), SUCCESS);
+    
+    testEquals(new DateTime(2156, 1, 3, 18,31,25,null), new DateTime(2156, 1,3,18,31,25,0), FAIL);
+    testEquals(new DateTime(null, 1, 3, 18,31,25,0), new DateTime(2156, 1,3,18,31,25,0), FAIL);
+    testEquals(new DateTime(2156, 1, 3, null,null,null,null), new DateTime(2156, 1,3,18,31,25,0), FAIL);
+  }
+  
+  public void testChangeTimeZone(){
+    TimeZone from = TimeZone.getTimeZone("America/Halifax");
+    TimeZone to = TimeZone.getTimeZone("America/Montreal");
+    testChangeTimeZone("2010-01-15 18:13:59.123456789", "2010-01-15 17:13:59.123456789", from, to);
+    testChangeTimeZone("2010-01-15 18:13:59.1", "2010-01-15 17:13:59.1", from, to);
+    testChangeTimeZone("2010-01-15 18:13:59", "2010-01-15 17:13:59", from, to);
+    testChangeTimeZone("2010-01-15 01:13:59", "2010-01-15 00:13:59", from, to);
+    testChangeTimeZone("2010-01-15 00:13:59", "2010-01-14 23:13:59", from, to);
+    testChangeTimeZone("2010-01-15 23:13:59", "2010-01-15 22:13:59", from, to);
+    testChangeTimeZone("2010-01-15 18:13", "2010-01-15 17:13", from, to);
+    testChangeTimeZone("2010-01-15 18", "2010-01-15 17", from, to);
+    
+    from = TimeZone.getTimeZone("Europe/London");
+    to = TimeZone.getTimeZone("America/Montreal");
+    testChangeTimeZone("2010-01-15 18:00", "2010-01-15 13:00", from, to);
+    testChangeTimeZone("2010-01-15 23:00", "2010-01-15 18:00", from, to);
+    testChangeTimeZone("2010-01-16 00:00", "2010-01-15 19:00", from, to);
+    testChangeTimeZone("2010-01-16 01:00", "2010-01-15 20:00", from, to);
+    testChangeTimeZone("2010-01-16 02:00", "2010-01-15 21:00", from, to);
+    
+    testChangeTimeZone("2010-08-15 18:00", "2010-08-15 13:00", from, to);
+    testChangeTimeZone("2010-08-15 23:00", "2010-08-15 18:00", from, to);
+    testChangeTimeZone("2010-08-16 00:00", "2010-08-15 19:00", from, to);
+    testChangeTimeZone("2010-08-16 01:00", "2010-08-15 20:00", from, to);
+    testChangeTimeZone("2010-08-16 02:00", "2010-08-15 21:00", from, to);
+
+    from = TimeZone.getTimeZone("Europe/London");
+    to = TimeZone.getTimeZone("Asia/Jakarta"); //+7 hours, no summer hour in Indonesia
+    testChangeTimeZone("2010-01-15 15:00", "2010-01-15 22:00", from, to);
+    testChangeTimeZone("2010-01-15 16:00", "2010-01-15 23:00", from, to);
+    testChangeTimeZone("2010-01-15 17:00", "2010-01-16 00:00", from, to);
+    testChangeTimeZone("2010-01-15 18:00", "2010-01-16 01:00", from, to);
+    testChangeTimeZone("2010-08-15 15:00", "2010-08-15 21:00", from, to);
+    testChangeTimeZone("2010-08-15 16:00", "2010-08-15 22:00", from, to);
+    testChangeTimeZone("2010-08-15 17:00", "2010-08-15 23:00", from, to);
+    testChangeTimeZone("2010-08-15 18:00", "2010-08-16 00:00", from, to);
+    testChangeTimeZone("2010-08-15 19:00", "2010-08-16 01:00", from, to);
+    
+    from = TimeZone.getTimeZone("Europe/London");
+    to = TimeZone.getTimeZone("America/St_Johns"); //-3h30m, Newfoundland is offset by a non-integral number of hours
+    testChangeTimeZone("2010-01-15 15:00", "2010-01-15 11:30", from, to);
+    testChangeTimeZone("2010-08-15 15:00", "2010-08-15 11:30", from, to);
+    testChangeTimeZone("2010-08-15 23:00", "2010-08-15 19:30", from, to);
+    testChangeTimeZone("2010-08-16 00:00", "2010-08-15 20:30", from, to);
+    testChangeTimeZone("2010-08-16 01:00", "2010-08-15 21:30", from, to);
+    
+    testChangeTimeZoneFails("2010-01-15", "2010-01-15", from, to);
+    testChangeTimeZoneFails("2010-01-15", "2010-01-14", from, to);
+    testChangeTimeZoneFails("2010-01", "2010-01", from, to);
+    testChangeTimeZoneFails("2010", "2010", from, to);
+  }
+  
+  public void testGetMilliseconds(){
+    testGetMillis("1970-01-01", TimeZone.getTimeZone("UTC"), 0);
+    testGetMillis("1970-01-02", TimeZone.getTimeZone("UTC"), 86400000);
+    testGetMillis("1969-12-31", TimeZone.getTimeZone("UTC"), -86400000);
+    testGetMillis("1970-01-01 00:00:00.0", TimeZone.getTimeZone("UTC"), 0);
+    testGetMillis("1970-01-01 00:00:00.001", TimeZone.getTimeZone("UTC"), 1);
+    testGetMillis("1970-01-01 00:00:00.1", TimeZone.getTimeZone("UTC"), 100);
+    testGetMillis("1970-01-01 00:00:01.0", TimeZone.getTimeZone("UTC"), 1000);
+    testGetMillis("1970-01-02 00:00:00.0", TimeZone.getTimeZone("UTC"), 86400000);
+    testGetMillis("1969-12-31 00:00:00.0", TimeZone.getTimeZone("UTC"), -86400000);
+    testGetMillis("1970-01-01 01:00", TimeZone.getTimeZone("UTC"), 3600000);
+    
+    testGetMillis("1970-01-01 00:00:00.0", TimeZone.getTimeZone("America/Montreal"), 18000000);
+    
+    //nanos are truncated, not rounded
+    testGetMillis("1970-01-01 00:00:00.0001", TimeZone.getTimeZone("UTC"), 0);
+    testGetMillis("1970-01-01 00:00:00.000000001", TimeZone.getTimeZone("UTC"), 0);
+    testGetMillis("1970-01-01 00:00:00.001999", TimeZone.getTimeZone("UTC"), 1);
+    
+    //TO-DO 
+    //consistency of 2 related methods
+    //dt from millis
+    //millis from dt 
+    //compare
+  }
+  
+  public void testInterConversion(){
+    testInterConversion("2010-01-15 19:53:22.123", TimeZone.getTimeZone("UTC"));
+    testInterConversion("2010-01-15 19:53:22.123", TimeZone.getTimeZone("Asia/Jakarta"));
+    testInterConversion("2010-01-15 19:53:22.123", TimeZone.getTimeZone("America/Montreal"));
+    testInterConversion("2010-09-15 19:53:22.123", TimeZone.getTimeZone("America/Montreal"));
+    testInterConversion("2010-09-15 19:53:22.1", TimeZone.getTimeZone("America/Montreal"));
+    testInterConversion("1802-09-15 19:53:22.1", TimeZone.getTimeZone("America/Montreal"));
+    testInterConversion("9875-09-15 19:53:22.1", TimeZone.getTimeZone("America/Montreal"));
+  }
+  
+  public void testForInstant(){
+    TimeZone utc = TimeZone.getTimeZone("UTC");
+    testForInstant(0, utc, "1970-01-01 00:00:00.000000000");
+    testForInstant(86400000, utc, "1970-01-02 00:00:00.000000000");
+    testForInstant(-86400000, utc,  "1969-12-31 00:00:00.000000000");
+    testForInstant(1, utc, "1970-01-01 00:00:00.00100000");
+    testForInstant(100, utc, "1970-01-01 00:00:00.100000000");
+    testForInstant(1000, utc, "1970-01-01 00:00:01.000000000");
+    testForInstant(3600000, utc, "1970-01-01 01:00:00.000000000");
+    testForInstant(18000000,  TimeZone.getTimeZone("America/Montreal"), "1970-01-01 00:00:00.000000000");
+  }
+  
+  public void testForInstantNanos(){
+    TimeZone utc = TimeZone.getTimeZone("UTC");
+    testForInstantNanos(0, utc, "1970-01-01 00:00:00.000000000");
+    testForInstantNanos(1, utc, "1970-01-01 00:00:00.000000001");
+    testForInstantNanos(1000, utc, "1970-01-01 00:00:00.000001000");
+    testForInstantNanos(1000000, utc, "1970-01-01 00:00:00.001000000");
+    testForInstantNanos(-1, utc, "1969-12-31 23:59:59.999999999");
+    testForInstantNanos(-2, utc, "1969-12-31 23:59:59.999999998");
+    testForInstantNanos(-1000, utc, "1969-12-31 23:59:59.999999000");
+    testForInstantNanos(-1001, utc, "1969-12-31 23:59:59.999998999");
+    testForInstantNanos(-1000000, utc, "1969-12-31 23:59:59.999000000");
+    testForInstantNanos(86400000000000L, utc, "1970-01-02 00:00:00.000000000");
+    testForInstantNanos(-86400000000000L, utc,  "1969-12-31 00:00:00.000000000");
+    testForInstantNanos(1000000L, utc, "1970-01-01 00:00:00.00100000");
+    testForInstantNanos(100000000L, utc, "1970-01-01 00:00:00.100000000");
+    testForInstantNanos(1000000000L, utc, "1970-01-01 00:00:01.000000000");
+    testForInstantNanos(3600000000000L, utc, "1970-01-01 01:00:00.000000000");
+    testForInstantNanos(18000000000000L,  TimeZone.getTimeZone("America/Montreal"), "1970-01-01 00:00:00.000000000");
+    testForInstantNanos(18000000000001L,  TimeZone.getTimeZone("America/Montreal"), "1970-01-01 00:00:00.000000001");
+    testForInstantNanos(17999999999999L,  TimeZone.getTimeZone("America/Montreal"), "1969-12-31 23:59:59.999999999");
+  }
+  
+  public void testGetNanosecondsFromEpoch(){
+    TimeZone utc = TimeZone.getTimeZone("UTC");
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000000", utc, 0L);
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000001", utc, 1L);
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000001000", utc, 1000L);
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:00.001000000", utc, 1000000L);
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:01.000000000", utc, 1000000000L);
+    testGetNanosecondsFromEpoch("1970-01-01 00:01:00.000000000", utc, 60*1000000000L);
+    testGetNanosecondsFromEpoch("1970-01-01 01:00:00.000000000", utc, 60*60*1000000000L);
+    testGetNanosecondsFromEpoch("1970-01-02 00:00:00.000000000", utc, 24*60*60*1000000000L);
+    testGetNanosecondsFromEpoch("1970-01-02 00:00:00.000000001", utc, 24*60*60*1000000000L + 1);
+
+    TimeZone montreal = TimeZone.getTimeZone("America/Montreal");
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000000", montreal, 5*60*60*1000000000L);
+    testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000001", montreal, 5*60*60*1000000000L + 1);
+
+    testGetNanosecondsFromEpoch("1969-12-31 23:59:59.999999999", utc, -1L);
+    testGetNanosecondsFromEpoch("1969-12-31 23:59:59.999999000", utc, -1000L);
+    testGetNanosecondsFromEpoch("1969-12-31 23:59:59.000000000", utc, -1000000000L);
+    testGetNanosecondsFromEpoch("1969-12-31 23:59:00.000000000", utc, -1000000000L*60);
+    testGetNanosecondsFromEpoch("1969-12-31 23:00:00.000000000", utc, -1000000000L*60*60);
+    testGetNanosecondsFromEpoch("1969-12-31 00:00:00.000000000", utc, (-1000000000L)*60*60*24);
+    testGetNanosecondsFromEpoch("1969-12-31 00:00:00.000000001", utc, (-1000000000L)*60*60*24 + 1);
+    testGetNanosecondsFromEpoch("1969-12-30 23:59:59.999999999", utc, (-1000000000L)*60*60*24 -1);
+  }
+  
+  public void testNanosecondRange(){
+    testNanosecondRange("0001-01-01 00:00:00.000000000");
+    testNanosecondRange("9999-12-31 23:59:59.999999999");
+  }
+  
+  // PRIVATE
+  
+  private static final boolean SUCCESS = true;
+  private static final boolean FAIL = false;
+  private static final boolean LESS = true;
+  private static final boolean MORE = false;
+  
+  private static void log(Object aThing){
+    System.out.println(String.valueOf(aThing));
+  }
+  
+  private void testStandardFormatCtorSuccess(String aDate){
+    try {
+       DateTime dateTime = new DateTime(aDate);
+    }
+    catch (Throwable ex){
+      fail("Cannot construct using standard format: " + Util.quote(aDate));
+    }
+  }
+  
+  private void testStandardFormatCtorPlusParseSuccess(String aDate, Integer y, Integer m, Integer d, Integer h, Integer min, Integer sec, Integer frac){
+    try {
+      DateTime dt = new DateTime(aDate);
+      assertTrue("y", eq(dt.getYear(), y));
+      assertTrue("m", eq(dt.getMonth(), m));
+      assertTrue("d", eq(dt.getDay(), d));
+      assertTrue("h", eq(dt.getHour(), h));
+      assertTrue("min", eq(dt.getMinute(), min));
+      assertTrue("sec", eq(dt.getSecond(), sec));
+      assertTrue("frac", eq(dt.getNanoseconds(), frac));
+    }
+    catch (Throwable ex){
+      fail("Cannot construct/parse using standard format: " + Util.quote(aDate) + ex);
+    }
+  }
+  
+  private void testStandardFormatCtorPlusParseFail(String aDate){
+    try {
+      DateTime dt = new DateTime(aDate);
+      dt.ensureParsed();
+      fail("Expected failure to parse: " + Util.quote(aDate));
+    }
+    catch (DateTime.ItemOutOfRange ex){
+      //good branch
+    }
+    catch (DateTimeParser.UnknownDateTimeFormat ex){
+      //good branch
+    }
+  }
+  
+  
+  private boolean eq(Integer aThis, Integer aThat){
+    boolean result = false;
+    if(aThis == null && aThat == null){
+      result = true;
+    }
+    else if (aThis != null && aThat != null){
+      result = aThis.equals(aThat);      
+    }
+    else {
+      log("At least one is null");
+    }
+    return result;
+  }
+  
+  private static void log(String aMsg) {
+    System.out.println(aMsg);
+  }
+  
+  private void testStandardFormatCtorFail(String aDate){
+    try {
+      DateTime dateTime = new DateTime(aDate);
+      fail("Expected failure didn't happen.");
+    }
+    catch (Throwable ex){
+      //good branch
+    }
+  }
+  
+  private void testRange(boolean aSuccess, String aDateTime){
+    if(aSuccess){
+      try {
+        DateTime dateTime = new DateTime(aDateTime);
+        dateTime.ensureParsed();
+      }
+      catch (IllegalArgumentException ex){
+        fail("Item out of range:" + aDateTime);        
+      }
+      catch (DateTimeParser.UnknownDateTimeFormat ex){
+        fail("Unknown format:" + aDateTime);        
+      }
+    }
+    if(!aSuccess){
+      try {
+        DateTime dateTime = new DateTime(aDateTime);
+        dateTime.ensureParsed(); 
+        fail("Item out of range?:" + aDateTime);        
+      }
+      catch (DateTime.ItemOutOfRange ex){
+        //do nothing - expected
+      }
+      catch (DateTimeParser.UnknownDateTimeFormat ex){
+        //do nothing - expected
+      }
+    }
+  }
+  
+  private void testLeapYear(boolean aSuccess, String aDate) {
+    DateTime dt = new DateTime(aDate);
+    if(aSuccess){
+      if(! dt.isLeapYear()){
+        fail("Expected leap year, but isn't");
+      }
+    }
+    else {
+      if (dt.isLeapYear()){
+        fail("Expected non-leap year, but actually is a leap year.");
+      }
+    }
+  }
+  
+  private void testLeapYearFails(String aDate) {
+    DateTime dt = new DateTime(aDate);
+    try {
+      dt.isLeapYear();
+      fail();
+    }
+    catch (RuntimeException ex){
+      //OK - expected branch
+    }
+  }
+  
+  private void testToString(boolean aSuccess, String aDate, String aExpected) {
+    DateTime dt = new DateTime(aDate);
+    if(aSuccess){
+      if(!dt.toString().equals(aExpected)){
+        fail("Expected toString of " + aExpected + " but really is " + dt.toString());
+      }
+    }
+    else {
+      if(dt.toString().equals(aExpected)){
+        fail("Expected failure for toString being " + aExpected);
+      }
+    }
+  }
+  
+  private void testToString(boolean aSuccess, Integer y, Integer m, Integer d, Integer h, Integer min, Integer sec, Integer frac, String aExpected) {
+    DateTime dt = new DateTime(y,m,d,h,min,sec,frac);
+    if(aSuccess){
+      if(!dt.toString().equals(aExpected)){
+        fail("Expected toString of " + aExpected + " but really is " + dt.toString());
+      }
+    }
+    else {
+      if(dt.toString().equals(aExpected)){
+        fail("Expected failure for toString being " + aExpected);
+      }
+    }
+  }
+  
+  private void testDayOfWeek(boolean aSuccess, String aDateTime, int aWeekday){
+    DateTime dt = new DateTime(aDateTime);
+    if(aSuccess){
+      if( dt.getWeekDay() != aWeekday) {
+        fail("Expected weekday '" + aWeekday + "', but was actually " + dt.getWeekDay());
+      }
+    }
+    else {
+      try {
+       int dayOfWeek = dt.getWeekDay();
+        fail("Expected failure");
+      }
+      catch(RuntimeException ex){
+        //OK - expected
+      }
+    }
+  }
+
+  private void testDayOfYear(boolean aSuccess, String aDateTime, int aExpectedDay){
+    DateTime dt = new DateTime(aDateTime);
+    if(aSuccess){
+      if( dt.getDayOfYear() != aExpectedDay) {
+        fail("Expected weekday '" + aExpectedDay + "', but was actually " + dt.getDayOfYear());
+      }
+    }
+    else {
+      try {
+        dt.getDayOfYear();
+        fail();
+      }
+      catch (RuntimeException ex){
+        //OK - expected branch
+      }
+    }
+  }
+
+  private void testSameDayAs(boolean aSuccess, String aA, String aB){
+    DateTime a = new DateTime(aA);
+    DateTime b = new DateTime(aB);
+    if(aSuccess){
+      if(!a.isSameDayAs(b)){
+        fail("The date " + aA + " should be the same day as this, but isn't: " + aB);
+      }
+    }
+    if(!aSuccess){
+      if(a.isSameDayAs(b)){
+        fail("The date " + aA + " should NOT be the same day as this, but it is : " + aB);
+      }
+    }
+  }
+  
+  
+  private void testCompare(String aThis, String aThat, boolean aIsLess){  
+    DateTime a = new DateTime(aThis);
+    DateTime b = new DateTime(aThat);
+    if(aIsLess){
+      if ( a.compareTo(b) >= 0 ) {
+        fail(aThis + " is meant to be less than this date, but isn't: " + aThat);
+      }
+    }
+  }
+  
+  private void  testBefore(String aThis, String aThat){
+    DateTime a = new DateTime(aThis);
+    DateTime b = new DateTime(aThat);
+    assertTrue(a.lt(b));
+  }
+
+  private void  testAfter(String aThis, String aThat){
+    DateTime a = new DateTime(aThis);
+    DateTime b = new DateTime(aThat);
+    assertTrue(a.gt(b));
+  }
+  
+  private void testTruncate(boolean aSuccess, String aInput, String aExpected, DateTime.Unit aPrecision){
+    DateTime a = new DateTime(aInput);
+    DateTime aTruncated = a.truncate(aPrecision);
+    DateTime expected = new DateTime(aExpected);
+    if(aSuccess){
+      assertTrue(aTruncated.equals(expected));
+    }
+    else {
+      assertFalse(aTruncated.equals(expected));
+    }
+  }
+
+  private void testNumDaysInMonth(String aInput, int aExpected){
+    DateTime a = new DateTime(aInput);
+    assertTrue(a.getNumDaysInMonth() == aExpected);
+  }
+
+  private void testModifiedJD(String aDateTime, Integer aExpected){
+    DateTime dt = new DateTime(aDateTime);
+    if( ! dt.getModifiedJulianDayNumber().equals(aExpected) ){
+       fail("Expected: " + aExpected + " Actual: " + dt.getModifiedJulianDayNumber());  
+    }
+  }
+  
+  private void testStartOfDay(String aDateTime, String aExpected){
+    DateTime dt = new DateTime(aDateTime);
+    DateTime expected = new DateTime(aExpected);
+    DateTime calc = dt.getStartOfDay();
+    if  ( ! calc.equals(expected)){
+      fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay());  
+    }
+  }
+  
+  private void testEndOfDay(String aDateTime, String aExpected){
+    DateTime dt = new DateTime(aDateTime);
+    DateTime expected = new DateTime(aExpected);
+    if  ( ! dt.getEndOfDay().equals(expected)){
+      fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay());  
+    }
+  }
+
+  private void testStartOfMonth(String aDateTime, String aExpected){
+    DateTime dt = new DateTime(aDateTime);
+    DateTime expected = new DateTime(aExpected);
+    DateTime calc = dt.getStartOfMonth();
+    if  ( ! calc.equals(expected)){
+      fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay());  
+    }
+  }
+  
+  private void testEndOfMonth(String aDateTime, String aExpected){
+    DateTime dt = new DateTime(aDateTime);
+    DateTime expected = new DateTime(aExpected);
+    DateTime calc = dt.getEndOfMonth();
+    if  ( ! calc.equals(expected)){
+      fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay());  
+    }
+  }
+  
+  private void testDateFromJD(String aExpected, int aJD){
+    DateTime expected = new DateTime(aExpected);
+    if( ! DateTime.fromJulianDayNumberAtNoon(aJD).equals(expected) ){
+      fail("Expected: " + aExpected + " Actual: " + DateTime.fromJulianDayNumberAtNoon(aJD).format("YYYY-MM-DD"));  
+    }
+  }
+
+  
+  private void testEquals(String aThis, String aThat, boolean aSuccess){
+    DateTime thisDt = new DateTime(aThis); 
+    DateTime thatDt = new DateTime(aThat);
+    if(aSuccess){
+      assertTrue(thisDt.equals(thatDt));
+    }
+    else {
+      assertFalse(thisDt.equals(thatDt));
+    }
+  }
+  
+  private void testEquals(DateTime aThis, DateTime aThat, boolean aSuccess){
+    if(aSuccess){
+      assertTrue(aThis.equals(aThat));
+    }
+    else {
+      assertFalse(aThis.equals(aThat));
+    }
+  }
+
+  private void testChangeTimeZone(String aForDate, String aExpected, TimeZone aFrom, TimeZone aTo){
+    DateTime from = new DateTime(aForDate);
+    DateTime actual = from.changeTimeZone(aFrom, aTo);
+    DateTime expected = new DateTime(aExpected);
+    if( !actual.equals(expected) ){
+      fail("Expected:"  + aExpected + " Actual:" + actual);
+    }
+  }
+  
+  private void testChangeTimeZoneFails(String aForDate, String aExpected, TimeZone aFrom, TimeZone aTo){
+    DateTime from = new DateTime(aForDate);
+    try {
+      DateTime actual = from.changeTimeZone(aFrom, aTo);
+      fail();
+    }
+    catch(RuntimeException ex){
+      //ok      
+    }
+  }
+  
+  private void testGetMillis(String aDate, TimeZone aTimeZone, int aExpected){
+    DateTime dt = new DateTime(aDate);
+    long actual = dt.getMilliseconds(aTimeZone);
+    if(actual != aExpected){
+      fail("Actual: " + actual + " Expected: " + aExpected);
+    }
+  }
+  
+  private void testForInstant(int aMillis, TimeZone aTimeZone, String aExpected){
+    DateTime dtInstant = DateTime.forInstant(aMillis, aTimeZone);
+    DateTime dt = new DateTime(aExpected);
+    if (! dtInstant.equals(dt)){
+      fail("Time from millis : " + dtInstant + " not agreeing with expected: " + dt);
+    }
+  }
+
+  private void testForInstantNanos(long aNanos, TimeZone aTimeZone, String aExpected){
+    DateTime dtInstant = DateTime.forInstantNanos(aNanos, aTimeZone);
+    DateTime dt = new DateTime(aExpected);
+    if (! dtInstant.equals(dt)){
+      fail("Time from nanos : " + dtInstant + " not agreeing with expected: " + aExpected);
+    }
+  }
+  
+  private void testGetNanosecondsFromEpoch(String aDateTime, TimeZone aTimeZone, long aExpected){
+    DateTime dt = new DateTime(aDateTime);
+    if ( dt.getNanosecondsInstant(aTimeZone) != aExpected ){
+      fail("DateTime nanos : " + dt.getNanosecondsInstant(aTimeZone) + " not agreeing with expected: " + aExpected);
+    }
+  }
+  
+  private void testNanosecondRange(String aDateTime){
+    DateTime dt = new DateTime(aDateTime);
+    try {
+      dt.getNanosecondsInstant(TimeZone.getTimeZone("UTC"));
+    }
+    catch (Throwable ex){
+      fail("Nanoseconds out of range for: " + aDateTime);
+    }
+  }
+  
+  private void testInterConversion(String aDateTime, TimeZone aTimeZone){
+    DateTime dt = new DateTime(aDateTime);
+    //loss of precision :
+    long millis = dt.getMilliseconds(aTimeZone);
+    DateTime again = DateTime.forInstant(millis, aTimeZone);
+    if( dt.compareTo(again) != 0){
+      fail(aDateTime + " not the same as " + again.format("YYYY-MM-DD hh:mm:ss.fffffffff"));
+    }
+  }
+  
+  private void testParseable(boolean aSuccess, String aText){
+    if(aSuccess){
+      if (! DateTime.isParseable(aText)){
+        fail("Expecting text to be parseable, but it's not: " + aText);
+      }
+    }
+    else {
+      if ( DateTime.isParseable(aText)){
+        fail("Expecting text to be un-parseable, but it is: " + aText);
+      }
+    }
+  }
+  
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTDateTimeFormatter.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,215 @@
+package hirondelle.web4j.model;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import junit.framework.TestCase;
+
+/** JUnit tests. */
+public final class TESTDateTimeFormatter   extends TestCase   {
+
+  /** Run the test cases.  */
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTDateTimeFormatter.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTDateTimeFormatter(String aName) {
+    super(aName);
+  }
+
+   // TEST CASES //
+
+  public void testSpeed(){
+      //.046
+      testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD h12:mm:ss", "2009-10-28 1:59:01");
+  }
+  
+  public void testSpeedJDK() throws ParseException {    
+    String dateTime =  "2009-10-28 01:59:01";
+    //.063 - slower than my classes - at least in this simple test
+    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
+    Date result = format.parse(dateTime);
+     String dateResurrected = format.format(result);
+  }
+  
+  public void testDate(){
+    testDate(SUCCESS, "2009-10-28", "YYYY-MM-DD", "2009-10-28");
+    testDate(SUCCESS, "2009-01-28", "YYYY-MM-DD", "2009-01-28");
+    testDate(SUCCESS, "2009-12-01", "YYYY-MM-DD", "2009-12-01");
+    testDate(SUCCESS, "2009-01-01", "YYYY-MM-DD", "2009-01-01");
+    
+    testDate(SUCCESS, "2009-10-28", "YYYY-M-D", "2009-10-28");
+    testDate(SUCCESS, "2009-10-03", "YYYY-M-D", "2009-10-3");
+    testDate(SUCCESS, "2009-05-28", "YYYY-M-D", "2009-5-28");
+    
+    testDate(SUCCESS, "2009-10-28", "YYYY-MM-D", "2009-10-28");
+    testDate(SUCCESS, "2009-10-03", "YYYY-MM-D", "2009-10-3");
+    testDate(SUCCESS, "2009-05-28", "YYYY-MM-D", "2009-05-28");
+    
+    testDate(SUCCESS, "2009-10-28", "YYYY-M-DD", "2009-10-28");
+    testDate(SUCCESS, "2009-10-03", "YYYY-M-DD", "2009-10-03");
+    testDate(SUCCESS, "2009-05-28", "YYYY-M-DD", "2009-5-28");
+    
+    testDate(SUCCESS, "2009-10-28", "YY-M-DD", "09-10-28");
+    testDate(SUCCESS, "2009-10-03", "YY-M-DD", "09-10-03");
+    testDate(SUCCESS, "2099-05-28", "YY-M-DD", "99-5-28");
+
+    testDate(SUCCESS, "2099-05-28", "YYYYMMDD", "20990528");
+    testDate(SUCCESS, "2099-11-28", "WWW MMM DD, YYYY", Locale.CANADA, "Sat Nov 28, 2099");
+    testDate(SUCCESS, "2099-11-28", "WWW-DD|th|", Locale.CANADA, "Sat-28th");
+
+    testDate(SUCCESS, "2009-12-31", "MM-DD-YYYY", "12-31-2009");
+    
+    testDate(SUCCESS, "2009-12-31", "***YYYY-MM-DD", "***2009-12-31");
+    testDate(SUCCESS, "2009-12-31", "***YYYY-MM-DD*", "***2009-12-31*");
+    testDate(SUCCESS, "2009-12-31", "*  YYYY-MM-DD *", "*  2009-12-31 *");
+    
+    testDate(SUCCESS, "2009-12-31", "WWWW, MMM D, YYYY", Locale.CANADA, "Thursday, Dec 31, 2009");
+    testDate(SUCCESS, "2009-12-31", "WWW, MMM D, YYYY", Locale.CANADA, "Thu, Dec 31, 2009");
+    
+    testDate(SUCCESS, "01:59:59", "hh:mm:ss", "01:59:59");
+    testDate(SUCCESS, "01:59:59", "h:mm:ss", "1:59:59");
+    testDate(SUCCESS, "01:59:59", "hh:m:ss", "01:59:59");
+    testDate(SUCCESS, "01:01:59", "hh:m:ss", "01:1:59");
+    testDate(SUCCESS, "01:59:59", "hh:mm:s", "01:59:59");
+    testDate(SUCCESS, "01:59:01", "hh:mm:s", "01:59:1");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss", "01:59:01");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.f", "01:59:01.1");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ff", "01:59:01.12");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fff", "01:59:01.123");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ffff", "01:59:01.1234");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fffff", "01:59:01.12345");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ffffff", "01:59:01.123456");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fffffff", "01:59:01.1234567");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ffffffff", "01:59:01.12345678");
+    testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fffffffff", "01:59:01.123456789");
+    
+    testDate(SUCCESS, "01:59:01", "hh", "01");
+    testDate(SUCCESS, "01:59:01", "mm", "59");
+    testDate(SUCCESS, "01:59:01", "ss", "01");
+    testDate(SUCCESS, "01:59:01", "hh ", "01 ");
+    testDate(SUCCESS, "01:59:01", "hh:mm", "01:59");
+    testDate(SUCCESS, "01:59:01", "h:mm", "1:59");
+    testDate(SUCCESS, "01:59:01", "mm:ss", "59:01");
+    testDate(SUCCESS, "01:59:01", "mm:s", "59:1");
+    
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD hh:mm:ss", "2009-10-28 01:59:01");
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYYMMDDThh:mm:ss", "20091028T01:59:01");
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MMM-DD hh:mm:ss", Locale.CANADA, "2009-Oct-28 01:59:01");
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MMM-DD", Locale.CANADA, "2009-Oct-28");
+    testDate(SUCCESS, "2009-10-28 01:59:01", ":hh:mm:ss:", ":01:59:01:");
+    testDate(SUCCESS, "2009-04-28 13:59:01", "DD MMM, YYYY hh:mm:ss", Locale.CANADA_FRENCH, "28 avr, 2009 13:59:01");
+
+
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ff", "01:59:01.01");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fff", "01:59:01.010");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ffff", "01:59:01.0100");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fffff", "01:59:01.01000");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ffffff", "01:59:01.010000");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fffffff", "01:59:01.0100000");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ffffffff", "01:59:01.01000000");
+    testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fffffffff", "01:59:01.010000000");
+    
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss", "01:59:01");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.f", "01:59:01.0");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ff", "01:59:01.00");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fff", "01:59:01.000");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ffff", "01:59:01.0000");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fffff", "01:59:01.00000");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ffffff", "01:59:01.000000");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fffffff", "01:59:01.0000000");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ffffffff", "01:59:01.00000000");
+    testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fffffffff", "01:59:01.000000001");
+    
+    testDate(SUCCESS, "01:59:01.000000000", "hh:mm:ss.fffffffff", "01:59:01.000000000");
+
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD h12:mm:ss", "2009-10-28 1:59:01");
+    testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ss", "2009-10-28 1:59:01");
+
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 01:59:01");
+    testDate(SUCCESS, "2009-10-28 12:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 12:59:01");
+    testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 01:59:01");
+    testDate(SUCCESS, "2009-10-28 23:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 11:59:01");
+
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD h12:mm:ss a", Locale.CANADA, "2009-10-28 1:59:01 AM");
+    testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ss a", Locale.CANADA, "2009-10-28 1:59:01 PM");
+    
+    //WRONG : 
+     //this fails - tokens cannot appear next to each other:
+    //testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ssa", Locale.CANADA, "2009-10-28 1:59:01PM");
+    //the workaround for the above is to use the escape character, with nothing in between:
+    testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ss||a", Locale.CANADA, "2009-10-28 1:59:01PM");
+  }
+  
+  public void testEscapeChar(){
+    testDate(SUCCESS, "2009-10-28 01:59:01", "|Date:|YYYY-MM-DD |Time:|hh12:mm:ss", "Date:2009-10-28 Time:01:59:01");
+    testDate(SUCCESS, "15:59:59", "h12| o'clock| a", Locale.CANADA, "3 o'clock PM");
+    //inside escaped regions, the tokens are uninterpreted :
+    testDate(SUCCESS, "2009-10-28 01:59:01", "|Date(YYYY-MM-DD):|YYYY-MM-DD |Timehh12:mm:ss:|hh12:mm:ss", "Date(YYYY-MM-DD):2009-10-28 Timehh12:mm:ss:01:59:01");
+  }
+
+  public void testCustomFormats() {
+    List<String> months = Arrays.asList("J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D");
+    List<String> weekdays = Arrays.asList("sunday", "monday", "tuesday", "humpday", "thursday", "friday", "saturday");
+    List<String> amPm = Arrays.asList("am", "pm");
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD hh:mm:ss a", months, weekdays, amPm, "2009-10-28 01:59:01 am");
+    testDate(SUCCESS, "2009-10-28 16:59:01", "YYYY-MM-DD h12:mm:ss a", months, weekdays, amPm, "2009-10-28 4:59:01 pm");
+    
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MMMM-DD hh:mm:ss a", months, weekdays, amPm, "2009-O-28 01:59:01 am");
+    testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD WWWW hh:mm:ss a", months, weekdays, amPm, "2009-10-28 humpday 01:59:01 am");
+  }
+  
+  // PRIVATE
+  
+  private static final boolean SUCCESS = true;
+  
+  private void testDate(boolean aSuccess, String aDate , String aFormat, String aExpectedResult){
+    DateTimeParser parser = new DateTimeParser();
+    DateTime dateTime = parser.parse(aDate);
+    DateTimeFormatter formatter = new DateTimeFormatter(aFormat);
+    String result = formatter.format(dateTime);
+    if(aSuccess){
+      if(! result.equals(aExpectedResult)){
+        throw new AssertionError("Expected:" + aExpectedResult + ", but result was:" + result);
+      }
+    }
+    else {
+      assertFalse(result.equals(aExpectedResult));
+    }
+  }
+  
+  private void testDate(boolean aSuccess, String aDate , String aFormat, Locale aLocale, String aExpectedResult){
+    DateTimeParser parser = new DateTimeParser();
+    DateTime dateTime = parser.parse(aDate);
+    DateTimeFormatter formatter = new DateTimeFormatter(aFormat, aLocale);
+    String result = formatter.format(dateTime);
+    if(aSuccess){
+      if(! result.equals(aExpectedResult)){
+        throw new AssertionError("Expected:" + aExpectedResult + ", but result was:" + result);
+      }
+    }
+    else {
+      assertFalse(result.equals(aExpectedResult));
+    }
+  }
+  
+  private void testDate(boolean aSuccess, String aDate , String aFormat, List<String> aMonths, List<String> aWeekdays, List<String> aAmPm,  String aExpectedResult){
+    DateTimeParser parser = new DateTimeParser();
+    DateTime dateTime = parser.parse(aDate);
+    DateTimeFormatter formatter = new DateTimeFormatter(aFormat, aMonths, aWeekdays, aAmPm);
+    String result = formatter.format(dateTime);
+    if(aSuccess){
+      if(! result.equals(aExpectedResult)){
+        throw new AssertionError("Expected:" + aExpectedResult + ", but result was:" + result);
+      }
+    }
+    else {
+      assertFalse(result.equals(aExpectedResult));
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTDateTimeInterval.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,403 @@
+package hirondelle.web4j.model;
+
+import static hirondelle.web4j.model.DateTime.DayOverflow;
+import junit.framework.TestCase;
+
+public class TESTDateTimeInterval extends TestCase {
+
+  /** Run the test cases.  */
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTDateTimeInterval.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTDateTimeInterval( String aName) {
+    super(aName);
+  }
+
+   // TEST CASES //
+  
+  public void testRanges(){
+    testRange(SUCCESS, 0,0,0,0,0,0,0);
+    testRange(SUCCESS, 9998,0,0,0,0,0,0);
+    testRange(SUCCESS, 0,9999,0,0,0,0,0);
+    testRange(SUCCESS, 0,0,9999,0,0,0,0);
+    testRange(SUCCESS, 0,0,0,9999,0,0,0);
+    testRange(SUCCESS, 0,0,0,0,9999,0,0);
+    testRange(SUCCESS, 0,0,0,0,0,9999,0);
+    testRange(SUCCESS, 0,0,0,0,0,0,999999999);
+    
+    testRange(FAIL, -1,0,0,0,0,0,0);
+    testRange(FAIL, 0,-1,0,0,0,0,0);
+    testRange(FAIL, 0,0,-1,0,0,0,0);
+    testRange(FAIL, 0,0,0,-1,0,0,0);
+    testRange(FAIL, 0,0,0,0,-1,0,0);
+    testRange(FAIL, 0,0,0,0,0,-1,0);
+    testRange(FAIL, 0,0,0,0,0,0,-1);
+    
+    testRange(FAIL, 10000,0,0,0,0,0,0);
+    testRange(FAIL, 0,10000,0,0,0,0,0);
+    testRange(FAIL, 0,0,10000,0,0,0,0);
+    testRange(FAIL, 0,0,0,10000,0,0,0);
+    testRange(FAIL, 0,0,0,0,10000,10000,0);
+    testRange(FAIL, 0,0,0,0,0,0,1000000000);
+  }
+  
+  
+  public void testSingleField(){
+    testDate("2001-01-01 23:45:19.0", "2001-01-01 23:45:19.0", 0,0,0,0,0,0,0);
+    
+    testDate("2001-01-01 23:45:19.0", "2002-01-01 23:45:19.0", 1,0,0,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2001-02-01 23:45:19.0", 0,1,0,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2001-01-02 23:45:19.0", 0,0,1,0,0,0,0);
+    
+    testDate("2001-01-01 13:45:19.0", "2001-01-01 14:45:19.0", 0,0,0,1,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-01-01 13:46:19.0", 0,0,0,0,1,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-01-01 13:45:20.0", 0,0,0,0,0,1,0);
+    testDate("2001-01-01 13:45:19.0", "2001-01-01 13:45:19.000000001", 0,0,0,0,0,0,1);
+    
+    testDate("2001-01-01 13:45:56.0", "2001-01-01 13:46:00.0", 0,0,0,0,0,4,0);
+    testDate("2001-01-01 13:45:56.0", "2001-01-01 13:46:01.0", 0,0,0,0,0,5,0);
+    testDate("2001-01-01 13:45:56.0", "2001-01-01 13:47:01.0", 0,0,0,0,0,65,0);
+
+    testDate("2001-01-01 13:45:56.0", "2001-01-01 13:45:56.999999999", 0,0,0,0,0,0,999999999);
+    testDate("2001-01-01 13:45:56.000000001", "2001-01-01 13:45:57.0", 0,0,0,0,0,0,999999999);
+    testDate("2001-01-01 13:45:56.000000002", "2001-01-01 13:45:57.000000001", 0,0,0,0,0,0,999999999);
+    
+    testDate("2001-01-01 13:45:19.0", "2001-01-01 14:00:19.0", 0,0,0,0,15,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-01-01 15:01:19.0", 0,0,0,0,76,0,0);
+
+    testDate("2001-01-01 10:45:19.0", "2001-01-01 11:45:19.0", 0,0,0,1,0,0,0);
+    testDate("2001-01-01 10:45:19.0", "2001-01-01 13:45:19.0", 0,0,0,3,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2001-01-02 01:45:19.0", 0,0,0,2,0,0,0);
+
+    testDate("2001-01-01 13:45:19.0", "2001-01-21 13:45:19.0", 0,0,20,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-01-31 13:45:19.0", 0,0,30,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-02-01 13:45:19.0", 0,0,31,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-02-28 13:45:19.0", 0,0,58,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-03-01 13:45:19.0", 0,0,59,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2001-03-02 13:45:19.0", 0,0,60,0,0,0,0);
+    
+    testDate("2001-01-01 13:45:19.0", "2001-04-01 13:45:19.0", 0,3,0,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2002-01-01 13:45:19.0", 0,12,0,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2002-04-01 13:45:19.0", 0,15,0,0,0,0,0);
+    
+    testDate("2001-01-01 13:45:19.0", "2004-01-01 13:45:19.0", 3,0,0,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "2011-01-01 13:45:19.0", 10,0,0,0,0,0,0);
+    testDate("2001-01-01 13:45:19.0", "3501-01-01 13:45:19.0", 1500,0,0,0,0,0,0);
+    
+    testDate("2001-01-01", "2002-01-01 00:00:00.0", 1,0,0,0,0,0,0);
+    testDate("2001-01-01", "2001-02-01 00:00:00.0", 0,1,0,0,0,0,0);
+    testDate("2001-01-01", "2001-01-02 00:00:00.0", 0,0,1,0,0,0,0);
+    testDate("2001-01-01", "2001-01-01 01:00:00.0", 0,0,0,1,0,0,0);
+    testDate("2001-01-01", "2001-01-01 00:01:00.0", 0,0,0,0,1,0,0);
+    testDate("2001-01-01", "2001-01-01 00:00:01.0", 0,0,0,0,0,1,0);
+    testDate("2001-01-01", "2001-01-01 00:00:00.0", 0,0,0,0,0,0,0);
+    testDate("2001-01-01 15:14:10.123456789", "2001-01-01 15:14:20.123456789", 0,0,0,0,0,10,0);
+
+    testDate("10:12:14", "0002-01-01 10:12:14.0", 1,0,0,0,0,0,0);
+    testDate("10:12:14", "0001-02-01 10:12:14.0", 0,1,0,0,0,0,0);
+    testDate("10:12:14", "0001-01-02 10:12:14.0", 0,0,1,0,0,0,0);
+    testDate("10:12:14", "0001-01-01 11:12:14.0", 0,0,0,1,0,0,0);
+    testDate("10:12:14", "0001-01-01 10:13:14.0", 0,0,0,0,1,0,0);
+    testDate("10:12:14", "0001-01-01 10:12:15.0", 0,0,0,0,0,1,0);
+  }
+  
+  public void testMultipleFields(){
+    testDate("2001-01-01 23:45:19.0", "2002-02-01 23:45:19.0", 1,1,0,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2002-02-02 23:45:19.0", 1,1,1,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2002-02-03 00:45:19.0", 1,1,1,1,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2002-02-03 00:46:19.0", 1,1,1,1,1,0,0);
+    testDate("2001-01-01 23:45:19.0", "2002-02-03 00:46:20.0", 1,1,1,1,1,1,0);
+    testDate("2001-01-01 23:45:19.0", "2002-02-03 00:46:20.000000001", 1,1,1,1,1,1,1);
+    
+    testDate("2001-01-01", "2002-02-01 00:00:00.0", 1,1,0,0,0,0,0);
+    testDate("2001-01-01", "2002-02-02 00:00:00.0", 1,1,1,0,0,0,0);
+    testDate("2001-01-01", "2002-02-02 01:00:00.0", 1,1,1,1,0,0,0);
+    testDate("2001-01-01", "2002-02-02 01:01:00.0", 1,1,1,1,1,0,0);
+    testDate("2001-01-01", "2002-02-02 01:01:01.0", 1,1,1,1,1,1,0);
+    testDate("2001-01-01", "2002-02-02 01:01:01.000000001", 1,1,1,1,1,1,1);
+    
+    testDate("10:12:14", "0002-02-01 10:12:14.0", 1,1,0,0,0,0,0);
+    testDate("10:12:14", "0002-02-02 10:12:14.0", 1,1,1,0,0,0,0);
+    testDate("10:12:14", "0002-02-02 11:12:14.0", 1,1,1,1,0,0,0);
+    testDate("10:12:14", "0002-02-02 11:13:14.0", 1,1,1,1,1,0,0);
+    testDate("10:12:14", "0002-02-02 11:13:15.0", 1,1,1,1,1,1,0);
+    testDate("10:12:14", "0002-02-02 11:13:15.000000001", 1,1,1,1,1,1,1);
+  }
+  
+  public void testMissingParts(){
+    testMissingParts("2001-01-01 23:45");
+    testMissingParts("2001-01-01 23");
+    testMissingParts("2001-01");
+    testMissingParts("2001");
+    testMissingParts("00:59");
+  }
+
+  public void testMultipleFieldsWithRollovers(){
+    testDate("2001-01-01 23:45:19.0", "2012-02-01 23:45:19.0", 10,13,0,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-02-29 23:45:19.0", 10,13,28,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-01 23:45:19.0", 10,13,29,0,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-02 00:45:19.0", 10,13,29,1,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 00:45:19.0", 10,13,29,25,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 03:45:19.0", 10,13,29,28,0,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:00:19.0", 10,13,29,28,15,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:15:19.0", 10,13,29,28,30,0,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:15:29.0", 10,13,29,28,30,10,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:15:59.0", 10,13,29,28,30,40,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:16:00.0", 10,13,29,28,30,41,0);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:16:00.000000001", 10,13,29,28,30,41,1);
+    testDate("2001-01-01 23:45:19.0", "2012-03-03 04:16:00.999999999", 10,13,29,28,30,41,999999999);
+    testDate("2001-01-01 23:45:19.000000001", "2012-03-03 04:16:01.000000000", 10,13,29,28,30,41,999999999);
+    testDate("2001-01-01 23:45:19.000000002", "2012-03-03 04:16:01.000000001", 10,13,29,28,30,41,999999999);
+    
+    //date only
+    testDate("2001-01-01", "2012-02-01 00:00:00.0", 10,13,0,0,0,0,0);
+    testDate("2001-01-01", "2012-02-28 00:00:00.0", 10,13,27,0,0,0,0);
+    testDate("2001-01-01", "2012-02-29 00:00:00.0", 10,13,28,0,0,0,0);
+    testDate("2001-01-01", "2012-03-01 00:00:00.0", 10,13,29,0,0,0,0);
+    testDate("2001-01-01", "2012-03-01 23:00:00.0", 10,13,29,23,0,0,0);
+    testDate("2001-01-01", "2012-03-02 00:00:00.0", 10,13,29,24,0,0,0);
+    testDate("2001-01-01", "2012-03-02 01:00:00.0", 10,13,29,25,0,0,0);
+    testDate("2001-01-01", "2012-03-02 01:59:00.0", 10,13,29,25,59,0,0);
+    testDate("2001-01-01", "2012-03-02 02:01:00.0", 10,13,29,25,61,0,0);
+    testDate("2001-01-01", "2012-03-02 02:01:59.0", 10,13,29,25,61,59,0);
+    testDate("2001-01-01", "2012-03-02 02:02:01.0", 10,13,29,25,61,61,0);
+    testDate("2001-01-01", "2012-03-02 02:02:01.999999999", 10,13,29,25,61,61,999999999);
+    
+    //time only
+    testDate("00:00:00", "0001-01-01 00:00:00.0", 0,0,0,0,0,0,0);
+    testDate("00:00:00", "0001-01-01 23:00:00.0", 0,0,0,23,0,0,0);
+    testDate("00:00:00", "0001-01-02 00:00:00.0", 0,0,0,24,0,0,0);
+    testDate("00:00:00", "0001-01-02 01:00:00.0", 0,0,0,25,0,0,0);
+    testDate("00:00:00", "0001-01-02 01:00:00.0", 0,0,0,25,0,0,0);
+    testDate("00:00:00", "0001-01-02 01:59:00.0", 0,0,0,25,59,0,0);
+    testDate("00:00:00", "0001-01-02 02:00:00.0", 0,0,0,25,60,0,0);
+    testDate("00:00:00", "0001-01-02 02:01:00.0", 0,0,0,25,61,0,0);
+    testDate("00:00:00", "0001-01-02 02:01:59.0", 0,0,0,25,61,59,0);
+    testDate("00:00:00", "0001-01-02 02:02:01.0", 0,0,0,25,61,61,0);
+    testDate("00:00:00", "0001-01-02 02:02:01.999999999", 0,0,0,25,61,61,999999999);
+    testDate("00:00:00.000000001", "0001-01-02 02:02:02.0", 0,0,0,25,61,61,999999999);
+    testDate("00:00:00.000000002", "0001-01-02 02:02:02.000000001", 0,0,0,25,61,61,999999999);
+  }  
+  
+  public void testDayOverflow(){
+    testDayOverflow("2001-01-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-02-28 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflow("2001-01-31 10:20:30.0", DateTime.DayOverflow.FirstDay, "2001-03-01 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflow("2001-12-31 10:20:30.0", DateTime.DayOverflow.Spillover, "2002-03-03 10:20:30.0", 0,2,0,0,0,0,0);
+    
+    testDayOverflowAbort(SUCCESS, "2001-01-31 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(SUCCESS, "2001-03-31 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(SUCCESS, "2001-03-31 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(SUCCESS, "2001-05-31 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(SUCCESS, "2001-10-31 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(FAIL, "2001-02-28 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(FAIL, "2001-04-30 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(FAIL, "2001-05-01 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(FAIL, "2001-07-31 10:20:30.0",  0,1,0,0,0,0,0);    
+    testDayOverflowAbort(FAIL, "2001-12-31 10:20:30.0",  0,1,0,0,0,0,0);    
+  }
+  
+  public void testSingleFieldMinus(){
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:19.0", 0,0,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-04-15 23:45:19.0", 1,0,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-03-15 23:45:19.0", 0,1,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-14 23:45:19.0", 0,0,1,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 22:45:19.0", 0,0,0,1,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:19.0", 0,0,0,0,1,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:18.0", 0,0,0,0,0,1,0);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:18.999999999", 0,0,0,0,0,0,1);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:18.000000001", 0,0,0,0,0,0,999999999);
+    testDateMinus("2001-04-15 23:45:19.999999999", "2001-04-15 23:45:19.0", 0,0,0,0,0,0,999999999);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:09.0", 0,0,0,0,0,10,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:00.0", 0,0,0,0,0,19,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:59.0", 0,0,0,0,0,20,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:49.0", 0,0,0,0,0,30,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:19.0", 0,0,0,0,0,60,0);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:35:19.0", 0,0,0,0,10,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:00:19.0", 0,0,0,0,45,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 22:59:19.0", 0,0,0,0,46,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 22:49:19.0", 0,0,0,0,56,0,0);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 13:45:19.0", 0,0,0,10,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 00:45:19.0", 0,0,0,23,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-14 23:45:19.0", 0,0,0,24,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-14 22:45:19.0", 0,0,0,25,0,0,0);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-05 23:45:19.0", 0,0,10,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-04-01 23:45:19.0", 0,0,14,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-03-31 23:45:19.0", 0,0,15,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-03-30 23:45:19.0", 0,0,16,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-03-01 23:45:19.0", 0,0,45,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2001-02-28 23:45:19.0", 0,0,46,0,0,0,0);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2001-01-15 23:45:19.0", 0,3,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-12-15 23:45:19.0", 0,4,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-01-15 23:45:19.0", 0,15,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-12-15 23:45:19.0", 0,16,0,0,0,0,0);
+    
+    testDateMinus("2001-04-15 23:45:19.0", "2000-04-15 23:45:19.0", 1,0,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-04-15 23:45:19.0", 2,0,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1989-04-15 23:45:19.0", 12,0,0,0,0,0,0);
+    
+    testDateMinus("1900-04-15 23:45:19.0", "900-04-15 23:45:19.0", 1000,0,0,0,0,0,0);
+  }
+  
+  public void testMultipleFieldsMinus(){
+    testDateMinus("2001-04-15 23:45:19.0", "2000-04-15 23:45:19.0", 1,0,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-03-15 23:45:19.0", 1,1,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 23:45:19.0", 1,1,1,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:45:19.0", 1,1,1,1,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:44:19.0", 1,1,1,1,1,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:44:18.0", 1,1,1,1,1,1,0);
+    testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:44:17.999999999", 1,1,1,1,1,1,1);
+  }
+  
+  public void testMultipleFieldsWithRolloversMinus(){
+    testDateMinus("2001-04-15 23:45:19.0", "1999-12-15 23:45:19.0", 1,4,0,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-30 23:45:19.0", 1,4,15,0,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-30 00:45:19.0", 1,4,15,23,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 23:45:19.0", 1,4,15,24,0,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 23:00:19.0", 1,4,15,24,45,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:59:19.0", 1,4,15,24,46,0,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:59:00.0", 1,4,15,24,46,19,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:58:59.0", 1,4,15,24,46,20,0);
+    testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:58:58.999999999", 1,4,15,24,46,20,1);
+  }
+  
+  public void testDayOverflowMinus(){
+    testDayOverflowMinus("2001-03-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-02-28 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflowMinus("2001-12-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-11-30 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflowMinus("2001-10-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-09-30 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflowMinus("2001-07-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-06-30 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflowMinus("2001-05-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-04-30 10:20:30.0", 0,1,0,0,0,0,0);
+    testDayOverflowMinus("2001-05-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-02-28 10:20:30.0", 0,3,0,0,0,0,0);
+  }
+  
+  public void testWeekIssue(){
+    testWeekIndex("2009-02-01", "2009-02-01", 1);
+    testWeekIndex("2009-02-01", "2009-02-02", 1);
+    testWeekIndex("2009-02-01", "2009-02-03", 1);
+    testWeekIndex("2009-02-01", "2009-02-04", 1);
+    testWeekIndex("2009-02-01", "2009-02-05", 1);
+    testWeekIndex("2009-02-01", "2009-02-06", 1);
+    testWeekIndex("2009-02-01", "2009-02-07", 1);
+    testWeekIndex("2009-02-01", "2009-02-08", 2);
+    testWeekIndex("2009-02-01", "2009-02-09", 2);
+    testWeekIndex("2009-02-01", "2009-02-10", 2);
+    testWeekIndex("2009-02-01", "2009-02-11", 2);
+    testWeekIndex("2009-02-01", "2009-02-12", 2);
+    testWeekIndex("2009-02-01", "2009-02-13", 2);
+    testWeekIndex("2009-02-01", "2009-02-14", 2);
+    testWeekIndex("2009-02-01", "2009-02-15", 3);
+    testWeekIndex("2009-02-01", "2009-02-16", 3);
+    
+    testWeekIndex("2009-04-26", "2009-04-26", 1);
+    testWeekIndex("2009-04-26", "2009-04-27", 1);
+    testWeekIndex("2009-04-26", "2009-04-28", 1);
+    testWeekIndex("2009-04-26", "2009-04-29", 1);
+    testWeekIndex("2009-04-26", "2009-04-30", 1);
+    testWeekIndex("2009-04-26", "2009-05-01", 1);
+    testWeekIndex("2009-04-26", "2009-05-02", 1);
+    testWeekIndex("2009-04-26", "2009-05-03", 2);
+ }
+  
+  // PRIVATE
+  
+  private static final boolean SUCCESS = true;
+  private static final boolean FAIL = false;
+  private static final String FORMAT = "YYYY-MM-DD hh:mm:ss.fffffffff";
+  
+  private void testDate(String aInput, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){
+    DateTime from = new DateTime(aInput);
+    DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DayOverflow.LastDay);
+    DateTime expectedResult = new DateTime(aExpected);
+    if (!result.equals(expectedResult)){
+      fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT));
+    }
+  }
+  
+  private void testRange(boolean aSuccess, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){
+    DateTime from = new DateTime("0001-02-28 11:23:56.0");
+    try { 
+      DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DateTime.DayOverflow.LastDay);
+      if(!aSuccess){
+        fail();
+      }
+    }
+    catch(IllegalArgumentException ex){
+      if(aSuccess){
+        fail();
+      }
+    }
+  }
+  
+  private void testDayOverflow(String aInput, DateTime.DayOverflow aOverflow, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){
+    DateTime from = new DateTime(aInput);
+    DateTime expectedResult = new DateTime(aExpected);
+    DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, aOverflow);
+    if (!result.equals(expectedResult)){
+      fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT));
+    }
+  }
+
+  private void testDayOverflowMinus(String aInput, DayOverflow aOverflow, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){
+    DateTime from = new DateTime(aInput);
+    DateTime expectedResult = new DateTime(aExpected);
+    DateTime result = from.minus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, aOverflow);
+    if (!result.equals(expectedResult)){
+      fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT));
+    }
+  }
+  
+  private void testDayOverflowAbort(boolean aShouldAbort, String aInput,  int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){
+    DateTime from = new DateTime(aInput);
+    try { 
+       DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DayOverflow.Abort);
+      if(aShouldAbort){
+        fail();
+      }
+    }
+    catch(RuntimeException ex){   
+      if (! aShouldAbort ){
+        fail();
+      }
+    }
+  }
+  
+  private void testDateMinus(String aInput, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){
+    DateTime from = new DateTime(aInput);
+    DateTime result = from.minus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DayOverflow.LastDay);
+    DateTime expectedResult = new DateTime(aExpected);
+    if (!result.equals(expectedResult)){
+      fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT));
+    }
+  }
+  
+  private void testMissingParts(String aInput){
+    DateTime from = new DateTime(aInput);
+    boolean hasFailed = true;
+    try { 
+      from.plus(0,0,0,0,0,0,0,DayOverflow.LastDay);
+      hasFailed = false;
+    }
+    catch (Throwable ex){
+      hasFailed = true;
+    }
+    if(!hasFailed){
+      fail();
+    }
+  }
+  
+  private void testWeekIndex(String aStartDate, String aEndDate, int aExpected){
+    DateTime start = new DateTime(aStartDate);
+    DateTime end = new DateTime(aEndDate);
+    if( end.getWeekIndex(start) != aExpected) {
+      fail("Expected:" + aExpected + " Actual:" + end.getWeekIndex(start) );
+    }
+    
+  }  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTDecimal.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,830 @@
+package hirondelle.web4j.model;
+
+import java.util.*;
+import java.math.*;
+
+import junit.framework.*;
+
+public final class TESTDecimal  extends TestCase {
+  public static void main(String args[]) {
+    junit.textui.TestRunner.run(TESTDecimal.class);
+  }
+
+  public void testCtor(){
+    testCtorSuccess("0");
+    testCtorSuccess("-0");
+    testCtorSuccess("0.00");
+    testCtorSuccess("4.432672E+7");
+    
+    testCtorSuccess("1" );
+    testCtorSuccess("1.0");
+    testCtorSuccess("1.00");
+    testCtorSuccess("10.00");
+    testCtorSuccess("-10.00");
+    testCtorSuccess("123456.78");
+    testCtorSuccess("9999.99");
+    testCtorSuccess("10000000000000000000000.00");
+    testCtorSuccess("-10000000000000000000000.00");
+
+    testCtorSuccess("1.23");
+    testCtorSuccess("123213");
+    testCtorSuccess("10.0032131");
+    testCtorSuccess("1654640.0");
+    testCtorSuccess("3132.321313");
+    
+    testCtorSuccess("03132.321313"); // leading zero
+    testCtorSuccess("01");
+    testCtorSuccess("010");
+    testCtorSuccess("01.0");
+    testCtorSuccess("10.0");
+    
+    testCtorSuccess(0);
+    testCtorSuccess(+0);
+    testCtorSuccess(-0);
+    testCtorSuccess(1);
+    testCtorSuccess(+1);
+    testCtorSuccess(-1);
+    testCtorSuccess(123);
+    testCtorSuccess(0123);
+    testCtorSuccess(123456789);
+    testCtorSuccess(-123456789);
+
+    testCtorSuccess(0.0);
+    testCtorSuccess(+0.0);
+    testCtorSuccess(-0.0);
+    testCtorSuccess(1.0);
+    testCtorSuccess(+1.0);
+    testCtorSuccess(-1.0);
+    testCtorSuccess(0123.0);
+    testCtorSuccess(123456789.0);
+    testCtorSuccess(-123456789.0);
+
+    testCtorBDSuccess("0");
+    testCtorBDSuccess("-0");
+    testCtorBDSuccess("0.00");
+    testCtorBDSuccess("4.432672E+7");
+    
+    testCtorBDSuccess("1" );
+    testCtorBDSuccess("1.0");
+    testCtorBDSuccess("1.00");
+    testCtorBDSuccess("010.00");
+    testCtorBDSuccess("-10.00");
+    testCtorBDSuccess("123456.78");
+    testCtorBDSuccess("9999.99");
+    testCtorBDSuccess("10000000000000000000000.00");
+    testCtorBDSuccess("-10000000000000000000000.00");
+
+    testCtorBDSuccess("1.23");
+    testCtorBDSuccess("123213");
+    testCtorBDSuccess("10.0032131");
+    testCtorBDSuccess("1654640.0");
+    testCtorBDSuccess("3132.321313");
+    
+    testMoneyCtorFail(null);
+  }
+
+  public void testEquals(){
+    Decimal us = Decimal.from("9.89");
+    Decimal us2 = Decimal.from("9.89");
+    Decimal us3 = Decimal.from("+9.89");
+    Decimal lz = Decimal.from("09.89");
+    
+    Decimal us4 = Decimal.from("9.88");
+
+    Decimal us5 = Decimal.from("10");
+    Decimal us6 = Decimal.from("10.00");
+    
+    assertTrue( us.equals(us2) );
+    assertTrue( us.equals(us3) );
+    assertTrue( us.equals(lz) );
+    
+    assertFalse( us.equals(us4) );
+    assertFalse( us5.equals(us6) );
+  }
+  
+  public void testCompareTo(){
+    Decimal us = Decimal.from("9.89");
+    Decimal us2 = Decimal.from("9.89");
+    Decimal us4 = Decimal.from("9.88");
+    Decimal lz = Decimal.from("09.89");
+
+    Decimal us5 = Decimal.from("10");
+    Decimal us6 = Decimal.from("10.00");
+    
+    assertTrue( us.compareTo(us2) == 0);
+    assertTrue( us.compareTo(lz) == 0);
+    assertTrue( us.compareTo(us4) > 0);
+    assertTrue( us5.compareTo(us6) == 0);
+  }
+  
+  public void testAmount(){
+    testAmount("1", "1");
+    testAmount("0", "0");
+    testAmount("0.00", "0.00");
+    testAmount("00.00", "00.00");
+    testAmount("-1", "-1");
+    testAmount("1.0", "1.0");
+    testAmount("23", "23");
+    testAmount("123000", "123000");
+    testAmount("123000.0001", "123000.0001");
+    testAmount("-123000", "-123000");
+  }
+  
+  public void testNumOfDecimals(){
+    testNumOfDecimals("12.13", 2);
+    testNumOfDecimals("12", 0);
+    testNumOfDecimals("123456", 0);
+    testNumOfDecimals("12.1", 1);
+    testNumOfDecimals("12.0", 1);
+    testNumOfDecimals("-12.9846", 4);
+    testNumOfDecimals("-123456", 0);
+    testNumOfDecimals("-12.1", 1);
+    testNumOfDecimals("-12.0", 1);
+    testNumOfDecimals("-12.9846", 4);
+    testNumOfDecimals("123000", 0);
+  }
+  
+  public void testIsInteger(){
+    testIsInteger("0", PASS);
+    testIsInteger("0.0", PASS);
+    testIsInteger("0.000", PASS);
+    testIsInteger("100", PASS);
+    testIsInteger("100.0", PASS);
+    testIsInteger("100.00000000", PASS);
+    testIsInteger("-100", PASS);
+    testIsInteger("-100.0", PASS);
+    testIsInteger("-100.00000000", PASS);
+    
+    testIsInteger("0.0000000001", FAIL);
+    testIsInteger("100.0000000001", FAIL);
+    testIsInteger("-100.0000000001", FAIL);
+    testIsInteger("2.3216549873211", FAIL);
+  }
+  
+  public void testIsPlus(){
+    testIsPlusFor("9.89", PASS);
+    testIsPlusFor("989", PASS);
+    testIsPlusFor("98900", PASS);
+    testIsPlusFor("0.01", PASS);
+
+    testIsPlusFor("+9.89", PASS);
+    testIsPlusFor("+989", PASS);
+    testIsPlusFor("+98900", PASS);
+    testIsPlusFor("+0.01", PASS);
+    
+    testIsPlusFor("0.00", FAIL);
+    testIsPlusFor("-0.01", FAIL);
+    testIsPlusFor("-9.89", FAIL);
+    testIsPlusFor("9", PASS);
+  }
+  
+  public void testIsMinus(){
+    testIsMinusFor("9.89", FAIL);
+    testIsMinusFor("-989", PASS);
+    testIsMinusFor("-989000", PASS);
+    testIsMinusFor("0.01", FAIL);
+    testIsMinusFor("0.00", FAIL);
+    testIsMinusFor("-0.01", PASS);
+    testIsMinusFor("-9.89", PASS);
+    testIsMinusFor("-100", PASS);
+  }
+  
+  public void testIsZero(){
+    testIsZeroFor("0.00", PASS);
+    testIsZeroFor("0", PASS);
+    testIsZeroFor("0.0", PASS);
+    testIsZeroFor("00.00", PASS);
+    testIsZeroFor(".00", PASS);
+    testIsZeroFor(".0", PASS);
+
+    testIsZeroFor("+0.00", PASS);
+    testIsZeroFor("+0", PASS);
+    testIsZeroFor("+0.0", PASS);
+    testIsZeroFor("+00.00", PASS);
+    testIsZeroFor("+.00", PASS);
+    testIsZeroFor("+.0", PASS);
+    
+    testIsZeroFor("0.01", FAIL);
+    testIsZeroFor("1.01", FAIL);
+  }
+
+  public void testEq(){
+    Decimal us1 = Decimal.from("10.00");
+    Decimal us2 = Decimal.from("10.20");
+    Decimal us3 = Decimal.from("10.20");
+    Decimal us4 = Decimal.from("99");
+    Decimal us5 = Decimal.from("99.00");
+    Decimal us6 = Decimal.from("00099.00");
+    Decimal val1 = Decimal.from("-5.00");
+    Decimal val2 = Decimal.from("-5");
+    assertTrue( us2.eq(us3) );
+    assertTrue( us4.eq(us5) );
+    assertTrue( us4.eq(us6) );
+    assertTrue( val1.eq(val2) );
+    assertFalse( us1.eq(us2) );
+  }
+
+  public void testGt(){
+    Decimal us1 = Decimal.from("1.00");
+    Decimal us2 = Decimal.from("10.20");
+    Decimal us3 = Decimal.from("10.200");
+    Decimal us4 = Decimal.from("1");
+    Decimal val1 = Decimal.from("-5");
+    Decimal val2 = Decimal.from("-5.00");
+    Decimal val3 = Decimal.from("-5.50");
+    
+    assertTrue( us2.gt(us1) );
+    assertTrue( us3.gt(us1) );
+    assertTrue( val1.gt(val3) );
+    assertTrue( val1.gt(val3) );
+    assertTrue( us1.gt(val1) );
+    
+    assertFalse( us2.gt(us3) );
+    assertFalse( us1.gt(us4) );
+    assertFalse( us4.gt(us1) );
+    assertFalse( val2.gt(val1) );
+  }
+  
+  public void testGteq(){
+    Decimal us1 = Decimal.from("1.00");
+    Decimal us2 = Decimal.from("10.20");
+    Decimal us3 = Decimal.from("10.200");
+    Decimal us4 = Decimal.from("1");
+    Decimal val1 = Decimal.from("-53");
+    Decimal val2 = Decimal.from("-53.0000001");
+    Decimal val3 = Decimal.from("-54");
+    assertTrue( us2.gteq(us1) );
+    assertTrue( us2.gteq(us3) );
+    assertTrue( us3.gteq(us1) );
+    assertTrue( us1.gteq(us4) );
+    assertTrue( val1.gteq(val2) );
+    assertTrue( val1.gteq(val3) );
+    assertTrue( us3.gteq(us2) );
+    
+    assertFalse( val2.gteq(val1) );
+    assertFalse( val3.gteq(val1) );
+  }
+  
+  public void testLt(){
+    Decimal us1 = Decimal.from("1.00");
+    Decimal us2 = Decimal.from("10.20");
+    Decimal us3 = Decimal.from("10.200");
+    Decimal us4 = Decimal.from("1");
+    Decimal val1 = Decimal.from("-23");
+    Decimal val2 = Decimal.from("-023");
+    Decimal val3 = Decimal.from("-23.00");
+    Decimal val4 = Decimal.from("-23.001");
+    assertTrue(val4.lt(val1));
+    assertFalse(val3.lt(val2));
+    assertFalse( us2.lt(us1) );
+    assertFalse( us2.lt(us3) );
+    assertFalse( us3.lt(us1) );
+    assertFalse( us1.lt(us4) );
+  }
+  
+  public void testLteq(){
+    Decimal us1 = Decimal.from("1.00");
+    Decimal us2 = Decimal.from("10.20");
+    Decimal us3 = Decimal.from("10.20");
+    Decimal us4 = Decimal.from("1");
+    Decimal val1 = Decimal.from("-16");
+    Decimal val2 = Decimal.from("-16.00");
+    Decimal val3 = Decimal.from("-16.001");
+    assertTrue( us1.lteq(us2) );
+    assertTrue( us2.lteq(us3) );
+    assertTrue( val3.lteq(val2) );
+    assertTrue( val1.lteq(val2) );
+    assertTrue( val3.lteq(val1) );
+    assertTrue( us1.lteq(us4) );
+
+    assertFalse( us3.lteq(us1) );
+    assertFalse( us3.lteq(us1) );
+    assertFalse( val1.lteq(val3) );
+  }
+
+  public void testPlus(){
+    testPlus("1", "2", "3");
+    testPlus("1.0", "2", "3.0");
+    testPlus("0", "55.5", "55.5");
+    testPlus("-1", "5", "4");
+    testPlus("-1", "5.0", "4.0");
+    testPlus("00010", "6", "16");
+    testPlus("00010", "0006", "00016");
+    testPlus("-11", "11", "0");
+    testPlus("123", "400", "523");
+  }
+  
+  public void testMinus(){
+    testMinus("1", "2", "-1");
+    testMinus("1.0", "2", "-1.0");
+    testMinus("0", "55.5", "-55.5");
+    testMinus("-1", "5", "-6");
+    testMinus("-1", "5.0", "-6.0");
+    testMinus("00010", "6", "4");
+    testMinus("00010", "0006", "0004");
+    testMinus("-11", "11", "-22");
+    testMinus("523", "123", "400");
+    testMinus("523", "123.0", "400.0");
+    testMinus("523", "123.00", "400.00");
+  }
+
+  public void testSum(){
+    testSum("0", "0");
+    testSum("0.0", "0.0");
+    testSum("00.0", "00.0");
+    testSum("1", "0", "1");
+    testSum("1.0", "0", "1.0");
+    testSum("1.00", "0", "1.00");
+    testSum("0.00", "-1", "1.00");
+    testSum("0.000", "-1.000", "1.00");
+    testSum("0.00000", "12.12345", "-12.12345");
+    testSum("90", "12", "-12", "45", "45");
+    testSum("66", "-12", "-12", "45", "45");
+    testSum("66.0001", "-12", "-12", "45", "45.0001");
+  }
+  
+  public void testTimes(){
+    testTimes("10", "2", "20");
+    testTimes("10", "3", "30");
+    testTimes("25", "2", "50");
+    testTimes("-25", "10", "-250");
+    testTimes("+12", "3", "36");
+    testTimes("8", "7", "56");
+
+    testTimes("10.00", "2", "20.00");
+    testTimes("10.00", "3", "30.00");
+    testTimes("10.00", "9", "90.00");
+    testTimes("10.00", "-2", "-20.00");
+    testTimes("100000.00", "-2", "-200000.00");
+    testTimes("10.0000", "2", "20.0000");
+
+    testTimes("10", "2.0", "20.0");
+    testTimes("10", "3.0", "30.0");
+    testTimes("25", "2.0", "50.0");
+    testTimes("-25", "10.0", "-250.0");
+    testTimes("+12", "3.0", "36.0");
+    testTimes("8", "7.0", "56.0");
+    
+    testTimes("987.65", "7.0", "6913.550");
+    testTimes("987.65", "7.123", "7035.03095");
+    testTimes("987.65", "-0.0125", "-12.345625");
+    
+    testTimes("0008", "7.0", "56.0");
+    testTimes("0008", "07.0", "56.0");
+    testTimes("0008", "7.0", "0056.0");
+    testTimes("-8", "-7.0", "56.0");
+    testTimes("-8000000", "-70.0", "560000000.0");
+  }
+
+  public void testDiv(){
+    testDiv("10", "2", "5");    
+    testDiv("10.00", "2", "5.00");    
+    testDiv("-10", "2", "-5");    
+    testDiv("10.0", "2", "5.0");    
+    testDiv("10", "3", "3.33333333333333333333");    
+    testDiv("-10", "3", "-3.33333333333333333333");    
+    testDiv("10.0", "3", "3.33333333333333333333");    
+    testDiv("10.00", "3", "3.33333333333333333333");
+    
+    testDiv("100.00", "3", "33.33333333333333333333");
+    testDiv("1025.25", "1", "1025.25");
+    testDiv("1025.25", "2", "512.625");
+    testDiv("1025.25", "3", "341.75");
+    testDiv("1025.25", "4", "256.3125");
+    testDiv("1025.25", "5", "205.05");
+    testDiv("1025.25", "6", "170.875");
+    testDiv("1025.25", "7", "146.46428571428571428571");
+    testDiv("1025.25", "8", "128.15625");
+    testDiv("1025.25", "9", "113.91666666666666666667");
+    testDiv("1025.25", "10", "102.525");
+    
+    testDiv("10", "2.0", "5");    
+    testDiv("10.00", "2.0", "5.0");    
+    testDiv("-10", "2.0", "-5");    
+    testDiv("10.0", "2.0", "5");    
+    testDiv("10", "3.0", "3.33333333333333333333");    
+    testDiv("-10", "3.00", "-3.33333333333333333333");    
+    testDiv("10.0", "3.000", "3.33333333333333333333");    
+    testDiv("10.00", "3.0000", "3.33333333333333333333");
+
+    testDiv("123456", "0.00003213", "3842390289.44911297852474323063");    
+
+    testDiv("100.00", "3.0", "33.33333333333333333333");
+    testDiv("1025.25", "1.0", "1025.25");
+    testDiv("1025.25", "2.0", "512.625");
+    testDiv("1025.25", "3.0", "341.75");
+    testDiv("1025.25", "4.0", "256.3125");
+    testDiv("1025.25", "5.0", "205.05");
+    testDiv("1025.25", "6.0", "170.875");
+    testDiv("1025.25", "7.0", "146.46428571428571428571");
+    testDiv("1025.25", "8.0000", "128.15625");
+    testDiv("1025.25", "9.0", "113.91666666666666666667");
+    testDiv("1025.25", "10.00", "102.525");
+    
+    testDiv("1025.25", "0.125", "8202");
+    testDiv("1025.25", "10.125", "101.25925925925925925926");
+    
+    testDiv("1025.25", "0.001", "1025250"); 
+ }
+  
+  public void testAbs(){
+    testAbs("0","0");
+    testAbs("0","+0");
+    testAbs("0","-0");
+    testAbs("12","-12");
+    testAbs("12.753","-12.753");
+    testAbs("12.753","-12.753");
+    testAbs("12.753","012.753");
+    testAbs("12.753","12.753");
+  }
+  
+  public void testNegate(){
+    testNegate("0","0");
+    testNegate("-1","1");
+    testNegate("-1.0","1.0");
+    testNegate("1","-1");
+    testNegate("1.00","-1.00");
+    testNegate("-1.00","01.00");
+  }
+  
+  public void testTypicalOperations(){
+    Decimal price = Decimal.from("125.48");
+    Decimal expectedCost = Decimal.from("627.40");
+    Decimal expectedCostWithTax = Decimal.from("658.77");
+    Decimal factor1 = Decimal.from("10");
+    Decimal expectedProduct1 = Decimal.from("1254.80");
+    Decimal factor2 = Decimal.from("0.125");
+    Decimal expectedProduct2 = Decimal.from("15.685");
+
+    Decimal denom = Decimal.from("0.125");
+    Decimal expectedQuotient = Decimal.from("1003.84");
+
+    Decimal cost = price.times(5);
+    assertTrue(cost.equals(expectedCost));
+    
+    Decimal costWithTax = cost.times(1.05); 
+    assertTrue(costWithTax.eq(expectedCostWithTax));
+    
+    Decimal product = price.times(factor1);
+    assertTrue(product.eq(expectedProduct1));
+    
+    product = price.times(factor2);
+    assertTrue(product.eq(expectedProduct2));
+
+    Decimal quotient = price.div(denom);
+    assertTrue(quotient.eq(expectedQuotient));
+  }
+  
+  public void testToString(){
+    Decimal us1 = Decimal.from("1000.00");
+    assertTrue( us1.toString().equals("1000.00"));
+  }
+  
+  public void testRoundToInteger(){
+    testRound("10.25", "10");
+    testRound("10", "10");
+    testRound("10.45", "10");
+    testRound("10.50", "10");
+    testRound("10.51", "11");
+    testRound("10.52", "11");
+    testRound("10.99", "11");
+    testRound("11.00", "11");
+    testRound("1000000", "1000000");
+    
+    testRound("-10.25", "-10");
+    testRound("-10", "-10");
+    testRound("-10.45", "-10");
+    testRound("-10.50", "-10");
+    testRound("-10.51", "-11");
+    testRound("-10.52", "-11");
+    testRound("-10.99", "-11");
+  }
+  
+  public void testRoundToNDecimals(){
+    testRound("10.25", 0, "10");
+    testRound("10.25", 1, "10.2");
+    testRound("10.25", 2, "10.25");
+    testRound("10.25", 3, "10.250");
+  }
+  
+  public void testRound2(){
+    testRound2("1710.10", "0.05", "1710.10");
+    testRound2("1710.11", "0.05", "1710.10");
+    testRound2("1710.12", "0.05", "1710.10");
+    testRound2("1710.13", "0.05", "1710.15");
+    testRound2("1710.14", "0.05", "1710.15");
+    testRound2("1710.15", "0.05", "1710.15");
+    testRound2("1710.16", "0.05", "1710.15");
+    testRound2("1710.17", "0.05", "1710.15");
+    testRound2("1710.18", "0.05", "1710.20");
+    testRound2("1710.19", "0.05", "1710.20");
+    testRound2("1710.20", "0.05", "1710.20");
+
+    testRound2("1710.101", "0.05", "1710.10");
+    testRound2("1710.1099999", "0.05", "1710.10");
+    testRound2("1710.121", "0.05", "1710.10");
+    testRound2("1710.124", "0.05", "1710.10");
+    testRound2("1710.125", "0.05", "1710.10");
+    
+    testRound2("1710.1250000000001", "0.05", "1710.15");
+    testRound2("1710.126", "0.05", "1710.15");
+    testRound2("1710.129", "0.05", "1710.15");
+    
+    testRound2("1710.20", "100", "1700");
+    testRound2("1710.20", "1000", "2000");
+    testRound2("171256789", "100", "171256800");
+    testRound2("171256789", "1000", "171257000");
+    testRound2("171256789", "1000000", "171000000");
+    
+    //same, but using overloads
+    
+    testRound2("1710.10", 0.05, "1710.10");
+    testRound2("1710.11", 0.05, "1710.10");
+    testRound2("1710.12", 0.05, "1710.10");
+    testRound2("1710.13", 0.05, "1710.15");
+    testRound2("1710.14", 0.05, "1710.15");
+    testRound2("1710.15", 0.05, "1710.15");
+    testRound2("1710.16", 0.05, "1710.15");
+    testRound2("1710.17", 0.05, "1710.15");
+    testRound2("1710.18", 0.05, "1710.20");
+    testRound2("1710.19", 0.05, "1710.20");
+    testRound2("1710.20", 0.05, "1710.20");
+
+    testRound2("1710.101", 0.05, "1710.10");
+    testRound2("1710.1099999", 0.05, "1710.10");
+    testRound2("1710.121", 0.05, "1710.10");
+    testRound2("1710.124", 0.05, "1710.10");
+    testRound2("1710.125", 0.05, "1710.10");
+    
+    testRound2("1710.1250000000001", 0.05, "1710.15");
+    testRound2("1710.126", 0.05, "1710.15");
+    testRound2("1710.129", 0.05, "1710.15");
+    
+    testRound2("1710.20", 100, "1700");
+    testRound2("1710.20", 1000, "2000");
+    testRound2("171256789", 100, "171256800");
+    testRound2("171256789", 1000, "171257000");
+    testRound2("171256789", 1000000, "171000000");
+  }
+  
+  public void testPow(){
+    testPow("1", 1, "1");
+    testPow("1", 0, "1");
+    testPow("1", -1, "1");
+    
+    testPow("0", 0, "1");
+    testPow("0", 1, "0");
+    
+    testPow("10", 0, "1");
+    testPow("10.00", 0, "1");
+    testPow("010.00", 0, "1");
+    testPow("10", 1, "10");
+    testPow("10.00", 1, "10.00");
+    testPow("-10.00", 1, "-10.00");
+    testPow("-10.123", 1, "-10.123");
+    testPow("10", 2, "100");
+    testPow("10.0", 2, "100.0");
+    testPow("5", 2, "25");
+    testPow("5.0", 2, "25.0");
+    testPow("10", -1, "0.1");
+    testPow("10", -2, "0.01");
+    testPow("10", -20, "0.00000000000000000001");
+    testPow("-123456.789", 0, "1");
+    testPow("-123456.789", 1, "-123456.789");
+    
+    testPow("2", 4, "16");
+    testPow("2.000", 4, "16.000");
+    testPow("2.0001", 4, "16.0032002400080001");
+    testPow("2", -4, "0.0625");
+    
+    testPow("1", "0" ,"1");
+    testPow("1", "0.0" ,"1");
+    testPow("1", "2" ,"1");
+    testPow("1", "3" ,"1");
+    testPow("1", "2.0" ,"1");
+    testPow("1", "2.00" ,"1");
+    testPow("1", "-1" ,"1");
+    testPow("1", "0.5" ,"1");
+    
+    testPow("2", "0" ,"1");
+    testPow("2", "0.0" ,"1");
+    testPow("2", "1" ,"2");
+    testPow("2", "1.0" ,"2");
+    testPow("2", "2.0" ,"4");
+    testPow("2", "2.5" ,"5.65685424949238");
+    testPow("2", "-2.5" ,"0.17677669529663687");
+    testPow("25", "0.5" ,"5");
+    testPow("25", "-0.5" ,"0.2");
+    testPow("81", "0.5" ,"9");
+    testPow("81.0000", "0.5" ,"9");
+    
+  }
+  
+  // PRIVATE 
+  
+  private void testCtorSuccess(String aAmount){
+    @SuppressWarnings("unused")
+    Decimal  decimal = Decimal.from(aAmount);
+  }
+  
+  private void testCtorBDSuccess(String aAmount){
+    @SuppressWarnings("unused")
+    Decimal  decimal = new Decimal(new BigDecimal(aAmount));
+  }
+  
+  private void testCtorSuccess(long aAmount){
+    @SuppressWarnings("unused")
+    Decimal decimal = Decimal.from(aAmount);
+  }
+  
+  private void testCtorSuccess(double aAmount){
+    @SuppressWarnings("unused")
+    Decimal decimal = Decimal.from(aAmount);
+  }
+  
+  private static final boolean PASS = true;
+  private static final boolean FAIL = false;
+
+  private void testMoneyCtorFail(String aAmount){
+    BigDecimal amount = aAmount == null ? null : new BigDecimal(aAmount);
+    try {
+      @SuppressWarnings("unused")
+      Decimal  money = new Decimal(amount);
+      fail("Should have failed.");
+    }
+    catch (IllegalArgumentException ex){
+      //expected
+      //log("Problemo : " + ex);
+    }
+  }
+  
+  private void testIsPlusFor(String aAmount, boolean aSucceed){
+    Decimal money = Decimal.from(aAmount);
+    if ( aSucceed ) {
+      assertTrue( money.isPlus() );
+    }
+    else {
+      assertFalse( money.isPlus() );
+    }
+  }
+  
+  private void testIsMinusFor(String aAmount, boolean aSucceed){
+    Decimal money = Decimal.from(aAmount);
+    if ( aSucceed ) {
+      assertTrue( money.isMinus() );
+    }
+    else {
+      assertFalse( money.isMinus() );
+    }
+  }
+  
+  private void testIsZeroFor(String aAmount, boolean aSucceed){
+    Decimal money = Decimal.from(aAmount);
+    if ( aSucceed ) {
+      assertTrue( money.isZero() );
+    }
+    else {
+      assertFalse( money.isZero() );
+    }
+  }
+
+  private void testTimes(String aValue, String aFactor, String aExpectedAnswer){
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    Decimal value = Decimal.from(aValue);
+    Decimal factor = Decimal.from(aFactor);
+    Decimal actual = value.times(factor);
+    if (! actual.equals(expected) ){
+      throw new RuntimeException("Actual " +  actual + " Expected: " + expected);
+    }
+  }
+  
+  private void testDiv(String aTop, String aBottom, String aExpectedAnswer){
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    Decimal top = Decimal.from(aTop);
+    Decimal bottom = Decimal.from(aBottom);
+    Decimal actual = top.div(bottom);
+    /* Uses eq(), not equals()!
+    * Found this case in which the values are the same, but the scales differ.
+    * actual 102525, with scale -1 (that is, add a zero to the end of the integer)
+    * expec  1025250, with scale 0
+    */
+    if (! actual.eq(expected) ){
+      throw new RuntimeException("Actual " +  actual + " Expected: " + expected);
+    }
+  }
+  
+  private void testRound(String aInput, String aExpectedAnswer){
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    Decimal money = Decimal.from(aInput);
+    Decimal calc = money.round();
+    assertTrue(calc.equals(expected));
+    assertTrue(calc.getAmount().scale() == 0);
+  }
+  
+  private void testRound(String aInput, int aNumDecimals, String aExpectedAnswer){
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    Decimal money = Decimal.from(aInput);
+    Decimal calc = money.round(aNumDecimals);
+    assertTrue(calc.equals(expected));
+    assertTrue(calc.getAmount().scale() == aNumDecimals);
+  }
+  
+  private void testRound2(String aInput, String aInterval, String aExpectedAnswer){
+    Decimal input = Decimal.from(aInput);
+    Decimal interval = Decimal.from(aInterval);
+    Decimal actual = input.round2(interval);
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    assertTrue(actual.equals(expected));
+  }
+  
+  private void testRound2(String aInput, long aInterval, String aExpectedAnswer){
+    Decimal input = Decimal.from(aInput);
+    Decimal actual = input.round2(aInterval);
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    assertTrue(actual.equals(expected));
+  }
+  
+  private void testRound2(String aInput, double aInterval, String aExpectedAnswer){
+    Decimal input = Decimal.from(aInput);
+    Decimal actual = input.round2(aInterval);
+    Decimal expected = Decimal.from(aExpectedAnswer);
+    assertTrue(actual.equals(expected));
+  }
+  
+  private void testNumOfDecimals(String aAmount, int aNumDecimals){
+    Decimal money = Decimal.from(aAmount);
+    assertTrue(money.getNumDecimals() == aNumDecimals);
+  }
+  
+  private void testIsInteger(String aValue, boolean aPass){
+    if(aPass){
+      assertTrue(Decimal.from(aValue).isInteger());
+    }
+    else {
+      assertFalse(Decimal.from(aValue).isInteger());
+    }
+  }
+  
+  private void testAmount(String aInput, String aExpectedAnswer){
+    BigDecimal expected = new BigDecimal(aExpectedAnswer);
+    Decimal input = Decimal.from(aInput);
+    assertTrue(input.getAmount().equals(expected));
+  }
+  
+  private void testPlus(String a, String b, String aExpected){
+    Decimal expected = Decimal.from(aExpected);
+    Decimal actual = Decimal.from(a).plus(Decimal.from(b));
+    assertTrue( actual.equals( expected) );
+  }
+  
+  private void testMinus(String a, String b, String aExpected){
+    Decimal expected = Decimal.from(aExpected);
+    Decimal actual = Decimal.from(a).minus(Decimal.from(b));
+    assertTrue( actual.equals( expected) );
+  }
+
+  public void testSum(String aExpected, String... aValues){
+    List<Decimal> values = new ArrayList<Decimal>();
+    for(String value : aValues){
+      values.add(Decimal.from(value));
+    }
+    Decimal expected = Decimal.from(aExpected);
+    Decimal actual = Decimal.sum(values);
+    assertTrue( actual.equals(expected) );
+  }
+  
+  public void testAbs(String aExpected, String aValue){
+    Decimal value = Decimal.from(aValue);
+    Decimal actual = value.abs();
+    Decimal expected = Decimal.from(aExpected);
+    assertTrue( actual.equals(expected) );
+  }
+  
+  public void testNegate(String aExpected, String aValue){
+    Decimal value = Decimal.from(aValue);
+    Decimal actual = value.negate();
+    Decimal expected = Decimal.from(aExpected);
+    assertTrue( actual.equals(expected) );
+  }
+  
+  private void testPow(String aBase, int aExponent, String aExpected){
+    Decimal base = Decimal.from(aBase);
+    Decimal actual = base.pow(aExponent);
+    Decimal expected = Decimal.from(aExpected);
+    if (! actual.eq(expected) ){
+      throw new RuntimeException("Actual " +  actual + " Expected: " + expected);
+    }
+  }
+
+  private void testPow(String aBase, String aExponent, String aExpected){
+    Decimal base = Decimal.from(aBase);
+    Decimal exponent = Decimal.from(aExponent);
+    Decimal actual = base.pow(exponent);
+    Decimal expected = Decimal.from(aExpected);
+    if (! actual.eq(expected) ){
+      throw new RuntimeException("Actual " +  actual + " Expected: " + expected);
+    }
+  }
+  
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTEqualsUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,197 @@
+package hirondelle.web4j.model;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+//import junit.swingui.TestRunner;
+import java.util.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> test cases for {@link ModelUtil}.
+*/
+public final class TESTEqualsUtil extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = { TESTEqualsUtil.class.getName()};
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+
+  /**
+   Canonical form of constructor.
+  */
+  public TESTEqualsUtil( String aName) {
+   super( aName );
+  }
+
+  // TEST CASES //
+  
+  public void testBoolean(){
+    assertTrue( ModelUtil.areEqual(true, true) );
+    assertTrue( ModelUtil.areEqual(false, false) );
+    assertTrue( !ModelUtil.areEqual(false, true) );
+    assertTrue( !ModelUtil.areEqual(true, false) );
+  }
+  
+  public void testChar(){
+    assertTrue( ModelUtil.areEqual('A', 'A') );
+    assertTrue( ! ModelUtil.areEqual('A', 'B') );
+  }
+  
+  public void testByte(){
+    byte zero = 0;
+    byte one = 1;
+    assertTrue( ModelUtil.areEqual(one, one) );
+    assertTrue( ! ModelUtil.areEqual(one, zero) );
+  }
+
+  public void testShort(){
+    short ten = 10;
+    short twenty = 20;
+    assertTrue( ModelUtil.areEqual(ten, ten) );
+    assertTrue( !ModelUtil.areEqual(ten, twenty) );
+  }
+  
+  public void testInt(){
+    int hundred = 100;
+    int thousand = 1000;
+    assertTrue( ModelUtil.areEqual(hundred, hundred) );
+    assertTrue( !ModelUtil.areEqual(hundred, thousand) );
+  }
+  
+  public void testLong(){
+    long hundred = 100;
+    long thousand = 1000;
+    assertTrue( ModelUtil.areEqual(hundred, hundred) );
+    assertTrue( !ModelUtil.areEqual(hundred, thousand) );
+  }
+  
+  public void testFloat(){
+    float hundred = 100.0F;
+    float thousand = 1000.0F;
+    assertTrue( ModelUtil.areEqual(hundred, hundred) );
+    assertTrue( !ModelUtil.areEqual(hundred, thousand) );
+  }
+
+  public void testDouble(){
+    double hundred = 100.0D;
+    double thousand = 1000.0D;
+    assertTrue( ModelUtil.areEqual(hundred, hundred) );
+    assertTrue( !ModelUtil.areEqual(hundred, thousand) );
+  }
+  
+  public void testNullObject(){
+    Object one = null;
+    Object two = null;
+    assertTrue( ModelUtil.areEqual(one, two) );
+    one = new Object();
+    assertTrue( ! ModelUtil.areEqual(one, two) );
+  }
+  
+  public void testStrings(){
+    testObjects("one", "one", Outcome.SUCCESS);
+    testObjects("o", "o", Outcome.SUCCESS);
+    testObjects("", "", Outcome.SUCCESS);
+    testObjects(" ", " ", Outcome.SUCCESS);
+    testObjects("  ",  "  ", Outcome.SUCCESS);
+    testObjects("\u00E9",  "\u00E9", Outcome.SUCCESS);
+    testObjects(" \u00E9",  " \u00E9", Outcome.SUCCESS);
+    testObjects("\u00E9",  "é", Outcome.SUCCESS);
+    
+    testObjects("one", "two", Outcome.FAIL);
+    testObjects("one", "one ", Outcome.FAIL);
+    testObjects("one", " one", Outcome.FAIL);
+    testObjects("", " ", Outcome.FAIL);
+    testObjects("\u00E9",  "é ", Outcome.FAIL);
+    testObjects("\u00E9",  "e", Outcome.FAIL);
+  }
+  
+  public void testIntegers(){
+    Integer one = new Integer(1);
+    Integer two = new Integer(2);
+    assertTrue( ModelUtil.areEqual(one, one) );
+    assertTrue( ! ModelUtil.areEqual(one, two) );
+  }
+  
+  public void testLists(){
+    List<String> bands = new ArrayList<String>();
+    bands.add("French Funk Federation");
+    bands.add("Les Rita Mitsouko");
+    
+    List<String> books = new ArrayList<String>();
+    books.add("L'Education Sentimentale");
+    
+    List<String> books2 = new ArrayList<String>();
+    books2.add("L'Education Sentimentale");
+    
+    List<String> books3 = new ArrayList<String>();
+    books3.add(" L'Education Sentimentale");
+    
+    testObjects(bands, bands, Outcome.SUCCESS);
+    testObjects(books, books, Outcome.SUCCESS);
+    testObjects(books, books2, Outcome.SUCCESS);
+    
+    testObjects(bands, books, Outcome.FAIL);
+    testObjects(books, books3, Outcome.FAIL);
+    testObjects(bands, null, Outcome.FAIL);
+  }
+  
+  public void testArrays(){
+    String[] a = {"1"};
+    String[] a2 = {"1"};
+    String[] b = {"1 "};
+    String[] c = {"1", "2"};
+    String[] c2 = {"1", "2"};
+    String[] d = {"1", "2 "};
+    testObjects(a, a, Outcome.SUCCESS);
+    testObjects(a, a2, Outcome.SUCCESS);
+    testObjects(a, b, Outcome.FAIL);
+    testObjects(c, c2, Outcome.SUCCESS);
+    testObjects(null, null, Outcome.SUCCESS);
+    
+    testObjects(c, d, Outcome.FAIL);
+    testObjects(c, null, Outcome.FAIL);
+    
+    //nested arrays
+    Object[] n1 = {a, c};
+    Object[] n2 = {a2, c2};
+    Object[] n3 = {a2, d};
+    testObjects(n1, n2, Outcome.SUCCESS);
+    testObjects(n1, n3, Outcome.FAIL);
+  }
+  
+  // FIXTURE //
+
+  /**
+   Build a fixture of test objects. This method is called anew for
+   each test, such that the tests will always start with the same
+   set of test objects, and the execution of one test will not interfere
+   with the execution of another.
+  */
+   protected void setUp(){
+   }
+
+   /**
+    Re-set test objects.
+   */
+   protected void tearDown() {
+   }
+
+   // PRIVATE  //
+   
+   private enum Outcome {SUCCESS, FAIL};
+   
+   private void testObjects(Object a, Object b, Outcome outcome){
+     if (Outcome.SUCCESS == outcome){
+       assertTrue(ModelUtil.areEqual(a, b));
+     }
+     else {
+       assertFalse(ModelUtil.areEqual(a, b));
+     }
+   }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTHashCodeUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,154 @@
+package hirondelle.web4j.model;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+import java.util.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> test cases for {@link ModelUtil}.
+
+ <P>These tests are rather odd, since the hashCode contract is rather odd: it 
+ doesn't specify the precise values to be returned. Hence, these test cases 
+ have knowledge of implementation details which are usually ignored by tests.
+*/
+public final class TESTHashCodeUtil extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = { TESTHashCodeUtil.class.getName()};
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+
+  /**
+   Canonical form of constructor.
+  */
+  public TESTHashCodeUtil( String aName) {
+   super( aName );
+  }
+
+  // TEST CASES //
+
+  public void testBoolean(){
+    int value = ModelUtil.hash(fSEED, true);
+    assertTrue(value == (fODD_PRIME*fSEED + 1));
+    
+    value = ModelUtil.hash(fSEED, false);
+    assertTrue(value == (fODD_PRIME*fSEED + 0));
+  }
+  
+  public void testChar(){
+    char val = 'A';
+    int value = ModelUtil.hash(fSEED, val);
+    assertTrue(value == (fODD_PRIME*fSEED + val));
+  }
+  
+  public void testInt(){
+    int val = 1003;
+    int value = ModelUtil.hash(fSEED,val);
+    assertTrue( value == (fODD_PRIME*fSEED + val));
+  }
+
+  public void testNullObject(){
+    Object item = null;
+    int value = ModelUtil.hash(fSEED, item);
+    assertTrue( value == fODD_PRIME*fSEED + 0);
+  }
+  
+  public void testString(){
+    String blah = "blah";
+    int blahHashCode = blah.hashCode();
+    int value = ModelUtil.hash(fSEED, blah);
+    assertTrue( value == (fODD_PRIME*fSEED + blahHashCode));
+  }
+  
+  public void testList(){
+    List<String> girlfriends = new ArrayList<String>();
+    girlfriends.add("Gilberte");
+    girlfriends.add("Albertine");
+    int girlfriendsHashCode = girlfriends.hashCode();
+    
+    int value = ModelUtil.hash(fSEED, girlfriends);
+    assertTrue(value == (fODD_PRIME*fSEED + girlfriendsHashCode));
+  }
+  
+  public void testNullArray(){
+    String[] stuff = null;
+    int value = ModelUtil.hash(fSEED, stuff);
+    assertTrue(value == fODD_PRIME*fSEED + 0);
+  }
+  
+  public void testStringArray(){
+    String gilberte = "Gilberte";
+    String albertine = "Albertine";
+    String[] names = new String[2];
+    names[0] = gilberte;
+    names[1] = albertine;
+    
+    int expectedVal = fODD_PRIME*fSEED + gilberte.hashCode();
+    expectedVal = fODD_PRIME*expectedVal + albertine.hashCode();
+    
+    int value = ModelUtil.hash(fSEED, names);
+    assertTrue(value == expectedVal);
+  }
+  
+  public void testIntArray(){
+    int[] ages = new int[2];
+    ages[0] = 41;
+    ages[1] = 57;
+    int expectedVal = fODD_PRIME*fSEED + 41;
+    expectedVal = fODD_PRIME*expectedVal + 57;
+    
+    int value = ModelUtil.hash(fSEED, ages);
+    assertTrue(value == expectedVal);
+  }
+  
+  public void testArrayOfNulls(){
+    Object[] stuff = new Object[2];
+    //all elements are now initialized to null
+    int expectedVal = fODD_PRIME*fSEED + 0;
+    expectedVal = fODD_PRIME*expectedVal + 0;
+    
+    int value = ModelUtil.hash(fSEED, stuff);
+    assertTrue(value == expectedVal);
+  }
+  
+  public void testNestedArray(){
+    Object[] stuff = new Object[2];
+    Object[] otherStuff = new Object[2];
+    
+    otherStuff[0] = "otherthing1";
+    otherStuff[1] = "otherthing2";
+    stuff[0] = "thing";
+    stuff[1] = otherStuff;
+    //just makes sure it doesn't blow up, or go into a loop
+    int value = ModelUtil.hash(fSEED, stuff);
+    assertTrue(value == 1757769324);
+  }
+
+  // FIXTURE //
+
+  /**
+   Build a fixture of test objects. This method is called anew for
+   each test, such that the tests will always start with the same
+   set of test objects, and the execution of one test will not interfere
+   with the execution of another.
+  */
+   protected void setUp(){
+   }
+
+   /**
+    Re-set test objects.
+   */
+   protected void tearDown() {
+   }
+
+   // PRIVATE  //
+   private int fSEED = ModelUtil.HASH_SEED;
+   private static final int fODD_PRIME = 37;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTMessageSerialization.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,80 @@
+package hirondelle.web4j.model;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInput;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutput;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+
+import junit.framework.TestCase;
+
+/**
+ Test several classes that implement end user messages.
+ <P>This test class is unusual, in that it is meant for several target classes, not one.
+ */
+public final class TESTMessageSerialization  extends TestCase {
+
+  /** Run the test cases.  */
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTMessageSerialization.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTMessageSerialization( String aName) {
+    super(aName);
+  }
+  
+  
+  public void testAppResponseMessage() throws FileNotFoundException, IOException, ClassNotFoundException  {
+    AppResponseMessage arm = AppResponseMessage.forSimple("Test");
+    testSerializable(arm);
+    arm = AppResponseMessage.forCompound("Test", "param1");
+    testSerializable(arm);
+    arm = AppResponseMessage.forCompound("Test", "param1", new Integer(56));
+    testSerializable(arm);
+    //fails, since StringTokenizer is not Serializable
+    //arm = AppResponseMessage.forCompound("Test", "param1", new Integer(56) , new StringTokenizer(""));
+    //testSerializable(arm);
+  }
+  
+  public void testMessageListImpl() throws FileNotFoundException, IOException, ClassNotFoundException {
+    MessageListImpl mli = new MessageListImpl();
+    testSerializable(mli);
+  }
+  
+  public void testAppException() throws FileNotFoundException, IOException, ClassNotFoundException {
+    AppException appEx = new AppException("Blah", new IllegalArgumentException());
+    testSerializable(appEx);
+  }
+  
+  // PRIVATE
+  
+  private  <T> void testSerializable(T aThing) throws FileNotFoundException, IOException, ClassNotFoundException  {
+    OutputStream file = new FileOutputStream( "C:\\Temp\\test.ser" );
+    OutputStream buffer = new BufferedOutputStream( file );
+    ObjectOutput output = new ObjectOutputStream( buffer );
+    try{
+      output.writeObject(aThing);
+    }
+    finally{
+      output.close();
+    }
+
+    InputStream inFile = new FileInputStream( "C:\\Temp\\test.ser" );
+    InputStream inBuffer = new BufferedInputStream( inFile );
+    ObjectInput input = new ObjectInputStream ( inBuffer );
+    try{
+      T recovered = (T)input.readObject();
+    }
+    finally{
+      input.close();
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/TESTPerformanceSnapshot.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,100 @@
+package hirondelle.web4j.model;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.webmaster.PerformanceSnapshot;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> tests for the  
+ {@link PerformanceSnapshot} class.
+
+ <P>Testing the 'end time', and its correct rollover to the next 
+ time frame, is left out here.
+*/
+public final class TESTPerformanceSnapshot extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = { TESTPerformanceSnapshot.class.getName() };
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+
+  /**
+   Canonical form of constructor.
+  */
+  public TESTPerformanceSnapshot( String aName) {
+    super(aName);
+  }
+
+  // TEST CASES //
+  
+  public void testCtor(){
+    assertState(fSnapshot, EXPOSURE_TIME.intValue(), 0, 0, 0, Consts.EMPTY_STRING);
+  }
+
+  public void testAddResponses() {
+    PerformanceSnapshot snapshot1 = fSnapshot.addResponseTime(1000, URL);
+    assertState(snapshot1, EXPOSURE_TIME.intValue(), 1, 1000, 1000, URL);
+    
+    PerformanceSnapshot snapshot2 = snapshot1.addResponseTime(3000, URL);
+    assertState(snapshot2, EXPOSURE_TIME.intValue(), 2, 3000, 2000, URL);
+    
+    PerformanceSnapshot snapshot3 = snapshot2.addResponseTime(1000, URL);
+    assertState(snapshot3, EXPOSURE_TIME.intValue(), 3, 3000, 1666, URL);
+    
+    PerformanceSnapshot snapshot4 = snapshot3.addResponseTime(6000, MAX_URL);
+    assertState(snapshot4, EXPOSURE_TIME.intValue(), 4, 6000, 2749, MAX_URL);
+  }
+
+  // FIXTURE //
+
+  /**
+   Build a fixture of test objects. This method is called anew for
+   each test, such that the tests will always start with the same
+   set of test objects, and the execution of one test will not interfere
+   with the execution of another.
+  */
+  protected void setUp(){
+    fSnapshot = new PerformanceSnapshot(EXPOSURE_TIME);
+  }
+
+  /**
+   Re-set test objects.
+  */
+  protected void tearDown() {
+  }
+
+  // PRIVATE  //
+  private PerformanceSnapshot fSnapshot;
+  private static final Integer EXPOSURE_TIME = new Integer(10);
+  private static final String URL = "std-url";
+  private static final String MAX_URL = "max-url";
+   
+  private void log(Object aThing){
+    System.out.println(aThing.toString());
+  }
+   
+  private void assertState(
+    PerformanceSnapshot aSnapshot, 
+    int aExposureTime, 
+    int aNumRequests, 
+    int aMaxResponseTime, 
+    int aAvgResponseTime, 
+    String aMaxURL
+  ){
+    //log(aSnapshot);
+    assertTrue(aSnapshot.getExposureTime().intValue() == aExposureTime);
+    assertTrue(aSnapshot.getNumRequests().intValue() == aNumRequests);
+    assertTrue(aSnapshot.getMaxResponseTime().intValue() == aMaxResponseTime);
+    assertTrue(aSnapshot.getAvgResponseTime().intValue() == aAvgResponseTime);
+    assertTrue(aSnapshot.getURLWithMaxResponseTime().equals(aMaxURL));
+   }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/ToStringUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,342 @@
+package hirondelle.web4j.model;
+
+import java.util.*;
+import java.util.logging.*;
+import java.util.regex.*;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.InvocationTargetException;
+
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+import hirondelle.web4j.util.Util;
+
+/**
+ Implements the <tt>toString</tt> method for some common cases.
+
+ <P>This class is intended only for cases where <tt>toString</tt> is used in
+ an informal manner (usually for logging and stack traces). It is especially
+ suited for <tt>public</tt> classes which model domain objects.
+
+ Here is an example of a return value of the {@link #getText} method :
+ <PRE>
+hirondelle.web4j.model.MyUser {
+LoginName: Bob
+LoginPassword: ****
+EmailAddress: bob@blah.com
+StarRating: 1
+FavoriteTheory: Quantum Chromodynamics
+SendCard: true
+Age: 42
+DesiredSalary: 42000
+BirthDate: Sat Feb 26 13:45:43 EST 2005
+}
+ </PRE>
+ (Previous versions of this classes used indentation within the braces. That has 
+ been removed, since it displays poorly when nesting occurs.)
+
+ <P>Here are two more examples, using classes taken from the JDK :
+ <PRE>
+java.util.StringTokenizer {
+nextElement: This
+hasMoreElements: true
+countTokens: 3
+nextToken: is
+hasMoreTokens: true
+}
+
+java.util.ArrayList {
+size: 3
+toArray: [blah, blah, blah]
+isEmpty: false
+}
+ </PRE>
+
+ There are two use cases for this class. The typical use case is :
+ <PRE>
+  public String toString() {
+    return ToStringUtil.getText(this);
+  }
+ </PRE>
+ 
+ <span class="highlight">However, there is a case where this typical style can
+ fail catastrophically</span> : when two objects reference each other, and each 
+ has <tt>toString</tt> implemented as above, then the program will loop 
+ indefinitely!
+
+ <P>As a remedy for this problem, the following variation is provided :
+ <PRE>
+  public String toString() {
+    return ToStringUtil.getTextAvoidCyclicRefs(this, Product.class, "getId");
+  }
+ </PRE>
+ Here, the usual behavior is overridden for any method 
+ which returns a <tt>Product</tt> : instead of calling <tt>Product.toString</tt>, 
+ the return value of <tt>Product.getId()</tt> is used to textually represent 
+ the object.
+*/
+final class ToStringUtil {
+
+  /**
+   Return an informal textual description of an object. 
+   <P>It is highly recommened that the caller <em>not</em> rely on details 
+   of the returned <tt>String</tt>. See class description for examples of return 
+   values.
+  
+   <P><span class="highlight">WARNING</span>: If two classes have cyclic references 
+   (that is, each has a reference to the other), then infinite looping will result 
+   if <em>both</em> call this method! To avoid this problem, use <tt>getText</tt>
+   for one of the classes, and {@link #getTextAvoidCyclicRefs} for the other class.
+  
+   <P>The only items which contribute to the result are the class name, and all
+   no-argument <tt>public</tt> methods which return a value. As well, methods
+   defined by the <tt>Object</tt> class, and factory methods which return an 
+   <tt>Object</tt> of the native class ("<tt>getInstance</tt>" methods) do not contribute.
+    
+   <P>Items are converted to a <tt>String</tt> simply by calling their 
+   <tt>toString method</tt>, with these exceptions : 
+   <ul>
+   <li>{@link Util#getArrayAsString(Object)} is used for arrays
+   <li>a method whose name contain the text <tt>"password"</tt> (not case-sensitive) have 
+   their return values hard-coded to <tt>"****"</tt>. 
+   </ul>
+  
+   <P>If the method name follows the pattern <tt>getXXX</tt>, then the word 'get'
+   is removed from the presented result.
+  
+   @param aObject the object for which a <tt>toString</tt> result is required.
+  */
+  static String getText(Object aObject) {
+    return getTextAvoidCyclicRefs(aObject, null, null);
+  }
+
+  /**
+   As in {@link #getText}, but, for return values which are instances of 
+   <tt>aSpecialClass</tt>, then call <tt>aMethodName</tt> instead of <tt>toString</tt>.
+  
+   <P> If <tt>aSpecialClass</tt> and <tt>aMethodName</tt> are <tt>null</tt>, then the 
+   behavior is exactly the same as calling {@link #getText}.
+  */
+  static String getTextAvoidCyclicRefs(Object aObject, Class aSpecialClass, String aMethodName) {
+    StringBuilder result = new StringBuilder();
+    addStartLine(aObject, result);
+
+    Method[] methods =  aObject.getClass().getDeclaredMethods();
+    for(Method method: methods){
+      if ( isContributingMethod(method, aObject.getClass()) ){
+        addLineForGetXXXMethod(aObject, method, result, aSpecialClass, aMethodName);
+      }
+    }
+
+    addEndLine(result);
+    return result.toString();
+  }
+  
+  // PRIVATE //
+
+  /*
+   Names of methods in the <tt>Object</tt> class which are ignored.
+  */
+  private static final String fGET_CLASS = "getClass";
+  private static final String fCLONE = "clone";
+  private static final String fHASH_CODE = "hashCode";
+  private static final String fTO_STRING = "toString";
+  
+  private static final String fGET = "get";
+  private static final Object[] fNO_ARGS = new Object[0];
+  private static final Class[] fNO_PARAMS = new Class[0];
+  /*
+   Previous versions of this class indented the data within a block. 
+   That style breaks when one object references another. The indentation
+   has been removed, but this variable has been retained, since others might 
+   prefer the indentation anyway.
+  */
+  private static final String fINDENT = EMPTY_STRING;
+  private static final String fAVOID_CIRCULAR_REFERENCES = "[circular reference]";
+  private static final Logger fLogger = Util.getLogger(ToStringUtil.class);
+
+  private static Pattern PASSWORD_PATTERN = Pattern.compile("password", Pattern.CASE_INSENSITIVE);
+  private static String HIDDEN_PASSWORD_VALUE = "****";
+
+  //prevent construction by the caller
+  private ToStringUtil() {
+    //empty
+  }
+  
+  private static void addStartLine(Object aObject, StringBuilder aResult){
+    aResult.append( aObject.getClass().getName() );
+    aResult.append(" {");
+    aResult.append(NEW_LINE);
+  }
+
+  private static void addEndLine(StringBuilder aResult){
+    aResult.append("}");
+    aResult.append(NEW_LINE);
+  }
+
+  /**
+   Return <tt>true</tt> only if <tt>aMethod</tt> is public, takes no args, 
+   returns a value whose class is not the native class, is not a method of 
+   <tt>Object</tt>.
+  */
+  private static boolean isContributingMethod(Method aMethod, Class aNativeClass){
+    boolean isPublic = Modifier.isPublic( aMethod.getModifiers() );
+    boolean hasNoArguments = aMethod.getParameterTypes().length == 0;
+    boolean hasReturnValue = aMethod.getReturnType() != Void.TYPE;
+    boolean returnsNativeObject = aMethod.getReturnType() == aNativeClass;
+    boolean isMethodOfObjectClass = 
+      aMethod.getName().equals(fCLONE) || 
+      aMethod.getName().equals(fGET_CLASS) || 
+      aMethod.getName().equals(fHASH_CODE) || 
+      aMethod.getName().equals(fTO_STRING)
+   ;
+    return 
+      isPublic && 
+      hasNoArguments && 
+      hasReturnValue && 
+      ! isMethodOfObjectClass && 
+      ! returnsNativeObject;
+  }
+
+  private static void addLineForGetXXXMethod(
+    Object aObject,
+    Method aMethod,
+    StringBuilder aResult,
+    Class aCircularRefClass, 
+    String aCircularRefMethodName
+  ){
+    aResult.append(fINDENT);
+    aResult.append( getMethodNameMinusGet(aMethod) );
+    aResult.append(": ");
+    Object returnValue = getMethodReturnValue(aObject, aMethod);
+    if ( returnValue != null && returnValue.getClass().isArray() ) {
+      aResult.append( Util.getArrayAsString(returnValue) );
+    }
+    else {
+      if (aCircularRefClass == null) {
+        aResult.append( returnValue );
+      }
+      else {
+        if (aCircularRefClass == returnValue.getClass()) {
+          Method method = getMethodFromName(aCircularRefClass, aCircularRefMethodName);
+          if ( isContributingMethod(method, aCircularRefClass)){
+            returnValue = getMethodReturnValue(returnValue, method);
+            aResult.append( returnValue );
+          }
+          else {
+            aResult.append(fAVOID_CIRCULAR_REFERENCES);
+          }
+        }
+      }
+    }
+    aResult.append( NEW_LINE );
+  }
+
+  private static String getMethodNameMinusGet(Method aMethod){
+    String result = aMethod.getName();
+    if (result.startsWith(fGET) ) {
+      result = result.substring(fGET.length());
+    }
+    return result;
+  }
+
+  /** Return value is possibly-null.  */
+  private static Object getMethodReturnValue(Object aObject, Method aMethod){
+    Object result = null;
+    try {
+      result = aMethod.invoke(aObject, fNO_ARGS);
+    }
+    catch (IllegalAccessException ex){
+      vomit(aObject, aMethod);
+    }
+    catch (InvocationTargetException ex){
+      vomit(aObject, aMethod);
+    }
+    result = dontShowPasswords(result, aMethod);
+    return result;
+  }
+  
+  private static Method getMethodFromName(Class aSpecialClass, String aMethodName){
+    Method result = null;
+    try {
+      result = aSpecialClass.getMethod(aMethodName, fNO_PARAMS);
+    }
+    catch ( NoSuchMethodException ex){
+      vomit(aSpecialClass, aMethodName);
+    }
+    return result;
+  }
+  
+
+  private static void vomit(Object aObject, Method aMethod){
+    fLogger.severe(
+      "Cannot get return value using reflection. Class: " +
+      aObject.getClass().getName() +
+      " Method: " +
+      aMethod.getName()
+    );
+  }
+  
+  private static void vomit(Class aSpecialClass, String aMethodName){
+    fLogger.severe(
+      "Reflection fails to get no-arg method named: " +
+      Util.quote(aMethodName) +
+      " for class: " +
+      aSpecialClass.getName()
+    );
+  }
+  
+  private static Object dontShowPasswords(Object aReturnValue, Method aMethod){
+    Object result = aReturnValue;
+    Matcher matcher = PASSWORD_PATTERN.matcher(aMethod.getName());
+    if ( matcher.find()) {
+      result = HIDDEN_PASSWORD_VALUE;
+    }
+    return result;
+  }
+  
+  /*
+   Two informal classes with cyclic references, used for testing. 
+  */
+  private static final class Ping {
+    public void setPong(Pong aPong){fPong = aPong; }
+    public Pong getPong(){ return fPong; }
+    public Integer getId() { return new Integer(123); }
+    public String getUserPassword(){ return "blah"; }
+    public String toString() {
+      return getText(this);
+    }
+    private Pong fPong;
+  }
+  private static final class Pong {
+    public void setPing(Ping aPing){ fPing = aPing; }
+    public Ping getPing() { return fPing; }
+    public String toString() {
+      return getTextAvoidCyclicRefs(this, Ping.class, "getId");
+      //to see the infinite looping, use this instead :
+      //return getText(this);
+    }
+    private Ping fPing;
+  }
+  
+  /**
+   Informal test harness.
+  */
+  public static void main (String... args) {
+    List<String> list = new ArrayList<String>();
+    list.add("blah");
+    list.add("blah");
+    list.add("blah");
+    System.out.println( ToStringUtil.getText(list) );
+    
+    StringTokenizer parser = new StringTokenizer("This is the end.");
+    System.out.println( ToStringUtil.getText(parser) );
+    
+    Ping ping = new Ping();
+    Pong pong = new Pong();
+    ping.setPong(pong);
+    pong.setPing(ping);
+    System.out.println( ping );
+    System.out.println( pong );
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/Validator.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,27 @@
+package hirondelle.web4j.model;
+
+/**
+ Validate a field in a Model Object.
+ 
+ <P>Model Objects are <em>not</em> required to use this interface when validating data, but it is  
+ often convenient to do so. In combination with {@link Check}, this interface is meant to reduce 
+ code repetition related to validation. 
+ 
+ <P>This interface is appropriate only for checks on a single field. 
+ 
+ <P>Please see {@link Check} for more information, and for some useful implementations.
+*/
+public interface Validator {
+
+  /**
+   Return <tt>true</tt> only if <tt>aObject</tt> passes this validation.
+   
+   <P><tt>aObject</tt> is a field in some Model Object, being validated in a constructor. If the 
+   field is a primitive value (such as <tt>int</tt>), then it must be converted by the caller into 
+   a corresponding wrapper object (such as {@link Integer}).
+   
+   @param aObject may be <tt>null</tt>. 
+  */
+  boolean isValid(Object aObject);
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/model/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,65 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Model</title>
+</head>
+<body>
+Tools for building business domain <a href="http://www.javapractices.com/Topic187.cjp">Model Objects</a> (MOs).
+
+<P>The important things about Model Objects in WEB4J are :
+<ul>
+ <li>Model Objects are <a href="http://www.javapractices.com/Topic29.cjp">immutable</a> (highly recommended, but not required)
+ <li>Model Objects implement their own <a href="http://www.javapractices.com/Topic209.cjp">validation logic</a>
+ <li>in general, Model Objects are not dumb data carriers. 
+ <li>if desired, Model Objects can easily avoid the <a href="http://www.javapractices.com/Topic84.cjp">Java Beans anti-pattern</a>. 
+</ul> 
+
+<h3>Restrictions on Model Objects</h3>
+These are the restrictions on Model Objects in WEB4J :
+<ul>
+ <li>they must be <tt>public</tt> classes
+ <li>the constructors must be <tt>public</tt>, and always take one or more arguments
+ <li>the constructors <em>perform all validation</em>, and must throw 
+ {@link hirondelle.web4j.model.ModelCtorException} if any problems occur
+ <li>to be easily used with WEB4J utility classes, the constructor arguments must belong 
+ to a set of common building block classes (<tt>Integer</tt>, <tt>BigDecimal</tt>, and so on)
+</ul> 
+
+<P>These items can be added to Model Objects, if desired, but they are never used by WEB4J :
+<ul> 
+ <li>no-argument constructors 
+ <li><tt>setXXX</tt> methods
+</ul>
+
+<h3>Validation</h3>
+Validation in Model Objects is always in the constructor, and can usually be implemented using the 
+{@link hirondelle.web4j.model.Check} class, which provides commonly needed implementions of the {@link hirondelle.web4j.model.Validator} interface.
+ When a problem occurs, an error message is added to {@link hirondelle.web4j.model.ModelCtorException}, for 
+ later presentation to the user.
+
+<h3>Object Methods</h3>
+<P>It is highly recommended that all Model Objects override <tt>equals</tt>, <tt>hashCode</tt>, and 
+<tt>toString</tt>. ({@link hirondelle.web4j.model.ModelUtil} can help you implement these methods.)
+
+<h3>Building Model Objects</h3>
+In general, there are two sources for the data attached to Model Objects : manual entry by the end user, and the database. 
+It is interesting that both of these sources should be treated as <em>unreliable</em>. Incoming HTTP request 
+parameters are not constrained, and must always be aggressively validated on the server. Databases are independent 
+servers, and cannot be assumed to be 'owned' by the application. Other applications and processes can interact 
+with the data as well. Thus, an application cannot, in general, make any assumptions regarding the 
+content of the database. 
+
+<P>Thus, a Model Object must allow for all possible input when creating objects from both these sources.
+
+<P>WEB4J has two main tools for this task :
+<ul>
+<li>{@link hirondelle.web4j.model.ModelFromRequest}, for building Model Objects from underlying request parameters
+<li>{@link hirondelle.web4j.database.Db}, for building Model Objects from an underlying <tt>ResultSet</tt>
+</ul>
+
+<P>Both of these tools are simple to use because they use effective <em>ordering</em> conventions for data.
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,16 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>WEB4J</title>
+</head>
+<body>
+Important items which apply to the whole application.
+
+<p>The top of a package hierarchy (here, the <tt>hirondelle.web4j</tt>
+package) is a good place for such items, since it is easy to find, and
+is often the first package examined by the reader.
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/Config.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,597 @@
+package hirondelle.web4j.readconfig;
+
+import hirondelle.web4j.database.ConnectionSource;
+import hirondelle.web4j.database.TxIsolationLevel;
+import hirondelle.web4j.util.Util;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringTokenizer;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+/**
+ (UNPUBLISHED) Access the config values needed by the framework.
+ 
+ <P>Assumptions:
+ <ul> 
+  <li><tt>init</tt> is called only once, upon startup, when the system is in single-threaded mode
+  <li>there is no re-init or reload mechanism
+  <li>after <tt>init</tt> is called, the data will never change, and this class is safe to be used 
+  in a multi-threaded environment
+  <li>all data returned by the getXXX methods is immutable, or a defensive copy is passed
+ </ul>  
+ 
+ <P>The typical user of this class is simple:
+<pre>Config config = new Config();
+  String blah = config.getBlah;
+</pre>
+ The caller usually has no need to store the data in their own static private field.
+ 
+  <P>The hard-coding in this class is desirable and necessary. Note that it makes no 
+  sense to add config items at runtime, since no code will be able to talk to it. 
+  
+  <P>Default values are defined internally by this class. Callers performing tests 
+  may use default values, or they may override any number of default values by calling <tt>init</tt>.
+  There is no method to revert to the original defaults.
+  
+  <P>
+   When an app as a whole is reloaded, it's a full reload, with new classes/classloaders.
+   When the Deployment Descriptor (DD) is touched, often the app reloads partially, with the same classes/classloaders, 
+   but a new Servlet object. (This depends on the Container.) 
+   Thus, static blocks are not re-executed in that case.
+    
+   <P>This means that classes should not copy/store data from Config in a static block, since that static block
+   will not see partial reloads (tweaks to the DD). This is a bit tricky.
+   As well, any 'first-use-initialization' logic in class X cannot talk to the config, since it will not be executed on a partial reload.
+   
+   <P>If all the parsing/caching is done by this class, then callers don't have to worry about partial reloads, and whether or 
+   not they are out of date. In addition, if the syntax is the same, then the parsing is centralized here.
+*/
+public final class Config {
+
+  /** Value - {@value} - special value for some settings, denoting an absent value. */
+  public static final String NONE = "NONE";
+  
+   /* NOTE: Most of this code was generated by a one-off script. */
+  
+  /**
+   Must be called once and only once, upon startup, when the system is in 
+   single-threaded mode. Throws an exception if a problem with the syntax is detected.
+   
+   <P>Note than this takes a generic map, not <tt>ServletContext</tt>. That's important, 
+   since it allows this Config to be used in any context, not just a servlet container.
+   <P>The key is case-sensitive.
+  */
+  public static void init(Map<String, String> aKeyValuePairs){
+    initAllItems(aKeyValuePairs);
+  }
+
+  /** 
+   Confirm that database settings in <tt>web.xml</tt> are known to {@link ConnectionSource}.
+   Order dependency: this method is called after <tt>init</tt>, since it needs to know the database names, 
+   which in turn requires a call to BuildImpl.init.   
+  */
+  public static void checkDbNamesInSettings(Set<String> aValidDbNames){
+    Set<String> namesInSettings = new LinkedHashSet<String>(); 
+    
+    namesInSettings.addAll(fHasAutoGeneratedKeys.getDbNames());
+    namesInSettings.addAll(fSqlFetcherDefaultTxIsolationLevel.getDbNames());
+    namesInSettings.addAll(fSqlEditorDefaultTxIsolationLevel.getDbNames());
+    namesInSettings.addAll(fErrorCodeForDuplicateKey.getDbNames());
+    namesInSettings.addAll(fErrorCodeForForeignKey.getDbNames());
+    namesInSettings.addAll(fMaxRows.getDbNames());
+    namesInSettings.addAll(fFetchSize.getDbNames());
+    namesInSettings.addAll(fIsSQLPrecompilationAttempted.getDbNames());
+    namesInSettings.addAll(fDateTimeFormatForPassingParamsToDb.getDbNames());
+    
+    Set<String> unknownNames = new LinkedHashSet<String>();
+    for(String name: namesInSettings){
+      if(Util.textHasContent(name) && ! aValidDbNames.contains(name)){
+        unknownNames.add(name);
+      }
+    }
+    
+    if(! unknownNames.isEmpty() ) {
+       throw new IllegalArgumentException("Web.xml contains settings that refer to databases that are not known to your implementation of ConnectionSource.getDatabaseNames(). Please check spelling and case for : " + Util.logOnePerLine(unknownNames));
+    }
+  }
+  
+  /** Intended for logging and trouble tickets. */
+  public Map<String, String> getRawMap(){
+    return Collections.unmodifiableMap(fMap);
+  }
+  
+  public Boolean isTesting(){
+    return fIsTesting;
+  }
+  
+  /** Value is present, and is not 'NONE'. */
+  public boolean isEnabled(String aValue){
+    return Util.textHasContent(aValue) && ! NONE.equalsIgnoreCase(aValue);
+  }
+  
+  public String getWebmaster(){
+    return fWebmaster;
+  }
+  
+  public String getImplicitMappingRemoveBasePackage(){
+    return fImplicitMappingRemoveBasePackage;
+  }
+  
+  public String getMailServerConfig(){
+    return fMailServerConfig;
+  }
+
+  public String getMailServerCredentials(){
+    return fMailServerCredentials;
+  }
+
+  public String getLoggingDirectory (){
+    return fLoggingDirectory;
+  }
+
+  public List<String> getLoggingLevels(){
+    return fLoggingLevels;
+  }
+
+  public List<String> getTroubleTicketMailingList(){
+    return Collections.unmodifiableList(fTroubleTicketMailingList);
+  }
+  
+  public Long getMinimumIntervalBetweenTroubleTickets(){
+    return fMinimumIntervalBetweenTroubleTickets;
+  }
+
+  /** Returns the value in nanos, not seconds. */
+  public Long getPoorPerformanceThreshold(){
+    long NANOSECONDS_PER_SECOND = 1000 *1000 * 1000;
+    return fPoorPerformanceThreshold  * NANOSECONDS_PER_SECOND;
+  }
+
+  public Long getMaxHttpRequestSize(){
+    return fMaxHttpRequestSize;
+  }
+
+  public Long getMaxFileUploadRequestSize(){
+    return fMaxFileUploadRequestSize;
+  }
+
+  public Long getMaxRequestParamValueSize(){
+    return fMaxRequestParamValueSize;
+  }
+
+  public Boolean getSpamDetectionInFirewall(){
+    return fSpamDetectionInFirewall;
+  }
+
+  public Boolean getFullyValidateFileUploads(){
+    return fFullyValidateFileUploads;
+  }
+
+  public Boolean getAllowStringAsBuildingBlock(){
+    return fAllowStringAsBuildingBlock;
+  }
+
+  public Map<String, List<String>> getUntrustedProxyForUserId(){
+    return fUntrustedProxyForUserId;
+  }
+
+  public String getCharacterEncoding(){
+    return fCharacterEncoding;
+  }
+
+  public Locale getDefaultLocale(){
+    return fDefaultLocale;
+  }
+
+  public TimeZone getDefaultUserTimeZone(){
+    return TimeZone.getTimeZone(fDefaultUserTimeZone.getID()); //mutable class
+  }
+
+  public Boolean hasTimeZoneHint(){ 
+    return fTimeZoneHint != null;
+  }
+  
+  public Calendar getTimeZoneHint(){
+    Calendar result = null;
+    if( hasTimeZoneHint() ) {
+      result = Calendar.getInstance(fTimeZoneHint);
+    }
+    return result;
+  }
+
+  //this could be deleted ? - no callers
+  public String getDecimalSeparator(){
+    return fDecimalSeparator;
+  }
+  
+  public Pattern getDecimalInputPattern(){
+    return fDecimalInputPattern; //derived from decimal separator
+  }
+
+  public String getBigDecimalDisplayFormat(){
+    return fBigDecimalDisplayFormat;
+  }
+
+  public String getBooleanTrueDisplayFormat(){
+    return fBooleanTrueDisplayFormat;
+  }
+
+  public String getBooleanFalseDisplayFormat(){
+    return fBooleanFalseDisplayFormat;
+  }
+
+  public String getEmptyOrNullDisplayFormat(){
+    return fEmptyOrNullDisplayFormat;
+  }
+
+  public String getIntegerDisplayFormat(){
+    return fIntegerDisplayFormat;
+  }
+
+  public String getIgnorableParamValue(){
+    return fIgnorableParamValue;
+  }
+
+  public Boolean getIsSQLPrecompilationAttempted(String aDbName){
+    return asBoolean(fIsSQLPrecompilationAttempted.getValue(aDbName));
+  }
+
+  public Integer getMaxRows(String aDbName){
+    return asInteger(fMaxRows.getValue(aDbName));
+  }
+
+  public Integer getFetchSize(String aDbName){
+    return asInteger(fFetchSize.getValue(aDbName));
+  }
+
+  public Boolean getHasAutoGeneratedKeys(String aDbName){
+    return asBoolean(fHasAutoGeneratedKeys.getValue(aDbName));
+  }
+
+  public List<Integer> getErrorCodesForDuplicateKey(String aDbName){
+    return asIntegerList(fErrorCodeForDuplicateKey.getValue(aDbName));
+  }
+
+  public List<Integer> getErrorCodesForForeignKey(String aDbName){
+    return asIntegerList(fErrorCodeForForeignKey.getValue(aDbName));
+  }
+
+  public TxIsolationLevel getSqlFetcherDefaultTxIsolationLevel(String aDbName){
+    return TxIsolationLevel.valueOf(fSqlFetcherDefaultTxIsolationLevel.getValue(aDbName));
+  }
+
+  public TxIsolationLevel getSqlEditorDefaultTxIsolationLevel(String aDbName){
+    return TxIsolationLevel.valueOf(fSqlEditorDefaultTxIsolationLevel.getValue(aDbName));
+  }
+
+  public String getDateFormat(String aDbName){
+    return asDateTimeFormat(fDateTimeFormatForPassingParamsToDb.getValue(aDbName), 0);
+  }
+  
+  public String getTimeFormat(String aDbName){
+    return asDateTimeFormat(fDateTimeFormatForPassingParamsToDb.getValue(aDbName), 1);
+  }
+  
+  public String getDateTimeFormat(String aDbName){
+    return asDateTimeFormat(fDateTimeFormatForPassingParamsToDb.getValue(aDbName), 2);
+  }
+
+  // PRIVATE 
+  
+  private static List<String> fErrors = new ArrayList<String>();
+  
+  private static void initAllItems(Map<String, String> aKeyValuePairs) {
+    fMap = aKeyValuePairs;
+    
+    fWebmaster = initThisStringNoDefault("Webmaster", aKeyValuePairs);
+    fImplicitMappingRemoveBasePackage = initThisStringNoDefault("ImplicitMappingRemoveBasePackage", aKeyValuePairs);
+    
+    if (hasValue("MailServerConfig", aKeyValuePairs)) {
+      fMailServerConfig = initThisString("MailServerConfig", aKeyValuePairs);
+    }
+    if (hasValue("MailServerCredentials", aKeyValuePairs)) {
+      fMailServerCredentials = initThisString("MailServerCredentials", aKeyValuePairs);
+    }
+    if (hasValue("LoggingDirectory", aKeyValuePairs)) {
+      fLoggingDirectory = initThisString("LoggingDirectory", aKeyValuePairs);
+    }
+    if (hasValue("LoggingLevels", aKeyValuePairs)) {
+      fLoggingLevels = initThisStringList("LoggingLevels", aKeyValuePairs);
+    }
+    if (hasValueNotNone("TroubleTicketMailingList", aKeyValuePairs)) {
+      fTroubleTicketMailingList = initThisStringList("TroubleTicketMailingList", aKeyValuePairs);
+    }
+    if (hasValue("MinimumIntervalBetweenTroubleTickets", aKeyValuePairs)) {
+      fMinimumIntervalBetweenTroubleTickets = initThisLong("MinimumIntervalBetweenTroubleTickets", aKeyValuePairs);
+    }
+    if (hasValue("PoorPerformanceThreshold", aKeyValuePairs)) {
+      fPoorPerformanceThreshold = initThisLong("PoorPerformanceThreshold", aKeyValuePairs);
+    }
+    if (hasValue("MaxHttpRequestSize", aKeyValuePairs)) {
+      fMaxHttpRequestSize = initThisLong("MaxHttpRequestSize", aKeyValuePairs);
+      checkTooLow(fMaxHttpRequestSize, 1000L, "MaxHttpRequestSize");
+    }
+    if (hasValue("MaxFileUploadRequestSize", aKeyValuePairs)) {
+      fMaxFileUploadRequestSize = initThisLong("MaxFileUploadRequestSize", aKeyValuePairs);
+      checkTooLow(fMaxFileUploadRequestSize, 1000L, "MaxFileUploadRequestSize");
+    }
+    if (hasValue("MaxRequestParamValueSize", aKeyValuePairs)) {
+      fMaxRequestParamValueSize = initThisLong("MaxRequestParamValueSize", aKeyValuePairs);
+      checkTooLow(fMaxRequestParamValueSize, 1000L, "MaxRequestParamValueSize");
+    }
+    if (hasValue("SpamDetectionInFirewall", aKeyValuePairs)) {
+      fSpamDetectionInFirewall = initThisBoolean("SpamDetectionInFirewall", aKeyValuePairs);
+    }
+    if (hasValue("FullyValidateFileUploads", aKeyValuePairs)) {
+      fFullyValidateFileUploads = initThisBoolean("FullyValidateFileUploads", aKeyValuePairs);
+    }
+    if (hasValue("AllowStringAsBuildingBlock", aKeyValuePairs)) {
+      fAllowStringAsBuildingBlock = initThisBoolean("AllowStringAsBuildingBlock", aKeyValuePairs);
+    }
+    if (hasValue("UntrustedProxyForUserId", aKeyValuePairs)) {
+      ParseUntrustedProxy untrusted = new ParseUntrustedProxy();
+      fUntrustedProxyForUserId = untrusted.parse(aKeyValuePairs.get("UntrustedProxyForUserId"));
+    }
+    if (hasValue("CharacterEncoding", aKeyValuePairs)) {
+      fCharacterEncoding = initThisString("CharacterEncoding", aKeyValuePairs);
+    }
+    if (hasValue("DefaultLocale", aKeyValuePairs)) {
+      fDefaultLocale = initThisLocale("DefaultLocale", aKeyValuePairs);
+    }
+    if (hasValue("DefaultUserTimeZone", aKeyValuePairs)) {
+      fDefaultUserTimeZone = initThisTimeZone("DefaultUserTimeZone", aKeyValuePairs);
+    }
+    if (hasValueNotNone("TimeZoneHint", aKeyValuePairs)) {
+      fTimeZoneHint = initThisTimeZone("TimeZoneHint", aKeyValuePairs);
+    }
+    if (hasValue("DecimalSeparator", aKeyValuePairs)) {
+      fDecimalSeparator = initThisString("DecimalSeparator", aKeyValuePairs);
+      fDecimalInputPattern = fDecimalParser.getDecimalFormatPattern(fDecimalSeparator);
+    }
+    if (hasValue("BigDecimalDisplayFormat", aKeyValuePairs)) {
+      fBigDecimalDisplayFormat = initThisString("BigDecimalDisplayFormat", aKeyValuePairs);
+    }
+    if (hasValue("BooleanTrueDisplayFormat", aKeyValuePairs)) {
+      fBooleanTrueDisplayFormat = initThisString("BooleanTrueDisplayFormat", aKeyValuePairs);
+    }
+    if (hasValue("BooleanFalseDisplayFormat", aKeyValuePairs)) {
+      fBooleanFalseDisplayFormat = initThisString("BooleanFalseDisplayFormat", aKeyValuePairs);
+    }
+    if (hasValue("EmptyOrNullDisplayFormat", aKeyValuePairs)) {
+      fEmptyOrNullDisplayFormat = initThisString("EmptyOrNullDisplayFormat", aKeyValuePairs);
+    }
+    if (hasValue("IntegerDisplayFormat", aKeyValuePairs)) {
+      fIntegerDisplayFormat = initThisString("IntegerDisplayFormat", aKeyValuePairs);
+    }
+    if (hasValue("IgnorableParamValue", aKeyValuePairs)) {
+      fIgnorableParamValue = initThisString("IgnorableParamValue", aKeyValuePairs);
+    }
+    if (hasValue("IsSQLPrecompilationAttempted", aKeyValuePairs)) {
+      fIsSQLPrecompilationAttempted = initThisDbConfig("IsSQLPrecompilationAttempted", aKeyValuePairs);
+    }
+    if (hasValue("MaxRows", aKeyValuePairs)) {
+      fMaxRows = initThisDbConfig("MaxRows", aKeyValuePairs);
+    }
+    if (hasValue("FetchSize", aKeyValuePairs)) {
+      fFetchSize = initThisDbConfig("FetchSize", aKeyValuePairs);
+    }
+    if (hasValue("HasAutoGeneratedKeys", aKeyValuePairs)) {
+      fHasAutoGeneratedKeys = initThisDbConfig("HasAutoGeneratedKeys", aKeyValuePairs);
+    }
+    if (hasValue("ErrorCodeForDuplicateKey", aKeyValuePairs)) {
+      fErrorCodeForDuplicateKey = initThisDbConfig("ErrorCodeForDuplicateKey", aKeyValuePairs);
+    }
+    if (hasValue("ErrorCodeForForeignKey", aKeyValuePairs)) {
+      fErrorCodeForForeignKey = initThisDbConfig("ErrorCodeForForeignKey", aKeyValuePairs);
+    }
+    if (hasValue("SqlFetcherDefaultTxIsolationLevel", aKeyValuePairs)) {
+      fSqlFetcherDefaultTxIsolationLevel = initThisDbConfig("SqlFetcherDefaultTxIsolationLevel", aKeyValuePairs);
+    }
+    if (hasValue("SqlEditorDefaultTxIsolationLevel", aKeyValuePairs)) {
+      fSqlEditorDefaultTxIsolationLevel = initThisDbConfig("SqlEditorDefaultTxIsolationLevel", aKeyValuePairs);
+    }
+    if (hasValue("DateTimeFormatForPassingParamsToDb", aKeyValuePairs)) {
+      fDateTimeFormatForPassingParamsToDb = initThisDbConfig("DateTimeFormatForPassingParamsToDb", aKeyValuePairs);
+    }
+    if (hasValue("IsTesting", aKeyValuePairs)) {
+      fIsTesting = initThisBoolean("IsTesting", aKeyValuePairs);
+    }
+    
+    if (! fErrors.isEmpty()){
+      throw new RuntimeException("Errors in config data: " + fErrors);
+    }
+  }
+  
+  private static boolean hasValue(String aName,  Map<String, String> aKeyValuePairs){
+    return Util.textHasContent(aKeyValuePairs.get(aName));    
+  }
+  
+  private static boolean hasValueNotNone(String aName,  Map<String, String> aKeyValuePairs){
+    String value = aKeyValuePairs.get(aName);
+    return Util.textHasContent(value) && (! NONE.equalsIgnoreCase(value));    
+  }
+
+  private static String initThisString(String aName, Map<String, String> aKeyValuePairs){
+    return aKeyValuePairs.get(aName);
+  }
+  
+  private static Long initThisLong(String aName, Map<String, String> aKeyValuePairs){
+    return asLong(aKeyValuePairs.get(aName));
+  }
+  
+  private static TimeZone initThisTimeZone(String aName, Map<String, String> aKeyValuePairs){
+    return asTimeZone(aKeyValuePairs.get(aName));
+  }
+  
+  private static List<String> initThisStringList(String aName, Map<String, String> aKeyValuePairs){
+    return asStringListWithNone(aKeyValuePairs.get(aName));
+  }
+  
+  private static String initThisStringNoDefault(final String aName, final Map<String, String> aKeyValuePairs){
+    String result = aKeyValuePairs.get(aName);
+    if (! Util.textHasContent(result)){
+      fErrors.add(aName + " has no value set.");
+    }
+    return result;
+  }
+  
+  private static Boolean initThisBoolean(String aName, Map<String, String> aKeyValuePairs){
+    String value = aKeyValuePairs.get(aName);
+    return Util.parseBoolean(value);
+  }
+  
+  private static DbConfigParser initThisDbConfig(String aName, Map<String, String> aKeyValuePairs){
+    return new DbConfigParser(aKeyValuePairs.get(aName));
+  }
+
+  private static Locale initThisLocale(String aName, Map<String, String> aKeyValuePairs){
+    return asLocale(aKeyValuePairs.get(aName));
+  }
+  
+  /* These declares define the default value of each item, if any. */
+
+  //used for iteration over raw values (logging)
+  private static Map<String, String> fMap;
+  
+  private static String fWebmaster = ""; //no default
+  private static String fImplicitMappingRemoveBasePackage  = ""; //no default
+  
+  private static String fMailServerConfig = "NONE";
+  private static String fMailServerCredentials = "NONE";
+  private static String fLoggingDirectory = "NONE";
+  private static List<String> fLoggingLevels = Arrays.asList("hirondelle.web4j.level=CONFIG");
+  private static List<String> fTroubleTicketMailingList = Collections.emptyList();
+  private static Long fMinimumIntervalBetweenTroubleTickets = Long.valueOf(30);
+  private static Long fPoorPerformanceThreshold = 20L;
+  private static Long fMaxHttpRequestSize = 51200L;
+  private static Long fMaxFileUploadRequestSize = 51200L;
+  private static Long fMaxRequestParamValueSize = 51200L;
+  private static Boolean fSpamDetectionInFirewall = Boolean.FALSE;
+  private static Boolean fFullyValidateFileUploads = Boolean.FALSE;
+  private static Boolean fAllowStringAsBuildingBlock = Boolean.FALSE;
+  private static Map<String, List<String>> fUntrustedProxyForUserId =  new LinkedHashMap<String, List<String>>();
+  private static String fCharacterEncoding = "UTF-8";
+  private static Locale fDefaultLocale = Util.buildLocale("en");
+  private static TimeZone fDefaultUserTimeZone = TimeZone.getTimeZone("GMT");
+  private static TimeZone fTimeZoneHint; //default is null in this case
+  
+  // these 3 are related to each other
+  private static String fDecimalSeparator = "PERIOD";
+  private static ParseDecimalFormat fDecimalParser = new ParseDecimalFormat();
+  private static Pattern fDecimalInputPattern = fDecimalParser.getDecimalFormatPattern(fDecimalSeparator);// the default, as usual
+  
+  private static String fBigDecimalDisplayFormat = "#,##0.00";
+  private static String fBooleanTrueDisplayFormat = "<![CDATA[  <input type='checkbox' name='true' value='true' checked readonly notab> ]]>";
+  private static String fBooleanFalseDisplayFormat = "<![CDATA[ <input type='checkbox' name='false' value='false' checked readonly notab>]]>";
+  private static String fEmptyOrNullDisplayFormat = "-";
+  private static String fIntegerDisplayFormat = "#,###";
+  private static String fIgnorableParamValue = "";
+  
+  //database items
+  private static DbConfigParser fIsSQLPrecompilationAttempted = new DbConfigParser("TRUE");
+  private static DbConfigParser fMaxRows = new DbConfigParser("300");
+  private static DbConfigParser fFetchSize = new DbConfigParser("25");
+  private static DbConfigParser fHasAutoGeneratedKeys = new DbConfigParser("FALSE");
+  private static DbConfigParser fErrorCodeForDuplicateKey =  new DbConfigParser("1");
+  private static DbConfigParser fErrorCodeForForeignKey = new DbConfigParser("2291");
+  private static DbConfigParser fSqlFetcherDefaultTxIsolationLevel = new DbConfigParser("DATABASE_DEFAULT");
+  private static DbConfigParser fSqlEditorDefaultTxIsolationLevel = new DbConfigParser("DATABASE_DEFAULT");
+  private static DbConfigParser fDateTimeFormatForPassingParamsToDb = new DbConfigParser("YYYY-MM-DD^hh:mm:ss^YYYY-MM-DD hh:mm:ss");
+
+  private static Boolean fIsTesting = Boolean.FALSE;
+  
+  /* Simple conversion methods. */
+  
+  private static Long asLong(String aValue){
+    return Long.valueOf(aValue);
+  }
+  
+  private static Integer asInteger(String aValue){
+    return Integer.valueOf(aValue);
+  }
+
+  private static TimeZone asTimeZone(String aValue){
+    TimeZone result = null;
+    try{
+      result = Util.buildTimeZone(aValue);
+    }
+    catch(Throwable ex){
+      fErrors.add(ex.getMessage());
+    }
+    return result;
+  }
+
+  private static Locale asLocale(String aValue){
+    return Util.buildLocale(aValue);
+  }
+  
+  /** 
+   Separated by a comma.
+   Special value 'NONE' denotes an empty list. 
+  */
+  private static List<String> asStringListWithNone(String aValue){
+    List<String> result = new ArrayList<String>();
+    if (! "NONE".equalsIgnoreCase(aValue)){
+      result.addAll(asStringList(aValue));
+    }
+    return result;
+  }
+  
+  /**  Separated by a comma. */
+  private static List<String> asStringList(String aValue){
+    List<String> result = new ArrayList<String>();
+    StringTokenizer parser = new StringTokenizer(aValue, ",");
+    while (parser.hasMoreElements()){
+      String item = (String)parser.nextElement();
+      result.add(item);
+    }
+    return result;
+  }
+  
+  
+  /** Separated by a comma. */
+  private static List<Integer> asIntegerList(String aValue){
+    List<Integer> result = new ArrayList<Integer>(); 
+    String DELIMITER = ",";
+    StringTokenizer parser = new StringTokenizer(aValue, DELIMITER);
+    while ( parser.hasMoreTokens() ) {
+      String errorCode = parser.nextToken();
+      result.add(new Integer(errorCode.trim()));
+    }
+    return result;
+  }
+  
+  private static Boolean asBoolean(String aValue){
+    return Util.parseBoolean(aValue);
+  }
+
+  private static String asDateTimeFormat(String aDbSetting, int aPart){
+    String[] formats = aDbSetting.split("\\^");
+    if(formats.length != 3) {
+      fErrors.add(
+        "DateTimeFormatForPassingParamsToDb setting in web.xml does not have a valid value: " + Util.quote(aDbSetting) + 
+        ". Does not have 3 entries, separated by a '^' character."
+      );
+    }
+    return formats[aPart];
+  }
+  
+  private static void checkTooLow(Long aValue, Long aMin, String aName) {
+    if (aValue < aMin){
+      fErrors.add(
+        "Configured value of " + aValue + " in web.xml for " + aName + 
+        " is less than " + aMin  + ". Please see web.xml for more information." 
+      );
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/ConfigReader.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,551 @@
+package hirondelle.web4j.readconfig;
+
+import java.util.*;
+import java.util.logging.*;
+import java.io.*;
+import java.util.regex.*;
+import java.lang.reflect.*;
+import javax.servlet.ServletContext;
+
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Util;
+
+/**
+ (UNPUBLISHED) Reads text files (having specific formats) located under the <tt>WEB-INF</tt> directory, 
+ and returns their contents as {@link Properties}.
+ 
+ <P>In addition, this class returns {@link Set}s of {@link Class}es that are present under
+ <tt>/WEB-INF/classes</tt>. This unusual policy allows the caller to use reflection upon 
+ such classes, to extract what would otherwise be configuration information. For example, the 
+ default implementation of {@link hirondelle.web4j.request.RequestParser} uses this technique to 
+ automatically map request URIs to concrete implementations of {@link hirondelle.web4j.action.Action}.
+ Thus, the action implementor can configure when the action is called simply by adding a 
+ field satisfying some simple conventions.
+
+<P><em>Design Note</em>: In addition to the configuration facilities present in 
+ <tt>web.xml</tt>, it is sometimes useful to use properties files (see {@link Properties}), 
+ or similar items. Such files may be placed in the same directory as the class 
+ which uses them, but, in a web application, there is also the option of placing 
+ such files in the <tt>WEB-INF</tt> directory. 
+*/
+public final class ConfigReader {
+  
+  /**  Must be called upon startup in a web container. */
+  public static void init(ServletContext aContext){
+    fContext = aContext;
+  }
+  
+  /** Type-safe enumeration for the kinds of text file supported by {@link ConfigReader}.  */
+  public enum FileType {
+    
+    /**
+     Standard java <tt>.properties</tt> file. 
+     See {@link Properties} for more information.
+    */
+    PROPERTIES_FILE,
+    
+    /**
+     Text file containing blocks of text, constants, and comments. 
+     <P>The {@link hirondelle.web4j.database.SqlStatement} class uses this style for 
+     underlying SQL statements, but it may be used for any similar case,  
+     where the format specified below is adequate.
+    
+     <P>The following terminology is used here :
+     <ul>
+     <li>Block - a multiline block of text with within braces, with an associated 
+     identifier (similar to a typical Java block) 
+     <li>Block Name - the identifier of a block, appearing on the first line, before the 
+     opening brace
+     <li>Block Body - the text appearing between the opening and closing braces. One
+     advantage of using this file type, instead of a 
+     {@link ConfigReader.FileType#PROPERTIES_FILE}, is 
+     that no line continuation characters are needed. 
+     </ul>
+     
+     <P>Example of a typical <tt>TEXT_BLOCK</tt> file (using  
+     SQL statements for Block Bodies) : 
+     <PRE>
+    -- This is an example comment.
+    -- This item exercises slight textual variations.
+    -- The Block Name here is 'ADD_MESSAGE'.
+     ADD_MESSAGE  {
+     INSERT INTO MyMessage -- another example comment
+      (LoginName, Body, CreationDate)
+      -- this line and the following line are also commented out
+      -- VALUES (?,?,?)
+      VALUES (?,?,?)
+     }
+    
+    -- Here, 'constants' is a reserved Block Name.
+    -- Any number of 'constants' blocks can be defined, anywhere 
+    -- in the file. Such constants must be defined before being
+    -- referenced in a Block Body, however.
+    constants {
+      num_messages_to_view = 5
+    }
+    
+    -- Example of referring to a constant defined above.
+    FETCH_RECENT_MESSAGES {
+     SELECT 
+     LoginName, Body, CreationDate 
+     FROM MyMessage 
+     ORDER BY Id DESC LIMIT ${num_messages_to_view}
+    }
+    
+    FETCH_NUM_MSGS_FOR_USER {
+     SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+    }
+    
+    -- Browse Messages according to various criteria.
+    -- Constants block used here just to demonstrate it 
+    -- is possible.
+    constants {
+      -- Each constant must appear on a *single line* only, which
+      -- is not the best for long SQL statements.
+      -- Typically, if a previously defined *sub-query* is needed, 
+      -- then a SQL statment may simply refer directly to that previously 
+      -- defined SQL statement. (See oracle.sql in the example application.)
+      base_query = SELECT Id, LoginName, Body, CreationDate FROM MyMessage 
+    }
+    
+    BROWSE_MESSAGES {
+     ${base_query}
+     ORDER BY {0} {1}
+    }
+    
+    BROWSE_MESSAGES_WITH_FILTER {
+     ${base_query}
+     WHERE {0} LIKE '{1}%' 
+     ORDER BY {2} {3}
+    }
+     </PRE> 
+     
+     <P>The format details are as follows :
+    <ul>
+     <li>empty lines can appear only outside of blocks
+     <li>the '<tt>--</tt>' character denotes a comment 
+     <li>there are no multiline comments. (Such comments are easier for the writer, 
+     but are much less clear for the reader.) 
+     <li>the body of each item is placed in a named block, bounded by 
+     '<tt>&lt;name&gt; {</tt>' on an initial line, and '<tt>}</tt>' on an end line.
+     The name given to the the block (<tt>ADD_MESSAGE</tt> for example) is how 
+     <tt>WEB4J</tt> identifies each block. The Block Name may correspond one to 
+     one with some item in code. For SQL statements, the Block Name must correspond 
+     to a <tt>public static final SqlId</tt> field. Upon startup, this allows verification 
+     that all items defined in the text source have a corresponding item in code.
+     <li>'<tt>--</tt>' comments can appear in the Block Body as well
+     <li>if desired, the Block Body may be indented, to make the Block more legible
+     <li>the Block Name of '<tt>constants</tt>' is reserved. A <tt>constants</tt> 
+     block defines one or more simple textual substitution constants, as 
+     <tt>name = value</tt> pairs, one per line, that may be 
+     referenced later on in the file (see example). They are defined only to allow 
+     such substitution to occur <em>later</em> in the file. Any number of <tt>constants</tt> 
+     blocks can appear in a file. 
+     <li>Block Names and the names of constants satisfy 
+     {@link hirondelle.web4j.util.Regex#SIMPLE_SCOPED_IDENTIFIER}.
+     <li>inside a Block Body, substitutions are denoted by the common 
+     syntax '<tt>${blah}</tt>', where <tt>blah</tt> refers to an item appearing 
+     earlier in the file, either the content 
+     of a previously defined Block, or the value of a constant defined in a 
+     <tt>constants</tt> Block 
+     <li>an item must be defined before it can be used in a substitution ; that is, 
+     it must appear earlier in the file
+     <li>no substitutions are permitted in a <tt>constants</tt> Block
+     <li>Block Names, and the names of constants, should be unique.
+    </ul>
+    */
+    TEXT_BLOCK,
+  }
+
+  /**
+   Fetch a single, specific file located in the <tt>WEB-INF</tt> 
+   directory, and populate a corresponding <tt>Properties</tt> object.
+  
+   @param aConfigFileName has content and is the "simple" name (without path info)
+   of a readable file in the <tt>WEB-INF</tt> directory (for example, <tt>"config.properties"</tt> or 
+   <tt>"statements.sql"</tt>).
+  */
+  public static Properties fetch(String aConfigFileName, FileType aFileType){
+    return basicFetch(fWEBINF + aConfigFileName, ! FOR_TESTING, aFileType);
+  }
+  
+  /**
+   Fetch all config files located anywhere under the <tt>WEB-INF</tt> directory whose 
+   "simple" file name (without path info) matches <tt>aFileNamePattern</tt>, and 
+   translate their content into a single {@link Properties} object.
+  
+   <P>If the caller needs only one file of a specific name, and that file is  
+   located in the <tt>WEB-INF</tt> directory itself, then use {@link #fetch} instead.
+  
+   <P><span class="highlight">The keys in all of the matching files should be unique.</span>
+   If a duplicate key exists, then the second instance will overwrite the first (as in
+   {@link HashMap#put}), and a <tt>SEVERE</tt> warning is logged.
+  
+   <P><em>Design Note:</em><br>
+   This method was initially created to reduce contention for the <tt>*.sql</tt> file, 
+   on projects having more than one developer. See 
+   {@link hirondelle.web4j.database.SqlStatement} and the example <tt>.sql</tt> files for 
+   more information.
+  
+   @param aFileNamePattern regular expression for the desired file name, without path 
+   information
+   @param aConfigFileType identifies the layout style of the config file
+  */
+  public static Properties fetchMany(Pattern aFileNamePattern, FileType aConfigFileType){
+    Properties result = new Properties();
+    Set<String> allFilePaths = getFilePathsBelow(fWEBINF);
+    Set<String> matchingFilePaths = matchTargetPattern(allFilePaths, aFileNamePattern);
+    fLogger.config(
+      "Desired configuration files under /WEB-INF/: " + Util.logOnePerLine(matchingFilePaths)
+    );
+    for (String matchingFilePath : matchingFilePaths){
+      Properties properties = basicFetch(matchingFilePath, ! FOR_TESTING, aConfigFileType);
+      addProperties(properties, result);
+    }
+    fLogger.config(
+      "Total number of distinct keys in configuration files : " + result.keySet().size()
+    );
+    logKeysFromManyFiles(result);
+    return result;
+  }
+  
+  /**
+   <em>Intended for testing only</em>, outside of a web container environment.
+  
+   <P>Fetches properties not from a file in the WEB-INF directory, 
+   but from a hard-coded absolute file name.
+   
+   @param aConfigFileName absolute file name, including path information
+  */
+  public static Properties fetchForTesting(String aConfigFileName, FileType aFileType){
+    return basicFetch(aConfigFileName, FOR_TESTING, aFileType);
+  }
+  
+  /** 
+   Return a possibly-empty {@link Set} of {@link Class} objects, for all concrete classes 
+   under <tt>/WEB-INF/classes/</tt> that implement <tt>aInterface</tt>.
+   
+   <P>More specifically, the classes returned here are 
+   not <tt>abstract</tt>, and not an <tt>interface</tt>. Only such classes 
+   should be of interest to the caller, since only such classes can be instantiated.
+   It is expected that almost all classes in an application will be concrete.
+  */
+  public static <T> Set<Class<T>> fetchConcreteClassesThatImplement(Class<T> aInterface){
+    if(aInterface != null){
+      fLogger.config("Fetching concrete classes that implement " + aInterface);
+    }
+    else {
+      fLogger.config("Fetching all concrete classes.");
+    }
+    Set<Class<T>> result = new LinkedHashSet<Class<T>>();
+    Set<String> allFilePaths = getFilePathsBelow(fWEBINF_CLASSES);
+    Set<String> classFilePaths = matchTargetPattern(allFilePaths, CLASS_FILES);
+    for (String classFilePath: classFilePaths){
+      String className = classFilePath.replace('/','.');
+      if( className.endsWith("package-info.class")){
+        fLogger.finest("Ignoring package-info.class.");
+      }
+      else {
+        className = className.substring(fWEBINF_CLASSES.length());
+        className = className.substring(0,className.lastIndexOf(fDOT_CLASS));
+        try {
+          Class<T> thisClass = (Class<T>)Class.forName(className); //cast OK
+          if ( isConcrete(thisClass) ){
+            if(aInterface == null){
+              result.add(thisClass);
+            }
+            else {
+              if(aInterface.isAssignableFrom(thisClass)){
+                result.add(thisClass);
+              }
+            }
+          }
+        }
+        catch(ClassNotFoundException ex){
+          fLogger.severe("Cannot load class using name : " + className);
+        }
+      }
+    }
+    return result;
+  }
+  
+  /**
+   As in {@link #fetchConcreteClassesThatImplement(Class)}, but with no test for implementing 
+   a specific interface. 
+  */
+  public static Set fetchConcreteClasses(){
+    return fetchConcreteClassesThatImplement(null);
+  }
+  
+  /**
+   Return all <tt>public static final</tt> fields declared in an application of type <tt>aFieldClass</tt>.
+   
+   <P>Restrict the search to concrete classes that implement an interface <tt>aContainingClassInterface</tt>,
+   as in {@link #fetchConcreteClassesThatImplement(Class)}. If <tt>aContainingClassInterface</tt> is 
+   <tt>null</tt>, then scan all classes for such fields.
+   
+   <P>Return a possibly-empty {@link Map} having :
+   <br>KEY - Class object of class from which the field is visible; see {@link Class#getFields()}. 
+   <br>VALUE - {@link Set} of objects of class aFieldClass
+  */
+  public static /*T(Contain),V(field)*/ Map/*<Class<T>, Set<V>>*/ fetchPublicStaticFinalFields(Class aContainingClassInterface, Class aFieldClass){
+    //see AppFirewallImpl for example
+    if(aContainingClassInterface == null){
+      fLogger.config("Fetching public static final fields of " + aFieldClass + ", from all concrete classes.");
+    }
+    else {
+      fLogger.config("Fetching public static final fields of " + aFieldClass + ", from concrete classes that implement " + aContainingClassInterface);
+    }
+    Map result = new LinkedHashMap();
+    Set classesToScan = null;
+    if(aContainingClassInterface == null){
+      classesToScan = fetchConcreteClasses();
+    }
+    else {
+      classesToScan = fetchConcreteClassesThatImplement(aContainingClassInterface);
+    }
+    Iterator iter = classesToScan.iterator();
+    while (iter.hasNext()){
+      Class thisClass = (Class)iter.next();
+      result.put(thisClass, getFields(thisClass, aFieldClass));
+    }
+    return result;
+  }
+
+  /**
+   As in {@link #fetchPublicStaticFinalFields(Class, Class)}, but with <tt>null</tt> value 
+   for <tt>aContainingClassInterface</tt>.
+  */
+  public static Map fetchPublicStaticFinalFields(Class aFieldClass){
+    return fetchPublicStaticFinalFields(null, aFieldClass);
+  }
+  
+  /**
+   Process all the raw <tt>.sql</tt> text data into a consolidated Map of SqlId to SQL statements.
+   @param aRawSql - the raw, unprocessed content of each <tt>.sql</tt> file (or data)
+  */
+  public static Map<String, String> processRawSql(List<String> aRawSql){
+    Map<String, String> result = new LinkedHashMap<String, String>();
+    for(String rawSqlFile : aRawSql){
+      result.putAll(processedSql(rawSqlFile));
+    }
+    return result;
+  }
+  
+  // PRIVATE
+  
+  /** This field requires the servlet jar to be present. It will be init-ed when this class is loaded (?). */
+  private static ServletContext fContext;
+  
+  private static final boolean FOR_TESTING = true;
+  
+  private static final String fWEBINF = "/WEB-INF/";
+  private static final String fWEBINF_CLASSES = "/WEB-INF/classes/";
+  
+  private static final Pattern CLASS_FILES = Pattern.compile("(?:.)*\\.class");
+  private static final String fSLASH = "/";
+  private static final String fDOT_CLASS = ".class";
+  private static final Logger fLogger = Util.getLogger(ConfigReader.class);
+  
+  private ConfigReader (){
+    //prevent construction
+  } 
+
+  /**
+   Return a <tt>Properties</tt> which reflects the contents of the 
+   given config file located under WEB-INF.
+  */
+  private static Properties basicFetch(String aConfigFilePath, boolean aIsTesting, FileType aFileType){
+    Args.checkForContent(aConfigFilePath);
+    if (  aIsTesting ) {
+      checkIsAbsolute(aConfigFilePath);
+    }
+    Properties result = new Properties();
+    InputStream input = null;
+    try {
+      if ( ! aIsTesting ) {
+        input = fContext.getResourceAsStream(aConfigFilePath);
+      }
+      else {
+        input = new FileInputStream(aConfigFilePath);
+      }
+      if (FileType.PROPERTIES_FILE == aFileType) {
+        result.load(input);
+      }
+      else {
+        result = loadTextBlockFile(input, aConfigFilePath);
+      }
+    }
+    catch (IOException ex){
+      vomit(aConfigFilePath, aIsTesting);
+    }
+    finally {
+      shutdown(input);
+    }
+    fLogger.finest("Number of keys in properties object : " + result.keySet().size());
+    return result;
+  }
+  
+  private static void checkIsAbsolute(String aConfigFileName){
+    if ( !isAbsolute(aConfigFileName) ) {
+      throw new IllegalArgumentException(
+        "Configuration file name is not absolute, "+ aConfigFileName
+      );
+    }
+  }
+  
+  private static boolean isAbsolute(String aFileName){
+    File file = new File(aFileName);
+    return file.isAbsolute();
+  }
+  
+  private static void shutdown(InputStream aInput){
+    try {
+      if (aInput != null) aInput.close();
+    }
+    catch (IOException ex ){
+      throw new IllegalStateException("Cannot close config file in WEB-INF directory.");
+    }
+  }
+  
+  private static void vomit(String aConfigFileName, boolean aIsTesting){
+    String message = null;
+    if ( ! aIsTesting ){
+      message = (
+        "Cannot open and load configuration file from WEB-INF directory: " + 
+        Util.quote(aConfigFileName)
+      );
+    }
+    else {
+      message = (
+        "Cannot open and load configuration file named " + 
+        Util.quote(aConfigFileName)
+      );
+    }
+    throw new IllegalStateException(message);
+  }
+  
+  /**
+   Return a Set of Strings starting with <tt>aStartDirectory</tt> and containing the full 
+   file path for all files under <tt>aStartDirectory</tt>. No sorting is performed.
+   
+   @param aStartDirectory starts with <tt>/WEB-INF/</tt>
+  */
+  private static Set<String> getFilePathsBelow(String aStartDirectory){
+    Set<String> result = new LinkedHashSet<String>();
+    Set<String> paths = fContext.getResourcePaths(aStartDirectory);
+    for ( String path : paths) {
+      if ( isDirectory(path) ) {
+        //recursive call !!!
+        result.addAll(getFilePathsBelow(path));
+      }
+      else {
+        result.add(path);
+      }
+    }
+    return result;
+  }
+  
+  /**
+   Return a Set of paths (as Strings), starting with "/WEB-INF/", for files under 
+   <tt>WEB-INF</tt> whose simple file name (<em>without</em> the path info) 
+   matches <tt>aFileNamePattern</tt>.
+  
+   @param aFullFilePaths Set of Strings starting with "<tt>/WEB-INF/</tt>", which 
+   denote paths to <b>all</b> files under the <tt>WEB-INF</tt> directory (recursive) 
+  */  
+  private static Set<String> matchTargetPattern(Set<String> aFullFilePaths, Pattern aFileNamePattern) {
+    Set<String> result = new LinkedHashSet<String>();
+    for ( String fullFilePath : aFullFilePaths){
+      int lastSlash = fullFilePath.lastIndexOf(fSLASH);
+      assert(lastSlash != -1);
+      if ( ! isDirectory(fullFilePath) ){
+        String simpleFileName = fullFilePath.substring(lastSlash + 1);
+        Matcher matcher = aFileNamePattern.matcher(simpleFileName);
+        if ( matcher.matches() ) {
+          result.add(fullFilePath);
+        }
+      }
+    }
+    return result;
+  }
+  
+  private static boolean isDirectory(String aFullFilePath){
+    return aFullFilePath.endsWith(fSLASH); 
+  }
+  
+  /**
+   Add all key-value pairs in <tt>aProperties</tt> to <tt>aResult</tt>.
+  
+   <P>If duplicate key is found, replaces old with new, and log the occurrence.
+  */
+  private static void addProperties(Properties aProperties, Properties aResult){
+    Enumeration keys = aProperties.propertyNames();
+    while ( keys.hasMoreElements() ) {
+      String key = (String)keys.nextElement();
+      if ( aResult.containsKey(key) ) {
+        fLogger.severe(
+          "WARNING. Same key found in more than one configuration file: " +Util.quote(key)+
+          "This condition almost always indicates an error. " +
+          "Overwriting old key-value pair with new key-value pair."
+        );
+      }
+      String value = aProperties.getProperty(key);
+      aResult.setProperty(key, value);
+    }  
+  }
+  
+  /**
+   Log all key names in <tt>aProperties</tt>, in alphabetical order.
+  */
+  private static void logKeysFromManyFiles(Properties aProperties){
+    SortedSet sortedKeys = new TreeSet(aProperties.keySet());
+    fLogger.config(Util.logOnePerLine(sortedKeys));
+  }
+  
+  private static Properties loadTextBlockFile(InputStream aInput,String aSqlFileName) throws IOException {
+    TextBlockReader sqlReader = new TextBlockReader(aInput, aSqlFileName);
+    return sqlReader.read(); 
+  }
+  
+  private static boolean isConcrete(Class aClass){
+    int modifiers = aClass.getModifiers();
+    return 
+      ! Modifier.isInterface(modifiers) && 
+      ! Modifier.isAbstract(modifiers) 
+    ;  
+  }
+  
+  private static Set getFields(Class aContainingClass, Class aFieldClass){
+    Set result = new LinkedHashSet();
+    List fields = Arrays.asList(aContainingClass.getFields());
+    Iterator fieldsIter = fields.iterator();
+    while (fieldsIter.hasNext()){
+      Field field = (Field)fieldsIter.next();
+      if( field.getType() == aFieldClass ){
+        int modifiers = field.getModifiers();
+        if(Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers)){
+          try {
+            result.add(field.get(null));
+          }
+          catch (IllegalAccessException ex){
+            fLogger.severe("Cannot get value of public static final field in " + aContainingClass);
+          }
+        }
+      }
+    }
+    return result;
+  }
+  
+  private static Map<String, String> processedSql(String aRawSql){
+    TextBlockReader textBlockReader = new TextBlockReader(aRawSql);
+    Properties props = null;
+    try {
+      props = textBlockReader.read();
+    }
+    catch (IOException ex) {
+      ex.printStackTrace();
+    }
+    return new LinkedHashMap(props);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/DbConfigParser.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,125 @@
+package hirondelle.web4j.readconfig;
+
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Util;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringTokenizer;
+
+/**
+ Parse database config settings.
+ 
+  <P>The format of these settings has 2 syntactical forms:
+  <ul>
+   <li>1 value, no ~ separator - the single value is applied to all databases.
+   <li>1 default value + N name=value pairs, separated by ~; the name=value pairs represent overrides to the default value; 
+   the default value is always present.
+   </ul>
+   
+   <P>Example of the second style:
+   <PRE>100~Translation=200~Access=300</PRE>
+   
+   Here, the first entry represents the setting for the default database.
+   It also represents the setting for all other databases, unless an override value is 
+   provided by one of the name-value pairs. 
+   
+   <P>The other entries are name=value pairs; the name must be a name of one of the
+   non-default databases known to ConnectionSource.
+   
+   <P>Thus, the database named 'Translation' has a value of 200, different from the default
+   value of 100. If the setting instead was 
+   
+   <PRE>100~Access=300</PRE>
+   
+   then the value for the Translation database would be 100 - same as the default.
+   
+   <P>The ~ separator is used instead of a comma, since the DateTime format setting can use a comma internally.
+*/
+final class DbConfigParser {
+  
+  /** 
+   Constructor.
+   Parses a setting into its component parts.
+   @param aRawText see class comment.  
+  */
+  DbConfigParser(String aRawText){
+    Args.checkForContent(aRawText);
+    fRawText = aRawText;
+    parse();
+  }
+
+  /**
+   Return the value appropriate to the given database. 
+   If no value was explicitly set for the given database, then return the value used by 
+   the default database. 
+   If <tt>aDatabaseName</tt> has no content, then the default value is returned.
+   */
+  String getValue(String aDatabaseName){
+    String result = "";
+    if(Util.textHasContent(aDatabaseName)) { 
+      result =  fValuesTable.get(aDatabaseName);
+      if (! Util.textHasContent(result)) {
+        result = getDefaultValue();
+      }
+    }
+    else {
+      result =  getDefaultValue();
+    }
+    return result;
+  }
+  
+  /**
+   Return the database names mentioned in this setting.  
+   <P>Always includes the default database, represented here as an empty String.
+   This method can be used to verify that the names are among the names defined by 
+   ConnectionSource. 
+  */
+  Set<String> getDbNames(){
+    return Collections.unmodifiableSet(fValuesTable.keySet());
+  }
+ 
+  // PRIVATE 
+  private String fRawText;
+  private Map<String, String> fValuesTable = new LinkedHashMap<String, String>();
+  
+  //The default db is defined here as having an empty name; this doesn't necessarily 
+  //have to match ConnectionSource.
+  private String DEFAULT_DB = "";
+  
+  /** Return the setting's default value. */
+  private String getDefaultValue(){
+    return fValuesTable.get(DEFAULT_DB);
+  }
+  
+  private void parse(){
+    String DELIM_TILDE = "~";
+    String DELIM_EQUALS = "=";
+    if(! fRawText.contains(DELIM_TILDE)){
+      fValuesTable.put(DEFAULT_DB, fRawText.trim());
+    }
+    else {
+      StringTokenizer parts = new StringTokenizer(fRawText, DELIM_TILDE);
+      String firstPart = parts.nextToken().trim();
+      if (firstPart.contains(DELIM_EQUALS)) {
+        throw new IllegalArgumentException("The first value contains an equals sign. Syntax of setting not correct : " + Util.quote(fRawText)); 
+      }
+      fValuesTable.put(DEFAULT_DB, firstPart);
+      while (parts.hasMoreTokens()) {
+        String part = parts.nextToken();
+        StringTokenizer nvp = new StringTokenizer(part, DELIM_EQUALS);
+        String name = nvp.nextToken();
+        String value = nvp.nextToken();
+        Args.checkForContent(name);
+        Args.checkForContent(value);
+        if(fValuesTable.containsKey(name)){
+          throw new IllegalArgumentException("Setting has invalid syntax. Have you repeated the name of a database?: " + Util.quote(fRawText));
+        }
+        fValuesTable.put(name.trim(), value.trim());
+      }
+    }
+    //fLogger.fine("Parsed database setting: " + fValuesTable);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/ParseDecimalFormat.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,55 @@
+package hirondelle.web4j.readconfig;
+
+import hirondelle.web4j.util.Regex;
+
+import java.util.regex.Pattern;
+
+final class ParseDecimalFormat {
+
+  /** Used to validate user input of numbers having a decimal point. */
+  Pattern getDecimalFormatPattern(String aDecimalSeparator){
+    return buildDecimalFormat(aDecimalSeparator);
+  }
+
+  // PRIVATE 
+  private static final String COMMA = "COMMA";
+  private static final String PERIOD = "PERIOD";
+  private static final String PERIOD_OR_COMMA = "PERIOD,COMMA";
+
+  /** Return the pattern applicable to numeric input of a number with a possible decimal portion. */
+  private Pattern buildDecimalFormat(String aDecimalSeparator){
+    String pattern = "";
+    String sign = "(?:-|\\+)?";
+    String digits = "[0-9]+";
+    String decimalSign = getDecimalSignPattern(aDecimalSeparator);
+    String places  = "[0-9]+";
+    // pattern = sign?(digits|digits.places|.places) 
+    pattern = sign + "(" + digits + Regex.OR + digits + decimalSign + places + Regex.OR + decimalSign + places + ")";
+    return Pattern.compile(pattern);
+  }
+ 
+  private String getDecimalSignPattern(String fDecimalSeparator){
+    String result = null;
+    if( PERIOD.equalsIgnoreCase(fDecimalSeparator) ) {
+      result = "(?:\\.)";
+    }
+    else if ( COMMA.equalsIgnoreCase(fDecimalSeparator) ){ 
+      result = "(?:,)";
+    }
+    else if ( PERIOD_OR_COMMA.equalsIgnoreCase(fDecimalSeparator)){
+      result = "(?:\\.|,)";
+    }
+    else {
+      vomit(
+        "In web.xml, the setting for DecimalSeparator is not in the expected format. " + 
+        "See web.xml for more information."
+      );
+    }
+    return result;
+  }
+ 
+  private void vomit(String aMessage){
+    //fLogger.severe(aMessage);
+    throw new IllegalArgumentException(aMessage);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/ParseUntrustedProxy.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,85 @@
+package hirondelle.web4j.readconfig;
+
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.StringTokenizer;
+import java.util.logging.Logger;
+
+import hirondelle.web4j.security.UntrustedProxyForUserIdImpl;
+
+final class ParseUntrustedProxy {
+  
+  Map<String, List<String>> parse(String aRawValue){
+    List<String> lines = parseSeparateLines(aRawValue);
+    for(String line: lines){
+       parseNounsAndVerbs(line);      
+    }
+    fLogger.config(Util.logOnePerLine(fRestrictedOperations));
+    return fRestrictedOperations;
+  }
+
+  // PRIVATE 
+  private static final Logger fLogger = Util.getLogger(Config.class); // borrow a public logger, since this class is not public
+  
+  /** Maps nouns to corresponding verbs. */
+  private Map<String, List<String>> fRestrictedOperations = new LinkedHashMap<String, List<String>>();
+  
+  private static final String INIT_PARAM_NAME = "UntrustedProxyForUserId";
+
+  private List<String> parseSeparateLines(String aRawValue){
+    List<String> result = new  ArrayList<String>();
+    StringTokenizer parser = new StringTokenizer(aRawValue, "\n\r");
+    while ( parser.hasMoreTokens() ) {
+      result.add( parser.nextToken().trim() );
+    }
+    return result;    
+  }
+
+  private void parseNounsAndVerbs(String aLine){
+    String verb = WebUtil.getFileExtension(aLine);
+    int verbStart = aLine.indexOf(".");
+    String noun = aLine.substring(0,verbStart);
+    if( isMissing(verb)  || isMissing(noun) ){
+      throw new RuntimeException(
+        "This line for the " + INIT_PARAM_NAME + " setting in web.xml does not have the expected form: " + Util.quote(aLine)
+      );
+    }
+    add(noun.trim(), verb.trim());
+  }
+  
+  private boolean isMissing(String aText){
+    return ! Util.textHasContent(aText);
+  }
+  
+  private void add(String aNoun, String aVerb){
+    if( fRestrictedOperations.containsKey(aNoun) ) {
+      addAnotherVerb(aNoun, aVerb);
+    }
+    else {
+      addNewNounAndVerb(aNoun, aVerb);
+    }
+  }
+  
+  private void addNewNounAndVerb(String aNoun, String aVerb){
+    List<String> verbs = new ArrayList<String>();
+    verbs.add(aVerb);
+    fRestrictedOperations.put(aNoun, verbs);
+  }
+  
+  private void addAnotherVerb(String aNoun, String aVerb){
+    if( UntrustedProxyForUserIdImpl.ALL_OPERATIONS.equals(aVerb) ){
+      throw new RuntimeException(
+        "When you use the '" + UntrustedProxyForUserIdImpl.ALL_OPERATIONS + "' character to represent ALL operations, then only one line can be present for that item. " + 
+        "In web.xml, you have a redundant setting for the init-param named " + INIT_PARAM_NAME + 
+        " which needs to be removed. It is related to " +  Util.quote(aNoun) + " and " + Util.quote(aVerb)
+      );
+    }
+    List<String> verbs = fRestrictedOperations.get(aNoun);
+    verbs.add(aVerb);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/TESTDbConfigParser.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,55 @@
+package hirondelle.web4j.readconfig;
+
+import junit.framework.TestCase;
+
+public final class TESTDbConfigParser extends TestCase {
+
+  /** Run the test cases. */
+  public static void main(String args[]) {
+    String[] testCaseName = {TESTDbConfigParser.class.getName()};
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTDbConfigParser(String aName) {
+    super(aName);
+  }
+  
+  // TEST CASES
+  
+  public void testParse(){
+    testParse("100", SUCCESS);
+    testParse("100~A=200", SUCCESS);
+    testParse("100~A=200~B=300", SUCCESS);
+    
+    testParse("=", FAIL);
+    testParse("100~200", FAIL); 
+    testParse("A=100~B=200", FAIL);
+    testParse("100~A=200~A=300", FAIL);
+  }
+  
+  // PRIVATE 
+  private static final boolean SUCCESS = true;
+  private static final boolean FAIL = false;
+  
+  private void testParse(String aRawText, boolean aSuccess) {
+    if(aSuccess){
+      try {
+        DbConfigParser parser = new DbConfigParser(aRawText);
+        //parser.parse();
+      }
+      catch(Throwable ex){
+        fail();
+      }
+    }
+    else {
+      try {
+        DbConfigParser parser = new DbConfigParser(aRawText);
+        //parser.parse();
+        fail();
+      }
+      catch(Throwable ex){
+        //OK
+      }
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/TextBlockReader.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,278 @@
+package hirondelle.web4j.readconfig;
+
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.Util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.LineNumberReader;
+import java.io.StringReader;
+import java.util.Map;
+import java.util.Properties;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ (UNPUBLISHED) Helper for {@link ConfigReader}, for reading {@link ConfigReader.FileType#TEXT_BLOCK} 
+ files.
+
+ <P>See {@link ConfigReader.FileType#TEXT_BLOCK} for specification of the format of such files.
+*/
+final class TextBlockReader {
+ 
+  /**
+   @param aInput has an underlying <tt>TextBlock</tt> file as source
+   @param aConfigFileName the underlying source file name
+  */
+  TextBlockReader(InputStream aInput, String aConfigFileName) {
+    fReader = new LineNumberReader(new InputStreamReader(aInput));
+    fConfigFileName = aConfigFileName;
+    fConstants = new Properties();
+  }
+  
+  TextBlockReader(String aRawSqlFile) {
+    fReader = new LineNumberReader(new StringReader(aRawSqlFile));
+    fConfigFileName = "<file name unspecified>";
+    fConstants = new Properties();
+  }
+  
+  /**
+   Parse the underlying <tt>TEXT_BLOCK</tt> file into a {@link Properties} object, which 
+   uses key-value pairs of <tt>String</tt>s.
+  
+   <P>Using this example entry in a <tt>*.sql</tt> file : 
+   <PRE>
+   FETCH_NUM_MSGS_FOR_USER {
+     SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+   }
+  </PRE> 
+   the key is <P><tt>FETCH_NUM_MSGS_FOR_USER</tt> <P> while the value is 
+   <P><tt>SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?</tt>.
+  */
+  Properties read() throws IOException {
+    fLogger.fine("Reading text block file : " + Util.quote(fConfigFileName));
+    final Properties result = new Properties();
+    String line = null;
+    while (( line = fReader.readLine()) != null){
+      if ( isIgnorable(line) ) {
+        continue;
+      }
+      if ( ! isInBlock() ){
+        startBlock(line);
+      }
+      else {
+        if ( ! isEndOfBlock(line) ){
+          addLine(line);
+        }
+        else {
+          endBlock(result);
+        }
+      }
+    }
+    return result;
+  }
+  
+  // PRIVATE 
+  private final LineNumberReader fReader;
+  private final String fConfigFileName;
+  private StringBuilder fBlockBody;
+  
+  /*
+   These two distinguish between regular blocks and 'constants' blocks. 
+   sql statements, and those for constants.
+  */
+  private boolean fIsReadingBlockBody;
+  private boolean fIsReadingConstants;
+  
+  /**
+   For regular Blocks, refers to the Block Name.
+   For constants, refers not to the name of the containing block (which is always 
+   the same), but to the identifier for the constant itself; this is line-level, 
+   not block-level.
+  */
+  private String fKey;
+  
+  /**
+   Remembers where the current block started. Used for debugging messages.
+  */
+  private int fBlockStartLineNumber;
+  
+  /**
+   Stores the constants, which are always intended for substitutions appearing 
+   later in the file. These items are not added to the final Properties object 
+   returned by this class; rather, they assist in performing substitutions of 
+   simple constants. 
+  
+   <P>There can be multiple Constants blocks in a TEXT_BLOCK file. This item 
+   collects them all into one data structure.
+  */
+  private final Properties fConstants;
+  
+  private static final String fCOMMENT = "--";
+  private static final String fEND_BLOCK = "}";
+  private static final String fSTART_BLOCK = "{";
+  private static final Pattern fKEY_NAME_PATTERN = Pattern.compile(Regex.SIMPLE_SCOPED_IDENTIFIER);
+  private static final Pattern fSUBSTITUTION_PATTERN = Pattern.compile(
+    "(?:\\$\\{)" + Regex.SIMPLE_IDENTIFIER +  "(?:\\})"
+  );
+  private static final String fCONSTANTS_BLOCK = "constants"; //ignore case
+  private static final String fCONSTANTS_SEPARATOR = "="; 
+  private static final int fNOT_FOUND = -1;
+  
+  private static final Logger fLogger = Util.getLogger(TextBlockReader.class);  
+  
+  private boolean isIgnorable(String aLine){
+    boolean result;
+    if ( isInBlock() ) {
+      //no empty lines within blocks allowed
+      result = isComment(aLine);
+    }
+    else {
+      result = ! Util.textHasContent(aLine) || isComment(aLine);
+    }
+    return result;
+  }
+
+  private boolean isInBlock(){
+    return fIsReadingBlockBody || fIsReadingConstants;
+  }
+  
+  private boolean isComment(String aLine){
+    return aLine.trim().startsWith(fCOMMENT);
+  }
+  
+  private void startBlock(String aLine){
+    fBlockStartLineNumber = fReader.getLineNumber();
+    fKey = getBlockName(aLine);
+    if ( fCONSTANTS_BLOCK.equalsIgnoreCase(fKey) ) {
+      fIsReadingConstants = true;
+      fIsReadingBlockBody = false;
+    }
+    else {
+      fBlockBody = new StringBuilder();
+      fIsReadingBlockBody = true;
+      fIsReadingConstants = false;
+    }
+  }
+  
+  private String getBlockName(String aLine){
+    int indexOfBrace = aLine.indexOf(fSTART_BLOCK);
+    if ( indexOfBrace == -1 ){
+      throw new IllegalArgumentException(
+        reportLineNumber() + 
+        "Expecting to find line defining a block, containing a trailing " + 
+        Util.quote(fSTART_BLOCK) + ". Found this line instead : " + Util.quote(aLine)
+      );
+    }
+    String candidateKey = aLine.substring(0, indexOfBrace).trim();
+    return verifiedKeyName(candidateKey);
+  }
+  
+  private String verifiedKeyName(String aCandidateKey){
+    if (Util.matches(fKEY_NAME_PATTERN, aCandidateKey)) {
+      return aCandidateKey;
+    }
+    String message = reportLineNumber() + "The name " + 
+      Util.quote(aCandidateKey) + " is not in the expected syntax. " + 
+      "It does not match the regular expression " + fKEY_NAME_PATTERN.pattern()
+    ;
+    throw new IllegalArgumentException(message);
+  }
+  
+  private boolean isEndOfBlock(String aLine){
+    return fEND_BLOCK.equals(aLine.trim());
+  }
+  
+  private void addLine(String aLine){
+    if ( fIsReadingBlockBody) {
+      addToBlockBody(aLine);
+    }
+    else if ( fIsReadingConstants ){
+      addConstant(aLine);
+    }
+  }
+  
+  private void addToBlockBody(String aLine){
+    fBlockBody.append(aLine);
+    fBlockBody.append(NEW_LINE);
+  }
+  
+  private void addConstant(String aLine){
+    int idxFirstSeparator = aLine.indexOf(fCONSTANTS_SEPARATOR);
+    if ( idxFirstSeparator == fNOT_FOUND ) {
+      String message = 
+        reportLineNumber() + "Cannot find expected constants separator character : " + 
+        Util.quote(fCONSTANTS_SEPARATOR)
+      ;
+      throw new IllegalAccessError(message);
+    }
+    String key = verifiedKeyName(aLine.substring(0, idxFirstSeparator).trim());
+    String value = aLine.substring(idxFirstSeparator + 1).trim();
+    addToResult(key, value, fConstants);
+  }
+  
+  /**
+   Allow a duplicate key, but log as SEVERE.
+  */
+  private void addToResult(String aKey, String aValue, Map aResult){
+    if ( aResult.containsKey(aKey) ) {
+      fLogger.severe(
+        "DUPLICATE Value found for this Block Name or constant name " + Util.quote(aKey) + 
+        ". This almost always indicates an error."
+      );
+    }
+    aResult.put(aKey, aValue);
+  }
+  
+  private void endBlock(Properties aResult){
+    if (fIsReadingBlockBody){
+      String finalBlockBody = resolveAnySubstitutions(fBlockBody.toString().trim(), aResult);
+      addToResult(fKey, finalBlockBody, aResult);
+    }
+    fIsReadingBlockBody = false;
+    fIsReadingConstants = false;
+    fBlockStartLineNumber = 0;
+  }
+  
+  private String resolveAnySubstitutions(String aRawBlockBody, Properties aResult){
+    StringBuffer result = new StringBuffer();
+    Matcher matcher = fSUBSTITUTION_PATTERN.matcher(aRawBlockBody);
+    while ( matcher.find() ){
+      matcher.appendReplacement(result, getReplacement(matcher, aResult));
+    }
+    matcher.appendTail(result);
+    return result.toString();
+  }
+  
+  private String getReplacement(Matcher aMatcher, Properties aProperties){
+    String replacementKey = aMatcher.group(1);
+    //the replacement may be another Block Body, or it may be a constant
+    String replacement = aProperties.getProperty(replacementKey);
+    if ( ! Util.textHasContent(replacement) ) {
+      replacement = fConstants.getProperty(replacementKey);
+    }
+    if ( ! Util.textHasContent(replacement) ) {
+      throw new IllegalArgumentException(
+        reportBlockStartLineNumber() + 
+        "The substitution variable ${" + replacementKey + 
+        "} is not defined. Substitution variables must be defined before they " + 
+        "are referenced. Please correct the ordering problem."
+      );
+    }
+    fLogger.finest(
+      "Replacement for " + Util.quote(replacementKey) + " is " + Util.quote(replacement)
+    );
+    return replacement;
+  }
+  
+  private String reportLineNumber(){
+    return "[" + fConfigFileName + ":" + Integer.toString(fReader.getLineNumber()) + "] ";
+  }
+  
+  private String reportBlockStartLineNumber(){
+    return "[" + fConfigFileName + ":" + Integer.toString(fBlockStartLineNumber) + "] ";
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/readconfig/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,13 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Utilities</title>
+</head>
+<body>
+Access to configured items.
+<br>&nbsp;
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/DateConverter.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,158 @@
+package hirondelle.web4j.request;
+
+import hirondelle.web4j.model.DateTime;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ Convert text into a {@link DateTime} or {@link Date} object, and vice versa.
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forDateConverter()} 
+ returns the configured implementation of this interface.
+ 
+ <P>This interface has methods which occur in pairs, one for a {@link DateTime}, and one for a {@link Date}.
+ The term 'date' used below refers to generically to both of these classes. WEB4J recommeds 
+ {@link DateTime} as the preferred class for representing dates. 
+ 
+ <P>The application programmer's implementation of this interface is used when WEB4J needs to 
+ build a date from user input, or format an existing date. Here is an  
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/DateConverterImpl.html">example implementation</a>.
+ 
+ <P><b><em>Design Notes</em></b><br>
+ Here are some forces concerning dates in Java web applications :
+<ul>
+ <li>usually, users expect date input formats to be the same for all forms.
+ <li>an application may have only dates, only date-times, or a mixture of the two.
+ <li>user input of a date or date-time may be with a single control, or with many controls. For example, 
+ the date may be entered with one control, and the hours and minutes with a second and third control, respectively.
+ (WEB4J works well with the one-control-only style. Using more than one control is still possible, since the application 
+ programmer always has access to the underlying request parameters. But, when using WEB4J, 
+ that style involves slightly more work for the application programmer.)
+ <li><span class="highlight">the needs of the eye differ from the needs of the hand.</span> That is, a date format 
+ which is easy to <em>read</em> is usually not easy to <em>enter</em> into a control. Thus, applications should support <em>two</em> formats for 
+ dates : a <em>hand-friendly</em> format (used for input), and an <em>eye-friendly</em> format, used for 
+ presentation <em>and</em> for input as well (allowing user input with an eye-friendly format is 
+ important for forms which change existing data).
+ <li>date formats may differ between Locales. For example, <tt>'01-31-2006'</tt> is natural for English speakers 
+ (January 31, 2006), while <tt>'31-01-2006'</tt> is more natural for French speakers (le 31 janvier 2006). 
+ <li>for <em>parsing</em> tasks, {@link java.text.SimpleDateFormat} is mediocre, and perhaps even untrustworthy. It should be used with great care.
+ One should also be aware that it's not thread-safe.
+ <li>if a new {@link Locale} is added to an application, then parsing and formatting of a date should not fail. 
+ Instead, reasonable defaults should be defined for unknown <tt>Locale</tt>s.
+</ul>
+*/
+public interface DateConverter {
+
+  /**
+   Parse textual user input of a "hand-friendly" format into a {@link Date} object.
+   
+   <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be 
+   <tt>'Jan 31, 2006'</tt>.
+   <P>The implementation must return <tt>null</tt> when the user input cannot be 
+   successfully parsed into a {@link Date}. It is recommended that the implementation 
+   have reasonable default behaviour for unexpected {@link Locale}s.
+   <P>This method is called by {@link RequestParser}.
+   
+   @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} 
+   (always has content).
+   @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method
+   by the framework.
+   @param aTimeZone is obtained from the configured {@link TimeZoneSource}, and passed to this method
+   by the framework.
+  */
+  Date parseHandFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone);
+  
+  /**
+   Parse textual user input of an "eye-friendly" format into a {@link Date} object.
+   
+   <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be 
+   <tt>'Jan 31, 2006'</tt>.
+   <P>The implementation must return <tt>null</tt> when the user input cannot be 
+   successfully parsed into a {@link Date}. It is recommended that the implementation 
+   have reasonable default behaviour for unexpected {@link Locale}s.
+   <P>This method is called by {@link RequestParser}.
+   
+   @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} 
+   (always has content).
+   @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method
+   by the framework.
+   @param aTimeZone is obtained from the configured {@link TimeZoneSource}, and passed to this method
+   by the framework.
+  */
+  Date parseEyeFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone);
+  
+  /**
+   Format a {@link Date} into an eye-friendly, legible format.
+    
+   <P>The implementation must return an empty <tt>String</tt> when the {@link Date} is null.
+   It is recommended that the implementation have reasonable default behaviour for unexpected {@link Locale}s.
+   <P>The framework will call this method when presenting listings using 
+   {@link hirondelle.web4j.database.Report}, and when presenting a Model Object in a form 
+   for a "change" operation.  
+   <P>This method is called by {@link Formats}.
+   
+   @param aDate to be presented to the user in a legible format
+   @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method
+   by the framework.
+   @param aTimeZone is obtained from the configured {@link TimeZoneSource}, and passed to this method
+   by the framework.
+   @return text compatible with {@link #parseEyeFriendly(String, Locale, TimeZone)}
+  */
+  String formatEyeFriendly(Date aDate, Locale aLocale, TimeZone aTimeZone);
+
+  /**
+    Parse textual user input of a "hand-friendly" format into a {@link DateTime} object.
+    
+    <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be 
+    <tt>'Jan 31, 2006'</tt>.
+    <P>The implementation must return <tt>null</tt> when the user input cannot be 
+    successfully parsed into a {@link DateTime}. It is recommended that the implementation 
+    have reasonable default behaviour for unexpected {@link Locale}s.
+    <P>This method is called by {@link RequestParser}.
+    
+    @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} 
+    (always has content).
+    @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method
+    by the framework.
+  */
+  DateTime parseHandFriendlyDateTime(String aInputValue, Locale aLocale);
+  
+  /**
+    Parse textual user input of an "eye-friendly" format into a {@link DateTime} object.
+    
+    <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be 
+    <tt>'Jan 31, 2006'</tt>.
+    <P>The implementation must return <tt>null</tt> when the user input cannot be 
+    successfully parsed into a {@link DateTime}. It is recommended that the implementation 
+    have reasonable default behaviour for unexpected {@link Locale}s.
+    <P>This method is called by {@link RequestParser}.
+    
+    @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} 
+    (always has content).
+    @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method
+    by the framework.
+  */
+  DateTime parseEyeFriendlyDateTime(String aInputValue, Locale aLocale);
+  
+  /**
+    Format a {@link DateTime} into an eye-friendly, legible format.
+     
+    <P>The implementation must return an empty <tt>String</tt> when the {@link DateTime} is null.
+    It is recommended that the implementation have reasonable default behaviour for unexpected {@link Locale}s.
+    <P>The framework will call this method when presenting listings using 
+    {@link hirondelle.web4j.database.Report}, and when presenting a Model Object in a form 
+    for a "change" operation.  
+    <P>This method is called by {@link Formats}.
+    
+    @param aDateTime to be presented to the user in a legible format
+    @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method
+    by the framework.
+    @return text compatible with {@link #parseEyeFriendlyDateTime(String, Locale)}
+  */
+  String formatEyeFriendlyDateTime(DateTime aDateTime, Locale aLocale);
+  
+  
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/Formats.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,364 @@
+package hirondelle.web4j.request;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.Code;
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.model.Decimal;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+
+import java.math.BigDecimal;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+/**
+ Standard display formats for the application.
+ 
+ <P>The formats used by this class are <em>mostly</em> configured in 
+ <tt>web.xml</tt>, and are read by this class upon startup. 
+ <span class="highlight">See the <a href='http://www.web4j.com/UserGuide.jsp#ConfiguringWebXml'>User Guide</tt> 
+ for more information.</span>
+ 
+ <P>Most formats are localized using the {@link java.util.Locale} passed to this object. 
+ See {@link LocaleSource} for more information.  
+
+ <P>These formats are intended for implementing standard formats for display of 
+ data both in forms ({@link hirondelle.web4j.ui.tag.Populate}) and in 
+ listings ({@link hirondelle.web4j.database.Report}).
+ 
+ <P>See also {@link DateConverter}, which is also used by this class.
+*/
+public final class Formats {
+  
+  /**
+   Construct with a {@link Locale} and {@link TimeZone} to be applied to non-localized patterns. 
+   
+   @param aLocale almost always comes from {@link LocaleSource}.
+   @param aTimeZone almost always comes from {@link TimeZoneSource}. A defensive copy is made of 
+   this mutable object.
+  */
+  public Formats(Locale aLocale, TimeZone aTimeZone){
+    fLocale = aLocale;
+    fTimeZone = TimeZone.getTimeZone(aTimeZone.getID()); //defensive copy
+    fDateConverter = BuildImpl.forDateConverter();
+  }
+  
+  /** Return the {@link Locale} passed to the constructor.  */
+  public Locale getLocale(){
+    return fLocale;
+  }
+  
+  /** Return a TimeZone of the same id as the one passed to the constructor.  */
+  public TimeZone getTimeZone(){
+    return TimeZone.getTimeZone(fTimeZone.getID());
+  }
+  
+  /** Return the format in which {@link BigDecimal}s and {@link Decimal}s are displayed in a form.  */
+  public DecimalFormat getBigDecimalDisplayFormat(){
+    return getDecimalFormat(fConfig.getBigDecimalDisplayFormat());
+  }
+  
+  /**
+   Return the regular expression for validating the format of numeric amounts input by the user, having a 
+   possible decimal portion, with any number of decimals.
+    
+   <P>The returned {@link Pattern} is controlled by a setting in <tt>web.xml</tt>, 
+   for decimal separator(s). It is suitable for both {@link Decimal} and {@link BigDecimal} values.
+   This item is not affected by a {@link Locale}. 
+   
+   <P>See <tt>web.xml</tt> for more information. 
+  */
+  public Pattern getDecimalInputFormat(){
+    return fConfig.getDecimalInputPattern();
+  }
+  
+  /** Return the format in which integer amounts are displayed in a report.  */
+  public DecimalFormat getIntegerReportDisplayFormat(){
+    return getDecimalFormat(fConfig.getIntegerDisplayFormat());
+  }
+  
+  /**
+   Return the text used to render boolean values in a report.
+   
+   <P>The return value does not depend on {@link Locale}.
+  */
+  public static String getBooleanDisplayText(Boolean aBoolean){
+    Config config = new Config();
+    return aBoolean ? config.getBooleanTrueDisplayFormat() : config.getBooleanFalseDisplayFormat();
+  }
+
+  /**
+   Return the text used to render empty or <tt>null</tt> values in a report.
+   
+   <P>The return value does not depend on {@link Locale}. See <tt>web.xml</tt> for more information.
+  */
+  public static String getEmptyOrNullText() {
+    return new Config().getEmptyOrNullDisplayFormat();
+  }
+  
+  /**
+   Translate an object into text, suitable for presentation <em>in an HTML form</em>.
+   
+   <P>The intent of this method is to return values matching those POSTed during form submission, 
+   not the visible text presented to the user. 
+   
+   <P>The returned text is not escaped in any way.
+   That is, <em>if special characters need to be escaped, the caller must perform the escaping</em>.
+   
+   <P>Apply these policies in the following order :
+  <ul>
+   <li>if <tt>null</tt>, return an empty <tt>String</tt>
+   <li>if a {@link DateTime}, apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)}
+   <li>if a {@link Date}, apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}
+   <li>if a {@link BigDecimal}, display in the form of {@link BigDecimal#toString}, with 
+   one exception : the decimal separator will be as configured in <tt>web.xml</tt>. 
+   (If the setting for the decimal separator allows for <em>both</em> a period and a comma, 
+   then a period is used.)
+   <li>if a {@link Decimal}, display the amount only, using the same rendering as for <tt>BigDecimal</tt> 
+   <li>if a {@link TimeZone}, return {@link TimeZone#getID()}
+   <li>if a {@link Code}, return {@link Code#getId()}.toString()
+   <li>if a {@link Id}, return {@link Id#getRawString()}
+   <li>if a {@link SafeText}, return {@link SafeText#getRawString()}
+   <li>otherwise, return <tt>aObject.toString()</tt>
+  </ul>
+   
+   <P>If <tt>aObject</tt> is a <tt>Collection</tt>, then the caller must call 
+   this method for every element in the <tt>Collection</tt>.
+  
+   @param aObject must not be a <tt>Collection</tt>.
+  */
+  public String objectToText(Object aObject) {
+    String result = null;
+    if ( aObject == null ){
+      result = Consts.EMPTY_STRING;
+    }
+    else if ( aObject instanceof DateTime ){
+      DateTime dateTime = (DateTime)aObject;
+      result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale);
+    }
+    else if ( aObject instanceof Date ){
+      Date date = (Date)aObject;
+      result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone);
+    }
+    else if ( aObject instanceof BigDecimal ){
+      BigDecimal amount = (BigDecimal)aObject;
+      result = renderBigDecimal(amount);
+    }
+    else if ( aObject instanceof Decimal ){
+      Decimal money = (Decimal)aObject;
+      result = renderBigDecimal(money.getAmount());
+    }
+    else if ( aObject instanceof TimeZone ) {
+      TimeZone timeZone = (TimeZone)aObject;
+      result = timeZone.getID();
+    }
+    else if ( aObject instanceof Code ) {
+      Code code = (Code)aObject;
+      result = code.getId().getRawString();
+    }
+    else if ( aObject instanceof Id ) {
+      Id id = (Id)aObject;
+      result = id.getRawString();
+    }
+    else if ( aObject instanceof SafeText ) {
+      //The Populate tag will safely escape all such text data.
+      //To avoid double escaping, the raw form is returned.
+      SafeText safeText = (SafeText)aObject;
+      result = safeText.getRawString();
+    }
+    else {
+      result = aObject.toString();
+    }
+    return result;
+  }
+  
+  /**
+   Translate an object into text suitable for direct presentation in a JSP.
+   
+   <P>In general, a report can be rendered in various ways: HTML, XML, plain text. 
+   Each of these styles has different needs for escaping special characters. 
+   This method returns a {@link SafeText}, which can escape characters in 
+   various ways. 
+   
+   <P>This method applies the following policies to get the <em>unescaped</em> text :
+   <P>
+   <table border=1 cellpadding=3 cellspacing=0>
+    <tr><th>Type</th> <th>Action</th></tr>
+    <tr>   
+     <td><tt>SafeText</tt></td> 
+     <td>use {@link SafeText#getRawString()}</td>
+    </tr>
+    <tr>   
+     <td><tt>Id</tt></td> 
+     <td>use {@link Id#getRawString()}</td>
+    </tr>
+    <tr>   
+     <td><tt>Code</tt></td> 
+     <td>use {@link Code#getText()}.getRawString()</td>
+    </tr>
+    <tr>
+     <td><tt>hirondelle.web4.model.DateTime</tt></td> 
+     <td>apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)} </td>
+    </tr>
+    <tr>
+     <td><tt>java.util.Date</tt></td> 
+     <td>apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)} </td>
+    </tr>
+    <tr>
+     <td><tt>BigDecimal</tt></td> 
+     <td>use {@link #getBigDecimalDisplayFormat} </td>
+    </tr>
+    <tr>
+     <td><tt>Decimal</tt></td> 
+     <td>use {@link #getBigDecimalDisplayFormat} on <tt>decimal.getAmount()</tt></td>
+    </tr>
+    <tr>
+     <td><tt>Boolean</tt></td> 
+     <td>use {@link #getBooleanDisplayText} </td>
+    </tr>
+    <tr>
+     <td><tt>Integer</tt></td> 
+     <td>use {@link #getIntegerReportDisplayFormat} </td>
+    </tr>
+    <tr>
+     <td><tt>Long</tt></td> 
+     <td>use {@link #getIntegerReportDisplayFormat} </td>
+    </tr>
+    <tr>   
+     <td><tt>Locale</tt></td> 
+     <td>use {@link Locale#getDisplayName(java.util.Locale)} </td>
+    </tr>
+    <tr>   
+     <td><tt>TimeZone</tt></td> 
+     <td>use {@link TimeZone#getDisplayName(boolean, int, java.util.Locale)} (with no daylight savings hour, and in the <tt>SHORT</tt> style </td>
+    </tr>
+    <tr>
+     <td>..other...</td> 
+     <td>
+       use <tt>toString</tt>, and pass result to constructor of {@link SafeText}. 
+     </td>
+    </tr>
+   </table>
+  
+   <P>In addition, the value returned by {@link #getEmptyOrNullText} is used if :
+   <ul>
+   <li><tt>aObject</tt> is itself <tt>null</tt>
+   <li>the result of the above policies returns text which has no content
+  </ul>
+  */
+  public SafeText objectToTextForReport(Object aObject) {
+    String result = null;
+    if ( aObject == null ){
+      result = null;
+    }
+    else if (aObject instanceof SafeText){
+      //it is odd to extract an identical object like this, 
+      //but it safely avoids double escaping at the end of this method
+      SafeText text = (SafeText) aObject;
+      result = text.getRawString();
+    }
+    else if (aObject instanceof Id){
+      Id id = (Id) aObject;
+      result = id.getRawString();
+    }
+    else if (aObject instanceof Code){
+      Code code = (Code) aObject;
+      result = code.getText().getRawString();
+    }
+    else if (aObject instanceof String) {
+      result = aObject.toString();
+    }
+    else if ( aObject instanceof DateTime ){
+      DateTime dateTime = (DateTime)aObject;
+      result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale);
+    }
+    else if ( aObject instanceof Date ){
+      Date date = (Date)aObject;
+      result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone);
+    }
+    else if ( aObject instanceof BigDecimal ){
+      BigDecimal amount = (BigDecimal)aObject;
+      result = getBigDecimalDisplayFormat().format(amount.doubleValue());
+    }
+    else if ( aObject instanceof Decimal ){
+      Decimal money = (Decimal)aObject;
+      result = getBigDecimalDisplayFormat().format(money.getAmount().doubleValue());
+    }
+    else if ( aObject instanceof Boolean ){
+      Boolean value = (Boolean)aObject;
+      result = getBooleanDisplayText(value);
+    }
+    else if ( aObject instanceof Integer ) {
+      Integer value = (Integer)aObject;
+      result = getIntegerReportDisplayFormat().format(value);
+    }
+    else if ( aObject instanceof Long ) {
+      Long value = (Long)aObject;
+      result = getIntegerReportDisplayFormat().format(value.longValue());
+    }
+    else if ( aObject instanceof Locale ) {
+      Locale locale = (Locale)aObject;
+      result = locale.getDisplayName(fLocale);
+    }
+    else if ( aObject instanceof TimeZone ) {
+      TimeZone timeZone = (TimeZone)aObject;
+      result = timeZone.getDisplayName(false, TimeZone.SHORT, fLocale);
+    }
+    else {
+      result = aObject.toString();
+    }
+    //ensure that all empty results have configured content
+    if ( ! Util.textHasContent(result) ) {
+      result = fConfig.getEmptyOrNullDisplayFormat();
+    }
+    return new SafeText(result);
+  }
+  
+  // PRIVATE
+  private final Locale fLocale;
+  private final TimeZone fTimeZone;
+  private final DateConverter fDateConverter;
+  private Config fConfig = new Config();
+ 
+  private DecimalFormat getDecimalFormat(String aFormat){
+    DecimalFormat result = null;
+    NumberFormat format = NumberFormat.getNumberInstance(fLocale);
+    if (format instanceof DecimalFormat){
+      result = (DecimalFormat)format;
+    }
+    else {
+      throw new AssertionError();
+    }
+    result.applyPattern(aFormat);
+    return result;
+  }
+  
+  /**
+   Return the pattern applicable to numeric input of a number with a possible decimal portion. 
+  */
+  private String replacePeriodWithComma(String aValue){
+    return aValue.replace(".", ",");
+  }
+  
+  private String renderBigDecimal(BigDecimal aBigDecimal){
+    String result = aBigDecimal.toPlainString();
+    if( "COMMA".equalsIgnoreCase(fConfig.getDecimalSeparator()) ){
+      result = replacePeriodWithComma(result);
+    }
+    return result;
+  }
+  
+  /** Informal test harness.   */
+  private static void main(String... args){
+    Formats formats = new Formats(Locale.CANADA, TimeZone.getTimeZone("Canada/Atlantic"));
+    System.out.println("en_fr: " + formats.objectToTextForReport(new Locale("en_fr")));
+    System.out.println("Canada/Pacific: " + formats.objectToTextForReport(TimeZone.getTimeZone("Canada/Pacific")));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/LocaleSource.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,59 @@
+package hirondelle.web4j.request;
+
+import java.util.Locale;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ Return the {@link Locale} associated with a given request.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forLocaleSource()} 
+ returns the configured implementation of this interface. See {@link LocaleSourceImpl} for a default implementation. 
+ This interface is similar to {@link TimeZoneSource}, and is used in much the same way.
+ 
+ <P>In general, a {@link Locale} is used for two distinct operations :
+<ul>
+ <li>render objects such as <tt>Integer</tt>, <tt>Date</tt>, and so on
+ <li>translate text from one language to another 
+</ul> 
+
+ <P>Since almost all applications at least <em>render</em> data, then almost all applications 
+ will also use a {@link Locale} - <em>even single-language applications.</em> 
+ 
+ <P>In a single-language application, the returned <tt>Locale</tt> is used by WEB4J for 
+ rendering these items : 
+<ul>
+ <li>user response messages containing numbers and dates
+ <li>presenting <tt>ResultSets</tt> as reports (dates, numbers)
+ <li>prepopulating forms (dates, numbers)
+ <li>displaying dates with {@link hirondelle.web4j.ui.tag.ShowDate}
+</ul>
+
+ <P>If the application is multilingual, then the returned <tt>Locale</tt> is <em>also</em>
+  used for translating text. See {@link hirondelle.web4j.ui.translate.Translator} and related classes 
+  for more information. 
+ 
+ <P>A very large number of policies can be defined by implementations of this interface. 
+ Possible sources of <tt>Locale</tt> information include :
+<ul>
+ <li>a single setting in <tt>web.xml</tt>, place into application scope upon startup 
+ <li>an object stored in session scope
+ <li>a request parameter
+ <li>a request header
+ <li>a cookie
+</ul>
+
+ <P>All WEB4J applications have at least one {@link Locale} - the <tt>DefaultLocale</tt> setting 
+ configured in <tt>web.xml</tt>. (This allows the application's <tt>Locale</tt> to be set 
+ explicitly, independent of the server's default <tt>Locale</tt> setting, or of browser 
+ header settings.) For applications which use only a single language, that <tt>Locale</tt> defines 
+ how WEB4J will format the items mentioned above. For multilingual applications, this <tt>web.xml</tt> setting is 
+ reinterpreted as the <em>default</em> <tt>Locale</tt>, which is overridden by implementations of this 
+ interface. 
+*/
+public interface LocaleSource {
+  
+  /** Return a {@link Locale} corresponding to a given underlying request.  */
+  public Locale get(HttpServletRequest aRequest);
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/LocaleSourceImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,31 @@
+package hirondelle.web4j.request;
+
+import java.util.Locale;
+import javax.servlet.http.HttpServletRequest;
+
+import hirondelle.web4j.util.WebUtil;
+import hirondelle.web4j.Controller;
+
+/**
+ Retrieve the {@link Locale} stored in any scope under the key 
+ {@link hirondelle.web4j.Controller#LOCALE}.
+ 
+ <P>Upon startup, the {@link hirondelle.web4j.Controller} will read in the <tt>DefaultLocale</tt>
+ configured in <tt>web.xml</tt>, and place it in application scope under the key 
+ {@link hirondelle.web4j.Controller#LOCALE}, as a {@link Locale} object (not a {@link String}).
+ 
+ <P><em>If desired</em>, the application programmer can also store a user-specific 
+ {@link Locale} in session scope, under the same key. Thus, 
+ this class will first find the user-specific <tt>Locale</tt>, overriding the default 
+ <tt>Locale</tt> stored in application scope. 
+ 
+ <P>If any other behavior is desired, then simply provide an alternate implementation of 
+ {@link LocaleSource}.
+*/
+public class LocaleSourceImpl implements LocaleSource {
+  
+  /** See class comment. */
+  public Locale get(HttpServletRequest aRequest){
+    return (Locale)WebUtil.findAttribute(Controller.LOCALE, aRequest);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/RequestParameter.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,269 @@
+package hirondelle.web4j.request;
+
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelFromRequest;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.security.ApplicationFirewall;
+import hirondelle.web4j.util.Util;
+
+import java.util.regex.Pattern;
+
+/**
+ <span class="highlight">Request parameter as a name and 
+ (usually) an associated <em>regular expression</em>.</span>
+
+ <P>This class does not <em>directly</em> provide access to the parameter <em>value</em>.
+ For such services, please see {@link RequestParser} and {@link ModelFromRequest}. 
+  
+ <P>This class separates request parameters into two kinds : file upload request 
+ parameters, and all others (here called "regular" request parameters).
+  
+ <P><b><a name="Regular">Regular Request Parameters</a></b><br>
+ Regular request parameters are associated with :
+ <ul>
+ <li>a name, corresponding to both an underlying HTTP request parameter name, and 
+ an underlying control name - see <a href="#NamingConvention">naming convention</a>. 
+ <li>a regular expression, used by {@link ApplicationFirewall} to perform 
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a>  
+ </ul> 
+ 
+ <P><b><a name="FileUpload">File Upload Request Parameters</a></b><br>
+ Files are uploaded using forms having : 
+<ul>
+ <li> <tt>method="POST"</tt>
+ <li> <tt>enctype="multipart/form-data"</tt>
+ <li> an <tt>&lt;INPUT type="file"&gt;</tt> control
+</ul>
+ 
+ <P>In addition, note that the Servlet API does <em>not</em> have extensive services for 
+ processing file upload parameters. It is likely best to use a third party tool for 
+ that task. 
+ 
+ <P>File upload request parameters, <em>as represented by this class</em>, have only a
+ name associated with them, and no regular expression. This is because WEB4J 
+ cannot perform <a href="ApplicationFirewall.html#HardValidation">hard validation</a>  
+ on the value of a file upload parameter - since the user may select any file whatsoever, 
+ validation of file contents can only be treated 
+ as a <a href="ApplicationFirewall.html#SoftValidation">soft validation</a>. If there is a 
+ problem, the response to the user must be polished, as part of the normal operation of 
+ the application.  
+ 
+ <P>As an example, an {@link Action} might perform 
+ <a href="ApplicationFirewall.html#SoftValidation">soft validation</a> on a file upload parameter 
+ for these items : 
+ <ul>   
+ <li>file size does not exceed a maximum value
+ <li>MIME type matches a regular expression  
+ <li>file name matches a regular expression
+ <li>text file content may be matched to a regular expression
+ </ul> 
+ 
+ <P><b><a name="NamingConvention">Naming Convention</a></b><br>
+ <span class="highlight">Parameter names are usually not arbitrary in WEB4J.</span>
+ Instead, a simple convention is used which allows for automated mapping between 
+ request parameter names and corresponding <tt>getXXX</tt> methods of Model Objects  
+ (see {@link hirondelle.web4j.ui.tag.Populate}). For example, a parameter 
+ named <tt>'Birth Date'</tt> (or <tt>'birthDate'</tt>) is mapped to a method named 
+ <tt>getBirthDate()</tt> when prepopulating a form with the contents of 
+ a Model Object. (The <tt>'Birth Date'</tt> naming style is recommended, since it 
+ has this advantage : when messages regarding form input are presented to the user, 
+ the control name may be used directly, without trivial mapping  
+ of a 'coder-friendly' parameter name into more user-friendly text.)
+  
+ <P> Some parameters - notably those passed to <tt>Template.jsp</tt> - are not
+ processed at all by the <tt>Controller</tt>, but are used directly in JSPs 
+ instead. Such parameters do not undergo 
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a> by the 
+ {@link hirondelle.web4j.security.ApplicationFirewall}, and are not represented by this class.
+ 
+ <P> See {@link java.util.regex.Pattern} for more information on regular expressions.
+*/
+public final class RequestParameter { 
+  
+  /**
+   Return a <a href="#Regular">regular parameter</a> hard-validated only for 
+   name and size. 
+   
+   <P>The size is taken from the <tt>MaxRequestParamValueSize</tt> setting in <tt>web.xml</tt>.
+    
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+  */
+  public static RequestParameter withLengthCheck(String aName){
+    Config config = new Config();
+    String regex = "(.){0," + config.getMaxRequestParamValueSize() + "}";
+    Pattern lengthPattern = Pattern.compile(regex, Pattern.DOTALL);
+    return withRegexCheck(aName, lengthPattern);
+  }
+  
+  /**
+   Return a <a href="#Regular">regular parameter</a> hard-validated for name and 
+   for value matching a regular expression.
+   
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+   @param aValueRegex regular expression for doing hard validation of the request 
+   parameter value(s).
+  */
+  public static RequestParameter withRegexCheck(String aName, Pattern aValueRegex){
+    return new RequestParameter(aName, aValueRegex);    
+  }
+  
+  /**
+   Return a <a href="#Regular">regular parameter</a> hard-validated for name and 
+   for value matching a regular expression.
+   
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+   @param aValueRegex regular expression for doing hard validation of the request 
+   parameter value(s).
+  */
+  public static RequestParameter withRegexCheck(String aName, String aValueRegex){
+    return new RequestParameter(aName, aValueRegex);    
+  }
+  
+  /**
+   Constructor for a <a href="#FileUpload">file upload</a> request parameter. 
+   
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+  */
+  public static RequestParameter forFileUpload(String aName){
+    return new RequestParameter(aName); 
+  }
+  
+  /** Return the request parameter name.  */
+  public String getName(){
+    return fName;
+  }
+   
+  /**
+   Return the regular expression associated with this <tt>RequestParameter</tt>. 
+   
+   <P>This regular expression is used to perform 
+   <a href="ApplicationFirewall.html#HardValidation">hard validation</a> of this parameter's value(s).
+   
+   <P>This method will return <tt>null</tt> only for <a href="#FileUpload">file upload</a> parameters.
+  */
+  public Pattern getRegex(){
+    return fRegex;
+  }
+  
+  /**
+   Return <tt>true</tt> only if {@link #forFileUpload} was used to build this object. 
+  */
+  public boolean isFileUploadParameter() {
+    return ! fIsRegularParameter;
+  }
+  
+  /**
+   Return <tt>true</tt> only if <tt>aRawParamValue</tt> satisfies the regular expression 
+   {@link #getRegex()}, <em>or</em> if this is a <a href="#FileUpload">file upload</a> 
+   request parameter.
+   
+   <P>Always represents a <a href="ApplicationFirewall.html#HardValidation">hard validation</a>, not a 
+   soft validation.
+  */
+  public boolean isValidParamValue(String aRawParamValue){
+    boolean result = false;
+    if ( isFileUploadParameter() ){
+      result = true;   
+    }
+    else {
+      result = Util.matches(fRegex, aRawParamValue);
+    }
+    return result;
+  }
+  
+  @Override public boolean equals(Object aThat){
+    if (this == aThat) return true;
+    if ( !(aThat instanceof RequestParameter) ) return false;
+    RequestParameter that = (RequestParameter)aThat;
+    return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+  }
+  
+  @Override public int hashCode(){
+    return ModelUtil.hashCodeFor(getSignificantFields());
+  }
+
+  /** Intended for debugging only.  */
+  @Override public String toString() {
+    String result = null;
+    if ( isFileUploadParameter() ) {
+      result = "Name[File Upload]: " + fName;  
+    }
+    else {
+      result = "Name:" + fName + " Regex:" + fRegex;  
+    }
+    return result;
+  } 
+  
+  // PRIVATE  
+  private final String fName;
+  private Pattern fRegex; //Patterns are immutable
+  private final boolean fIsRegularParameter;
+  
+  /**
+   Constructor for a <a href="#Regular">"regular"</a> request parameter. 
+   
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+   @param aRegex regular expression for doing hard validation of the request 
+   parameter value(s).
+  */
+  private RequestParameter(String aName, String aRegex) { 
+    this(aName, Pattern.compile(aRegex));
+    validateState();
+  }
+   
+  /**
+   Constructor for a <a href="#Regular">"regular"</a> request parameter.
+    
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+   @param aRegex regular expression for doing hard validation of the request 
+   parameter value(s).
+  */
+  private RequestParameter(String aName, Pattern aRegex) {
+    fName = aName;
+    fRegex = aRegex;
+    fIsRegularParameter = true;
+    validateState();
+  }
+  
+  /**
+   Constructor for a <a href="#FileUpload">file upload</a> request parameter.
+    
+   @param aName name of the underlying HTTP request parameter. See 
+   <a href="#NamingConvention">naming convention</a>. 
+  */
+  private RequestParameter(String aName) {
+    fName = aName;
+    fIsRegularParameter = false;
+    validateState();
+  }
+ 
+  private void validateState(){
+    //use the model ctor exception only to gather errors together
+    //it is never actually thrown.
+    ModelCtorException ex = new ModelCtorException();
+    if ( ! Util.textHasContent(fName) ){
+      ex.add("Name must have content.");
+    }
+    if ( fIsRegularParameter && (fRegex == null || ! Util.textHasContent(fRegex.pattern()) ) ){
+      ex.add("For regular request parameters, regex pattern must be present.");
+    }
+    if ( ! fIsRegularParameter && fRegex != null  ){
+      ex.add("For file upload parameters, regex pattern must be null.");
+    }
+    if ( ex.isNotEmpty() ) {
+      throw new IllegalArgumentException(ex.getMessages().toString());
+    }
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[] {fName, fRegex};
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/RequestParser.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,272 @@
+package hirondelle.web4j.request; 
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.BadRequestException;
+import hirondelle.web4j.model.ConvertParam;
+import hirondelle.web4j.model.ConvertParamError;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.util.Util;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ Abstract Base Class (ABC) for mapping a request to an {@link Action}. 
+
+ <P>See the {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured.
+
+ <P><span class="highlight">Almost all concrete implementations of this Abstract Base Class will need to 
+ implement only a single method</span> - {@link #getWebAction()}. WEB4J provides a default implementation 
+ {@link RequestParserImpl}. 
+ 
+ <P>The role of this class is to view the request at a higher level than the underlying 
+ Servlet API. In particular, its services include :
+<ul>
+ <li>mapping a request to an {@link Action}
+ <li>parsing request parameters into common "building block" objects (and Collections thereof), 
+ such as {@link Date}, {@link BigDecimal} and so on, using the configured implementation of 
+ {@link hirondelle.web4j.model.ConvertParam}. (The application programmer will usually use 
+ {@link hirondelle.web4j.model.ModelFromRequest} to build Model Objects.) 
+</ul>
+ 
+ <P><a href="RequestParameter.html#FileUpload">File upload</a> parameters are not returned 
+ by this class. Such parameters must be examined in an {@link Action}. The Servlet API 
+ before version 3.0 of the specification has poor support for file upload parameters, 
+ and use of a third party tool is recommended. 
+ 
+ <P>The various <tt>toXXX</tt> methods are offered as a convenience for accessing <tt>String</tt> 
+ and <tt>String</tt>-like data. All such <tt>toXXX</tt> methods apply the filtering (and possible 
+ preprocessing) performed by {@link hirondelle.web4j.model.ConvertParam}.   
+*/
+public abstract class RequestParser {
+  
+  /**
+   Return the configured concrete instance of this Abstract Base Class.
+   <P>See the {@link hirondelle.web4j.BuildImpl} for important information on how 
+   this item is configured.
+  */
+  public static RequestParser getInstance(HttpServletRequest aRequest, HttpServletResponse aResponse){
+    List<Object> args = new ArrayList<Object>();
+    args.add(aRequest);
+    args.add(aResponse);
+    RequestParser result = (RequestParser)BuildImpl.forAbstractionPassCtorArgs(
+      RequestParser.class.getName(), 
+      args
+    );
+    return result;
+  }
+  
+  /** Constructor called by subclasses.  */
+  public RequestParser(HttpServletRequest aRequest, HttpServletResponse aResponse){
+    fRequest = aRequest;
+    fResponse = aResponse;
+    fLocale = BuildImpl.forLocaleSource().get(aRequest);
+    fTimeZone = BuildImpl.forTimeZoneSource().get(aRequest);
+    fConvertUserInput = BuildImpl.forConvertParam();
+    fConversionError = BuildImpl.forConvertParamError();
+  }
+
+  /**
+   Map a given request to a corresponding {@link Action}.
+  
+   <P>The mapping is determined entirely by concrete subclasses, and must  
+   be implemented by the application programmer. {@link RequestParserImpl} is 
+   provided as a default implementation, and is very likely adequate for most 
+   applications.
+   
+   <P>If the incoming request does not map to a known {@link Action}, then throw 
+   a {@link BadRequestException}. <span class="highlight">Such requests 
+   are expected only for bugs and for malicious attacks, and never as part of the normal operation 
+   of the program.</span>
+  */
+  abstract public Action getWebAction() throws BadRequestException;
+  
+  /**
+   Return the parameter value exactly as it appears in the request.
+    
+   <P>Can return <tt>null</tt> values, empty values, values containing 
+   only whitespace, and values equal to the <tt>IgnorableParamValue</tt> configured in <tt>web.xml</tt>.
+  */
+  public final String getRawParamValue(RequestParameter aReqParam){
+    String result = fRequest.getParameter(aReqParam.getName());
+    return result;
+  }
+  
+  /**
+   Return a multi-valued parameter's values exactly as they appear in the request.
+   
+   <P>Can return <tt>null</tt> values, empty values, values containing 
+   only whitespace, and values equal to the <tt>IgnorableParamValue</tt> configured in <tt>web.xml</tt>.
+  */
+  public final String[] getRawParamValues(RequestParameter aReqParam){
+    String[] result = fRequest.getParameterValues(aReqParam.getName());
+    return result;
+  }
+
+  /**
+   Return a building block object.
+   
+   <P>Uses all methods of the configured implementation of {@link ConvertParam}. 
+   @param aReqParam underlying request parameter
+   @param aSupportedTargetClass must be supported - see {@link ConvertParam#isSupported(Class)}
+  */
+  public <T> T toSupportedObject(RequestParameter aReqParam, Class<T> aSupportedTargetClass) throws ModelCtorException {
+    T result = null;
+    if( ! fConvertUserInput.isSupported(aSupportedTargetClass) ){
+      throw new AssertionError("This class is not supported by ConvertParam: " + Util.quote(aSupportedTargetClass));
+    }
+    String filteredValue = fConvertUserInput.filter(getRawParamValue(aReqParam));
+    if( Util.textHasContent(filteredValue) ){
+      try {
+        result  = fConvertUserInput.convert(filteredValue, aSupportedTargetClass, fLocale, fTimeZone);
+      }
+      catch (ModelCtorException ex){
+        ModelCtorException conversionEx = fConversionError.get(aSupportedTargetClass, filteredValue, aReqParam);
+        throw conversionEx;
+      }
+    }
+    return result; 
+  }
+
+  /**
+   Return an ummodifiable <tt>List</tt> of building block objects.
+   
+   <P>Uses all methods of the configured implementation of {@link ConvertParam}.
+   <P>
+   <em>Design Note</em><br>
+   <tt>List</tt> is returned here since HTML specs state that browsers submit param values 
+   in the order of appearance of the corresponding controls in the web page. 
+   @param aReqParam underlying request parameter
+   @param aSupportedTargetClass must be supported - see {@link ConvertParam#isSupported(Class)}
+  */
+  public <T> List<T> toSupportedObjects(RequestParameter aReqParam, Class<T> aSupportedTargetClass) throws ModelCtorException {
+    List<T> result = new ArrayList<T>();
+    ModelCtorException conversionExceptions = new ModelCtorException();
+    if( ! fConvertUserInput.isSupported(aSupportedTargetClass) ){
+      throw new AssertionError("This class is not supported by ConvertParam: " + Util.quote(aSupportedTargetClass));
+    }
+    String[] rawValues = getRawParamValues(aReqParam);
+    if(rawValues != null){
+      for(String rawValue: rawValues){
+        String filteredValue = fConvertUserInput.filter(rawValue); //possibly null
+        //is it possible to have a multi-valued boolean param???        
+        if ( Util.textHasContent(filteredValue) || Boolean.class == aSupportedTargetClass){ 
+          try {
+            T convertedItem  = fConvertUserInput.convert(filteredValue, aSupportedTargetClass, fLocale, fTimeZone);
+            result.add(convertedItem);
+          }
+          catch (ModelCtorException ex){
+            AppException conversionEx = fConversionError.get(aSupportedTargetClass, filteredValue, aReqParam);
+            conversionExceptions.add(conversionEx);
+          }
+        }
+        else {
+          result.add(null);
+        }
+      }
+      if (conversionExceptions.isNotEmpty()) throw conversionExceptions;
+    }
+    return Collections.unmodifiableList(result);
+  }
+
+  /** Return a single-valued request parameter as {@link SafeText}.  */
+  public final SafeText toSafeText(RequestParameter aReqParam) {
+    SafeText result = null;
+    try {
+      result = toSupportedObject(aReqParam, SafeText.class); 
+    }
+    catch (ModelCtorException ex){
+      changeToRuntimeException(ex);
+    }
+    return result;
+  }
+  
+  /** Return a multi-valued request parameter as a {@code Collection<SafeText>}.  */
+  public final Collection<SafeText> toSafeTexts(RequestParameter aReqParam) {
+    Collection<SafeText> result = null;
+    try {
+      result = toSupportedObjects(aReqParam, SafeText.class);
+    }
+    catch (ModelCtorException ex){
+      changeToRuntimeException(ex);
+    }
+    return result;
+  }
+    
+  /** Return a single-valued request parameter as an {@link Id}.  */
+  public final Id toId(RequestParameter aReqParam) {
+    Id result = null;
+    try {
+      result = toSupportedObject(aReqParam, Id.class);
+    }
+    catch(ModelCtorException ex){
+      changeToRuntimeException(ex);
+    }
+    return result;
+  }
+   
+  /** Return a multi-valued request parameter as a {@code Collection<Id>}.  */
+  public final Collection<Id> toIds(RequestParameter aReqParam) {
+    Collection<Id> result = null;
+    try {
+      result = toSupportedObjects(aReqParam, Id.class);
+    }
+    catch (ModelCtorException ex){
+      changeToRuntimeException(ex);
+    }
+    return result;
+  }
+  
+  /** Return the underlying request.   */
+  public final HttpServletRequest getRequest(){
+    return fRequest;
+  }
+  
+  /** Return the response associated with the underlying request.  */
+  public final HttpServletResponse getResponse(){
+    return fResponse;
+  }
+  
+  /**
+   Return <tt>true</tt> only if the request is a <tt>POST</tt>, and has  
+   content type starting with <tt>multipart/form-data</tt>.
+  */
+  public final boolean isFileUploadRequest(){
+    return 
+      fRequest.getMethod().equalsIgnoreCase("POST") && 
+      fRequest.getContentType().startsWith("multipart/form-data")
+    ;
+  }
+  
+  // PRIVATE //
+  private final HttpServletRequest fRequest;
+  private final HttpServletResponse fResponse;
+  private final Locale fLocale;
+  private final TimeZone fTimeZone;
+  private final ConvertParam fConvertUserInput;
+  private final ConvertParamError fConversionError;
+  
+  /**
+   Change from a checked to an unchecked ex.
+   
+   <P>This is unusual, and a bit ugly. For stringy data, there isn't any possibility of a 
+   parse error. Requiring Action constructors to catch or throw a ModelCtorEx is distasteful
+   (this would happen for items that have an Operation built in the constructor.)
+  */
+  private void changeToRuntimeException(ModelCtorException ex){
+    throw new IllegalArgumentException(ex);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/RequestParserImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,294 @@
+package hirondelle.web4j.request;
+
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.ConfigReader;
+import hirondelle.web4j.util.Util;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Modifier;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ <span class="highlight">Maps each HTTP request to a concrete {@link Action}.</span>
+ 
+ <P> Default implementation of {@link RequestParser}.
+ 
+ <P>This implementation extracts the <a  href="#URIMappingString">URI Mapping String</a> from the 
+ underlying request, and maps it to a specific {@link Action} class, and calls its constructor by passing 
+ a {@link RequestParser}. (Here, each {@link Action} must have a <tt>public</tt> constructor 
+ which takes a {@link RequestParser} as its single parameter.)
+ 
+ <P>There are two kinds of mapping available :
+<ul>
+ <li><a href="#ImplicitMapping">implicit mapping</a> - simple, and recommended
+ <li><a href="#ExplicitMapping">explicit mapping</a> - requires an extra step, and overrides the implicit mapping
+</ul> 
+  
+ <P><a name="URIMappingString"><h3>URI Mapping String</h3>
+ The 'URI Mapping String' is extracted from the underlying request. It is simply the concatention of 
+ {@link HttpServletRequest#getServletPath()} and {@link HttpServletRequest#getPathInfo()} 
+ (minus the extension - <tt>.do</tt>, for example).  
+ 
+ <P>(The servlet path is the part of the URI which has been mapped to a servlet by the <tt>servlet-mapping</tt> 
+ entries in the <tt>web.xml</tt>.)
+
+ <P><a name="ImplicitMapping"><h3>Implicit Mapping</h3>
+ If no <a href="#ExplicitMapping">explicit mapping</a> exists in an <tt>Action</tt>, then it will <em>implicitly</em> 
+ map to the <a href="#URIMappingString">URI Mapping String</a> that corresponds to a <em>modified</em> version of its 
+ package-qualified name : 
+<ul>
+ <li>take the package-qualified class name
+ <li>change '.' characters to '/'
+ <li><em>remove</em> the base package prefix, configured in <tt>web.xml</tt> as <tt>ImplicitMappingRemoveBasePackage</tt>
+</ul>
+
+ <P>Example of an implicit mapping :
+ <table cellpadding="3" cellspacing="0" border="1">
+  <tr><td>Class Name:</td><td>hirondelle.fish.main.member.MemberEdit</th></tr>
+  <tr><td><tt>ImplicitMappingRemoveBasePackage</tt> (web.xml):</td><td>hirondelle.fish</th></tr>
+  <tr><td>Implicit Mapping calculated as:</td><td>/main/member/MemberEdit</th></tr>
+ </table>
+ 
+ <P>Which maps to the following requests:
+ 
+ <P><table cellpadding="3" cellspacing="0" border="1">
+  <tr><td>Request 1:</td><td>http://www.blah.com/fish/main/member/MemberEdit.list</th></tr>
+  <tr><td>Request 2:</td><td>http://www.blah.com/fish/main/member/MemberEdit.do?Operation=List</th></tr>
+  <tr><td>URI Mapping String calculated as:</td><td>/main/member/MemberEdit</th></tr>
+ </table>
+ 
+ <P><a name="ExplicitMapping"><h3>Explicit Mapping</h3>
+ An <tt>Action</tt> may declare an explicit mapping to a <a href="#URIMappingString">URI Mapping String</a> 
+ simply by declaring a field of the form (for example) : 
+ <PRE>
+  public static final String EXPLICIT_URI_MAPPING = "/translate/basetext/BaseTextEdit";
+ </PRE>
+ Explicit mappings override implicit mappings. 
+
+<P><h3>Fine-Grained Security</h3>
+ Fine-grained security allows <tt>&lt;security-constraint&gt;</tt> items to be specifed for various extensions, 
+ where the extensions represent various action verbs, such as <tt>.list</tt>, <tt>.change</tt>, and so on. 
+ In that case, the conventional <tt>.do</tt> is replaced with several different extensions.
+ See the User Guide for more information on fine-grained security.
+
+ <P><h3>Looking Up Action, Given URI</h3>
+ It's a common requirement to look up an action class, given a URI. Various sources 
+ can be used to perform that task:
+<ul>
+ <li>the application's javadoc listing of Constant Field Values can be 
+ quickly searched for an explicit <tt>EXPLICIT_URI_MAPPING</tt> 
+ <li>all mappings are logged upon startup at <tt>CONFIG</tt> level
+ <li>the source code itself can be searched, if necessary
+</ul>
+*/
+public class RequestParserImpl extends RequestParser {
+
+  /**
+   Scan for {@link Action} mappings. Called by the framework upon startup. Scans for all classes 
+   that implement {@link Action}. Stores either an <a href="ImplicitMapping">implicit</a>  
+   or an <a href="#ExplicitMapping">explicit</a> mapping. Implicit mappings are the recommended style. 
+   
+   <P>If a problem with mapping is detected, then a {@link RuntimeException} is thrown, and 
+   the application will not load. This protects the application, by forcing some important 
+   errors to occur during startup, instead of during normal operation. Possible errors include :
+   <ul>
+   <li>the <tt>EXPLICIT_URI_MAPPING</tt> field is not a <tt>public static final String</tt>
+   <li>the same mapping is used for more than one {@link Action}
+   </ul>
+  */
+  public static void initWebActionMappings(){
+    scanMappings();
+    fLogger.config("URI Mappings : " + Util.logOnePerLine(fUriToActionMapping));
+  }
+
+  /**
+   Constructor.
+    
+   @param aRequest passed to the super class.
+   @param aResponse passed to the super class.
+  */
+  public RequestParserImpl(HttpServletRequest aRequest, HttpServletResponse aResponse) {
+    super(aRequest, aResponse);
+    if (aRequest.getPathInfo() != null){
+      fURIMappingString = aRequest.getServletPath() + aRequest.getPathInfo();
+    }
+    else {
+      fURIMappingString = aRequest.getServletPath();
+    }
+    fLogger.fine("*** ________________________ NEW REQUEST _________________");
+    fURIMappingString = removeExtension(fURIMappingString);
+    fLogger.fine("URL Mapping String: " + fURIMappingString);
+  }
+  
+  /**
+   Map an HTTP request to a concrete implementation of {@link Action}.
+  
+   <P>Extract the <a href="#URIMappingString">URI Mapping String</a> from the underlying request, and 
+   map it to an {@link Action}.
+  */
+  @Override public final Action getWebAction() {
+    Action result = null;
+    AppException problem = new AppException();
+    Class webAction = fUriToActionMapping.get(fURIMappingString);
+    if ( webAction == null ) {
+      throw new RuntimeException("Cannot map URI to an Action class : " + Util.quote(fURIMappingString));
+    }
+    
+    Class[] ctorArgs = {RequestParser.class};
+    try {
+      Constructor ctor = webAction.getConstructor(ctorArgs);
+      result = (Action)ctor.newInstance(new Object[]{this});
+    }
+    catch(NoSuchMethodException ex){
+      problem.add("Action does not have public constructor having single argument of type 'RequestParser'.");
+    }
+    catch(InstantiationException ex){
+      problem.add("Cannot call Action constructor using reflection (class is abstract). " + ex);
+    }
+    catch(IllegalAccessException ex){
+      problem.add("Cannot call Action constructor using reflection (constructor not public). " + ex);
+    }
+    catch(IllegalArgumentException ex){
+      problem.add("Cannot call Action constructor using reflection. " + ex);
+    }
+    catch(InvocationTargetException ex){
+      String message = ex.getCause() == null ? ex.toString() : ex.getCause().getMessage();
+      problem.add("Cannot call Action constructor using reflection (constructor threw exception). " + message);
+    }
+    
+    if( problem.isNotEmpty() ){
+      throw new RuntimeException("Problem constructing Action for URI " + Util.quote(fURIMappingString) + " " + Util.logOnePerLine(problem.getMessages()));
+    }
+    fLogger.info("URI " + Util.quote(fURIMappingString) + " successfully mapped to an instance of " + webAction);
+    
+    return result;
+  }
+  
+  /**
+   Return the <tt>String</tt> configured in <tt>web.xml</tt> as being the 
+   base or root package that is to be ignored by the default Action mapping mechanism. 
+   
+   See <tt>web.xml</tt> for more information.
+  */
+  public static final String getImplicitMappingRemoveBasePackage(){
+    //why is this method public?
+    return new Config().getImplicitMappingRemoveBasePackage();
+  }
+   
+  // PRIVATE
+  
+  /**
+   Portion of the complete URL, which contains sufficient information to 
+   to decide which {@link Action} is to be returned. 
+  */
+  private String fURIMappingString;
+  
+  /**
+   Conventional field name used in {@link Action} classes. 
+  */
+  private static final String EXPLICIT_URI_MAPPING = "EXPLICIT_URI_MAPPING";
+  
+  /**
+   Maps URIs to implementations of {@link Action}.
+   
+   <P>Key - String, taken from public static final field named {@link #EXPLICIT_URI_MAPPING}.
+   <br>Value - Class for the {@link Action} having a <tt>EXPLICIT_URI_MAPPING</tt> field 
+   of that given value.
+   
+   <P>At runtime, the request is inspected, and the corresponding {@link Action} is 
+   created, using a constructor of a specific signature.
+  */
+  private static final Map<String, Class<Action>> fUriToActionMapping = new LinkedHashMap<String, Class<Action>>();
+  
+  private static final Logger fLogger = Util.getLogger(RequestParserImpl.class);
+  
+  private static void scanMappings(){
+    fUriToActionMapping.clear(); //needed for reloading application : reloading app does not reload this class.
+    Set<Class<Action>> actionClasses = ConfigReader.fetchConcreteClassesThatImplement(Action.class);
+    AppException problems = new AppException();
+    for(Class<Action> actionClass: actionClasses){
+      Field explicitMappingField = null;
+      try {
+        explicitMappingField = actionClass.getField(EXPLICIT_URI_MAPPING);
+      }
+      catch (NoSuchFieldException ex){
+        addMapping(actionClass,  getImplicitURI(actionClass), problems);
+        continue;
+      }
+      addExplicitMapping(actionClass, explicitMappingField, problems);
+    }
+    //ensure that any problems will cause a failure to startup
+    //thus, runtime exception are replaced with startup time exceptions
+    if ( problems.isNotEmpty() ) {
+      throw new RuntimeException("Problem(s) occurred while creating mapping of URIs to WebActions. " + Util.logOnePerLine(problems.getMessages()));
+    }
+  }
+
+  private static void addExplicitMapping(Class<Action> aActionClass, Field aExplicitMappingField, AppException aProblems) {
+    int modifiers = aExplicitMappingField.getModifiers();
+    if (  Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers) ) {
+      try {
+        Object fieldValue = aExplicitMappingField.get(null);
+        if ( ! (fieldValue instanceof String) ){
+          aProblems.add("Value for for " + EXPLICIT_URI_MAPPING + " field is not a String.");
+        }
+        addMapping(aActionClass, fieldValue.toString(), aProblems);
+      }
+      catch(IllegalAccessException ex){
+        aProblems.add("Action " + aActionClass + ": cannot get value of field " + aExplicitMappingField);
+      }
+    }
+    else {
+      aProblems.add("Action " + aActionClass + ": field is not public static final : " + aExplicitMappingField);
+    }
+  }
+
+  private static void addMapping(Class<Action> aClass, String aURI, AppException aProblems) {
+    if( ! fUriToActionMapping.containsKey(aURI) ){
+      fUriToActionMapping.put(aURI, aClass);
+    }
+    else {
+      aProblems.add("Action " + aClass + ": mapping for URI " + aURI + " already in use by  " + fUriToActionMapping.get(aURI));
+    }
+  }
+  
+  private static String getImplicitURI(Class<Action> aActionClass){
+    String result = aActionClass.getName(); //eg: com.blah.module.Whatever
+    
+    String prefix = getImplicitMappingRemoveBasePackage(); //com.blah
+    if( ! Util.textHasContent(prefix) ){
+      throw new RuntimeException("Init-param ImplicitMappingRemoveBasePackage must have content. See web.xml.");      
+    }
+    if( prefix.endsWith(".")){
+      throw new RuntimeException("Init-param ImplicitMappingRemoveBasePackage must not include a trailing dot : " + Util.quote(prefix) + ". See web.xml.");
+    }
+    if ( ! result.startsWith(prefix) ){
+      throw new RuntimeException("Class named " + Util.quote(aActionClass.getName()) + " does not start with expected base package " + Util.quote(prefix) + " See ImplicitMappingRemoveBasePackage in web.xml.");
+    }
+    
+    result = result.replace('.','/'); // com/blah/module/Whatever
+    result = result.substring(prefix.length()); // /module/Whatever
+    fLogger.finest("Implicit mapping for " + Util.quote(aActionClass) + " is : " + Util.quote(result));
+    return result;
+  }
+  
+  private String removeExtension(String aURI){
+    int firstPeriod = aURI.indexOf(".");
+    if ( firstPeriod == NOT_FOUND ) {
+      fLogger.severe("Cannot find extension for " + Util.quote(aURI));
+    }
+    return aURI.substring(0,firstPeriod);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/TimeZoneSource.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,82 @@
+package hirondelle.web4j.request;
+
+import hirondelle.web4j.action.ActionImpl;
+import java.util.TimeZone;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ Return the {@link TimeZone} associated with a given request.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forTimeZoneSource()} 
+ returns the configured implementation of this interface. See {@link TimeZoneSourceImpl} for a default implementation. 
+ This interface is similar to {@link LocaleSource}, and is used in much the same way.
+ 
+ <P>In general, a {@link TimeZone} is used for two distinct operations :
+<ul>
+ <li>render {@link java.util.Date} objects
+ <li>parse user input into a <tt>Date</tt> object
+</ul>
+
+ <P>By default, a JRE will perform such operations using the <em>implicit</em> value returned by
+ {@link TimeZone#getDefault()}. The main reason for defining this interface is to 
+ provide an alternative to this mechanism, since it is inappropriate for most server applications.
+ 
+ <P><i>For your Actions, the fastest way to access the time zone is usually via {@link ActionImpl#getTimeZone()}.</i>
+ 
+ <P>The <tt>TimeZone</tt> returned by this interface is used by WEB4J for : 
+<ul>
+ <li>user response messages containing dates
+ <li>presenting <tt>ResultSets</tt> as reports with {@link hirondelle.web4j.database.Report}
+ <li>displaying dates with {@link hirondelle.web4j.ui.tag.ShowDate}
+ <li>populating forms
+ <li>parsing form entries 
+</ul>
+
+ <P>A very large number of policies can be defined by implementations of this interface. 
+ Possible sources of <tt>TimeZone</tt> information include :
+<ul>
+ <li>a single setting in <tt>web.xml</tt>, place into application scope upon startup 
+ <li>an object stored in session scope
+ <li>a request parameter
+ <li>a request header
+ <li>a cookie
+</ul>
+
+ <h3>Java versus Databases</h3> 
+ <P>Java always represents dates internally using the number of milliseconds from its epoch. In Java, a 
+ {@link java.util.Date} is always an unambiguous instant. When parsing and formatting dates, it will always use 
+ a {@link TimeZone} (either implicity or explicitly). On the other hand, it is often that the case that 
+ a database column storing a date does <em>not</em> store dates internally in an unambiguous way. For example, 
+ many dates are stored as just '<tt>05-31-2007 06:00</tt>', for example, without any time zone information.
+ 
+ <P>If that is the case, then there is a mismatch : constructing a {@link java.util.Date} out of many 
+ database columns will <em>require</em> a {@link TimeZone} to be specified, either explicitly or implicitly.
+ See {@link java.sql.ResultSet#getDate(int, java.util.Calendar)}, 
+ {@link java.sql.PreparedStatement#setDate(int, java.sql.Date, java.util.Calendar)}, and related methods.
+ 
+ <P>The storage of dates in a database is <em>not</em> handled by this interface. That is 
+ treated as a separate issue. 
+ 
+ <h3>web.xml</h3>
+ There are two settings related to time zones in <tt>web.xml</tt>. The two settings correspond to two 
+ distinct ideas : the time zone appropriate for dates <em>presented</em> to the end user, and the time zone in 
+ which the date is <em>stored</em>.
+ 
+ <P>The <tt>DefaultUserTimeZone</tt> setting is used by {@link TimeZoneSourceImpl}. 
+ For applications that use only a single time zone, then this setting is used to specify that time 
+ zone. It provides independence of the default JRE time zone, which will vary according to the server location.
+ For applications that use more than one time zone, then this same setting can be reinterpreted as the 
+ <em>default</em> time zone, which can be overridden by implementations of this interface.
+ 
+ <P>The <tt>TimeZoneHint</tt> setting is used by the WEB4J data layer to indicate the time zone in  
+ which a date should be stored. If specified, this setting is communicated to the underlying 
+ database driver using {@link java.sql.PreparedStatement#setTimestamp(int, java.sql.Timestamp, java.util.Calendar)}
+ and {@link java.sql.ResultSet#getTimestamp(int, java.util.Calendar)}. 
+*/
+public interface TimeZoneSource {
+  
+  /** Return a {@link TimeZone} corresponding to a given underlying request.  */
+   public TimeZone get(HttpServletRequest aRequest);
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/TimeZoneSourceImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,32 @@
+package hirondelle.web4j.request;
+
+import java.util.TimeZone;
+import javax.servlet.http.HttpServletRequest;
+
+import hirondelle.web4j.util.WebUtil;
+import hirondelle.web4j.Controller;
+
+/**
+ Retrieve the {@link TimeZone} stored in any scope under the key 
+ {@link hirondelle.web4j.Controller#TIME_ZONE}.
+ 
+ <P>Upon startup, the {@link hirondelle.web4j.Controller} will read in the <tt>DefaultUserTimeZone</tt>
+ configured in <tt>web.xml</tt>, and place it in application scope under the key 
+ {@link hirondelle.web4j.Controller#TIME_ZONE}, as a {@link TimeZone} object.
+ 
+ <P><em>If desired</em>, the application programmer can also store a user-specific 
+ {@link TimeZone} in session scope, <em>under the same key</em>. Thus, 
+ this class will first find the user-specific <tt>TimeZone</tt>, overriding the default 
+ <tt>TimeZone</tt> stored in application scope. 
+ 
+ <P>If any other behavior is desired, then simply provide an alternate implementation of 
+ {@link TimeZoneSource}.
+*/
+public final class TimeZoneSourceImpl implements TimeZoneSource {
+
+  /** See class comment. */
+  public TimeZone get(HttpServletRequest aRequest){
+    return (TimeZone)WebUtil.findAttribute(Controller.TIME_ZONE, aRequest);
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/request/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,19 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>User Interface</title>
+</head>
+<body>
+Items related to the underlying HTTP request.
+
+<P>Mapping of incoming requests to an {@link hirondelle.web4j.action.Action} is done by 
+{@link hirondelle.web4j.request.RequestParserImpl}.
+By default, it uses a simple mapping scheme which requires no configuration.
+
+<P>The main tool for building full Model Objects out of submitted forms is {@link hirondelle.web4j.model.ModelFromRequest}.
+
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/ApplicationFirewall.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,129 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.model.BadRequestException;
+import hirondelle.web4j.model.ConvertParamError;
+import hirondelle.web4j.model.ModelFromRequest;
+import hirondelle.web4j.request.RequestParser;
+
+/**
+ Perform <a href="#HardValidation">hard validation</a> on each incoming request.
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forApplicationFirewall()} 
+ returns the configured implementation of this interface.
+ 
+ <P>The main intent of an application firewall is to defend against malicious attacks. As a side effect, 
+ an application firewall will often detect obvious bugs during unit testing - usually unexpected request 
+ parameters.  
+ 
+ <P><span class="highlight">The <a href="http://www.owasp.org/">Open Web Application Security Project</a>
+ is a superb resource for learning about web application security. Implementors of this interface are 
+ highly recommended to read and use its guidelines.</span>
+ 
+ <P><span class="highlight">WEB4J divides validation tasks into <em>hard validation</em> and 
+ <em>soft validation</em>.</span> They are distinguished by : 
+ <ul>
+ <li>when they are applied
+ <li>their behavior when they fail
+ </ul>
+ 
+ <P><b><a name="HardValidation">"Hard" Validation</a></b><br>
+ Hard validation is applied earlier in processing. A failed hard validation 
+ represents either a bug, or a malicious request (a "hack").
+ When a hard validation fails, a curt and unpolished response can be sent. 
+ Hard validations are performed by an <tt>ApplicationFirewall</tt>, ultimately called by the 
+ {@link hirondelle.web4j.Controller}, as the first step in processing a request, 
+ before any {@link Action} is executed. Items for hard validation might include :
+ <ul>
+ <li>overall request size
+ <li>parameter names 
+ <li>parameter values (<em>not business validations</em>, however - see below) 
+ <li>HTTP headers
+ <li>cookies
+ </ul> 
+ 
+ <P><span class="highlight">For request parameters, hard validation must include only those checks whose failure 
+ would constitute a bug or a malicious request.</span> 
+ 
+ <P><b><a name="SoftValidation">"Soft" Validation</b><br>
+ Soft validation is applied later in processing.
+ If a soft validation fails, resulting response pages use a polished presentation.
+ They are applied to items that are input <em>directly</em> by the user, for example the content 
+ of a <tt>text</tt> input control. Invalid values are handled as part of the normal operation of 
+ the program. Soft validations are "problem domain" validations, and are usually implemented in a 
+ Model Object constructor. 
+ 
+ <P>To clarify, here are two examples, using the default implementation of
+ {@link ApplicationFirewallImpl}.
+ 
+ <P><b>Example 1</b> 
+ <br>A <tt>select</tt> control named <tt>Spin</tt> submits two fixed values, <tt>UP</tt> and 
+ <tt>DOWN</tt>. Under normal operation of the program, no other values are expected. In this 
+ case, the submitted request parameter should undergo these checks :
+ 
+ <P> <em>Hard validation</em> - must be one of the two values <tt>UP</tt> or <tt>DOWN</tt>.  
+ This is implemented by simply defining, in the 
+ {@link Action} that handles this parameter, a single field : 
+<PRE>
+public static final RequestParameter SPIN = RequestParameter.<a href="RequestParameter.html#withRegexCheck(java.lang.String,%20java.lang.String)">withRegexCheck</a>("Spin", "(UP|DOWN)");
+</PRE>
+ {@link ApplicationFirewallImpl} uses such fields to determine, for each {@link Action}, how to 
+ do hard validation for request parameters. It checks permitted parameter names, and permitted parameter values
+ versus a regular expression.
+ 
+ <P> <em>Soft validation</em> - none. In this case, the hard validation checks the parameter value completely, 
+ so there is no further validation to be performed.
+ </ul> 
+
+ <P><b>Example 2</b> 
+ <br>A text input control named <tt>Age</tt> accepts any text as input. That text should correspond to 
+ an integer in the range <tt>0..130</tt>. In this case, the validation is <em>shared</em> between 
+ hard validation and soft validation :
+ 
+ <P><em>Hard validation</em> - can only make a basic sanity check. For instance, a check that the parameter value 
+ is not an unreasonable size - under 5K, for instance. This is meant only to detect obvious hacks. It has 
+ nothing to do with business logic. That is, this size check does <em>not</em> correspond to the maximum number of
+ characters expected (3), since failure of a hard validation produces a response which should <em>not</em> be seen by 
+ the typical user during normal operation of the program. In this case, the field declared in the {@link Action}
+ is : 
+<PRE>
+public static final RequestParameter AGE = RequestParameter.<a href="RequestParameter.html#withLengthCheck(java.lang.String)">withLengthCheck</a>("Age");
+</PRE>
+ (The actual maximum length is set in <tt>web.xml</tt>.)
+ 
+ <P><em>Soft validation #1</em> - first, make sure the user input can be translated into an {@link Integer}. This is a very 
+ common task, and is implemented by {@link RequestParser}, using its various <tt>toXXX</tt> methods (and, 
+ at a higher lever, by {@link ModelFromRequest}). When user input cannot be parsed into 
+ an {@link Integer}, then an error message is displayed to the user. See {@link ConvertParamError}.
+ 
+ <P><em>Soft validation #2</em> - make sure the {@link Integer} returned by the previous validation is in the 
+ range <tt>0..150</tt>. This is an example of a typical business validation. These are usually implemented 
+ in the constructor of a Model Object. Again, if a problem is detected, then an error message 
+ is displayed to to the user. 
+ 
+ <P>{@link hirondelle.web4j.model.Check} and {@link hirondelle.web4j.model.Validator} are provided to help you   
+ implement soft validations.
+*/
+public interface ApplicationFirewall {
+
+  /**
+   Perform <a href="#HardValidation">hard validation</a> on each HTTP request.
+   
+   <P>If a problem is detected, then a {@link BadRequestException} is thrown, indicating the
+   standard HTTP status code, as defined in {@link javax.servlet.http.HttpServletRequest}. 
+   (An error message may also be included, if desired.) 
+   The response will then be sent immediately, without further processing, using   
+   {@link javax.servlet.http.HttpServletResponse#sendError(int)}, or
+   {@link javax.servlet.http.HttpServletResponse#sendError(int, java.lang.String)} if  
+   {@link BadRequestException#getErrorMessage()} has content.
+  
+   @param aAction corresponding to this request. If the underlying request is unknown 
+   to {@link RequestParser#getWebAction()}, then that method will throw a 
+   {@link BadRequestException}, and this method will not be called.
+   @param aRequestParser provides the raw underlying request, through 
+   {@link RequestParser#getRequest()};
+  */
+  void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException;
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/ApplicationFirewallImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,379 @@
+package hirondelle.web4j.security;
+
+import static hirondelle.web4j.util.Consts.FAILS;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.action.Operation;
+import hirondelle.web4j.model.BadRequestException;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.ConfigReader;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.util.Util;
+
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+/**
+ Default implementation of {@link ApplicationFirewall}.
+
+ <P>Upon startup, this class will inspect all {@link Action}s in the application. 
+ All <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} fields accessible 
+ to each {@link Action} will be collected, and treated here as the set of acceptable 
+ {@link RequestParameter}s for each {@link Action} class. Thus, when this class is used to implement 
+ {@link ApplicationFirewall},  <span class="highlight">each {@link Action} must declare all expected request 
+ parameters as a <tt>public static final</tt> {@link RequestParameter} field, in order to pass hard validation.</span> 
+
+ <h3>File Upload Forms</h3>
+ If a POSTed request includes one or more file upload controls, then the underlying HTTP request has 
+ a completely different structure from a regular request having no file upload controls.
+ Unfortunately, the Servlet API has very poor support for forms that include a file upload control: only the raw underlying request is available, <em>in an unparsed form</em>. 
+ For such forms, POSTed data is not available in the usual way, and by default <tt>request.getParameter(String)</tt> will return <tt>null</tt> -  
+ <em>not only for the file upload control, but for all controls in the form</em>. 
+ 
+ <P>An elegant way around this problem involves <em>wrapping</em> the request, 
+ using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made 
+ available through the usual <tt>request</tt> methods.
+ If such a wrapper is used, then file upload forms can be handled in much the same way as any other form.
+  
+ <P>To indicate to this class if such a wrapper is being used for file upload requests, use the <tt>FullyValidateFileUploads</tt> setting 
+ in <tt>web.xml</tt>. 
+
+ <P>Settings in <tt>web.xml</tt> affecting this class :
+ <ul>
+ <li><tt>MaxHttpRequestSize</tt>
+ <li><tt>MaxFileUploadRequestSize</tt>
+ <li><tt>MaxRequestParamValueSize</tt> (used by {@link hirondelle.web4j.request.RequestParameter})
+ <li><tt>SpamDetectionInFirewall</tt>
+ <li><tt>FullyValidateFileUploads</tt>
+ </ul>
+ 
+ <P>The above settings control the validations performed by this class :
+ <table   border="1" cellpadding="3" cellspacing="0">
+  <tr>
+   <th>Check</th>
+   <th>Regular</th>
+   <th>File Upload (Wrapped)</th>
+   <th>File Upload</th>
+  </tr>
+  <tr>
+   <td>Overall request size &lt;= <tt>MaxHttpRequestSize</tt> </td>
+   <td>Y</td>
+   <td>N</td>
+   <td>N</td>
+  </tr>
+  <tr>
+   <td>Overall request size &lt;= <tt>MaxFileUploadRequestSize</tt> </td>
+   <td>N</td>
+   <td>Y</td>
+   <td>Y</td>
+  </tr>
+  <tr>
+   <td>Every param <em>name</em> is among the {@link hirondelle.web4j.request.RequestParameter}s  for that {@link Action}</td>
+   <td>Y</td>
+   <td>Y&#042;</td>
+   <td>N</td>
+  </tr>
+  <tr>
+   <td>Every param <em>value</em> satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}</td>
+   <td>Y</td>
+   <td>Y&#042;&#042;</td>
+   <td>N</td>
+  </tr>
+  <tr>
+   <td>If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size &lt;= <tt>MaxRequestParamValueSize</tt></td>
+   <td>Y</td>
+   <td>Y&#042;&#042;</td>
+   <td>N</td>
+  </tr>
+  <tr>
+   <td>If <tt>SpamDetectionInFirewall</tt> is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}</td>
+   <td>Y</td>
+   <td>Y&#042;&#042;</td>
+   <td>N</td>
+  </tr>
+  <tr>
+   <td>If a request param named <tt>Operation</tt> exists and it returns <tt>true</tt> for {@link Operation#hasSideEffects()}, then the underlying request must be a <tt>POST</tt></td>
+   <td>Y</td>
+   <td>Y</td>
+   <td>N</td>
+  </tr>
+  <tr>
+   <td><a href='#CSRF'>CSRF Defenses</a></td>
+   <td>Y</td>
+   <td>Y</td>
+   <td>N</td>
+  </tr>
+ </table>
+ &#042; For file upload controls, the param name is checked only if the return value of <tt>getParameterNames()</tt> (for the wrapper) includes it.
+ <br>&#042;&#042;Except for file upload controls. For file upload <em>controls</em>, no checks on the param <em>value</em> are made by this class.<br>
+ 
+ <a name='CSRF'></a>
+<h3>Defending Against CSRF Attacks</h3>
+ If the usual WEB4J defenses against CSRF attacks are active (see package-level comments), 
+ then <i>for every <tt>POST</tt> request executed within a session</i> the following will also be performed as a defense against CSRF attacks :
+<ul>
+ <li>validate that a request parameter named 
+ {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY} is present. (This
+ request parameter is deemed to be a special 'internal' parameter, and does not need to be explicitly declared in 
+ your <tt>Action</tt> like other request parameters.)
+ <li>validate that its value matches items stored in session scope. First check versus an item stored  
+ under the key {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY}; if that check fails, then 
+ check versus an item stored under the key 
+ {@link hirondelle.web4j.security.CsrfFilter#PREVIOUS_FORM_SOURCE_ID_KEY}
+</ul>
+
+ See {@link hirondelle.web4j.security.CsrfFilter} for more information.
+*/
+public class ApplicationFirewallImpl implements ApplicationFirewall {
+
+  /** Map actions to expected params. */
+  public static void init(){
+    fExpectedParams = ConfigReader.fetchPublicStaticFinalFields(Action.class, RequestParameter.class);
+    fLogger.config("Expected Request Parameters per Web Action." + Util.logOnePerLine(fExpectedParams));
+  }
+
+  /**
+   Perform checks on the incoming request. 
+   
+   <P>See class description for more information.
+   
+   <P>Subclasses may extend this implementation, following the form :
+   <PRE>
+  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
+    super(aAction, aRequestParser);
+    //place additional validations here
+    //for example, one might check that a Content-Length header is present,
+    //or that all header values are within some size range
+  }
+   </PRE>
+  */
+  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
+    if( aRequestParser.isFileUploadRequest() ){
+      fLogger.fine("Validating a file upload request.");
+    }
+    checkForExtremeSize(aRequestParser);
+    if ( aRequestParser.isFileUploadRequest() && ! fConfig.getFullyValidateFileUploads() )  {
+      fLogger.fine("Unable to parse request in the usual way: file upload request is not wrapped. Cannot read parameter names and values. See FullyValidateFileUploads setting in web.xml.");
+    }
+    else {
+      checkParamNamesAndValues(aAction, aRequestParser);
+      checkSideEffectOperations(aAction, aRequestParser);
+      defendAgainstCSRFAttacks(aRequestParser);
+    }
+  }
+  
+  // PRIVATE 
+
+  private Config fConfig = new Config();
+  
+  /**
+   Maps {@link Action} classes to a List of expected {@link hirondelle.web4j.request.RequestParameter} objects.
+   If the incoming request contains a request parameter whose name or value is not consistent with this 
+   list, then a {@link hirondelle.web4j.model.BadRequestException} is thrown.
+   
+   <P>Key - class object
+   <br>Value - Set of RequestParameter objects; may be empty, but not null.
+   
+   <P>This is a mutable object field, but is not modified after startup, so this class is thread-safe.
+  */
+  private static Map<Class<Action>, Set<RequestParameter>> fExpectedParams = new LinkedHashMap<Class<Action>, Set<RequestParameter>>();
+
+  /** Special, 'internal' request parameter, used by the framework to defend against CSRF attacks.  */
+  private static final RequestParameter fCSRF_REQ_PARAM = RequestParameter.withLengthCheck(CsrfFilter.FORM_SOURCE_ID_KEY);
+  
+  private static final String CURRENT_TOKEN_CSRF = CsrfFilter.FORM_SOURCE_ID_KEY;
+  private static final String PREVIOUS_TOKEN_CSRF = CsrfFilter.PREVIOUS_FORM_SOURCE_ID_KEY;
+  private static final Logger fLogger = Util.getLogger(ApplicationFirewallImpl.class);
+  
+  /**
+   Some denial-of-service attacks place large amounts of data in the request 
+   params, in an attempt to overload the server. This method will check for 
+   such requests. This check must be performed first, before any further 
+   processing is attempted.
+  */
+  private void checkForExtremeSize(RequestParser aRequest) throws BadRequestException {
+    fLogger.fine("Checking for extreme size.");
+    if ( isRequestExcessivelyLarge(aRequest) ) {
+      throw new BadRequestException(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+    }
+  }
+
+  private boolean isRequestExcessivelyLarge(RequestParser aRequestParser){
+    boolean result = false;
+    if ( aRequestParser.isFileUploadRequest() ) {
+      result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxFileUploadRequestSize(); 
+    }
+    else {
+      result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxHttpRequestSize(); 
+    }
+    return result;
+  }
+   
+  void checkParamNamesAndValues(Action aAction, RequestParser aRequestParser) throws BadRequestException {
+    if ( fExpectedParams.containsKey(aAction.getClass()) ){
+      Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
+      //this method may return file upload controls - depends on interpretation, whether to include file upload controls in this method
+      Enumeration paramNames = aRequestParser.getRequest().getParameterNames();
+      while ( paramNames.hasMoreElements() ){
+        String incomingParamName = (String)paramNames.nextElement();
+        fLogger.fine("Checking parameter named " + Util.quote(incomingParamName));
+        RequestParameter knownParam = matchToKnownParam(incomingParamName, expectedParams);
+        if( knownParam == null ){
+          fLogger.severe("*** Unknown Parameter *** : " + Util.quote(incomingParamName) + ". Please add public static final RequestParameter field for this item to your Action.");
+          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
+        }
+        if ( knownParam.isFileUploadParameter() ) {
+          fLogger.fine("File Upload parameter - value not validatable here: " + knownParam.getName());
+          continue; //prevents checks on values for file upload controls
+        }
+        Collection<SafeText> paramValues = aRequestParser.toSafeTexts(knownParam);
+        if( ! isInternalParam( knownParam) ) {
+          checkParamValues(knownParam, paramValues);
+        }
+      }
+    }
+    else {
+      String message = "Action " + aAction.getClass() + " not known to ApplicationFirewallImpl.";
+      fLogger.severe(message);
+      //this is NOT a BadRequestEx, since not outside the control of this framework.
+      throw new RuntimeException(message);
+    }
+  }
+  
+  /** If no match is found, return <tt>null</tt>.   Matches to both regular and 'internal' request params. */
+  private RequestParameter matchToKnownParam(String aIncomingParamName, Collection<RequestParameter> aExpectedParams){
+    RequestParameter result = null;
+    for (RequestParameter reqParam: aExpectedParams){
+      if ( reqParam.getName().equals(aIncomingParamName) ){
+        result = reqParam;
+        break;
+      }
+    }
+    if( result == null && fCSRF_REQ_PARAM.getName().equals(aIncomingParamName) ){
+      result = fCSRF_REQ_PARAM;
+    }
+    return result;
+  }
+  
+  private void checkParamValues(RequestParameter aKnownReqParam, Collection<SafeText> aParamValues) throws BadRequestException {
+    for(SafeText paramValue: aParamValues){
+      if ( Util.textHasContent(paramValue) ) {
+        if ( ! aKnownReqParam.isValidParamValue(paramValue.getRawString()) ) {
+          fLogger.severe("Request parameter named " + aKnownReqParam.getName() + " has an invalid value. Its size is: " + paramValue.getRawString().length());
+          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
+        }
+        if( fConfig.getSpamDetectionInFirewall() ){
+          SpamDetector spamDetector = BuildImpl.forSpamDetector();
+          if( spamDetector.isSpam(paramValue.getRawString()) ){
+            fLogger.fine("SPAM detected.");
+            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
+          }
+        }
+      }
+    }
+  }
+  
+  private void checkSideEffectOperations(Action aAction, RequestParser aRequestParser) throws BadRequestException {
+    fLogger.fine("Checking for side-effect operations.");
+    Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
+    for (RequestParameter reqParam : expectedParams){
+      if ( "Operation".equals(reqParam.getName()) ){
+        String rawValue = aRequestParser.getRawParamValue(reqParam);
+        if (Util.textHasContent(rawValue)){
+          Operation operation = Operation.valueOf(rawValue);
+          if ( isAttemptingSideEffectOperationWithoutPOST(operation, aRequestParser) ){
+            fLogger.severe("Security problem. Attempted operation having side effects outside of a POST. Please use a <FORM> with method='POST'.");
+            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
+          }
+        }
+      }
+    }
+  }
+  
+  private boolean isAttemptingSideEffectOperationWithoutPOST(Operation aOperation, RequestParser aRequestParser){
+    return aOperation.hasSideEffects() && !aRequestParser.getRequest().getMethod().equals("POST");
+  }
+
+  /**
+   An internal request param is not declared explicitly by the application programmer. Rather, it is defined and 
+   used only by the framework.
+  */
+  private boolean  isInternalParam(RequestParameter aRequestParam) {
+    return aRequestParam.getName().equals(fCSRF_REQ_PARAM.getName());
+  }
+
+  private void defendAgainstCSRFAttacks(RequestParser aRequestParser) throws BadRequestException {
+    if( requestNeedsDefendingAgainstCSRFAttacks(aRequestParser) ) {
+      Id postedTokenValue = aRequestParser.toId(fCSRF_REQ_PARAM);
+      if ( FAILS == toIncludeCsrfTokenWithForm(postedTokenValue) ){
+        fLogger.severe("CSRF token not included in POSTed request. Rejecting this request, since it is likely an attack.");
+        throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
+      }
+      
+      if( FAILS == matchCurrentCSRFToken(aRequestParser, postedTokenValue) ) {
+        if( FAILS == matchPreviousCSRFToken(aRequestParser, postedTokenValue) ) {
+          fLogger.severe("CSRF token does not match the expected value. Rejecting this request, since it is likely an attack.");
+          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);        
+        }
+      }
+      fLogger.fine("Success: no CSRF problem detected.");
+    }
+  }
+  
+  private boolean requestNeedsDefendingAgainstCSRFAttacks(RequestParser aRequestParser){
+    boolean isPOST =  aRequestParser.getRequest().getMethod().equalsIgnoreCase("POST");
+    boolean sessionPresent = isSessionPresent(aRequestParser);
+    boolean csrfFilterIsTurnedOn = false;
+    if( sessionPresent ) {
+      Id csrfTokenInSession = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
+      csrfFilterIsTurnedOn = (csrfTokenInSession != null);
+    }
+    
+    if( isPOST &&  sessionPresent && ! csrfFilterIsTurnedOn )  {
+      fLogger.warning("POST operation, but no CSRF form token present in existing session. This application does not have WEB4J defenses against CSRF attacks configured in the recommended way.");  
+    }
+    
+    boolean result =  isPOST && sessionPresent && csrfFilterIsTurnedOn;
+    fLogger.fine("Session exists, and the CsrfFilter is turned on : " + csrfFilterIsTurnedOn);
+    fLogger.fine("Does the firewall need to check this request for CSRF attacks? : " + result);
+    return result;
+  }
+  
+  private boolean toIncludeCsrfTokenWithForm(Id aCsrfToken){
+    return aCsrfToken != null;
+  }
+  
+  private boolean matchCurrentCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue) {
+    Id currentToken = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
+    return aPostedTokenValue.equals(currentToken);
+  }
+  
+  private boolean matchPreviousCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue){
+    //in the case of an anonymous session, with no login, this item will be null
+    Id previousToken = getCsrfTokenInSession(PREVIOUS_TOKEN_CSRF, aRequestParser);
+    return aPostedTokenValue.equals(previousToken);
+  }
+
+  private boolean isSessionPresent(RequestParser aRequestParser){
+    boolean DO_NOT_CREATE = false;
+    HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
+    return session != null;
+  }
+  
+  /** Only called when session is present. No risk of null pointer exception. */
+  private Id getCsrfTokenInSession(String aKey, RequestParser aRequestParser){
+    boolean DO_NOT_CREATE = false;
+    HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
+    return (Id)session.getAttribute(aKey);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/CsrfDAO.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,87 @@
+package hirondelle.web4j.security;
+
+import java.util.logging.Logger;
+import javax.servlet.http.HttpSessionBindingEvent;
+import javax.servlet.http.HttpSessionBindingListener;
+import hirondelle.web4j.database.Db;
+import hirondelle.web4j.database.SqlId;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.database.DAOException;
+import hirondelle.web4j.model.Id;
+
+/** 
+ Reads and writes the logged-in user's form-source id in the database.
+ 
+ <P>Saves the users form-source id when the session ends.
+*/
+final class CsrfDAO implements HttpSessionBindingListener {
+
+  /** Read in the SqlIds needed to read and write the form-souce id.   */
+  static void init(String aReadSql, String aWriteSql){
+    READ_SQL = aReadSql;
+    WRITE_SQL = aWriteSql;
+  }
+  
+  CsrfDAO(String aLoggedInUserName, Id aCurrentFormSourceId){
+    fUserName = Id.from(aLoggedInUserName);  
+    fCurrentSourceId = aCurrentFormSourceId;
+  }
+
+  /** This implementation does nothing.  */
+  public void valueBound(HttpSessionBindingEvent aBindingEvent) {
+    //do nothing
+  }
+  
+  /** Save the user's current form-source id.    */
+  public void valueUnbound(HttpSessionBindingEvent aBindingEvent) {
+    saveCurrentFormSourceId();
+  }
+  
+  /** 
+   Return the form-source id for the user's immediately preceding session.
+   
+  <P>Returns <tt>null</tt> if there is no previous form-source id for the logged-in user.
+  */
+  Id fetchPreviousFormSourceId() throws DAOException {
+    Id result = null;
+    fLogger.fine("Fetching previous form-source id for " + Util.quote(fUserName) + ", using SqlId " + Util.quote(READ_SQL));
+    result = Db.fetchValue(Id.class, getReadSql(), fUserName);
+    if( result == null ) {
+      fLogger.fine("No previous form-source id found for this user.");
+    }
+    return result;
+  }
+  
+  // PRIVATE 
+
+  private static String READ_SQL;
+  private static String WRITE_SQL;
+
+  /** Id is used since app may disallow using String. It's also more descriptive. */
+  private final Id fUserName;
+  
+  /** Saved when session ends.  */
+  private final Id fCurrentSourceId;
+  
+  private static final Logger fLogger = Util.getLogger(CsrfFilter.class);
+  
+  /** Called only when a session is about to end.  */
+  private void saveCurrentFormSourceId()  {
+    fLogger.finest("Saving current form-source id " + Util.quote(fCurrentSourceId) + ", for " + Util.quote(fUserName));
+    fLogger.finest("Using SqlId " + Util.quote(getWriteSql()));
+    try {
+      Db.edit(getWriteSql(), fCurrentSourceId, fUserName);
+    }
+    catch (DAOException ex){
+      fLogger.severe("Database problem encountered when attempting to save user's form-source id (when session ended).");
+    }
+  }
+  
+  private SqlId getReadSql() {
+    return SqlId.fromStringId(READ_SQL);
+  }
+  
+  private SqlId getWriteSql() {
+    return SqlId.fromStringId(WRITE_SQL);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/CsrfFilter.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,334 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.Controller;
+import hirondelle.web4j.database.DAOException;
+import hirondelle.web4j.database.SqlId;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+
+import java.io.CharArrayWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Random;
+import java.util.logging.Logger;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import javax.servlet.http.HttpSession;
+
+/**
+ Protect your application from a 
+ <a href='http://en.wikipedia.org/wiki/Cross-site_request_forgery'>Cross Site Request Forgery</a> (CSRF).
+
+ <P>Please see the package overview for important information regarding CSRF attacks, and security in general.
+ 
+ <P>This filter maintains various items needed to protect against CSRF attacks. It acts both as a 
+ pre-processor and as a post-processor. The behavior of this class is controlled by detecting two important events: 
+ <ul>
+  <li>the creation of new sessions (which does <i>not</i> necessarily imply a successful user login has also occured)
+  <li>a successful user login (which <i>does</i> imply a session has also been created)
+ </ul> 
+
+ <h4>Pre-processing</h4>
+ When <i>a new session</i> is detected (but not necessarily a user login), then this class will do the following :
+ <ul>
+ <li>calculate a random form-source id, and place it in session scope, under the key {@link #FORM_SOURCE_ID_KEY}. 
+ This value is difficult to guess.
+ <li>wrap the response in a custom wrapper, to implement the post-processing performed by this filter (see below)
+ </ul>
+ 
+ In addition, if <i>a new user login</i> is detected, then this class will do the following :
+ <ul>
+ <li>if there is any 'old' form-source id, place it in session scope as well, under the 
+ key {@link #PREVIOUS_FORM_SOURCE_ID_KEY}. The 'old' form-source id is simply the form-source id 
+ used in the <em>immediately preceding session for the same user</em>.
+ <li>place in session scope an object which will store the form-source id when the session expires or is invalidated, under 
+ the key {@link #FORM_SOURCE_DAO_KEY}.
+ </ul> 
+ 
+ <P>The above behavior of this class upon user login requires interaction with your database. 
+ It's configured in <tt>web.xml</tt> using two items : 
+ <tt>FormSourceIdRead</tt> and <tt>FormSourceIdWrite</tt>. These two items are 
+ {@link hirondelle.web4j.database.SqlId} references. 
+ They tell this class which SQL statements to use when reading and writing form-source ids 
+ to the database. As usual, these {@link SqlId} items must be declared somewhere in your 
+ application as <tt>public static final</tt> fields, and the corresponding SQL statements 
+ must appear somewhere in an <tt>.sql</tt> file.
+ 
+ <P>(Please see these items in the example application for an illustration : <tt>web.xml</tt>, 
+ <tt>UserDAO</tt>, and <tt>csrf.sql</tt>.) 
+ 
+ <h4>Post-processing</h4>
+ If a session is present, then this class will use a custom response wrapper to alter the response:
+ <ul>
+ <li>if the response has <tt>content-type</tt> of <tt>text/html</tt> (or <tt>null</tt>), then scan 
+ the response for all {@code <FORM>} tags with <tt>method='POST'</tt>. 
+ <li>for each such {@code <FORM>} tag, add a hidden parameter in the following style :
+<PRE>&lt;input type='hidden' name='web4j_key_for_form_source_id' value='151jdk65654dasdf545sadf6a5s4f'&gt;</PRE>
+</ul>
+ 
+ The name of the hidden parameter is taken from {@link #FORM_SOURCE_ID_KEY}, 
+ and the <tt>value</tt> of that hidden parameter is the random token created during the pre-processing stage.
+
+<h4>ApplicationFirewall</h4>
+This class cooperates closely with {@link hirondelle.web4j.security.ApplicationFirewallImpl}. It is the 
+firewall which performs the actual test to make sure the POSTed form came from your web app. 
+
+ <h4>Warning Regarding Error Pages</h4>
+ This Filter uses a wrapper for the response. When a Filter wraps the response, the error page 
+ customization defined by <tt>web.xml</tt> will likely not function. 
+ (This may be a defect of the Servlet API itself - see section 9.9.3.) That is, when an error occurs when using this 
+ Filter, the generic error pages defined by the container may be served, instead of the custom 
+ error pages you have configured in <tt>web.xml</tt>.
+ 
+ <P>This filter will only affect the response if its content-type is <tt>text/html</tt> or <tt>null</tt>.
+ It will not affect any other type of response.
+*/
+public class CsrfFilter implements Filter {
+
+  /** 
+   <em>Key</em> for item stored in session scope, and also <em>name</em> of hidden 
+   request parameter added to POSTed forms.
+   
+   <P>Value - {@value}.
+   <P>The <em>value</em> of this item is generated randomly for each new user login, and contains a 
+   simple token that is hard to guess. Each POSTed form will be required by {@link ApplicationFirewallImpl} 
+   to include a hidden parameter of this <em>name</em>, and the <em>value</em> of such hidden parameters 
+   are matched to the corresponding item stored in session scope under the same key. These checks verify that  
+   POSTed forms have come from a trusted source.
+  */
+  public static final String FORM_SOURCE_ID_KEY = "web4j_key_for_form_source_id";
+
+  /** 
+   Key for item stored in session scope.
+   
+   <P>Value - {@value}.
+   <P>The value of this item is retrieved from the database for each new user login, and 
+   represents the form-source id for the user's <em>immediately preceding</em> session. 
+   When a match of form-source id against {@link #FORM_SOURCE_ID_KEY} fails, then a second 
+   match is attempted against this item.
+   
+   <P>Please see the package description for an explanation of why this is necessary.
+  */
+  public static final String PREVIOUS_FORM_SOURCE_ID_KEY = "web4j_key_for_previous_form_source_id";
+  
+  /**
+   Key for item stored in session scope.
+     
+   <P>Value - {@value}.
+   <P>This item points to an {@link javax.servlet.http.HttpSessionBindingListener} object placed in each new session. 
+   When the session ends, that object will be unbound from the session, and will save the user's current form-source id 
+   to the database, for future use.  
+  */
+  public static final String FORM_SOURCE_DAO_KEY = "web4j_key_for_form_source_dao";
+
+  /** 
+   Read in filter configuration. 
+   
+   <P>Reads in {@link hirondelle.web4j.database.SqlId} references used to read and write the user's form-source id.
+   <P>See class comment and package-level description for further information.
+  */
+  public void init(FilterConfig aFilterConfig)  {
+    fLogger.config("INIT : " + this.getClass().getName() + ". Reading in SqlIds for reading and writing form-source ids.");
+    String read_sql = aFilterConfig.getInitParameter("FormSourceIdRead");
+    String write_sql = aFilterConfig.getInitParameter("FormSourceIdWrite");
+    checkValidSqlId(read_sql);
+    checkValidSqlId(write_sql);
+    CsrfDAO.init(read_sql, write_sql);
+  }
+  
+  /** This implementation does nothing.  */
+  public void destroy() {
+    fLogger.config("DESTROY : " + this.getClass().getName());
+  }
+  
+  /**
+   Protect against CSRF attacks.
+  
+   <P>See class comment and package-level description for further information.
+  */
+  public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException {
+    fLogger.fine("START CSRF Filter.");
+    HttpServletRequest request = (HttpServletRequest)aRequest;
+    HttpServletResponse response = (HttpServletResponse)aResponse;
+    
+    addItemsForNewSessions(request);
+    
+    if(isServingHtml(response)){
+      fLogger.fine("Serving html. Wrapping response.");
+      CharResponseWrapper wrapper = new CharResponseWrapper(response);
+      aChain.doFilter(aRequest, wrapper); //AppFirewall and BadRequest
+      
+      CharArrayWriter buffer = new CharArrayWriter(); 
+      CsrfModifiedResponse modifiedResponse = new CsrfModifiedResponse(request, response);
+      String originalOutput = wrapper.toString();
+      buffer.write(modifiedResponse.addNonceTo(originalOutput));
+      String encoding = (String)WebUtil.findAttribute(Controller.CHARACTER_ENCODING, request);
+      aResponse.setContentLength(buffer.toString().getBytes(encoding).length);
+      
+      aResponse.getWriter().write(buffer.toString()); //this will use the response's encoding
+      aResponse.getWriter().close();
+    }
+    else {
+      fLogger.fine("Not serving html. Not modifiying response.");
+      aChain.doFilter(aRequest, aResponse); //do nothing special
+    }
+    fLogger.fine("END CSRF Filter.");
+  }
+  
+  /**
+   Add a CSRF token to an existing session <i>that has no user login</i>.
+   
+   <P><i>This method is called only when a session created by an Action, instead of the usual login mechanism.</i>
+   See {@link hirondelle.web4j.action.ActionImpl#createSessionAndCsrfToken()} for important information.  
+  */
+  public void addCsrfToken(HttpServletRequest aRequest) throws ServletException {
+    addItemsForNewSessions(aRequest);
+  }
+  
+  // PRIVATE
+  
+  //WARNING : Filters always need to be thread-safe !!
+  
+  private static final Logger fLogger = Util.getLogger(CsrfFilter.class);
+  private static final boolean DO_NOT_CREATE = false;
+  
+  private static void checkValidSqlId(String aSqlId){
+    if ( ! Util.textHasContent(aSqlId) ) {
+      String message = "SqlId required as Filter init-param, but has no content: " + Util.quote(aSqlId); 
+      fLogger.severe(message);
+    }
+  }
+  
+  private void addItemsForNewSessions(HttpServletRequest aRequest) throws ServletException {
+    HttpSession session = aRequest.getSession(DO_NOT_CREATE);
+    if ( sessionExists(session) ){
+      if ( hasNoFormSourceIdInSession(session) ){
+        Id currentFormSourceId = calcFormSourceId();
+        addFormSourceIdToSession(session, currentFormSourceId);
+        if( userHasLoggedIn(aRequest) ){
+          CsrfDAO formSourceDAO = new CsrfDAO(aRequest.getUserPrincipal().getName(), currentFormSourceId);
+          addPreviousFormSourceIdToSession(session, formSourceDAO);          
+          addFormSourceDAOToSession(session, formSourceDAO);
+        }
+      }
+    }
+  }
+  
+  private boolean sessionExists(HttpSession aSession){
+    return aSession != null;
+  }
+  
+  private boolean hasNoFormSourceIdInSession(HttpSession aSession){
+    return aSession.getAttribute(FORM_SOURCE_ID_KEY) == null;
+  }
+
+  private boolean userHasLoggedIn(HttpServletRequest aRequest){
+    return aRequest.getUserPrincipal() != null;
+  }
+  
+  private void addFormSourceIdToSession(HttpSession aSession, Id aCurrentFormSourceId) {
+    fLogger.fine("Adding new form-source id to user's session.");
+    aSession.setAttribute(FORM_SOURCE_ID_KEY, aCurrentFormSourceId);
+  }
+  
+  private Id calcFormSourceId(){
+    String token = getHashFor( getRandomNumber().toString() );
+    return new Id(token);    
+  }
+  
+  private void addPreviousFormSourceIdToSession(HttpSession aSession, CsrfDAO aDAO) throws ServletException {
+    fLogger.fine("Adding previous form-source id to session.");
+    try {
+      Id previousFormSourceId = aDAO.fetchPreviousFormSourceId();
+      if( previousFormSourceId == null ) {
+        fLogger.fine("No previous form-source id found.");
+      }
+      else {
+        fLogger.fine("Adding previous form-source id to session.");
+        aSession.setAttribute(PREVIOUS_FORM_SOURCE_ID_KEY, previousFormSourceId);
+      }
+    }
+    catch (DAOException ex){
+      throw new ServletException("Cannot fetch previous form-source id from database.", ex);
+    }
+  }
+  
+  private void  addFormSourceDAOToSession(HttpSession aSession, CsrfDAO aDAO) {
+    fLogger.fine("Adding CsrfDAO object to session.");
+    aSession.setAttribute(FORM_SOURCE_DAO_KEY, aDAO);
+  }
+  
+  private synchronized Long getRandomNumber() {
+    Random random = new Random();
+    return random.nextLong();
+  }
+  
+  private String getHashFor(String aText) {
+    String result = null;
+    try {
+      MessageDigest sha = MessageDigest.getInstance("SHA-1");
+      byte[] hashOne = sha.digest(aText.getBytes());
+      result = hexEncode(hashOne);
+    }
+    catch (NoSuchAlgorithmException ex){
+      String message = "MessageDigest cannot find SHA-1 algorithm."; 
+      fLogger.severe(message);
+      throw new RuntimeException(message);
+    }
+    return result;
+  }
+  
+  /**
+   The byte[] returned by MessageDigest does not have a nice
+   textual representation, so some form of encoding is usually performed.
+  
+   This implementation follows the example of David Flanagan's book
+   "Java In A Nutshell", and converts a byte array into a String
+   of hex characters.
+  */
+  static private String hexEncode( byte[] aInput){
+    StringBuilder result = new StringBuilder();
+    char[] digits = {'0', '1', '2', '3', '4','5','6','7','8','9','a','b','c','d','e','f'};
+    for (int idx = 0; idx < aInput.length; ++idx) {
+      byte b = aInput[idx];
+      result.append( digits[ (b&0xf0) >> 4 ] );
+      result.append( digits[ b&0x0f] );
+    }
+    return result.toString();
+  }
+
+  private static final String TEXT_HTML = "text/html";
+  
+  /** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive).  */
+  private boolean isServingHtml(HttpServletResponse aResponse){
+    String contentType = aResponse.getContentType();
+    boolean missingContentType = ! Util.textHasContent(contentType);
+    boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML);
+    return missingContentType || startsWithHTML;
+  }
+  
+  private static final class CharResponseWrapper extends HttpServletResponseWrapper {
+    public String toString() {
+        return fOutput.toString();
+    }
+    public CharResponseWrapper(HttpServletResponse response){
+        super(response);
+        fOutput = new CharArrayWriter();
+    }
+    public PrintWriter getWriter(){
+        return new PrintWriter(fOutput);
+    }
+    private CharArrayWriter fOutput;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/CsrfModifiedResponse.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,116 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.Util;
+
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+/** Add a nonce to POSTed forms in any 'text/html' response. */
+final class CsrfModifiedResponse {
+
+  CsrfModifiedResponse(HttpServletRequest aRequest, HttpServletResponse aResponse){
+    fResponse = aResponse;
+    fRequest = aRequest;
+  }
+  
+  String addNonceTo(String aUnmodifiedResponse){
+    String result = aUnmodifiedResponse;
+    if(isServingHtml() && Util.textHasContent(aUnmodifiedResponse))  {
+      fLogger.fine("Adding nonce to forms having method=POST, if any.");
+      result = addHiddenParamToPostedForms(aUnmodifiedResponse);
+    }
+    return result;
+  }
+  
+  // PRIVATE
+  
+  private HttpServletRequest fRequest;
+  private HttpServletResponse fResponse;
+
+  /**
+    Group 1 is the FORM start tag *plus the body*, and group 2 is the FORM end tag.
+    Note the reluctant qualifier for group 2, to ensure multiple forms are not glommed together.
+  */
+  private static final String REGEX =
+     "(<form" + Regex.ALL_BUT_END_OF_TAG +"method=" + Regex.QUOTE + "POST" + Regex.QUOTE + Regex.ALL_BUT_END_OF_TAG + ">"  
+     +  Regex.ANY_CHARS + "?)" + 
+     "(</form>)"
+  ; 
+  private static final Pattern FORM_PATTERN = Pattern.compile(REGEX, Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
+
+  private static final String TEXT_HTML = "text/html";
+  
+  /**
+   Problem: this class is package private. If we use the 'regular' logger, named for this class, then the output will not 
+   show up. So, we use a logger attached to a closely related, public class. (Neat!) 
+  */
+  private static final Logger fLogger = Util.getLogger(CsrfFilter.class);
+
+  /** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive).  */
+  private boolean isServingHtml(){
+    String contentType = fResponse.getContentType();
+    boolean missingContentType = ! Util.textHasContent(contentType);
+    boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML);
+    return missingContentType || startsWithHTML;
+  }
+  
+  private String addHiddenParamToPostedForms(String aOriginalInput) {
+    StringBuffer result = new StringBuffer();
+    Matcher formMatcher = FORM_PATTERN.matcher(aOriginalInput);
+    while ( formMatcher.find() ){
+      fLogger.fine("Found a POSTed form. Adding nonce.");
+      formMatcher.appendReplacement(result, getReplacement(formMatcher));
+    }
+    formMatcher.appendTail(result);
+    return result.toString();
+  }
+  
+  private String getReplacement(Matcher aMatcher){
+    //escape, since '$' char may appear in input
+    return EscapeChars.forReplacementString(aMatcher.group(1) +  getHiddenInputTag() +  aMatcher.group(2));
+  }
+  
+  private String getHiddenInputTag(){
+    return "<input type='hidden' name='" + getHiddenParamName() + "' value='" + getHiddenParamValue().toString() + "'>";
+  }
+  
+  /** 
+   Return the form-source id value, stored in the user's session.
+   If there is no session, or if there is no form-source id in the session, throw a RuntimeException.  
+  */
+  private Id getHiddenParamValue(){
+    Id result = null;
+    boolean DO_NOT_CREATE = false;
+    HttpSession session = fRequest.getSession(DO_NOT_CREATE);
+    if ( session != null ) {
+      result = (Id)session.getAttribute(CsrfFilter.FORM_SOURCE_ID_KEY);
+      if( result == null ){
+        String message =  "Session exists, but no CSRF token value is stored in the session"; 
+        fLogger.severe(message);
+        throw new RuntimeException(message);
+      }
+    }
+    else {
+      String message = 
+        "No session exists! CsrfFilter can only work when a session is present, and the user has logged in. " + 
+        "Ensure CsrfFilter is mapped (using url-pattern) only to URLs having mandatory login and/or a valid session."
+      ; 
+      fLogger.severe(message);
+      throw new RuntimeException(message);
+    }
+    return result;
+  }
+  
+  /** Return the name of the hidden form parameter.  */
+  private String getHiddenParamName(){
+    return CsrfFilter.FORM_SOURCE_ID_KEY;
+  }
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/FetchIdentifierOwner.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,29 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.Id;
+
+/**
+  Look up the login name of the user who owns an  
+  <a href='UntrustedProxyForUserId.html#UntrustedIdentifier'>untrusted identifer</a>.
+  
+  <P>See the <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>User Guide</a> 
+  and {@link hirondelle.web4j.security.UntrustedProxyForUserId} for important information regarding 
+  this interface. 
+*/
+public interface FetchIdentifierOwner {
+  
+  /**
+   Return the user login name of the user that 'owns' the <a href='UntrustedProxyForUserId.html#UntrustedIdentifier'>untrusted proxy for the 
+   user id</a> used in the current request. If an owner cannot be found, then return <tt>null</tt>. 
+   
+    <P>The meaning of the untrusted identifier depends on the context, and changes for each action/operation.
+    A typical implmentation will follow these steps:
+   <ul>
+    <li>use a request parameter value, whose value contains the untrusted identifier
+    <li>the value of the untrusted identifier is then passed to a <tt>SELECT</tt> statement, 
+    which returns a single value - the owner's login name
   </ul>
+  */
+  Id fetchOwner() throws AppException;
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/LoginTasks.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,65 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.model.AppException;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+/** 
+ Perform tasks required after successful user login.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forLoginTasks()} returns the configured implementation of this interface. 
+ There is no default implementation of this interface. You must supply one. 
+ (You can always supply an implmentation that does nothing, if you wish.) 
+
+ <P>This interface exists to allow an application to react to successful user logins.
+ At least one of those tasks must put an item in session scope. 
+ The web4j <tt>Controller</tt> will use the presence/absence of that item to determine if the login tasks have already been performed.
+  
+ <P>Example tasks :
+ <ul>
   <li>place user preferences in session scope
   <li>place the user id in session scope; the id is different from the user login name (see important note below).
+   It's highly recommended that such an id be placed in session scope using {@link hirondelle.web4j.action.ActionImpl#USER_ID} as its key.
+   Doing so lets your Actions easily access the id using {@link hirondelle.web4j.action.ActionImpl#getUserId()}.
 </ul>
+ 
+    <h3>User Login Name Versus User Id</h3>
+ After a successful login, the container will always place the user's <i>login name</i> in session scope. 
+   However, it's often convenient or desirable to have a corresponding user <i>id</i> in session scope as well.
+    In this context, the user id is a database identifier, the primary key of an underlying user record in the database (almost always an integer value).  
+    Having the user id in session scope often helps to enforce <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>data ownership constraints</a>.
+     
+   <h3>The User Id Is a Server Secret</h3>
+  The user login name and the user id must be treated very differently.
+   While the user login name can of course be displayed to the user, <b>the user id is a server-side secret, and should never be made visible to the user.</b>
+   It's nobody's business, including the user to whom it belongs.  
+   You can place the user id in session scope, and your server-side code can use it, but its value can never be part of the HTTP response in any way.
+   Exposing it allows one user to access the data of another.
+   <b>Always treat the user id as a server-side secret. Failure to do so is a huge security risk.</b>
+   
+   <h3>Consolidating User Preferences Into One Object</h3>
+   If there are many user preferences, and not just one, then it might make sense to place a single  
+   object into session scope, which gathers together all such preferences into a single object.
+   Note, however, that the <em>default</em> implementations of 
+   {@link hirondelle.web4j.request.LocaleSource} and {@link hirondelle.web4j.request.TimeZoneSource}
+   are not consistent with such a style, since they expect their data to be stored under separate 
+   keys defined in {@link hirondelle.web4j.Controller}. 
+*/
+public interface LoginTasks {
+
+  /**
+   React to a successful user log in. 
+   Typically, implementations will look up data related to the user, and place it in session scope.
+   
+   <P>This method is called only if all of the following are true:
+   <ul>
    <li>a session already exists
    <li>the user has successfully logged in
+    <li>{@link #hasAlreadyReacted(HttpSession)} returns <tt>false</tt>
   </ul> 
+  */
+  void reactToUserLogin(HttpSession aExistingSession, HttpServletRequest aRequest) throws AppException;
+
+  /** 
+   Return <tt>true</tt> only if the user login has already been processed by {@link #reactToUserLogin(HttpSession, HttpServletRequest)}.
+   Typically, implementations will simply return <tt>true</tt> only if an item of a given name is already in session scope.
+  */
+  boolean hasAlreadyReacted(HttpSession aSession);
+ 
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/PermittedCharacters.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,36 @@
+package hirondelle.web4j.security;
+
+/**
+ Characters accepted by the {@link SafeText} class.
+ 
+ <P>This interface exists because it is important for a web application 
+ to defend strongly against Cross-Site Scripting (XSS) -- likely the single most  
+ prevalent form of attack on the web.
+ 
+ <P>As principal line of defense against XSS, WEB4J provides the {@link SafeText} class, 
+ to be used to model all free form user input. <tt>SafeText</tt> escapes a large number of 
+ special characters. If they are contained in a {@link SafeText} object, any scripts 
+ that depend on one or more of these special characters will very likely be 
+ rendered unexecutable.
+ 
+ <P>As a second line of defense, this interface permits you to specify exactly which characters 
+ should be accepted by the {@link SafeText} constructor. This is often called a  
+ 'white list' of acceptable characters.
+ 
+ <P>The default implementation of this interface 
+ ({@link hirondelle.web4j.security.PermittedCharactersImpl}) 
+ should be useful for a wide number of applications.
+*/
+public interface PermittedCharacters {
+  
+  /**
+   Return <tt>true</tt> only if the given character is to be permitted by {@link SafeText}.
+   
+   @param aCodePoint character in the text being passed to the {@link SafeText} constructor.
+   The text, in turn, may come from user input, or from the database. For more information on 
+   code points, please see {@link Character}. (Code points are used insteard of char since they are 
+   more general than char.)
+  */
+  public boolean isPermitted(int aCodePoint);
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/PermittedCharactersImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,30 @@
+package hirondelle.web4j.security;
+
+/**
+ Default implementation of {@link hirondelle.web4j.security.PermittedCharacters}.
+ 
+ <P>This class permits only those characters which return <tt>true</tt> for
+ {@link Character#isValidCodePoint(int)}.
+ 
+ <P>Since {@link SafeText} already escapes a long list of special characters, those 
+ special characters are automatically safe for inclusion here.
+ <em>That is, you can usually accept almost any special character, because 
+ <tt>SafeText</tt> already does so much escaping anyway.</em>
+ 
+ <P>Given the importance of this issue for web application security, however, 
+ WEB4J still allows you to define your own implementation of this interface, as 
+ desired. 
+ 
+ <P>This is a very liberal implementation. Applications should consider replacing this 
+ implementation with something less liberal. For example, an alternate implementation 
+ might disallow carriage returns and line feeds, or might specify the characters of 
+ some particular block of Unicode.
+*/
+public class PermittedCharactersImpl implements PermittedCharacters {
+
+  /** See class comment. */
+  public boolean isPermitted(int aCodePoint) {
+    return Character.isValidCodePoint(aCodePoint);
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/SafeText.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,306 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Util;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.logging.Logger;
+
+/**
+ Models free-form text entered by the user, and 
+ protects your application from 
+ <a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>Cross Site Scripting</a> (XSS). 
+ 
+ <P>Free-form text refers to text entered by the end user. 
+ It differs from other data in that its content is not tightly 
+ constrained. Examples of free-form text might include a user name, a description 
+ of something, a comment, and so on. If you model free-form text as a simple 
+ <tt>String</tt>, then when presenting that text in a web page, you must take 
+ special precautions against Cross Site Scripting attacks, by escaping 
+ special characters. When modeling such data as <tt>SafeText</tt>, 
+ however, such special steps are not needed, since the escaping is built
+ directly into its {@link #toString} method. 
+ 
+ <P>It is worth noting that there are two defects with JSTL' s handling of this problem :
+ <ul>
+ <li>the {@code <c:out>} tag <em>escapes only 5 of the 12 special characters</em> identified
+ by the Open Web App Security Project as being a concern.
+ <li>used in a JSP, the Expression Language allows pleasingly concise presentation, but 
+ <em>does not escape special characters in any way</em>. Even when one is aware of this, 
+ it is easy to forget to take precautions against Cross Site Scripting attacks.
+ </ul>
+ 
+ <P>Using <tt>SafeText</tt> will protect you from both of these defects.
+ Since the correct escaping is built into {@link #toString}, you may freely use JSP 
+ Expression Language, without needing to do any escaping in the view. Note that if you use 
+ {@code <c:out>} with <tt>SafeText</tt> (not recommeded), then you must 
+ use <tt>escapeXml='false'</tt> to avoid double-escaping of special characters.
+ 
+ <P>There are various ways of presenting text :
+ <ul>
+ <li>as HTML (most common) - use {@link #toString()} to escape a large number of 
+  special characters. 
+ <li>as XML - use {@link #getXmlSafe()} to escape 5 special characters.
+ <li>as JavaScript Object Notation (JSON) - use {@link #getJsonSafe()} to escape 
+  a number of special characters 
+  <li>as plain text - use {@link #getRawString()} to do no escaping at all.
+ </ul> 
+ 
+ <h4>Checking For Vulnerabilities Upon Startup</h4>
+ WEB4J will perform checks for Cross-Site Scripting vulnerabilities 
+ upon startup, by scanning your application's classes for <tt>public</tt> Model Objects 
+ having <tt>public getXXX</tt> methods that return a <tt>String</tt>. It will log such  
+ occurrences to encourage you to investigate them further.
+ 
+ <P><em>Design Notes :</em><br>
+ This class is <tt>final</tt>, immutable, {@link Serializable}, 
+ and {@link Comparable}, in imitation of the other building block classes 
+ such as {@link String}, {@link Integer}, and so on.
+ 
+ <P>The reason why protection against Cross-Site Scripting is not implemented as a 
+ Servlet Filter is because a filter would have no means of distinguishing between safe and 
+ unsafe markup. 
+ 
+ <P>One might object to escaping special characters in the Model, instead of in the View. 
+ However, from a practical point of view, it seems more likely that the programmer will  
+ remember to use <tt>SafeText</tt> once in the Model, than remember to do the 
+ escaping repeatedly in the View. 
+*/
+public final class SafeText implements  Serializable, Comparable<SafeText> {
+
+  /**
+   Returns <tt>true</tt> only if the given character is always escaped by
+   {@link #toString()}. For the list of characters, see {@link EscapeChars#forHTML(String)}. 
+   
+   <P>Recommended that your implementation of {@link PermittedCharacters}
+   use this method. This will allow you to accept many special characters in your 
+   list of permissible characters. 
+  */
+  public static boolean isEscaped(int aCodePoint){
+    return ESCAPED_CODE_POINTS.contains(aCodePoint);
+  }
+  
+  /**
+   Constructor.
+   
+   @param aText free-form text input by the end user, which may contain 
+   Cross Site Scripting attacks.  Non-null. The text is trimmed by this 
+   constructor.
+  */
+  public SafeText(String aText) {
+    fText = Util.trimPossiblyNull(aText);  
+    validateState();
+  }
+  
+  /**
+   Factory method.
+   
+   Simply a slightly more compact way of building an object, as opposed to 'new'.
+  */
+  public static SafeText from(String aText){
+    return new SafeText(aText);
+  }
+  
+  /** 
+   Return the text in a form safe for an HTML document.
+   
+   Passes the raw text through {@link EscapeChars#forHTML(String)}.  
+  */
+  @Override public String toString(){
+    if( ! Util.textHasContent(fEscapedForHTML) ){
+      fEscapedForHTML = EscapeChars.forHTML(fText);
+    }
+    return fEscapedForHTML;
+  }
+  
+  /** Return the (trimmed) text passed to the constructor.  */
+  public String getRawString(){
+    return fText;
+  }
+  
+  /** 
+   Return the text in a form safe for an XML element.
+  
+   <P>Arbitrary text can be rendered safely in an XML document in two ways :
+   <ul>
+   <li>using a <tt>CDATA</tt> block
+   <li>escaping special characters {@code &, <, >, ", '}.
+   </ul>
+   
+   <P>This method will escape the above five special characters, and replace them with 
+   character entities, using {@link EscapeChars#forXML(String)}
+  */
+  public String getXmlSafe(){
+    return EscapeChars.forXML(fText);
+  }
+  
+  /**
+   Return the text in a form safe for <a href='http://www.json.org/'>JSON</a> (JavaScript Object Notation) data.
+   
+   <P>This method is intended for the <i>data</i> elements of JSON. 
+   It is intended for <i>values</i> of things, not for their <i>names</i>.
+   Typically, only the values will come from end user input, while the names will 
+   be hard-coded.
+  */
+  public String getJsonSafe(){
+    return EscapeChars.forJSON(fText);
+  }
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      SafeText that = (SafeText)aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;
+  }
+  
+  @Override public int hashCode(){
+    if ( fHashCode == 0){
+      fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
+    }
+    return fHashCode;
+  }
+  
+  public int compareTo(SafeText aThat){
+    final int EQUAL = 0;
+    if ( this == aThat ) return EQUAL;
+    
+    int comparison = this.fText.compareTo(aThat.fText);
+    if ( comparison != EQUAL ) return comparison;
+    
+    return EQUAL;
+  }
+  
+  // PRIVATE //
+  
+  /** @serial   */
+  private final String fText;
+  /** The return value of toString, cached like fHashCode.  */
+  private String fEscapedForHTML;
+  private int fHashCode;
+  private static final Logger fLogger = Util.getLogger(SafeText.class);
+  
+  private Object[] getSignificantFields(){
+    return new Object[] {fText};
+  }
+  
+  /** During deserialization, this method cannot be called, since the implementation of PermittedChars is null. */
+  private void validateState() {
+    if (fText == null){
+      throw new NullPointerException("Free form text cannot be null.");
+    }
+    String badCharacters = findBadCharacters(fText);
+    if( Util.textHasContent(badCharacters) ) {
+      throw new IllegalArgumentException("Unpermitted character(s) in text: " + Util.quote(badCharacters) );
+    }
+  }
+  
+  private String findBadCharacters(String aArbitraryText){
+    String result = Consts.EMPTY_STRING; //default
+    StringBuilder badCharacters = new StringBuilder();
+    PermittedCharacters whitelist = getPermittedChars();
+    
+    int idx = 0;
+    int length = aArbitraryText.length();
+    while ( idx < length ) {
+      int codePoint = aArbitraryText.codePointAt(idx);
+      if( ! whitelist.isPermitted(codePoint) ) {
+        fLogger.severe("Bad Code Point : " + codePoint);
+        char[] badChar = Character.toChars(codePoint);
+        badCharacters.append(String.valueOf(badChar));        
+      }
+      idx = idx  + Character.charCount(codePoint);
+    }
+    
+    if( Util.textHasContent(badCharacters.toString()) ) {
+      result = badCharacters.toString();
+      fLogger.severe("Bad Characters found in request, disallowed by PermittedCharacters implementation: " + result);
+    }
+    return result;
+  }
+  
+  private PermittedCharacters getPermittedChars(){
+    return BuildImpl.forPermittedCharacters();
+  }
+  
+  /**
+   For evolution of this class, see Sun guidelines : 
+   http://java.sun.com/j2se/1.5.0/docs/guide/serialization/spec/version.html#6678 
+  */
+  private static final long serialVersionUID = 7526472295633676147L;
+
+  /**
+   Always treat de-serialization as a full-blown constructor, by
+   validating the final state of the de-serialized object.
+  */
+  private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
+     aInputStream.defaultReadObject();
+     //partial validation only, without looking for 'bad' characters (BuildImpl not available):
+     if (fText == null){
+       throw new NullPointerException("Free form text cannot be null.");
+     }
+  }
+  
+  /**
+   This is the default implementation of writeObject.
+   Customise if necessary.
+  */
+  private void writeObject(ObjectOutputStream aOutputStream) throws IOException {
+    aOutputStream.defaultWriteObject();
+  }
+  
+  /** List of characters that this class will always escape.  */
+  private static List<Character> ESCAPED = Arrays.asList(
+      '<', 
+      '>' ,
+      '&' ,
+      '"' ,
+      '\t' ,
+      '!' ,
+      '#' ,
+      '$' ,
+      '%' ,
+      '\'' ,
+      '(' ,
+      ')' ,
+      '*' ,
+      '+' ,
+      ',' ,
+      '-' ,
+      '.' ,
+      '/' ,
+      ':' ,
+      ';' ,
+      '=' ,
+      '?' ,
+      '@' ,
+      '[' ,
+      '\\' ,
+      ']' ,
+      '^' ,
+      '_' ,
+      '`' ,
+      '{' ,
+      '|' ,
+      '}' ,
+      '~'
+   );
+
+  /** As above, but translated into a form that uses code points. */
+  private static List<Integer> ESCAPED_CODE_POINTS = new ArrayList<Integer>();
+  static {
+    for (Character character : ESCAPED){
+      ESCAPED_CODE_POINTS.add(Character.toString(character).codePointAt(0));
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/SpamDetector.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,39 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.request.RequestParser;
+
+/**
+ Determine if text is likely spam.
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forSpamDetector()} 
+ returns the configured implementation of this interface.
+ 
+ <P><a href="http://en.wikipedia.org/wiki/Forum_spam">Spam</a> refers to unwanted input from 
+ undesirable parties (usually advertising of some sort) that is often POSTed to servers using automated means.
+ 
+ <P>Most spam contains <em>links</em>. Implementations are encouraged to detect unwanted links.
+ 
+ <P>The <tt>SpamDetectionInFirewall</tt> setting in <tt>web.xml</tt> can instruct the 
+ {@link hirondelle.web4j.security.ApplicationFirewall} to use the configured <tt>SpamDetector</tt> 
+ to reject <em>all</em> requests containing at least one parameter that appears to be spam. 
+ Such filtering is applied as a 
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a>, and will <em>not</em> result in 
+ a polished response to the end user.
+ 
+ <P>If that policy is felt to be too aggressive, then the only alternative is to check <em>all 
+ items input as text</em> using {@link hirondelle.web4j.model.Check#forSpam()} (usually  
+ in a Model Object constructor). Such checks do <em>not</em> need to be applied to
+ numeric or date data, since the regular conversion validations done by {@link RequestParser} for 
+ numbers and dates will already detect and reject any spam.  
+*/
+public interface SpamDetector {
+  
+  /** 
+   Determine if the given text is very likely spam.
+   
+   @param aText value of a request parameter.
+  */
+  boolean isSpam(String aText);
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/SpamDetectorImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,33 @@
+package hirondelle.web4j.security;
+
+import java.util.regex.*;
+import hirondelle.web4j.util.Util;
+
+/**
+ Fails all text containing links, except those appearing in 
+ <a href='../ui/translate/Text.html#WikiStyleFormatting'>wiki-style formatting</a>.
+ 
+ <P>Almost all spam contains links. This implementation will reject all text that contains 
+ '<tt>http://</tt>', <em>except</em> if the text contains any wiki-style links in the form 
+ <tt>[link:http://www.google.ca Google]</tt>. 
+*/
+public class SpamDetectorImpl implements SpamDetector {
+
+  /** See class comment. */
+  public boolean isSpam(String aText) {
+    boolean result = false;
+    if( Util.contains(WIKI_LINK_PATTERN, aText)){
+      //OK
+    }
+    else {
+      if ( Util.contains(STANDARD_LINK_PATTERN, aText)) {
+        result = true;
+      }
+    }
+    return result;
+  }
+  
+  // PRIVATE //
+  private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[link:http://");
+  private static final Pattern STANDARD_LINK_PATTERN = Pattern.compile("http://");
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/SuppressUnwantedSessions.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,160 @@
+package hirondelle.web4j.security;
+
+import java.io.*;
+import java.util.logging.*;
+import javax.servlet.Filter;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import javax.servlet.http.HttpSession;
+import hirondelle.web4j.util.Util;
+
+/**
+ Suppress the creation of unwanted sessions.
+ 
+ <P><b>Using this filter means that browsers must have cookies enabled.</b>
+ Some users believe that disabling cookies protects them. For web applications,  
+ this seems unadvisable since its replacement -- URL rewriting -- has a 
+ much higher security risk. URL rewriting is <em>dangerous</em> since it is a vector for 
+ session hijacking and session fixation.
+ 
+ <P>This class can be used only when form-based login is used. 
+ When form-based login is used, the generation of the initial <tt>JSESSIONID</tt> 
+ cookie is done only once per session, by the container. This filter helps you enforce the 
+ policy that form-based login should be the only time a session cookie 
+ is generated. 
+ 
+ <P>Superfluous sessions and session ids represent a security risk.
+ Here, the following approach is taken:
+ <ul>
+ <li>during form-based login, the <em>container</em> sends a session cookie to the browser 
+ <li>at no other time does the <em>web application</em> itself send a <tt>JSESSIONID</tt>, either in a 
+ cookie or in a rewritten URL
+ <li>upon logoff, the <em>web application</em> instructs the browser to delete the <tt>JSESSIONID</tt> cookie
+ </ul>
+ 
+ <P>Note how the container and the web application work together to manage the <tt>JSESSIONID</tt> cookie.
+ 
+ <P>It's unfortunate that the Servlet API and Java Server Pages make it 
+ a bit too easy to create new sessions. To circumvent that, this filter uses  
+ custom wrappers for the underlying HTTP request and response. 
+ These wrappers alter the implementations of the following methods related to creating sessions :
+ <ul>
+ <li><tt>{@link javax.servlet.http.HttpServletRequest#getSession()}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletRequest#getSession(boolean)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeRedirectURL(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeRedirectURL(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeRedirectUrl(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeURL(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeUrl(java.lang.String)}</tt>
+ </ul>
+ 
+ <b>Calls to the <tt>getSession</tt> methods are in effect all coerced to <tt>getSession(false)</tt>.</b> 
+ <b>Since this doesn't affect the form-based login mechanism, the user will 
+ still receive a <tt>JSESSIONID</tt> cookie during form-based login</b>. This policy ensures that your code 
+ cannot mistakenly create a superfluous session. 
+ 
+ <P>The <tt>encodeXXX</tt> methods are no-operations, and simply return the given String unchanged.
+ This policy in effect <b>disables URL rewriting</b>. URL rewriting is a security risk since it allows 
+ session ids to appear in simple links, which are subject to session hijacking. 
+ 
+ <P>As a convenience, this class will also detect sessions that do not have a user login, 
+ and will log such occurrences as a warning.
+*/
+public class SuppressUnwantedSessions implements Filter {
+  
+   public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException {
+     fLogger.fine("START SuppressUnwantedSessions Filter.");
+     
+     HttpServletResponse response = (HttpServletResponse) aResponse;
+     HttpServletRequest request = (HttpServletRequest) aRequest;
+     
+     DisableSessionCreation requestWrapper = new DisableSessionCreation(request);
+     DisableUrlRewriting responseWrapper = new DisableUrlRewriting(response);
+     
+     aChain.doFilter(requestWrapper,  responseWrapper);
+     
+     checkForSessionWithNoLogin((HttpServletRequest)aRequest);
+     
+     fLogger.fine("END SuppressUnwantedSessions Filter.");
+   }
+   
+   /** This implementation does nothing.   */
+   public void init(FilterConfig aFilterConfig)  {
+     fLogger.config("INIT : " + this.getClass().getName());
+   }
+   
+   /** This implementation does nothing.  */
+   public void destroy() {
+     fLogger.config("DESTROY : " + this.getClass().getName());
+   }
+   
+   // PRIVATE //
+   private static final Logger fLogger = Util.getLogger(SuppressUnwantedSessions.class);
+   private static final boolean DO_NOT_CREATE = false;
+    
+   private void checkForSessionWithNoLogin(HttpServletRequest aRequest) {
+     HttpSession session = aRequest.getSession(DO_NOT_CREATE);
+     if ( sessionAlreadyExists(session) ){
+       if( ! userHasLoggedIn(aRequest)){
+         fLogger.warning("Session exists, but user has not yet logged in.");
+       }
+     }
+   }
+   
+   private boolean sessionAlreadyExists(HttpSession aSession){
+     return aSession != null;
+   }
+   
+   private boolean userHasLoggedIn(HttpServletRequest aRequest){
+     return aRequest.getUserPrincipal() != null;
+   }
+   
+  /** Alter the implementation of <tt>getSession()</tt> methods.  */
+  private static final class DisableSessionCreation extends HttpServletRequestWrapper {
+     DisableSessionCreation(HttpServletRequest aRequest){
+       super(aRequest);
+     }
+     @Override public HttpSession getSession() {
+       // FYI - Tomcat 5.5 JSPs call getSession(). 
+       fLog.fine("Calling getSession(). Method not supported by this wrapper. Coercing to getSession(false).");
+       return getSession(false);
+     }
+     @Override public HttpSession getSession(boolean aCreateIfAbsent) {
+       if( aCreateIfAbsent ){
+         fLog.warning("Calling getSession(true). Method call not supported by this wrapper. Coercing to getSession(false).");
+       }
+       else {
+         //fLogger.finest("Calling getSession(false).");
+       }
+       return super.getSession(NEVER_CREATE);
+     }
+     private static final Logger fLog = Util.getLogger(DisableSessionCreation.class);
+     private static final boolean NEVER_CREATE = false;
+   }
+  
+  /** Disable URL rewriting entirely.  */
+  private static final class DisableUrlRewriting extends HttpServletResponseWrapper {
+    DisableUrlRewriting(HttpServletResponse aResponse){ 
+      super(aResponse);
+    }
+    @Override  public String encodeUrl(String aURL) {
+      return aURL;
+    }
+    @Override public String encodeURL(String aURL) {
+      return aURL;
+    }
+    @Override public String encodeRedirectURL(String aURL) {
+      return aURL;
+    }
+    @Override public String encodeRedirectUrl(String aURL) {
+      return aURL;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/TESTPermittedCharactersImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,81 @@
+package hirondelle.web4j.security;
+
+import junit.framework.*;
+
+public class TESTPermittedCharactersImpl extends TestCase {
+  
+  /** Run the test cases.  */
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTPermittedCharactersImpl.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+  
+  public TESTPermittedCharactersImpl(String aName) {
+    super(aName);
+  }
+  
+  public void testChars(){
+    PermittedCharacters permitted = new PermittedCharactersImpl();
+    testSuccess('a', permitted);
+    testSuccess('A', permitted);
+    testSuccess('z', permitted);
+    testSuccess('Z', permitted);
+    testSuccess('0', permitted);
+    testSuccess('9', permitted);
+    testSuccess(' ', permitted);
+    testSuccess('~', permitted);
+    testSuccess('~', permitted);
+    testSuccess('\r', permitted);
+    testSuccess('\n', permitted);
+    testSuccess('\u00E9', permitted); //French e acute
+    testSuccess('?', permitted);
+    testSuccess('\u00E7', permitted); //French c cedilla
+    testSuccess('\t', permitted);
+    testSuccess('<', permitted);
+    testSuccess('\u0660', permitted);
+    testSuccess('\u0b87', permitted);
+    testSuccess('?', permitted);  
+    testSuccess('$', permitted);  
+    testSuccess('\u20AC', permitted); //euro symbol
+  }
+  
+  public void testThai(){
+    //Thai is in the range 0E00..0E7F
+    //String thai ="\u0E01\u0E02"; //success
+    String thai ="\u0E48"; //success E39, E48, and E34 used to be a problem - they are vowels and tone marks
+    PermittedCharacters permitted = new PermittedCharactersImpl();
+    int idx = 0;
+    int length = thai.length();
+    while ( idx < length ) {
+      int codePoint = thai.codePointAt(idx);
+      if( permitted.isPermitted(codePoint)) {
+        //OK
+      }
+      else {
+        fail("Expected success with " + codePoint);
+      }
+      idx = idx  + Character.charCount(codePoint);
+    }
+  }
+  
+  // PRIVATE //
+  private void testSuccess(char aChar, PermittedCharacters aPermitted){
+    if ( aPermitted.isPermitted(aChar)){
+      //OK
+    }
+    else {
+      fail("Expected success with " + aChar);
+    }
+  }
+  
+  
+  
+  private void testFailure(char aChar, PermittedCharacters aPermitted){
+    if( aPermitted.isPermitted(aChar) ) {
+      fail("Expected failure for " + aChar);
+    }
+    else {
+      //OK
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/UntrustedProxyForUserId.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,58 @@
+package hirondelle.web4j.security;
+
+import hirondelle.web4j.request.RequestParser;
+
+/**
+ Determines if a request has an ownership constraint which needs explicit validation for a user id proxy.
+ 
+ <P>This interface addresses the issue of 
+ <a href='http://www.owasp.org/index.php/Top_10_2007-A4'>Insecure Direct Object Reference</a>, which 
+ is an important security issue for web applications. The issue centers around proper enforcement of 
+ <b>data ownership constraints</b>. 
+ 
+ <P>Please see the <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>User Guide</a> for 
+ more information on this important topic.
+  
+ <a name='UntrustedProxyForUserId'></a>
+ <h3>Untrusted Proxy For User Id</h3>
+ An untrusted proxy for the user id is defined here as satisfying these two criteria: 
+ <ul>
   <li>it's "owned" by a specific user (that is, it has an associated data ownership constraint) 
+   <li>it's open to manipulation by the end user (for example, by simply changing a request parameter)
 </ul>
+ 
+ <P>An untrusted identifier typically appears in a link, or in a form's target URL.
+ This interface is for defining which requests use an untrusted identifier, and which need to enforce a data 
+ ownership constraint in a particular way.
+ 
+ <P>Note that, as explained in the <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>User Guide</a>, 
+ not all data ownership constraints involve an untrusted proxy for the user id - only some do. 
+ 
+ <P>The {@link hirondelle.web4j.Controller} processes each request using your application's configured 
+ implementation of this interface. Most applications will likely use the default implementation, 
+ {@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}. 
+ The <tt>Controller</tt> logic is roughly as follows:
+  <PRE>
+get the configured implementation of UntrustedProxyForUserId
+if the current request has an untrusted id {
+  cast the Action to {@link hirondelle.web4j.security.FetchIdentifierOwner}
+  fetch the login name of the user who owns the untrusted id
+  compare it to the login name of the current user  
+  proceed with the Action only if there is a match
+}</PRE>
+
+(Reminder: whenever a user logs in, the login name of the current user is always placed into session scope by the Servlet Container.)
+ 
+ <P>Implementations of this interface will typically extract two items from the underlying request, to determine if the 
+ request has an untrusted proxy for the user id :
+ <ul>
   <li>the 'noun' - identifies <i>what data</i> is being operated on 
   <li>the 'verb' - what is being <i>done</i> to the data (the operation) 
 </ul>
+ 
+ <P>In some cases, only the noun will be important, since <i>all</i> operations on the data can be restricted to the owner. 
+ In other cases, both the noun <i>and</i> the verb will be needed to determine if there is a data ownership constraint.
+*/
+public interface UntrustedProxyForUserId {
+  
+  /**
+   Returns <tt>true</tt> only if the given request uses an untrusted proxy for the user id.
+  */
+  boolean usesUntrustedIdentifier(RequestParser aRequestParser);
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/UntrustedProxyForUserIdImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,112 @@
+package hirondelle.web4j.security;
+
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+import hirondelle.web4j.action.Operation;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+
+import java.util.List;
+
+/**
+ Default implementation of {@link UntrustedProxyForUserId}.
+
+ <P>This implementation depends on settings in <tt>web.xml</tt>, which are read in on startup.
+  Later, each request URL is parsed by {@link #usesUntrustedIdentifier(RequestParser)}, 
+  and an attempt is made to find a match to the aforementioned settings in <tt>web.xml</tt>.
+  
+  <P>This class uses settings in <tt>web.xml</tt> to define requests having ownership constraints that use an untrusted proxy 
+  for the user id. It uses a <i>roughly</i> similar style as used for role-based constraints.
+  Here is an example of a number of several such ownership constraints defined in <tt>web.xml</tt>:<PRE>&lt;init-param&gt;
+  &lt;description&gt;
+    Operations having an ownership constraint that uses an untrusted identifier. 
+  &lt;/description&gt;
+  &lt;param-name&gt;UntrustedProxyForUserId&lt;/param-name&gt;
+  &lt;param-value&gt;
+    FoodAction.*
+    VacationAction.add
+    VacationAction.delete
+  &lt;/param-value&gt;
+&lt;/init-param&gt;
+</PRE>
+
+  <P>Each line is treated as a separate constraint, one per line. You can define as many as required.
+  The period character separates the 'noun' (the Action) from the 'verb' (the {@link Operation}).
+ 
+  <P>The special '*' character refers to all verbs/operations attached to a given noun/action.
+*/
+public final class UntrustedProxyForUserIdImpl implements UntrustedProxyForUserId {
+  
+  /** 
+    Return <tt>true</tt> only if the given request matches one of the items defined by the <tt>UntrustedProxyForUserId</tt> setting 
+    in <tt>web.xml</tt>. 
+    
+    <P>For example, given the URL : 
+    <PRE>'.../VacationAction.list?X=Y'</PRE>
+    this method will parse the URL into a 'noun' and a 'verb' :
+ <PRE>noun: 'VacationAction'
+verb: 'list'</PRE>
+
+    It will then compare the noun-and-verb to the settings defined in <tt>web.xml</tt>.
+    If there's a match, then this method returns <tt>true</tt>.
+  */
+  public boolean usesUntrustedIdentifier(RequestParser aRequestParser) {
+    boolean result = false; //by default, there is no ownership constraint
+    String noun = extractNoun(aRequestParser); //WebUtil needs response too! servlet path + path info?
+    if( isRestrictedRequest(noun) ){
+      List<String> restrictedVerbs = fConfig.getUntrustedProxyForUserId().get(noun);
+      if ( hasAllOperationsRestricted(restrictedVerbs) ) {
+        result = true;
+      }
+      else {
+        String verb = extractVerb(aRequestParser);
+        if ( restrictedVerbs.contains(verb) ) {
+          result = true;
+        }
+      }
+    }
+    return result;
+  }
+  
+  /** Special character denoting all operations/verbs. */
+  public static final String ALL_OPERATIONS = "*";
+  
+  // PRIVATE 
+  
+  private Config fConfig = new Config();
+  
+  private boolean isRestrictedRequest(String aNoun){
+    return fConfig.getUntrustedProxyForUserId().containsKey(aNoun);
+  }
+  
+  private boolean hasAllOperationsRestricted(List<String> aVerbs){ 
+    return aVerbs.contains(ALL_OPERATIONS);
+  }
+  
+  /**
+   For the example URL '.../BlahAction.do?X=Y', this method returns 'BlahAction' as the noun.
+   Relies on the presence of '/' and '.' characters.
+  */
+  private String extractNoun(RequestParser aRequestParser){
+    String uri = getURI(aRequestParser);
+    int firstPeriod = uri.indexOf(".");
+    if( firstPeriod == NOT_FOUND ) {
+      throw new RuntimeException("Cannot find '.' character in URL: " + Util.quote(uri));
+    }
+    int lastSlash = uri.lastIndexOf("/");
+    if( lastSlash == NOT_FOUND ) {
+      throw new RuntimeException("Cannot find '/' character in URL: " + Util.quote(uri));
+    }
+    return uri.substring(lastSlash + 1, firstPeriod);
+  }
+
+  /** Return the part of the URL after the '.' character, but before any '?' character (if present). */
+  private String extractVerb(RequestParser aRequestParser){
+    return WebUtil.getFileExtension(getURI(aRequestParser));
+  }
+  
+  private String getURI(RequestParser aRequestParser){
+    return aRequestParser.getRequest().getRequestURI();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,149 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Application Security</title>
+</head>
+<body>
+Tools for making your web application more secure.
+
+<P>The <a href='http://www.owasp.org/index.php/Main_Page'>Open Web App Security Project</a> (OWASP) is an excellent guide 
+for increasing the security of your web application, and is highly recommended.
+
+<P>An important point to understand is the separation of validation into two distinct parts - 
+<em>hard validation</em>, and <em>soft validation</em> - see {@link hirondelle.web4j.security.ApplicationFirewall} 
+for more information.
+
+<h3>Recommendations and Reminders</h3>
+
+<h4>SafeText</h4>
+Free-form user input should always be modeled as {@link hirondelle.web4j.security.SafeText}, not <tt>String</tt>. 
+This provides protection against XSS (Cross Site Scripting) attacks, without forcing you to continually 
+escape special characters in JSPs.
+
+<h4>POST versus GET</h4>
+Forms should always specify the correct <tt>method</tt> attribute. The general rule is that <em>any 
+action that has a side effect</em> (database edits, logging off) should be a <tt>POST</tt>, while an action <em>without</em> 
+any side-effect (list or search operations, reports) should be a <tt>GET</tt>.
+
+<h4>Content-Type</h4>
+Always specify the <tt>content-type</tt>. This can be done in template JSPs, to reduce the repetition of 
+identical markup. Example using a directive to specify an HTTP header :
+
+<PRE>&lt;%@ page contentType="text/html" %&gt;</PRE>
+
+Such directives must appear at the <em>start</em> of the page. 
+
+<P>The <tt>jsp-property-group</tt> setting in <tt>web.xml</tt> can specify an <tt>include-prelude</tt> JSP, which 
+will be automatically included at the start of your JSPs.
+ 
+<h4>Filter</h4>
+The {@link hirondelle.web4j.security.CsrfFilter} should be configured, to 
+protect against CSRF attacks.
+
+<P>This filter requires configuration for <tt>FormSourceIdRead</tt> and <tt>FormSourceIdWrite</tt>. These 
+items reference <tt>SqlId</tt>s. As usual, these <tt>SqlId</tt>s need to be declared in one of your application's classes, and 
+also appear in an <tt>.sql</tt> file. (This ensures the usual matching of <tt>.sql</tt> file content to <tt>SqlId</tt>s will not fail.
+See hirondelle.web4j.database for more information.)
+
+<h4>ApplicationFirewall</h4>
+The {@link hirondelle.web4j.security.ApplicationFirewallImpl} provided by the framework is an 
+excellent default for the {@link hirondelle.web4j.security.ApplicationFirewall} interface. It performs sanity checks on 
+every request, to make sure they aren't hacks.
+You can subclass that implementation in order to <em>add</em> further checks, if desired.
+(Replacing the implementation entirely is possible, but is usually an extensive task, and should likely be 
+attempted only if you are experienced.)
+
+<h4>SpamDetector</h4>
+Implementations for the {@link hirondelle.web4j.security.SpamDetector} interface can vary widely. The 
+{@link hirondelle.web4j.security.SpamDetectorImpl} is a simple default, but you will occasionally need to replace it with 
+something more appropriate> This will usually be easy to implement. 
+
+
+<h3>Cross Site Scripting (XSS) Attacks</h3>
+The {@link hirondelle.web4j.security.SafeText} class is provided as the main defense against 
+<a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>XSS</a> attacks. The {@link hirondelle.web4j.security.SafeText#toString()}
+method performs proper escaping for HTML, and {@link hirondelle.web4j.security.SafeText#getXmlSafe()} performs proper 
+escaping for XML documents.  For the most common case of presenting data in a web page, a <tt>SafeText</tt> 
+object will be rendered safely by default. 
+It is highly recommended that all free-form text input by the user be represented in a Model Object using <tt>SafeText</tt> instead of <tt>String</tt>. 
+
+<P>
+<span class='highlight'>Using <tt>SafeText</tt> will greatly increase your safety when using JSTL</span>.
+The JSTL is a bit defective when it comes to protecting you from XSS attacks:
+<ul>
+<li>the <tt>&lt;c:out&gt;</tt> tag escapes characters by default, but it only escapes for XML, <em>and not for HTML</em>. 
+That is, it escapes only for 5 of the 12 characters <a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>recommended</a> 
+by the OWASP. The same is true for JSTL's <tt>fn:escapeXml()</tt> function.
+<li>JSTL's Expression Language is nice and concise, but <em>it doesn't escape any characters at all</em>. It is dangerously open to XSS attacks, 
+since the application programmer always needs to remember to manually escape special characters.
+</ul>
+
+When JSTL is used with a <tt>SafeText</tt> object, however, these problems do not occur, since by default <tt>SafeText.toString()</tt>
+will do the correct escaping for you in the background.
+
+<h3>Cross Site Request Forgery (CSRF) Attacks</h3>
+The central idea of protecting against <a href='http://www.owasp.org/index.php/CSRF'>CSRF</a> attacks is that a 
+server should be able to answer the question "Did this POSTed form really come from me?", and not from some unknown third party.
+The {@link hirondelle.web4j.security.CsrfFilter} is provided as a defense against CSRF attacks.
+
+<P>To use it, you must have the <tt>CsrfFilter</tt> configured as a Servlet Filter in <tt>web.xml</tt>.
+As part of that configuration, you must provide two <tt>SqlId</tt>'s, to tell the Filter what SQL statements to use to 
+read and write 'form-source ids', the tokens used to identify forms generated by your web app.
+
+<P>Although it is not really necessary for those simply wanting to use <tt>CsrfFilter</tt>, the following is a description of its 
+implementation.
+
+<h4>Form-Source Ids</h4>
+When a user logs in, {@link hirondelle.web4j.security.CsrfFilter} will create a 'form-source id', and store it in the 
+user's session under the key <tt>'web4j_key_for_form_source_id'</tt>. 
+This form-source id is constant for each session, and is hard to guess.  
+The <tt>CsrfFilter</tt> will automatically modify all of your forms having <tt>method='POST'</tt>  
+to include a hidden request parameter of the same name (<tt>web4j_key_for_form_source_id</tt>), 
+whose value is simply the value created upon login.
+ 
+ <h4>Verifying Form-Source Ids</h4>
+The default {@link hirondelle.web4j.security.ApplicationFirewallImpl} will verify that each POSTed 
+form includes a parameter named <tt>web4j_key_for_form_source_id</tt>, and that its value matches <em>either</em>
+the current value stored in the user's session, <em>or</em> the form-source id used in the <em>immediately preceding</em> 
+session for the same user.
+ 
+<P><em>The form-source id used in the previous session is needed to ensure smooth behavior upon re-login.</em> 
+Here is the use case in more detail :
+<ul>
+ <li>the user logs in
+ <li>the user navigates to a form (containing a hidden form-source id param)
+ <li>the session expires 
+ <li>the user posts the form, without knowing the session has expired
+</ul> 
+
+<P>When the form is POSTed, the user will of course need to log in a second time. After successful authentication, the action 
+ will continue in the usual way. In this case, however, note that there is a new session. Thus, the <em>old</em> form-source id attached to the 
+first session is POSTed, not the <em>current</em> one. What is to be done? Well, if this use case is to be handled gracefully, 
+then the 'old' form-source id must be remembered (in the database) and treated as second valid value. 
+Then, a POSTed form will succeed only if its <tt>web4j_key_for_form_source_id</tt> has <em>either</em> 
+the current form-source id value, <em>or</em> the 'old' form-source id value of the <em>immediately preceding</em> session.
+ 
+ <P>This is the reason for the two <tt>SqlId</tt> configuration settings for the <tt>CsrfFilter</tt>: they tell the Filter how to read and 
+ write the form-source id for a given user. This allows the Filter to remember the previous form-source id value for each user.
+ 
+ <P>When a user logs out, or when a session is about to expire, <tt>CsrfFilter</tt> will extract the user's form-source id from the session, 
+ and store it in the database for possible future use.
+ 
+ <h4>Sessions With No Login</h4>
+ There are some common forms for which no valid login is possible :
+<ul>
+ <li>creating an account
+ <li>recovering a lost password
+</ul> 
+In this case, a session is used, but without the usual corresponding login. 
+Since the usual login mechanism does not apply, the Action itself must ensure that a session exists, and that a CSRF token is created. 
+This is done simply by calling {@link hirondelle.web4j.action.ActionImpl#createSessionAndCsrfToken()} (typically in the Action's constructor).
+
+<P>There is one difference when a session does not include a user login: when a session expires, there's no 
+way to recover using a 'previous' token from a previous session. Again, this is because the session is not attached to a specific user. 
+
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/AlternatingRow.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,78 @@
+package hirondelle.web4j.ui.tag;
+
+import java.io.IOException;
+import javax.servlet.jsp.JspException;
+import java.util.regex.*;
+import hirondelle.web4j.ui.tag.TagHelper;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.Util;
+
+/**
+ Generate table rows which alternate in appearance, to increase legibility.
+
+ <P>The body of this tag contains one or more <tt>TR</tt> tags. Each <tt>TR</tt> tag contains a 
+ <tt>class</tt> attribute, specifying a Cascading Style Sheet class. This tag 
+ will simply remove or update the <tt>class</tt> attribute for alternate occurrences of 
+ each <tt>TR</tt> tag found in its body.
+ 
+ <P>If the optional <tt>altClass</tt> attribute is specified, then the <tt>class</tt> 
+ attribute of each <tt>TR</tt> is updated to an alternate value, instead of being removed. 
+*/
+public final class AlternatingRow extends TagHelper {
+ 
+  /**
+   Optional name of a CSS class. 
+   
+   <P>The CSS class for each <tt>TR</tt> tag found in the body of this tag will be 
+   updated to this value, for alternating rows. If this item is not specified, then the <tt>TR</tt>'s 
+   class attribute is simply removed instead of updated.
+   
+   @param aAltClass must have content.
+  */
+  public void setAltClass(String aAltClass){
+    checkForContent("AltClass", aAltClass);
+    fAltClass = aAltClass;
+  }
+  
+  /**
+   For each <tt>TR</tt> tag found in the body, remove or update the <tt>class</tt> attribute.
+   
+   <P>If no <tt>altClass</tt> is specified, then the <tt>class</tt> attribute is simply removed entirely. 
+   Otherwise, it updated to the <tt>altClass</tt> value.
+  */
+  protected String getEmittedText(String aOriginalBody) throws JspException, IOException {
+    StringBuffer result = new StringBuffer();
+    Matcher matcher = TR_PATTERN.matcher(aOriginalBody);
+    int matchIdx = 0;
+    while (matcher.find()){
+      ++ matchIdx;
+      if( isEvenRow(matchIdx) ){
+        matcher.appendReplacement(result, getReplacement(matcher));
+      }
+      else {
+        String noChange = matcher.group(0);
+        matcher.appendReplacement(result, noChange);
+      }
+    }
+    matcher.appendTail(result);
+    return result.toString();
+  }
+  
+  // PRIVATE //
+  private String fAltClass;
+  private static final Pattern TR_PATTERN = Pattern.compile("<tr" + "("+ Regex.ALL_BUT_END_OF_TAG + ")" + "class=" + Regex.QUOTED_ATTR + "("+ Regex.ALL_BUT_END_OF_TAG + ")"+ ">", Pattern.CASE_INSENSITIVE);
+  
+  private String getReplacement(Matcher aMatcher){
+    //construct replacement: <TR + G1 + class='G2' + G3 + >
+    StringBuilder result = new StringBuilder("<TR" + aMatcher.group(1)); 
+    if( Util.textHasContent(fAltClass) ){
+      result.append("class='" + fAltClass + "'"); 
+    }
+    result.append(aMatcher.group(3) + ">");
+    return result.toString();
+  }
+  
+  private boolean isEvenRow(int aMatchIdx){
+    return aMatchIdx % 2 == 0;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/HighlightCurrentPage.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,238 @@
+package hirondelle.web4j.ui.tag;
+
+import java.util.regex.*;
+import java.util.logging.*;
+import hirondelle.web4j.Controller;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Regex;
+
+/**
+ Suppress self-linking anchor tags (links), where a 
+ link has the current page as its destination.
+
+ <P>Self-linking is usually considered to be poor style, since the user is simply 
+ led back to the current page. 
+ 
+ <P>Rendering links to the current page in a distinct manner has two benefits :
+<ul>
+ <li>the user is prevented from following a useless link
+ <li>the user can see 'where they are' in a menu of items
+</ul>
+ 
+ <P>This tag provides two distinct techniques for identifying self-linking: 
+<ul>
+ <li>the current URI <em>ends with</em> the link's <tt>href</tt> target. 
+ This is the default mechanism. The 'ends with' is used since the current URI 
+ (stored by the <tt>Controller</tt> in request scope) is absolute, while most 
+ <tt>HREF</tt> attributes contain relative links.    
+ <li>the trimmed link text (the body of the anchor tag) matches the <tt>TTitle</tt> request parameter 
+ used by the WEB4J templating mechanism.
+</ul>
+ 
+ <P>If these policies are inadequate, then {@link #isSelfLinkingByHref(String, String)} and 
+ {@link #isSelfLinkingByTitle(String, String)} may be overridden as desired. 
+ 
+ <P>Example use case.
+<PRE>
+&lt;w:highlightCurrentPage styleClass="highlight"&gt;
+  &lt;a href='ShowHomePage.do'&gt;Home&lt;/a&gt;
+  &lt;a href='ShowSearch.do'&gt;Search&lt;/a&gt;
+  &lt;a href='ShowContact.do'&gt;Contact&lt;/a&gt;
+&lt;/w:highlightCurrentPage&gt;
+</PRE>
+ If the current URI is
+ <PRE>
+ http://www.blah.com/fish/main/home/ShowHomePage.do
+ </PRE>
+ then the output of this tag will be
+<PRE>
+  &lt;span class="highlight"&gt;Home&lt;/span&gt;
+  &lt;a href='ShowSearch.do'&gt;Search&lt;/a&gt;
+  &lt;a href='ShowContact.do'&gt;Contact&lt;/a&gt;
+</PRE>
+ If the <tt>styleClass</tt> attribute is not used, then the output would simply be :
+<PRE>
+  Home
+  &lt;a href='ShowSearch.do'&gt;Search&lt;/a&gt;
+  &lt;a href='ShowContact.do'&gt;Contact&lt;/a&gt;
+</PRE>
+
+ <P>This final example uses the <tt>TTitle</tt> mechanism :
+<PRE>
+&lt;w:highlightCurrentPage useTitle="true"&gt;
+  &lt;a href='ShowHomePage.do'&gt;Home&lt;/a&gt;
+  &lt;a href='ShowSearch.do'&gt;Search&lt;/a&gt;
+  &lt;a href='ShowContact.do'&gt;Contact&lt;/a&gt;
+&lt;/w:highlightCurrentPage&gt;
+</PRE>
+ For a page with <tt>TTitle</tt> parameter as <tt>'Home'</tt>, the output of this tag 
+ would be as above:
+<PRE>
+  Home
+  &lt;a href='ShowSearch.do'&gt;Search&lt;/a&gt;
+  &lt;a href='ShowContact.do'&gt;Contact&lt;/a&gt;
+</PRE>
+
+ <P><em>Nuisance restriction</em> : the <tt>href</tt> attribute must appear as the 
+ first attribute in the &lt;A&gt; tags.
+*/
+public class HighlightCurrentPage extends TagHelper {
+
+  /**
+   Name of the Cascading Style Sheet class used for highlighting text related to the current 
+   page (optional).
+  
+   <P>For example, the link text could be highlighted in yellow in order 
+   to make it stand out more clearly from the other links. If this attribute is not 
+   specified, then the link text is simply presented as is, with no special styling.
+  
+   @param  aCascadingStyleSheetClassName satisfies {@link Util#textHasContent(String)}.
+  */
+  public final void setStyleClass(String aCascadingStyleSheetClassName){
+    checkForContent("StyleClass", aCascadingStyleSheetClassName);
+    fHighlightClass = aCascadingStyleSheetClassName;
+  }
+  
+  /**
+   Use the <tt>TTitle</tt> request parameter passed to this page.
+   
+   <P>Optional. Default is <tt>false</tt>. If set to <tt>true</tt>, 
+   then the default mechanism is overridden, and self-linking pages will be identified 
+   by comparing the link text to the page <tt>TTitle</tt> parameter. (The <tt>TTitle</tt>
+   parameter is used in the WEB4J page templating mechanism).
+  */
+  public final void setUseTitle(Boolean aUseTitle) {
+    fUseTTitle = aUseTitle;
+  }
+  
+  /**
+   Alter any links in the tag body which refer to the current page.
+   
+   <P>See examples in the class comment.
+  */
+  @Override protected final String getEmittedText(String aOriginalBody){
+    StringBuffer result = new StringBuffer();
+    Matcher matcher = LINK_PATTERN.matcher(aOriginalBody);
+    while ( matcher.find() ) {
+      matcher.appendReplacement(result, getReplacement(matcher));
+    }
+    matcher.appendTail(result);
+    return result.toString();
+  }
+
+  /**
+   Determine if self-linking is present, using the current URI and the link's <tt>HREF</tt> target.
+    
+   <P>Overridable default implementation that simply checks if <tt>aCurrentURI</tt> <em>ends with</em> 
+   <tt>aHrefTarget</tt>. In addition, <tt>aCurrentURI</tt> is passed through {@link EscapeChars#forHrefAmpersand(String)}
+   before the comparison is made. This ensures the text will match a valid <tt>HREF</tt> attribute. 
+  */
+  protected boolean isSelfLinkingByHref(String aCurrentURI, String aHrefTarget){
+    String currentURI = EscapeChars.forHrefAmpersand(aCurrentURI);
+    return currentURI.endsWith(aHrefTarget);
+  }
+  
+  /**
+   Determine if self-linking is present, using the link text and the <tt>TTitle</tt> request parameter.
+   
+   <P>Overridable default implementation that checks if the trimmed <tt>aLinkText</tt> 
+   (the body of the &lt;A&gt; tag) and <tt>aTTitle</tt> are the same.
+  */
+  protected boolean isSelfLinkingByTitle(String aLinkText, String aTTitle){
+    return aLinkText.trim().equals(aTTitle);
+  }
+  
+  /** Used for offline unit testing only.  */
+  void testSetCurrentURI(String aCurrentURI){
+    fTestingCurrentURI = aCurrentURI;
+  }
+  
+  /** Used for offline unit testing only. */
+  void testSetTitleParam(String aTTitleParam){
+    fTestingTitleParam = aTTitleParam;  
+  }
+  
+  // PRIVATE //
+  private String fHighlightClass;
+  private Boolean fUseTTitle = Boolean.FALSE;
+  
+  private String fTestingCurrentURI;
+  private String fTestingTitleParam;
+
+  /**
+   Group 0 - entire link
+   Group 1 - href attribute
+   Group 2 - link text (untrimmed)
+  */
+  private static final Pattern LINK_PATTERN = Pattern.compile( 
+    "<a" +  "(?:\\s)* href=" + Regex.QUOTED_ATTR + Regex.ALL_BUT_END_OF_TAG + Regex.END_TAG + "(" + Regex.ALL_BUT_END_OF_TAG + ")" + "</a>",
+    Pattern.CASE_INSENSITIVE
+  );
+  
+  private static final int WHOLE_LINK = 0;
+  private static final int HREF_TARGET = 1;
+  private static final int LINK_TEXT = 2;
+  private static final String TITLE_PARAM = "TTitle";
+  private static final Logger fLogger = Util.getLogger(HighlightCurrentPage.class);
+  
+  private String getReplacement(Matcher aMatcher){
+    String result = aMatcher.group(WHOLE_LINK);
+    if ( isSelfLink(aMatcher) ){
+      String linkText = aMatcher.group(LINK_TEXT);
+      result = getHighlightedText(linkText);
+    }
+    return EscapeChars.forReplacementString(result);
+  }
+  
+  private String getCurrentURI(){
+    String result = null;
+    if( ! isTesting(fTestingCurrentURI) ){
+      result = (String)getRequest().getAttribute(Controller.CURRENT_URI);
+    }
+    else {
+      result = fTestingCurrentURI;
+    }
+    return result;
+  }
+  
+  private String getTitleParam(){
+    String result = null;
+    if( ! isTesting(fTestingTitleParam) ){
+      result = getRequest().getParameter(TITLE_PARAM);
+    }
+    else {
+      result = fTestingTitleParam;
+    }
+    return result;
+  }
+  
+  private boolean isSelfLink(Matcher aMatcher){
+    boolean result = false;
+    if( ! fUseTTitle ){
+      String hrefTarget = aMatcher.group(HREF_TARGET);
+      String currentURI = getCurrentURI();
+      result = isSelfLinkingByHref(currentURI, hrefTarget);
+      fLogger.finest("Is self-linking (href)? " + result);
+      fLogger.finest("Current URI " + Util.quote(currentURI) + " HREF Target : " + Util.quote(hrefTarget));
+    }
+    else {
+      String linkText = aMatcher.group(LINK_TEXT);
+      result = isSelfLinkingByTitle(linkText, getTitleParam());
+      fLogger.finest("Is self-linking (TTitle)? " + result);
+    }
+    return result;
+  }
+  
+  private String getHighlightedText(String aLinkText){
+    String result = aLinkText;
+    if( Util.textHasContent(fHighlightClass)){
+      result = "<span class=\"" + fHighlightClass + "\">" + result + "</span>";
+    }
+    return result;
+  }
+  
+  private boolean isTesting(String aTestItem){
+    return Util.textHasContent(aTestItem);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/Pager.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,298 @@
+package hirondelle.web4j.ui.tag;
+
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+import java.util.logging.Logger;
+
+import hirondelle.web4j.database.SqlId;
+import hirondelle.web4j.Controller;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.EscapeChars;
+
+/**
+ Generate links for paging through a long list of records.
+ 
+ <P>With this class, paging is implemented in two steps :
+ <ul>
   <li>emitting the proper links (with the proper request parameters) to identify each page 
   <li>extracting the data from the database, using the given request parameters, and perhaps subsetting the <tt>ResultSet</tt>
  </ul>
+  
+ The first step is performed by this tag, but the second step is not (see below). 
+ For an example of paging, please see the 'Discussion' feature of the <i>Fish &amp; Chips Club</i> application.
+ 
+ <h3>Emitting Links For Paging</h3>
+ <P>This tag works by emitting various links which <i>are modifications of the current URI</i>.
+ 
+ <P><b>Example</b>
+ <PRE>
+ &lt;w:pager pageFrom="PageIndex"&gt; 
+  &lt;a href="placeholder_first_link"&gt;First&lt;/a&gt; | 
+  &lt;a href="placeholder_next_link"&gt;Next&lt;/a&gt; | 
+  &lt;a href="placeholder_previous_link"&gt;Previous&lt;/a&gt; | 
+ &lt;/w:pager&gt;
+ </PRE>
+ 
+ <P>The <tt>placeholder_xxx</tt> items act as placeholders. 
+ <em>They are replaced by this tag with modified values of the current URI.</em> 
+ The &lt;w:pager&gt; tag has a single <tt>pageFrom</tt> attribute.
+ It tells this tag which request parameter it will need to modify, in order to generate the various links. 
+ This modified request parameter represents the page index, in the range <tt>1..9999</tt> : for example, <tt>PageIndex=1</tt>, 
+ <tt>PageIndex=2</tt>, and so on.
+ 
+ <P>For example, if the following URI displays "page 5" :
+ <PRE>http://www.blah.com/main/SomeAction.do?PageIndex=5&Criteria=Blah</PRE>
+ then this tag will let you emit the following links, derived from the above URI simply by replacing the value of the <tt>PageIndex</tt> request parameter :
+ <PRE>http://www.blah.com/main/SomeAction.do?PageIndex=1&Criteria=Blah
+http://www.blah.com/main/SomeAction.do?PageIndex=6&Criteria=Blah
+http://www.blah.com/main/SomeAction.do?PageIndex=4&Criteria=Blah</PRE>
+ 
+  <P>Of course, these generated links don't do the actual subsetting of the data. 
+  Rather, these links simply <i>alter the current URI to use the desired value for the page index request parameter.</i> 
+  The resulting request parameters are then used elsewhere to extract the desired data (as described below).
+  
+ <P><b>Link Suppression</b>
+ <P>If the emission of a link would create a 'link to self', then this tag will not emit the link. 
+ For example, if you are already on the first page, then the First and Previous links will not be emitted as links - only the bare text, without the link, is emitted.  
+   
+ 
+<h3>Extracting and Subsetting the Data</h3>
+ There are three alternatives to implementing the subsetting of the <tt>ResultSet</tt> : 
+ <ul>
   <li>in the JSP itself
   <li>in the Data Access Object (DAO)
   <li>directly in the underlying <tt>SELECT</tt> statement
+ </ul>
+ 
+ <P><b>Subsetting in the JSP</b><br>
+<ul>
+ <li>this is the simplest style
+ <li>it can be applied quickly, to add paging to any existing listing
+ <li>the DAO performs a single, unchanging <tt>SELECT</tt> that always returns the <i>same</i> set of records for all pages
+ <li>it requires only a single change to the {@link hirondelle.web4j.action.Action} - the addition of a 
+ <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} field for the page index parameter
+ <li>the JSP performs the subsetting to display a single page at a time, simply using <tt>&lt;c:out&gt;</tt>
+</ul>
+
+ The JSP performs the subsetting with simple JSTL.
+ In the following example, the page size is hard-coded to 25 items per page.
+ The <tt>&lt;c:out&gt;</tt> tag has <tt>begin</tt> and <tt>end</tt> attributes which can control the range of rendered items : 
+<PRE>
+&lt;c:forEach 
+  var="item" 
+  items="${items}" 
+  begin="${25 * (param.PageIndex - 1)}" 
+  end="${25 * param.PageIndex - 1}"
+&gt;
+  ...display each item...
+&lt;/c:forEach&gt;
+</PRE>
+
+Note the different expressions for begin and end :
+<PRE>begin = 25 * (PageIndex - 1)
+end = (25 * PageIndex) - 1
+</PRE>
+
+which give the following values :
+<P>
+<table border='1' cellspacing='0' cellpadding='3'>
+ <tr>
+  <th>Page</th>
+  <th>Begin</th>
+  <th>End</th>
+ </tr>
+ <tr>
+  <td>1</td>
+  <td>0</td>
+  <td>24</td>
+ </tr>
+ <tr>
+  <td>2</td>
+  <td>25</td>
+  <td>49</td>
+ </tr>
+ <tr>
+  <td>...</td>
+  <td>...</td>
+  <td>...</td>
+ </tr>
+</table>
+
+<P>An alternate variation would be to allow the page <i>size</i> to also come from a request parameter :
+<PRE>
+&lt;c:forEach 
+  var="item" 
+  items="${items}" 
+  begin="${param.PageSize * (param.PageIndex - 1)}" 
+  end="${param.PageSize * param.PageIndex - 1}"
+&gt;
+  ...display each line...
+&lt;/c:forEach&gt;
+</PRE>
+
+
+  <P><b>Subsetting in the DAO</b><br>
+<ul>
+ <li>the full <tt>ResultSet</tt> is still returned by the <tt>SELECT</tt>, as in the JSP case above 
+ <li>however, this time the DAO performs the subsetting, using 
+ {@link hirondelle.web4j.database.Db#listRange(Class, SqlId, Integer, Integer, Object[])}. This avoids  
+ the cost of unnecessarily parsing records into Model Objects.
+ <li>the <tt>&lt;c:out&gt;</tt> tag does not use any <tt>begin</tt> and <tt>end</tt> attributes, since the subsetting 
+ has already been done.
+</ul>
+ 
+ <P><b>Subsetting in the SELECT</b><br>
+<ul>
+ <li>the underlying <tt>SELECT</tt> is constructed to return only the desired records. (Such a <tt>SELECT</tt>
+ may be difficult to construct, according to the capabilities of the target database.)
+ <li>the <tt>SELECT</tt> will likely need two request parameters to form the proper query: a page index and
+ a page size, from which <tt>start</tt> and <tt>end</tt> indices may be calculated 
+ <li>the DAO and JSP are implemented as in any regular listing 
+</ul>
+ 
+<P>Here are the kinds of calculations you may need when constructing such a SELECT statement   
+<P>For items enumerated with a 0-based index :
+ <PRE>
+ start_index = page_size * (page_index - 1)
+ end_index = page_size * page_index - 1
+ </PRE>
+ 
+ <P>For items enumerated with a 1-based index : 
+ <PRE>
+ start_index = page_size * (page_index - 1) + 1
+ end_index = page_size * page_index
+ </PRE>
+ 
+ <h3>Setting In <tt>web.xml</tt></h3>
+ Note that there is a <tt>MaxRows</tt> setting in <tt>web.xml</tt> which controls the maximum number of records returned by 
+ a <tt>SELECT</tt>. 
+*/
+public final class Pager extends TagHelper {
+
+  /**
+   Name of request parameter that holds the index of the current page. 
+   
+   <P>This request parameter takes values in the range <tt>1..9999</tt>. 
+   
+    <P>(The name of this method is confusing. It should rather be named <tt>setPageIndex</tt>.)
+  */
+  public void setPageFrom(String aPageIndexParamName) {
+    checkForContent("PageFrom", aPageIndexParamName);
+    fPageIndexParamName = aPageIndexParamName.trim();
+    fPageIdxPattern = Pattern.compile(fPageIndexParamName + "=(?:\\d){1,4}");
+  }
+
+  /** See class comment. */
+  @Override protected String getEmittedText(String aOriginalBody) {
+    fCurrentPageIdx = getNumericParamValue(fPageIndexParamName);
+    fCurrentURI = getCurrentURI();
+    fLogger.fine("Current URI: " + fCurrentURI);
+    fLogger.fine("Current Page : " + fCurrentPageIdx);
+    String firstURI = getFirstURI();
+    String nextURI = getNextURI();
+    String previousURI = getPreviousURI();
+    String result = getBodyWithUpdatedPlaceholders(aOriginalBody, firstURI, nextURI, previousURI);
+    return result;
+  }
+
+  // PRIVATE //
+
+  private String fPageIndexParamName;
+  private Pattern fPageIdxPattern;
+  private String fCurrentURI;
+  private int fCurrentPageIdx;
+  
+  private static final String NO_LINK = Consts.EMPTY_STRING;
+  private static final int FIRST_PAGE = 1;
+  private static final String PLACEHOLDER_FIRST_LINK = "placeholder_first_link";
+  private static final String PLACEHOLDER_NEXT_LINK = "placeholder_next_link";
+  private static final String PLACEHOLDER_PREVIOUS_LINK = "placeholder_previous_link";
+  /** Regex for an anchor tag 'A'. */
+  private static final Pattern LINK_PATTERN = Pattern.compile(Regex.LINK, Pattern.CASE_INSENSITIVE);
+  private static final Logger fLogger = Util.getLogger(Pager.class);
+
+  private String getCurrentURI() {
+    return (String)getRequest().getAttribute(Controller.CURRENT_URI);
+  }
+
+  private boolean isFirstPage() {
+    return fCurrentPageIdx == FIRST_PAGE;
+  }
+  
+  private int getNumericParamValue(String aParamName) {
+    String value = getRequest().getParameter(aParamName);
+    Integer result = Integer.valueOf(value);
+    if (result < 1) { 
+      throw new IllegalArgumentException("Param named " + Util.quote(aParamName) + " must be >= 1. Value: " + Util.quote(result) + ". Page Name: " + getPageName()); 
+    }
+    return result.intValue();
+  }
+
+  private String getFirstURI() {
+    return isFirstPage() ? NO_LINK : forPage(FIRST_PAGE); 
+  }
+
+  private String getNextURI() {
+    return  forPage(fCurrentPageIdx + 1); 
+  }
+  
+  private String getPreviousURI() {
+    return isFirstPage() ? NO_LINK : forPage(fCurrentPageIdx - 1); 
+  }
+  
+  private String forPage(int aNewPageIndex){
+    String result = null;
+    StringBuffer uri = new StringBuffer();
+    Matcher matcher = fPageIdxPattern.matcher(fCurrentURI);
+    while ( matcher.find() ){
+      matcher.appendReplacement(uri, getReplacement(aNewPageIndex));
+    }
+    matcher.appendTail(uri);
+    result = getResponse().encodeURL(uri.toString());
+    return EscapeChars.forHTML(result);
+  }
+  
+  private String getReplacement(int aNewPageIdx){
+    return EscapeChars.forReplacementString(fPageIndexParamName + "=" + aNewPageIdx);
+  }
+  
+  private String getBodyWithUpdatedPlaceholders(String aOriginalBody, String aFirstURI, String aNextURI, String aPreviousURI){
+    fLogger.fine("First URI: " + aFirstURI);
+    fLogger.fine("Next URI: " + aNextURI);
+    fLogger.fine("Previous URI: " + aPreviousURI);
+    
+    StringBuffer result = new StringBuffer();
+    Matcher matcher = LINK_PATTERN.matcher(aOriginalBody);
+    while ( matcher.find() ){
+      fLogger.finest("Getting href as first group.");
+      String href = matcher.group(Regex.FIRST_GROUP);
+      String replacement = null;
+      if ( PLACEHOLDER_FIRST_LINK.equals(href) ){
+        replacement = aFirstURI;
+      }
+      else if ( PLACEHOLDER_NEXT_LINK.equals(href) ){
+        replacement = aNextURI;
+      }
+      else if ( PLACEHOLDER_PREVIOUS_LINK.equals(href) ){
+        replacement = aPreviousURI;
+      }
+      else {
+        String message = "Body of pager tag can only contain links to special placeholder text. Page Name "+ getPageName();
+        fLogger.severe(message);
+        throw new IllegalArgumentException(message);
+      }
+      matcher.appendReplacement(result, getReplacementHref(matcher, replacement));
+    }
+    matcher.appendTail(result);
+    return result.toString();
+  }
+  
+  private String getReplacementHref(Matcher aMatcher, String aReplacementHref){
+    String result = null;
+    int LINK_BODY = 3;
+    int ATTRS_AFTER_HREF = 2;
+    if ( Util.textHasContent(aReplacementHref) ){
+      result = "<A HREF=\"" + aReplacementHref + "\" " + aMatcher.group(ATTRS_AFTER_HREF)+" >" + aMatcher.group(LINK_BODY) + "</A>";
+    }
+    else {
+      result = aMatcher.group(LINK_BODY);
+    }
+    return EscapeChars.forReplacementString(result);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/Populate.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,339 @@
+package hirondelle.web4j.ui.tag;
+
+import java.util.*;
+import java.util.logging.*;
+import javax.servlet.jsp.JspException;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.action.Operation;
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.util.Util;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+
+/**
+ Custom tag which populates form controls in a simple, elegant way.
+ 
+ <P>From the point of view of this tag, there are 3 sources of data for a form control: 
+ <ul>
   <li>the HTML defined in your JSP can define an initial default value
   <li>Request parameter values
  <li>a Model Object
+ </ul>
+ 
+ <P>For reference, here is the logic that defines which data source is used, and related 
+ naming conventions :
+<PRE>
+if a Model Object of the given name is in any scope {
+  override the default HTML for each control
+  use the Model Object 
+  (match control names to getXXX methods of the Model Object)
+}
+else if the request is a POST  {
+  override the default HTML for each control
+  must populate <i>every</i> control using request parameter values
+  (match control names to request param names) 
+}
+else if the request is a GET {
+  if control name has a matching req param name {
+    override the default HTML for each control
+    populate control using request parameter values
+    (match control names to request param names) 
+  }
+  else {
+    use the default HTML for that control
+  }
+}
</PRE>
+ 
+ <P><span class='highlight'>This tag simply wraps static HTML forms</span>. 
+ This is very economical since it does not force the page author to completely
+ replace well-known static HTML with a large set of custom tags. 
+
+<h3>Example use case</h3>
+ This use case corresponds to either an 'add' or a 'change' of a Model Object. The <tt>using</tt> 
+ attribute signifies that a 'change' case is possible. (This example works with 
+ an {@link hirondelle.web4j.action.ActionTemplateListAndEdit} action.)
+    
+<PRE>
+&lt;c:url value="RestoAction.do" var="baseURL"/&gt;
+&lt;form action='${baseURL}' method="post" class="user-input"&gt; 
+<b>&lt;w:populate using="itemForEdit"&gt;</b>  
+&lt;input name="Id" type="hidden"&gt;
+&lt;table align="center"&gt;
+&lt;tr&gt;
+ &lt;td&gt;&lt;label&gt;Name&lt;/label&gt; *&lt;/td&gt;
+ &lt;td&gt;&lt;input name="Name" type="text"&gt;&lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td&gt;&lt;label&gt;Location&lt;/label&gt;&lt;/td&gt;
+ &lt;td&gt;&lt;input name="Location" type="text"&gt;&lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td&gt;&lt;label&gt;Price&lt;/label&gt;&lt;/td&gt;
+ &lt;td&gt;&lt;input name="Price" type="text"&gt;&lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td&gt;&lt;label&gt;Comment&lt;/label&gt;&lt;/td&gt;
+ &lt;td&gt;&lt;input name="Comment" type="text"&gt;&lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td align="center" colspan=2&gt;
+  &lt;input type='submit' value="Edit"&gt;
+ &lt;/td&gt;
+&lt;/tr&gt;
+&lt;/table&gt;
+<b>&lt;/w:populate&gt;</b>
+ &lt;tags:hiddenOperationParam/&gt;
+&lt;/form&gt;
+</PRE>
+    
+ Here, the <tt>itemForEdit</tt> Model Object has the following methods, corresponding to 
+ the above populated controls :
+ <PRE>
+  public Id getId() {...}
+  public SafeText getName() {...}
+  public SafeText getLocation() {...}
+  public BigDecimal getPrice() {...}
+  public SafeText getComment() {...}
+</PRE>
+ 
+ <h3>Example without <tt>using</tt> attribute</h3>
+ No <tt>using</tt> attribute is specified when :
+ <ul>
+ <li>only an 'add' operation is performed, and not a 'change' operation.
+ <li>or, only a <tt>Search</tt> {@link Operation} is performed. In this case, a form with <tt>method="GET"</tt> is used 
+ to specify parameters to a <tt>SELECT</tt> statement.
+ </ul>
+ 
+ <P>Here is an example of a form used only for 'add' operations :
+<PRE>
+<b>&lt;w:populate&gt;</b>
+&lt;c:url value="AddMessageAction.do?Operation=Apply" var="baseURL"/&gt; 
+&lt;form action='${baseURL}' method=post class="user-input"&gt;
+&lt;table align="center"&gt;
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;label&gt;Message&lt;/label&gt; *
+ &lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;textarea name="Message Body"&gt;
+  &lt;/textarea&gt;
+ &lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td colspan=2&gt;
+  &lt;label&gt;Preview First ?&lt;/label&gt; &lt;input type="radio" name="Preview" value="true"&gt; Yes
+ &lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+ &lt;td align="center" colspan=2&gt;
+  &lt;input type="submit" value="Add Message"&gt; 
+ &lt;/td&gt;
+&lt;/tr&gt;
+&lt;/table&gt;
+&lt;/form&gt;
+<b>&lt;/w:populate&gt;</b>
+ </PRE>
+
+<h3>Supported Controls</h3>
+ <P>The following form input items are called <em>supported controls</em> here, and 
+ include all items which undergo population by this class :
+<ul>
+ <li><tt>INPUT</tt> tags with type=<tt>text</tt>, <tt>password</tt>, <tt>radio</tt>, 
+ <tt>checkbox</tt>, <tt>hidden</tt>
+ <li>HTML5 input tags with type=<tt>search</tt>, <tt>email</tt>, <tt>url</tt>, <tt>number</tt>, <tt>tel</tt>, <tt>color</tt>, <tt>range</tt>
+ <li><tt>SELECT</tt> tags
+ <li><tt>TEXTAREA</tt> tags
+</ul>
+
+ <P>Population is implemented by editing these supported control attributes :
+<ul>
+ <li>the <tt>checked</tt> attribute for INPUT tags of type <tt>radio</tt> 
+ and <tt>checkbox</tt>
+ <li>the <tt>value</tt> attribute for the remaining INPUT tags (of the different types listed above) 
+ <li>the <tt>selected</tt> attribute for OPTION tags appearing in a SELECT
+ <li>the body of a TEXTAREA tag
+</ul>
+
+<P>The body of this tag is HTML, with the following minor restrictions:
+<ul>
+ <li>all supported controls must include a <tt>name</tt> attribute
+ <li>all supported INPUT controls must include a <tt>type</tt> attribute 
+ <li>all attributes must be quoted, using either single or double quotes. For example, 
+ <tt>&lt;input type='text' ... &gt;</tt> is allowed but 
+ <tt>&lt;input type=text ... &gt;</tt> is not
+ <li> for SELECT tags, the &lt;/option&gt; end tag is not optional, and must be included.
+ <li>INPUT tags with <tt>type='email'</tt> are treated as always being single-valued
+ <li>the repetitive form of <tt>selected='selected'</tt> can't be used
+</ul>
+
+HTML often allows alternate ways of expressing the exact same thing.
+For example, the <tt>selected</tt> attribute can be expressed as <tt>selected='selected'</tt>, or simply as 
+the single word <tt>selected</tt> - this tag only accepts the second form, not the first.
+These sorts of restrictions are a nuisance, and result from the way the framework parses HTML internally.
+Sometimes you will need to tweak your form hypertext in order to let this tag parse the form correctly. Sorry about that.
+
+<P><b>Warning: unfortunately, INPUT controls of type color and range can't represent nullable items in a database.</b> 
+This is because the (draft) HTML5 specification doesn't allow such controls to POST non-empty
+values when forms are submitted.
+The only workaround for this defect of the specification is to define magic values which map to null. 
+Use such controls with caution. 
+
+<h3>Prepopulating only portions of a form</h3>
+ There is no requirement that the entire HTML form be wrapped by this tag. If 
+ desired, only part of a form may be placed in the body of this tag. This is useful 
+ when some form controls take a fixed, static value.
+
+ <h3>Convention Regarding Control Names</h3>
+ This tag depends on a specfic convention to allow automatic 'binding' between supported controls 
+ and corresponding <tt>getXXX</tt> methods of the Model Object. This convention is explained in 
+ {@link hirondelle.web4j.request.RequestParameter}.  
+
+ <h3>Deriving values from <tt>getXXX()</tt> methods of the Model Object</h3>
+ The return value is found. Any primitives are converted into corresponding wrapper 
+ objects. The {@link hirondelle.web4j.request.Formats#objectToText} method is then used to
+ translate the object into text. If the return value of the <tt>getXXX</tt> is a 
+ <tt>Collection</tt>, then the above is applied to each element. 
+ 
+ <h3>Escaping special characters</h3>
+ When this tag assigns a text value to the content of an <tt>INPUT</tt> or <tt>TEXTAREA</tt> tag, then the 
+ value is always escaped for special characters using {@link hirondelle.web4j.util.EscapeChars#forHTML(String)}.
+ 
+ <h3><tt>GET</tt> versus <tt>POST</tt></h3>
+ This tag depends on the proper <tt>GET/POST</tt> behavior of forms : a <tt>POST</tt>
+ request must only be used when an edit to the database is being attempted. (This is the usual style, 
+ and would not be regarded by most as being a restriction.)
+*/
+public final class Populate extends TagHelper {
+
+  /**
+   Key for the Model Object to be used for form population. 
+  
+   <P>This attribute is specified only if the form can be used to edit or change an 
+   existing Model Object. If the Model Object is present, then it will be used by this tag to 
+   populate supported controls.
+  
+   <P>This tag searches for the Model Object in the same way as <tt>JspContext.findAttribute(String)</tt>,
+   by searching scopes in a specific order : page scope, request scope, session scope, and finally 
+   application scope. 
+  
+   @param aModelObjectKey satisfies {@link Util#textHasContent(String)}.
+  */
+  public void setUsing(String aModelObjectKey){
+    checkForContent("Using", aModelObjectKey);
+    fModel = getPageContext().findAttribute(aModelObjectKey);
+  }
+  
+  /**
+   Emit the possibly-changed body of this tag, by possibly editing supported form controls 
+   contained in the body of this tag.
+  */
+  @Override protected String getEmittedText(String aOriginalBody) throws JspException {
+    String result = null;
+    setUseCaseStyle();
+    if ( Style.ECHO == fStyle ){
+      result = aOriginalBody;
+    }
+    else {
+      result = getEditedBody(aOriginalBody, fStyle);
+    }
+    return result;
+  }
+  
+  // PRIVATE
+
+  /**
+   The ModelObject which is to be used to populate supported controls in the "edit" use case. 
+   Is identified by the value of the 'using' attribute
+  */
+  private Object fModel;
+  
+  /** Use case style  */
+  private Style fStyle;
+  
+  enum Style {ECHO, USE_MODEL_OBJECT, MUST_RECYCLE_PARAMS, RECYCLE_PARAM_IF_PRESENT}
+  
+  private static final String GET = "GET";
+  private static final String POST = "POST";
+  private static final Logger fLogger = Util.getLogger(Populate.class);
+  
+  private void setUseCaseStyle(){
+    String PREAMBLE = "Form population use case: ";
+    if( fModel != null ) {
+      fLogger.fine(PREAMBLE +"'Using' object is specified and present. All controls will be populated using getXXX methods of the 'using' object.");
+      fStyle = Style.USE_MODEL_OBJECT;
+    }
+    else if( isRequest(POST) ){
+      /* Minor Problem: delete ids infecting ADD operations. */
+      fLogger.fine(PREAMBLE + "POST. All controls will be populated using request parameter values.");
+      fStyle = Style.MUST_RECYCLE_PARAMS;
+    }
+    else if( isRequest(GET) ) {
+      if ( hasNoRequestParameters() ) {
+        fLogger.fine(PREAMBLE + "GET, with no request parameters present. Echoing the HTML of entire form as is.");
+        fStyle = Style.ECHO;        
+      }
+      else {
+        fLogger.fine(PREAMBLE + "GET. Any request parameter whose name matches a form control will be used to populate that control.");
+        fStyle = Style.RECYCLE_PARAM_IF_PRESENT;
+      }
+    }
+    else {
+      throw new AssertionError("Unexpected use case.");
+    }
+  }
+  
+  private boolean isRequest(String aRequestStyle){
+    return getRequest().getMethod().equalsIgnoreCase(aRequestStyle);
+  }
+
+  private String getEditedBody(String aOriginalBody, Style aUseCaseStyle) throws JspException {
+    PopulateHelper populator = new PopulateHelper(new Wrapper(), aOriginalBody, aUseCaseStyle);
+    return populator.getEditedBody();
+  }
+  
+  /** This exists solely to provide a particular 'view' of this object that does not leak into the public API. */
+  private class Wrapper implements PopulateHelper.Context {
+    public String getReqParamValue(String aParamName){
+      String value = getRequest().getParameter(aParamName);
+      return value == null ? EMPTY_STRING : value;
+    }
+    public Collection<String> getReqParamValues(String aParamName){
+      Collection<String> result = Collections.emptyList(); //default return value
+      String[] values = getRequest().getParameterValues(aParamName);
+      if ( values != null ) {
+        result = Collections.unmodifiableCollection( Arrays.asList(values) );
+      }
+      return result;
+    }
+    public boolean hasRequestParamNamed(String aParamName) {
+      boolean result = false; 
+      Enumeration allParamNames = getRequest().getParameterNames();
+      while (allParamNames.hasMoreElements()){
+        if (allParamNames.nextElement().equals(aParamName)) {
+          result = true;
+          break;
+        }
+      }
+      return result;
+    }
+    public boolean isModelObjectPresent(){
+      return fModel != null;
+    }
+    public Object getModelObject(){
+      return fModel;
+    }
+    public Formats getFormats(){
+      Locale locale = BuildImpl.forLocaleSource().get(getRequest());
+      TimeZone timeZone = BuildImpl.forTimeZoneSource().get(getRequest());
+      return new Formats(locale, timeZone);
+    }
+  }
+  
+  private boolean hasNoRequestParameters(){
+    Enumeration namesEnum = getRequest().getParameterNames();
+    int numParams = 0;
+    while ( namesEnum.hasMoreElements() ){
+      ++numParams;
+      namesEnum.nextElement();
+    }
+    return numParams == 0;
+  }
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/PopulateHelper.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,630 @@
+package hirondelle.web4j.ui.tag;
+
+import static hirondelle.web4j.util.Consts.DOUBLE_QUOTE;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.ui.tag.Populate.Style;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.Util;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.jsp.JspException;
+
+/**
+ Helper class for the custom tag {@link Populate}.
+
+ <P>Performs most of the work of <tt>Populate</tt>, by editing the tag's body, as needed.
+*/
+final class PopulateHelper {
+  
+  /**
+   Context of a given HTTP request, from which information regarding 
+   request parameters and available Model Objects may be extracted.
+  
+   <P>This interface exists only to allow testing of {@link PopulateHelper} outside of a 
+   servlet context, where a {@link Populate} object is not available. During testing,
+   fake implementations of this interface are created to simulate the context 
+   of a given HTTP request.
+    
+   <P>Note that the Populate class does not implement this interface, since that would 
+   pollute its public API with internal details. Instead, Populate uses an internal wrapper class
+   to provide the desired 'view'.
+  */
+  interface Context {
+    
+    /**
+     Return the value of the request parameter corresponding to <tt>aParamName</tt>.
+    
+     <P>If the param is absent, or if its value is null, then return an empty String.
+     
+     <P>HTTP POSTs "missing" items inconsistently:
+    <ul>
+     <li>empty text/password: param posted, but value is empty <tt>String</tt>
+     <li>unselected radio/checkbox: no param is posted at all (null)
+     <li>unselected SELECT tags: param is posted using first item as value
+    </ul>
+    */
+    String getReqParamValue(String aParamName);
+    
+    /** Returns <tt>true</tt> only if the request includes a parameter of the given name.   */
+    boolean hasRequestParamNamed(String aParamName);
+    
+    /**
+     Return the value of a multi-valued request parameter corresponding to
+     <tt>aParamName</tt>. Intended for use with multi-valued parameters. 
+    
+     @return unmodifiable <tt>Collection</tt> of <tt>Strings</tt>; if the parameter is
+     missing from the request, then return an empty <tt>Collection</tt>.
+    */
+    Collection<String> getReqParamValues(String aParamName);
+    
+    /** Return <tt>true</tt> only if the <tt>using</tt> Model Object is present in some scope.    */
+    boolean isModelObjectPresent();
+    
+    /** Return the <tt>using</tt> Model Object.    */
+    Object getModelObject();
+    
+    /** Return the {@link hirondelle.web4j.request.Formats} needed to render objects.  */
+    Formats getFormats();    
+  }
+  
+  /**
+   Constructor. 
+   @param aContext source of information regarding request parameters, 
+   any '<tt>using</tt>' Model Object, and formatting styles.
+   @param aOriginalBody the HTML content of the <tt>Populate</tt> tag, after
+   any JSP processing, but before any prepopulation.
+   @param aUseCaseStyle defines the high level use case. 
+  */
+  PopulateHelper(Context aContext, String aOriginalBody, Style aUseCaseStyle){
+    fContext = aContext;
+    fOriginalBody = aOriginalBody; 
+    fUseCaseStyle = aUseCaseStyle;
+  }
+
+  /** Replace supported form controls with edited versions, as needed, reflecting any prepopulation.  */
+  String getEditedBody() throws JspException {
+    StringBuffer result = new StringBuffer();
+    Matcher control = CONTROL_PATTERN.matcher(fOriginalBody);
+    while ( control.find() ) {
+      control.appendReplacement(result, getReplacement(control));
+    }
+    control.appendTail(result);
+    return result.toString();
+  }
+
+  // PRIVATE 
+
+  private final Context fContext;
+  private final String fOriginalBody;
+  private final Style fUseCaseStyle;
+  
+  /*
+   All of these fields hold dynamic data, and are updated for each control 
+   found in the tag body. Not all of these items apply to all controls.
+  */
+  private String fControl; //entire text of the supported control, as extracted from body
+  private String fControlFlavor; //distinguishes INPUT, SELECT, OPTION, and TEXTAREA
+  private String fNameAttr; //required in all supported controls
+  private String fTypeAttr; //required in all INPUT tags only
+  private String fValueAttr; //optional for INPUT and OPTION tags
+  
+  /* The significant values of fControlFlavor.  */
+  private static final String SELECT = "select";
+  private static final String INPUT = "input";
+  private static final String TEXT_AREA = "textarea";
+  
+  /* The significant values of the fTypeAttr.  */
+  private static final String RADIO = "radio";
+  private static final String CHECKBOX = "checkbox";
+  private static final String TEXT = "text";
+  private static final String PASSWORD = "password";
+  private static final String HIDDEN = "hidden";
+  
+  /* HTML5 types (non date-time) */
+  private static final String SEARCH = "search"; //same as text
+  private static final String EMAIL = "email"; //text, but can be multi-valued!
+  private static final String URL = "url"; //same as text
+  private static final String TELEPHONE = "tel"; //same as text
+  private static final String NUMBER = "number"; //can have a fractional part, floating point; same as Decimal? or BigDecimal? or both?
+  
+  /*HTML controls which are always submitted, and never null, according to the HTML5 draft specification. */ 
+  private static final String RANGE = "range"; //can have a decimal
+  private static final String COLOR = "color";
+  
+  /* HTML5 date-time types - not yet supported; browser support is poor, and inconsistencies lead to errors.  */
+  private static final String DATE = "date"; //no offset; 2012-05-01
+  private static final String TIME = "time"; //no offset; 01:23|01:23:45|01:23:45.123
+  private static final String DATETIME_LOCAL = "datetime-local"; //no offset 2012-05-31T00:01:05.123 (trailing parts optional)
+  private static final String MONTH = "month"; //no offset; 2012-05 actually includes year-and-month
+  private static final String WEEK = "week"; //no offset; 2012-W53
+  private static final String DATETIME = "datetime"; //WITH offset DO I SUPPORT THIS? For DateTime, no. For general text, yes.
+  
+  /* Patterns which retrieve attribute values.  */
+  private static final Pattern NAME_PATTERN = getPatternForAttrValue("name");
+  private static final Pattern TYPE_PATTERN = getPatternForAttrValue("type");
+  private static final Pattern VALUE_PATTERN = getPatternForAttrValue("value");
+  
+  /** Returns value of the attribute as group 1.  */
+  private static final Pattern getPatternForAttrValue(String aAttrName){
+    String regex = " " + aAttrName + "=" + Regex.QUOTED_ATTR;
+    return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
+  }
+
+  /** If match found, then the type attribute is supported by this class.  */
+  private static final Pattern SUPPORTED_TYPE_PATTERN = Pattern.compile( "(" + 
+      RADIO + "|" + CHECKBOX + "|" + TEXT + "|" + HIDDEN + "|" +  PASSWORD + "|" + 
+      SEARCH + "|" + EMAIL + "|" + URL + "|" + TELEPHONE + "|"  + NUMBER +  "|" + COLOR + "|" + RANGE  + "|" +  
+    ")", 
+    Pattern.CASE_INSENSITIVE
+   );
+  
+  private static final Pattern CONTROL_PATTERN = Pattern.compile(
+    "(?:" + "<(input) " + Regex.ALL_BUT_END_OF_TAG + ">" + 
+    "|" +
+    "<(textarea) " + Regex.ALL_BUT_END_OF_TAG +  ">" + Regex.WS +
+       Regex.ALL_BUT_START_OF_TAG + 
+    "</textarea>" +
+    "|" +
+    "<(select)" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.WS +
+    "(?:<option" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.ALL_BUT_START_OF_TAG + 
+    "</option>" + Regex.WS + ")+" + 
+    "</select>)",
+    Pattern.CASE_INSENSITIVE | Pattern.DOTALL
+  );
+  
+  private static final Pattern FLAVOR_PATTERN = Pattern.compile(
+    Regex.START_TAG + "(input|select|option|textarea)", Pattern.CASE_INSENSITIVE
+  );
+
+  /** If match found, then item contains the 'checked' attribute.  */
+  private static final Pattern CHECKED_PATTERN = Pattern.compile(
+    "( checked)", Pattern.CASE_INSENSITIVE
+  );
+
+  /** If match found, then item contains the 'selected' attribute.   */
+  private static final Pattern SELECTED_PATTERN = Pattern.compile(
+    "( selected)", Pattern.CASE_INSENSITIVE
+  );
+  
+  /**
+   Fetches an entire OPTION tag.
+   Group 1 is the value attr, group 2 is the trimmed text of the option tag's body.
+  */
+  private static final Pattern OPTION_PATTERN = Pattern.compile(
+   Regex.START_TAG + "option" +
+    "(?: selected" + 
+    "| value=" + Regex.QUOTED_ATTR +"|"+ Regex.ALL_BUT_END_OF_TAG + ")*" + 
+    Regex.END_TAG + 
+    Regex.TRIMMED_TEXT + "</option>",
+    Pattern.CASE_INSENSITIVE
+  );
+
+  /** The body of the textarea tag, plus the bounding start and end tags.  */
+  private static final Pattern TEXT_AREA_BODY_PATTERN = Pattern.compile(
+    Regex.END_TAG + Regex.ALL_BUT_START_OF_TAG + Regex.START_TAG,
+    Pattern.CASE_INSENSITIVE
+  );
+  
+  private static final Class[] NO_ARGS = new Class[]{};
+  private static final Object[] NO_PARAMS = null;
+  private static final Logger fLogger = Util.getLogger(PopulateHelper.class);  
+
+  /** Get a possible replacement for the given control. */
+  private String getReplacement(Matcher aMatchForControl) throws JspException {
+    setControl(aMatchForControl);
+    setControlFlavor();
+    setOptionalItems();
+    String result = isSupportedControl() ? EscapeChars.forRegex(getPossiblyEditedControl()) : fControl;
+    return result;
+  }
+  
+  private void setControl(Matcher aMatchForControl){
+    fControl = aMatchForControl.group(Regex.ENTIRE_MATCH);
+    fLogger.finer("Found supported control: " + fControl);
+  }
+  
+  private void setControlFlavor() throws JspException {
+    Matcher matchForFlavor = FLAVOR_PATTERN.matcher(fControl);
+    if ( matchForFlavor.find() ) {
+      fControlFlavor = matchForFlavor.group(Regex.FIRST_GROUP);
+      fLogger.finer("Flavor of supported control: " + fControlFlavor);
+    }
+    else {
+      throw new JspException("No flavor found for supported control.");
+    }
+  }
+  
+  /**
+   This method serves as a defensive measure. It ensures that items which do 
+   not apply to every control are set to null, and thus no "mixing" of data between 
+   controls is possible. As well, erroneous use of an item which has not been 
+   populated for that control will result in an immediate NullPointerException.
+  */
+  private void setOptionalItems(){
+    fNameAttr = null;
+    fTypeAttr = null;
+    fValueAttr = null;
+  }
+  
+  /** Some input controls are explicitly NOT supported: file, reset, submit. */
+  private boolean isSupportedControl() throws JspException {
+    boolean result = true;
+    if ( isInputTag() ){
+      fTypeAttr = getCompulsoryAttrValue(TYPE_PATTERN);
+      fLogger.finer("Type attr: " + fTypeAttr);
+      result = Util.matches(SUPPORTED_TYPE_PATTERN, fTypeAttr);
+    }
+    return result;
+  }
+
+  
+  private String getPossiblyEditedControl() throws JspException {
+    String result = "";
+    fNameAttr = getCompulsoryAttrValue(NAME_PATTERN);
+    fLogger.finer("Control name: " + Util.quote(fNameAttr));
+    if( fUseCaseStyle == Style.USE_MODEL_OBJECT || fUseCaseStyle == Style.MUST_RECYCLE_PARAMS ) {
+      result = getEditedControl();
+    }
+    else if(fUseCaseStyle == Style.RECYCLE_PARAM_IF_PRESENT){
+      if( fContext.hasRequestParamNamed(fNameAttr) ) {
+        result = getEditedControl();
+      }
+      else {
+        result = getUneditedControl();        
+      }
+    }
+    return result;
+  }
+
+  /** Return the control without any editing - just a simple echo of the tag's content. */
+  private String getUneditedControl(){
+    return fControl;
+  }
+  
+  /** Perform the core task of editing a control.   */
+  private String getEditedControl() throws JspException {
+    String result = null;
+    if ( isInputTag() ){
+      if ( isTextLike() ) {
+        //single-valued
+        fLogger.finer("Editing 'value' attr for text pr text-like field");
+        result = editTextBox(EscapeChars.forHTML(getPrepopValue()));
+      }
+      else if ( isRadioButton() ){
+        //single-valued
+        fLogger.finer("Editing 'checked' attr for radio field");
+        result = editRadioButton(getPrepopValue());
+      }
+      else if ( isCheckbox() ){
+        //may be single- or multi-valued
+        fLogger.finer("Editing 'checked' attr for checkbox field");
+        result = editCheckBox(getPrepopValues());
+      }
+    }
+    else if ( isSelectTag() ) {
+      //may or may not be multi-valued, depending on the 
+      //presence of the 'multiple' attribute
+      fLogger.finer("Editing 'selected' attr for option fields");
+      result = editOption(getPrepopValues());
+    }
+    else if ( isTextAreaTag() ) {
+      //single-valued
+      fLogger.finer("Editing tag body for textarea field");
+      result = editTextArea(EscapeChars.forHTML(getPrepopValue()));
+    }
+    else {
+      throw new AssertionError("Unexpected control flavor: " + fControlFlavor);
+    }
+    fLogger.finest("Edited control: " + Util.quote(result));
+    return result;
+  }
+  
+  private boolean isInputTag(){
+    return fControlFlavor.equalsIgnoreCase(INPUT);
+  }
+  
+  private boolean isSelectTag(){
+    return fControlFlavor.equalsIgnoreCase(SELECT);
+  }
+  
+  private boolean isTextAreaTag() {
+    return fControlFlavor.equalsIgnoreCase(TEXT_AREA);
+  }
+
+  /** That is, is NOT a radio or check-box. Such controls use 'value', not 'checked'. */
+  private boolean isTextLike(){
+    return 
+      fTypeAttr.equalsIgnoreCase(TEXT) || 
+      fTypeAttr.equalsIgnoreCase(PASSWORD) || 
+      fTypeAttr.equalsIgnoreCase(HIDDEN) ||
+      fTypeAttr.equalsIgnoreCase(SEARCH) ||
+      fTypeAttr.equalsIgnoreCase(EMAIL) ||
+      fTypeAttr.equalsIgnoreCase(URL) ||
+      fTypeAttr.equalsIgnoreCase(TELEPHONE) ||
+      fTypeAttr.equalsIgnoreCase(NUMBER) || 
+      fTypeAttr.equalsIgnoreCase(COLOR) || 
+      fTypeAttr.equalsIgnoreCase(RANGE) 
+    ;
+  }
+  
+  private boolean isRadioButton(){
+    return fTypeAttr.equalsIgnoreCase(RADIO);
+  }
+  
+  private boolean isCheckbox(){
+    return fTypeAttr.equalsIgnoreCase(CHECKBOX);
+  }
+  
+  /** Return the first group of the first match found in <tt>fControl</tt>.   */
+  private String getCompulsoryAttrValue(Pattern aPattern) throws JspException {
+    String result = null;
+    Matcher matcher = aPattern.matcher(fControl);
+    if ( matcher.find() ) {
+      result = matcher.group(Regex.FIRST_GROUP);
+    }
+    else {
+      throw new JspException(
+        "Cannot prepopulate, since does not contain expected attribute: " + fControl + " using Pattern " + aPattern
+      );
+    }
+    return result;
+  }
+  
+  /** <tt>aPrepopValue</tt> is possibly-null.   */
+  private String editTextBox(String aPrepopValue){
+    String result = null;
+    String target = getTargetValueAttr(aPrepopValue);
+    Matcher matchForValue = VALUE_PATTERN.matcher(fControl);
+    if ( matchForValue.find() ) {
+      fLogger.finest("Replacing value attribute using target : " + Util.quote(target));
+      result = matchForValue.replaceAll(target);
+    }
+    else {
+      result = addAttribute(target);
+    }
+    return result;
+  }
+  
+  private String getTargetValueAttr(String aPrepopValue){
+    String result = " value=" + DOUBLE_QUOTE + aPrepopValue + DOUBLE_QUOTE;
+    return EscapeChars.forRegex(result);
+  }
+
+  /** Add an attribute immediately after the start of the tag.   */
+  private String addAttribute(String aTarget){
+    fLogger.finest("Adding attribute, with target :" + aTarget);
+    Matcher matchForFlavor = FLAVOR_PATTERN.matcher(fControl);
+    return matchForFlavor.replaceAll("$0" + aTarget);
+  }
+  
+  /** <tt>aPrepopValue</tt> may be null.   */
+  private String editRadioButton(String aPrepopValue) throws JspException {
+    String result = fControl; //used if no edits needed
+    fValueAttr = getCompulsoryAttrValue(VALUE_PATTERN);
+    Matcher matchForChecked = CHECKED_PATTERN.matcher(fControl);
+    if ( valueMatchesPrepop(aPrepopValue) ) {
+      if ( ! matchForChecked.find() ) {
+        result = addAttribute(" checked");
+      }
+    }
+    else {
+      if (  matchForChecked.find() ) {
+        result = matchForChecked.replaceAll(EMPTY_STRING);
+      }
+    }
+    return result;
+  }
+  
+  private String editCheckBox(Collection<String> aPrepopValues) throws JspException {
+    String result = fControl; //used if no edits needed
+    fValueAttr = getCompulsoryAttrValue(VALUE_PATTERN);
+    Matcher matchForChecked = CHECKED_PATTERN.matcher(fControl);
+    if ( valueMatchesPrepop(aPrepopValues) ) {
+      if ( ! matchForChecked.find() ) {
+        result = addAttribute(" checked");
+      }
+    }
+    else {
+      if (  matchForChecked.find() ) {
+        result = matchForChecked.replaceAll(EMPTY_STRING);
+      }
+    }
+    return result;
+  }
+  
+  /** <tt>aPrepopValue</tt> is possibly-null.   */
+  private boolean valueMatchesPrepop(String aPrepopValue){
+    return fValueAttr.equals(aPrepopValue);
+  }
+  
+  private boolean valueMatchesPrepop(Collection<String> aPrepopValues){
+    return aPrepopValues.contains(fValueAttr);
+  }
+  
+  private String editTextArea(String aPrepopValue){
+    String result = null;
+    Matcher matchForTextBody = TEXT_AREA_BODY_PATTERN.matcher(fControl);
+    if ( matchForTextBody.find() ) {
+      result = matchForTextBody.replaceAll( getTargetTextBody(aPrepopValue) );
+    }
+    return result;
+  }
+  
+  private String getTargetTextBody(String aPrepopValue){
+    String result = ">" + aPrepopValue + "<";
+    return EscapeChars.forRegex(result);
+  }
+  
+  private String editOption(Collection<String> aPrepopValues){
+    String result = fControl; //used if no edits needed
+    Matcher matchForOption = OPTION_PATTERN.matcher(fControl);
+    while ( matchForOption.find() ){
+      String option = matchForOption.group(Regex.ENTIRE_MATCH);
+      fLogger.finer("Found option :" + option);
+      Matcher matchForSelected = SELECTED_PATTERN.matcher(option);
+      String valueAttr = getValueAttrForOption(matchForOption);
+      fLogger.finest("Value attr: " + valueAttr);
+      if ( aPrepopValues.contains(valueAttr) ) {
+        String editedOption = ensureSelected(matchForSelected, option);
+        result = result.replaceAll(EscapeChars.forRegex(option), editedOption);
+      }
+      else {
+        String editedOption = ensureUnselected(matchForSelected, option);
+        result = result.replaceAll(EscapeChars.forRegex(option), editedOption);
+      }
+    }
+    fLogger.finest("Edited select tag: " + result);
+    return result;
+  }
+
+  private String ensureSelected(Matcher aMatchForSelected, String aOption){
+    String result = aOption; //use if no edit needed
+    if ( ! aMatchForSelected.find() ){
+      Matcher matchForFlavor = FLAVOR_PATTERN.matcher(aOption);
+      result = matchForFlavor.replaceAll("$0" + " selected");
+    }
+    fLogger.finest("Edited option: " + result);
+    return result;
+  }
+  
+  private String ensureUnselected(Matcher aMatchForSelected, String aOption) {
+    String result = aOption; //use if no edits needed
+    if ( aMatchForSelected.find() ){
+      result = aMatchForSelected.replaceAll(EMPTY_STRING);
+    }
+    fLogger.finest("Edited option: " + result);
+    return result;
+  }
+  
+  private String getValueAttrForOption(Matcher aOptionMatcher){
+    String value = aOptionMatcher.group(Regex.FIRST_GROUP);
+    String text = aOptionMatcher.group(Regex.SECOND_GROUP);
+    return Util.textHasContent(value) ? value : text;
+  }
+  
+  /*
+  
+   The remaining methods below relate to extracting data from the HTTP request context.
+  
+   Note that SELECT items are always modeled as Collections of Strings. In the common case 
+   of not allowing multiple selections, the Collection will contain only one element.
+  
+   Several methods come in pairs, one of which is simply the plural form of another,
+   with a final 's' at the end. The 'plural' methods are called only for SELECT tags.
+  */
+
+  /** Returns possibly-null String.   */
+  private String getPrepopValue() throws JspException {
+    String result = null;
+    if ( fContext.isModelObjectPresent() ) {
+      fLogger.finer("Getting prepop from model object.");
+      result = getPropertyValue();
+    }
+    else{
+      fLogger.finer("Getting prepop from params.");
+      result = fContext.getReqParamValue(fNameAttr);
+    }
+    fLogger.finest("Prepop value: " + result);
+    return result;
+  }
+  
+  /**
+   Returns an immutable Collection of Strings (may have no elements).
+  
+   This method is called only for CHECKBOX and SELECT elements, which may allow multiple values.
+   In the common case when the SELECT does not include the 'multiple' attr, there 
+   will be only one element in the returned value.
+  */
+  private Collection<String> getPrepopValues() throws JspException {
+    Collection<String> result = null;
+    if ( fContext.isModelObjectPresent() ) {
+      fLogger.finer("Getting prepops from model object.");
+      result = getPropertyValues();
+    }
+    else{
+      fLogger.finer("Getting prepops from params.");
+      result = fContext.getReqParamValues(fNameAttr);
+    }
+    fLogger.finest("Prepop values: " + result);
+    return result;
+  }
+
+  /**
+   Return getXXX value as string, after applying {@link Formats#objectToText}.
+  */
+  private String getPropertyValue() throws JspException {
+    return fContext.getFormats().objectToText(getReturnValue());
+  }
+  
+  /**
+   Return an non-empty unmodifiable Collection of Strings. 
+  
+   <P>If the corresponding getXXX does not return a Collection:
+  <ul>
+   <li>call {@link Formats#objectToText} on the return value of getXXX
+   <li>return that single item wrapped in a Collection.
+  </ul>
+  
+   <P>If the corresponding getXXX method returns a Collection, then 
+  <ul>
+   <li>call {@link Formats#objectToText} on each item in the Collection
+   <li>add each string to the result of this method
+  </ul>
+  */
+  private Collection<String> getPropertyValues() throws JspException {
+    Collection<String> result = new ArrayList<String>();
+    Object returnValue = getReturnValue();
+    if ( returnValue instanceof Collection) {
+      Collection returnValueCollection = (Collection) getReturnValue(); //OK
+      Iterator iter = returnValueCollection.iterator();
+      while ( iter.hasNext() ) {
+        Object value = iter.next();
+        result.add( fContext.getFormats().objectToText(value) );
+      }
+    }
+    else {
+      result.add( fContext.getFormats().objectToText(returnValue) );
+    }
+    return Collections.unmodifiableCollection(result);
+  }
+  
+  /**  Returns possibly-null <tt>Object</tt>.  */
+  private Object getReturnValue() throws JspException {
+    Object result = null;
+    Class modelObjectClass = fContext.getModelObject().getClass();
+    try {
+      String methodName = "get" + Util.withNoSpaces(Util.withInitialCapital(fNameAttr));
+      Method getMethod = modelObjectClass.getMethod(methodName, NO_ARGS); 
+      /* 
+       Implementation Note
+       Here, the Reflection API wraps any primitive return value by using the 
+       corresponding wrapper class. Thus, the underlying model object is not constrained 
+       to use wrappers instead of primitives.
+      */
+      result = getMethod.invoke(fContext.getModelObject(), NO_PARAMS);
+    }
+    catch (NoSuchMethodException ex){
+      throw new JspException(
+        "Missing expected public method : get" + fNameAttr + " in " + modelObjectClass
+      );
+    }
+    catch (IllegalAccessException ex ){
+      throw new JspException("Illegal Access for method : get" + fNameAttr);
+    }
+    catch (InvocationTargetException ex){
+      throw new JspException(
+        "Method through an exception : get" + fNameAttr + " named " + ex.getCause()
+      );
+    }
+    return result;
+  }
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/ShowDate.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,262 @@
+package hirondelle.web4j.ui.tag;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.util.TimeSource;
+import hirondelle.web4j.request.DateConverter;
+import hirondelle.web4j.request.TimeZoneSource;
+import hirondelle.web4j.ui.translate.Translator;
+import hirondelle.web4j.util.Util;
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.logging.Logger;
+
+/**
+ Display a {@link Date} in a particular format.
+ 
+ <P>This class uses:
+ <ul>
+   <li>{@link hirondelle.web4j.request.LocaleSource} to determine the Locale associated with the current request
+   <li>{@link TimeZoneSource} for the time zone associated with the current request
+   <li>{@link DateConverter} to format the given date
+   <li>{@link Translator} for localizing the argument passed to {@link #setPatternKey}.
+ </ul>
+ 
+ <h3>Examples</h3>
+ <P>Display the current system date :
+<PRE>{@code 
+<w:showDate/>
+}</PRE>
+
+ <P>Display a specific <tt>Date</tt> object, present in any scope :
+<PRE>&lt;w:showDate <a href="#setName(java.lang.String)">name</a>="dateOfBirth"/&gt;</PRE>
+
+ <P>Display a date returned by some object in scope :
+<PRE>{@code 
+<c:set value="${visit.lunchDate}" var="lunchDate"/>
+<w:showDate name="lunchDate"/>
+}</PRE>
+ 
+ <P>Display with a non-default date format : 
+<PRE>&lt;w:showDate name="lunchDate" <a href="#setPattern(java.lang.String)">pattern</a>="E, MMM dd"/&gt;</PRE>
+ 
+ <P>Display with a non-default date format sensitive to {@link Locale} :
+<PRE>&lt;w:showDate name="lunchDate" <a href="#setPatternKey(java.lang.String)">patternKey</a>="next.visit.lunch.date"/&gt;</PRE>
+ 
+ <P>Display in a specific time zone :
+<PRE>&lt;w:showDate name="lunchDate" <a href="#setTimeZone(java.lang.String)">timeZone</a>="America/Montreal"/&gt;</PRE>
+ 
+ <P>Suppress the display of midnight, using a pipe-separated list of 'midnights' :
+<PRE>&lt;w:showDate name="lunchDate" <a href="#setSuppressMidnight(java.lang.String)">suppressMidnight</a>="12:00 AM|00 h 00"/&gt;</PRE>
+*/
+public final class ShowDate extends TagHelper {
+  
+  /**
+   Optionally set the name of a {@link Date} object already present in some scope. 
+   Searches from narrow to wide scope to find the corresponding <tt>Date</tt>.
+    
+   <P>If this method is called and no corresponding object can be found using the 
+   given name, then this tag will emit an empty String. 
+   
+   <P>If this method is not called at all, then the current system date is used, as 
+   defined by the configured {@link TimeSource}.
+  
+   @param aName must have content.
+  */
+  public void setName(String aName){
+    checkForContent("Name", aName);
+    Object object = getPageContext().findAttribute(aName);
+    if ( object == null ) {
+      fDateObjectMissing = true;
+      fLogger.fine("Cannot find object named " + Util.quote(aName) + " in any scope. Page Name : " + getPageName());
+    }
+    else {
+      if ( ! (object instanceof Date) ) {
+        throw new IllegalArgumentException(
+          "Object named " + Util.quote(aName) + " is not a java.util.Date. Page Name :" + getPageName() 
+        );
+      }
+      fDate = (Date)object;
+    }
+  }
+   
+  /**
+   Optionally set the format for rendering the date.
+   
+   <P>Setting this attribute will override the default format of 
+   {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}.
+   
+   <P><span class="highlight">Calling this method is suitable only when 
+   the date format does not depend on {@link Locale}.</span> Otherwise,  
+   {@link #setPatternKey(String)} must be used instead. 
+  
+   <P>Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)}
+   can be called at a time.
+   
+   @param aDateFormat has content, and is in the form expected by 
+   {@link java.text.SimpleDateFormat}.
+  */
+  public void setPattern(String aDateFormat){
+    checkForContent("Pattern", aDateFormat);
+    fDateFormat = new SimpleDateFormat(aDateFormat, getLocale());
+  }
+
+  /**
+   Optionally set the format for rendering the date according to {@link Locale}.
+   
+   <P>Setting this attribute will override the default format of 
+   {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}.
+   
+   <P>This method uses a {@link Translator} to look up the "real" 
+   date pattern to be used, according to the {@link Locale} returned 
+   by {@link hirondelle.web4j.request.LocaleSource}.
+   
+   <P>For example, if the value '<tt>format.next.lunch.date</tt>' is passed to 
+   this method, then that value is passed to a {@link Translator}, which will return 
+   a pattern specific to the {@link Locale} attached to this request, such as 
+   '<tt>EEE, dd MMM</tt>'. 
+   
+   <P>Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)}
+   can be called at a time.
+   
+   @param aFormatKey has content, and, when passed to {@link Translator}, will 
+   return a date pattern in the form expected by {@link java.text.SimpleDateFormat}.
+  */
+  public void setPatternKey(String aFormatKey){
+    checkForContent("PatternKey", aFormatKey);
+    fDateFormatKey = aFormatKey;
+  }
+   
+  /**
+   Optionally set the {@link TimeZone} for formatting the date.
+  
+   <P>If this attribute is not set, then {@link TimeZoneSource} is used.
+   
+   @param aCustomTimeZone in the style expected by {@link TimeZone#getTimeZone(java.lang.String)}. 
+   If the format is not in the expected style, then UTC is used (same as Greenwich Mean Time). 
+  */
+  public void setTimeZone(String aCustomTimeZone){
+    fCustomTimeZone = TimeZone.getTimeZone(aCustomTimeZone);
+  }
+   
+  /**
+   Optionally suppress the display of midnight. 
+   
+   <P>For example, set this attribute to '<tt>00:00:00</tt>' to force '<tt>1999-12-31 00:00:00</tt>' to display as 
+   <tt>1999-12-31</tt>, without the time.
+   
+   <P>If this attribute is set, and if any of the <tt>aMidnightStyles</tt> is found <em>anywhere</em> in the formatted date, 
+   then the formatted date is truncated, starting from the given midnight style. That is, all text appearing after 
+   the midnight style is removed, including any time zone information. (Then the result is trimmed.)
+   
+   @param aMidnightStyles is pipe-separated list of Strings which denote the possible forms of 
+   midnight. Example value : '00:00|00 h 00'.
+  */
+  public void setSuppressMidnight(String aMidnightStyles){
+    StringTokenizer parser = new StringTokenizer(aMidnightStyles, "|");
+    while ( parser.hasMoreElements() ){
+      fMidnightStyles = new ArrayList<String>();
+      String midnightStyle = (String)parser.nextElement();
+      if( Util.textHasContent(midnightStyle)){
+        fMidnightStyles.add(midnightStyle.trim());
+      }
+    }
+    fLogger.fine("Midnight styles: " + fMidnightStyles);
+  }
+   
+  protected void crossCheckAttributes() {
+    if(fDateFormatKey != null && fDateFormat != null){
+      String message = "Cannot specify both 'pattern' and 'patternKey' attributes at the same time. Page Name : " + getPageName();
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+  }
+   
+  @Override protected String getEmittedText(String aOriginalBody) {
+    String result = EMPTY_STRING;
+    if( fDateObjectMissing ) return result;
+    
+    Locale locale = getLocale();
+    TimeZone timeZone = getTimeZone();
+    if(fDateFormat == null && fDateFormatKey == null){
+      DateConverter dateConverter = BuildImpl.forDateConverter();
+      result = dateConverter.formatEyeFriendly(fDate, locale, timeZone);
+    }
+    else if(fDateFormat != null && fDateFormatKey == null){
+      adjustForTimeZone(fDateFormat, timeZone);
+      result = fDateFormat.format(fDate);
+    }
+    else if(fDateFormat == null && fDateFormatKey != null){
+      Translator translator = BuildImpl.forTranslator();
+      String localPattern = translator.get(fDateFormatKey, locale);
+      DateFormat localDateFormat = new SimpleDateFormat(localPattern, locale);
+      adjustForTimeZone(localDateFormat, timeZone);
+      result = localDateFormat.format(fDate);
+    }
+    else {
+      throw new IllegalArgumentException("Cannot specify both 'pattern' and 'patternKey' attributes at the same time. Page Name : " + getPageName());
+    }
+    if( hasMidnightStyles() ) {
+      result = removeMidnightIfPresent(result);
+    }
+    return result;
+  }
+  
+  // PRIVATE 
+  private Date fDate = new Date(BuildImpl.forTimeSource().currentTimeMillis()); //defaults to 'now'
+  
+  /** Flags if a named object is not found in any scope. */
+  private boolean fDateObjectMissing = false;
+  
+  private DateFormat fDateFormat;
+  private String fDateFormatKey;
+  private TimeZone fCustomTimeZone;
+  private List<String> fMidnightStyles = new ArrayList<String>();
+  private static final Logger fLogger = Util.getLogger(ShowDate.class);
+  
+  private void adjustForTimeZone(DateFormat aFormat, TimeZone aTimeZone){
+    aFormat.setTimeZone(aTimeZone);
+  }
+   
+  private Locale getLocale(){
+    return BuildImpl.forLocaleSource().get(getRequest());
+  }
+  
+  private TimeZone getTimeZone(){
+    TimeZone result = null;
+    if( fCustomTimeZone != null ){
+      result = fCustomTimeZone;
+    }
+    else {
+      TimeZoneSource timeZoneSource = BuildImpl.forTimeZoneSource();
+      result = timeZoneSource.get(getRequest());
+    }
+    return result;
+  }
+   
+  private boolean hasMidnightStyles(){
+    return ! fMidnightStyles.isEmpty();
+  }
+   
+  private String removeMidnightIfPresent(String aFormattedDate){
+    String result = aFormattedDate;
+    for(String midnightStyle : fMidnightStyles){
+      if ( hasMidnight(aFormattedDate, midnightStyle) ){
+        result = removeMidnight(aFormattedDate, midnightStyle);        
+      }
+    }
+    return result.trim();
+  } 
+   
+  private boolean hasMidnight(String aFormattedDate, String aMidnightStyle){
+    return aFormattedDate.indexOf(aMidnightStyle) != NOT_FOUND; 
+  }
+   
+  private String removeMidnight(String aFormattedDate, String aMidnightStyle){
+    int midnight = aFormattedDate.indexOf(aMidnightStyle);
+    return aFormattedDate.substring(0,midnight);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/ShowDateTime.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,246 @@
+package hirondelle.web4j.ui.tag;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.util.TimeSource;
+import hirondelle.web4j.request.DateConverter;
+import hirondelle.web4j.ui.translate.Translator;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.model.DateTime;
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+
+import java.util.*;
+import java.util.logging.Logger;
+
+/**
+ Custom tag to display a {@link DateTime} in a particular format. 
+ 
+ <P>This class uses:
+ <ul>
+   <li>{@link hirondelle.web4j.request.LocaleSource} to determine the Locale associated with the current request
+   <li>{@link DateConverter} to format the given date
+   <li>{@link Translator} for localizing the argument passed to {@link #setPatternKey}.
+ </ul>
+ 
+ <h3>Examples</h3>
+ <P>Display the current system date, with the default format defined by {@link DateConverter} :
+<PRE>{@code 
+<w:showDateTime/>
+}</PRE>
+
+ <P>Display a specific date object, present in any scope :
+<PRE>&lt;w:showDateTime <a href="#setName(java.lang.String)">name</a>="dateOfBirth"/&gt;</PRE>
+
+ <P>Display a date returned by some object in scope :
+<PRE>{@code 
+<c:set value="${visit.lunchDate}" var="lunchDate"/>
+<w:showDateTime name="lunchDate"/>
+}</PRE>
+ 
+ <P>Display with a non-default date format : 
+<PRE>&lt;w:showDateTime name="lunchDate" <a href="#setPattern(java.lang.String)">pattern</a>="YYYY-MM-DD"/&gt;</PRE>
+ 
+ <P>Display with a non-default date format sensitive to {@link Locale} :
+<PRE>&lt;w:showDateTime name="lunchDate" <a href="#setPatternKey(java.lang.String)">patternKey</a>="next.visit.lunch.date"/&gt;</PRE>
+ 
+ <P>Suppress the display of midnight, using a pipe-separated list of 'midnights' :
+<PRE>&lt;w:showDateTime name="lunchDate" <a href="#setSuppressMidnight(java.lang.String)">suppressMidnight</a>="12:00 AM|00 h 00"/&gt;</PRE>
+*/
+public final class ShowDateTime extends TagHelper {
+
+  /**
+   Optionally set the name of a {@link DateTime} object already present in some scope. 
+   Searches from narrow to wide scope to find the corresponding object.
+    
+   <P>If this method is called and no corresponding object can be found using the 
+   given name, then this tag will emit an empty String. 
+   
+   <P>If this method is not called at all, then the current system date is used, as 
+   defined by the configured {@link TimeSource}.
+  
+   @param aName must have content.
+  */
+  public void setName(String aName){
+    checkForContent("Name", aName);
+    Object object = getPageContext().findAttribute(aName);
+    if ( object == null ) {
+      handleErrorCondition("Cannot find object named " + Util.quote(aName) + " in any scope.");
+    }
+    else {
+      if (object instanceof DateTime){
+        fTarget = Target.OBJECT_DATE_TIME;
+        fDateTime = (DateTime)object;
+      }
+      else {
+        handleErrorCondition(
+          "Object named " + Util.quote(aName) + " is not a hirondelle.web4j.model.DateTime. It is a "  +
+          object.getClass().getName()
+        ); 
+      }
+    }
+  }
+  
+  /**
+   Optionally set the format for rendering the date.
+   
+   <P>Setting this attribute will override the default format used by  
+   {@link DateConverter}.
+   
+   <P><span class="highlight">Calling this method is suitable only when 
+   the date format does not depend on {@link Locale}.</span> Otherwise,  
+   {@link #setPatternKey(String)} must be used instead. 
+  
+   <P>Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)}
+   can be called at a time.
+   
+   @param aFormat has content, and is a date format suitable for the <tt>format</tt> .
+   methods of {@link DateTime}. 
+  */
+  public void setPattern(String aFormat){
+    checkForContent("Pattern", aFormat);
+    fFormat = aFormat;
+  }
+
+  /**
+   Optionally set the format for rendering the date according to {@link Locale}.
+   
+   <P>Setting this attribute will override the default format used by  
+   {@link DateConverter}.
+   
+   <P>This method uses a {@link Translator} to look up the "real" 
+   date pattern to be used, according to the {@link Locale} returned 
+   by {@link hirondelle.web4j.request.LocaleSource}.
+   
+   <P>For example, if the value '<tt>format.next.lunch.date</tt>' is passed to 
+   this method, then that value is passed to a {@link Translator}, which will return 
+   a pattern specific to the {@link Locale} attached to this request, such as 
+   '<tt>EEE, dd MMM</tt>' (for a <tt>Date</tt>) or <tt>YYYY-MM-DD</tt> (for a {@link DateTime}). 
+   
+   <P>Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)}
+   can be called at a time.
+   
+   @param aFormatKey has content, and, when passed to {@link Translator}, will 
+   return a date format suitable for the <tt>format</tt> methods of {@link DateTime}. 
+  */
+  public void setPatternKey(String aFormatKey){
+    checkForContent("PatternKey", aFormatKey);
+    fFormatKey = aFormatKey;
+  }
+   
+  /**
+   Optionally suppress the display of midnight. 
+   
+   <P>For example, set this attribute to '<tt>00:00:00</tt>' to force '<tt>1999-12-31 00:00:00</tt>' to display as 
+   <tt>1999-12-31</tt>, without the time.
+   
+   <P>If this attribute is set, and if any of the <tt>aMidnightStyles</tt> is found <em>anywhere</em> in the formatted date, 
+   then the formatted date is truncated, starting from the given midnight style. That is, all text appearing after 
+   the midnight style is removed, including any time zone information. (Then the result is trimmed.)
+   
+   @param aMidnightStyles is pipe-separated list of Strings which denote the possible forms of 
+   midnight. Example value : '00:00|00 h 00'.
+  */
+  public void setSuppressMidnight(String aMidnightStyles){
+    StringTokenizer parser = new StringTokenizer(aMidnightStyles, "|");
+    while ( parser.hasMoreElements() ){
+      fMidnightStyles = new ArrayList<String>();
+      String midnightStyle = (String)parser.nextElement();
+      if( Util.textHasContent(midnightStyle)){
+        fMidnightStyles.add(midnightStyle.trim());
+      }
+    }
+    fLogger.fine("Midnight styles: " + fMidnightStyles);
+  }
+   
+  protected void crossCheckAttributes() {
+    if(fFormatKey != null && fFormat != null){
+      handleErrorCondition("Cannot specify both 'pattern' and 'patternKey' attributes at the same time.");
+    }
+  }
+   
+  @Override protected String getEmittedText(String aOriginalBody) {
+    String result = EMPTY_STRING;
+    if( fFoundError ) return result;
+    
+    if(Target.CURRENT_DATE_TIME == fTarget){
+      fDateTime = DateTime.now(BuildImpl.forTimeZoneSource().get(getRequest()));
+    }
+    result = formatDateTime();
+    
+    if( hasMidnightStyles() ) {
+      result = removeMidnightIfPresent(result);
+    }
+    return result;
+  }
+
+  // PRIVATE 
+  private DateTime fDateTime; //defaults to now; cannot init here, since request does not exist yet
+  
+  /** The item to be formatted. */
+  private enum Target {
+    /** If no object is specified at all, then the current date time is assumed. */
+    CURRENT_DATE_TIME, 
+    OBJECT_DATE_TIME, 
+  }
+  private Target fTarget = Target.CURRENT_DATE_TIME; //default
+  
+  private String fFormat;
+  private String fFormatKey;
+  private List<String> fMidnightStyles = new ArrayList<String>();
+  
+  /** Flags presence of error conditions. If true, then only an empty String is emitted. */
+  private boolean fFoundError;
+  
+  private static final Logger fLogger = Util.getLogger(ShowDate.class);
+
+  private String formatDateTime(){
+    String result = "";
+    Locale locale = getLocale();
+    if(fFormat == null && fFormatKey == null){
+      DateConverter dateConverter = BuildImpl.forDateConverter();
+      result = dateConverter.formatEyeFriendlyDateTime(fDateTime, locale);
+    }
+    else if(fFormat != null && fFormatKey == null){
+      result = fDateTime.format(fFormat, getLocale());
+    }
+    else if(fFormat == null && fFormatKey != null){
+      Translator translator = BuildImpl.forTranslator();
+      String localPattern = translator.get(fFormatKey, locale);
+      result = fDateTime.format(localPattern, locale);
+    }
+    return result;
+  }
+  
+  private Locale getLocale(){
+    return BuildImpl.forLocaleSource().get(getRequest());
+  }
+  
+  private boolean hasMidnightStyles(){
+    return ! fMidnightStyles.isEmpty();
+  }
+   
+  private String removeMidnightIfPresent(String aFormattedDate){
+    String result = aFormattedDate;
+    for(String midnightStyle : fMidnightStyles){
+      if ( hasMidnight(aFormattedDate, midnightStyle) ){
+        result = removeMidnight(aFormattedDate, midnightStyle);        
+      }
+    }
+    return result.trim();
+  } 
+   
+  private boolean hasMidnight(String aFormattedDate, String aMidnightStyle){
+    return aFormattedDate.indexOf(aMidnightStyle) != NOT_FOUND; 
+  }
+   
+  private String removeMidnight(String aFormattedDate, String aMidnightStyle){
+    int midnight = aFormattedDate.indexOf(aMidnightStyle);
+    return aFormattedDate.substring(0,midnight);
+  }
+  
+  private void handleErrorCondition(String aMessage){
+    fFoundError = true;
+    String message = aMessage + " Page Name : " + getPageName();
+    fLogger.severe(message);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/ShowForRole.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,150 @@
+package hirondelle.web4j.ui.tag;
+
+import java.util.*;
+import java.util.logging.*;
+import java.security.Principal;
+import hirondelle.web4j.util.Util;
+import static hirondelle.web4j.util.Consts.PASSES;
+import static hirondelle.web4j.util.Consts.FAILS;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+
+/**
+ Toggle display according to the user's role.
+ 
+ <P><span class="highlight">It's important to note that the <em>sole</em> use of this 
+ tag does <em>not</em> robustly enforce security constraints.</span>
+ This tag is meant as a "cosmetic convenience" for removing items from JSPs (usually a link). 
+ The problem is that a hacker can always construct any given URI manually and send it to the server. 
+ Such malicious requests can only be handled robustly by a <tt>security-constraint</tt>
+ defined in <tt>web.xml</tt>.   
+ 
+ <P>Example:
+ <PRE>
+ &lt;w:show ifRole="webmaster,translator"&gt;
+   show tag content only if the user is logged in, 
+   and has at least 1 of the specified roles
+ &lt;/w:show&gt;
+ </PRE>
+ 
+ Example with role specified by negation:
+ <PRE> 
+ &lt;w:show ifRoleNot="read-only"&gt;
+   show tag content only if the user is logged in, 
+   and has none of the specified roles
+ &lt;/w:show&gt;
+ </PRE>
+
+ Example with logic attached not to role, but simply whether or not the user has logged in:
+ <PRE>
+ &lt;w:show ifLoggedIn="true"&gt;
+   show tag content only if the user is logged in 
+ &lt;/w:show&gt;
+ 
+ &lt;w:show ifLoggedIn="false"&gt;
+   show tag content only if the user is not logged in 
+ &lt;/w:show&gt;</pre>
+ 
+ The above styles are all mutually exclusive. You can specify only 1 attribute at a time with this tag.
+ 
+ <P>The body of this class is either echoed as is, or is suppressed entirely.
+ 
+ <P>By definition (in the servlet specification), a user is logged in when <tt>request.getUserPrincipal()</tt> 
+ returns a value having content. When a user is logged in, the container can assign 
+ 1 or more roles to the user. Roles are only assigned after a successful login.
+*/
+public final class ShowForRole extends TagHelper {
+
+  /** Optional, comma-delimited list of accepted roles. */
+  public void setIfRole(String aRoles){
+    fAcceptedRoles = getRoles(aRoles);
+  }
+  
+  /** Optional, comma-delimited list of denied roles.  */
+  public void setIfRoleNot(String aRoles){
+    fDeniedRoles = getRoles(aRoles); 
+  }
+  
+  /** 
+   Optional, simple flag indicating if user is or is not logged in.
+   @param aFlag - see {@link Util#parseBoolean(String)} for the list of accepted values. 
+  */
+  public void setIfLoggedIn(String aFlag){
+    fIfLoggedIn = Util.parseBoolean(aFlag); //null if the flag is null
+  }
+  
+  /**
+   One and only one of the {@link #setIfRole}, {@link #setIfRoleNot}, or 
+   {@link #setIfLoggedIn(String)} attributes must be set.
+  */
+  protected void crossCheckAttributes() {
+    int numAttrsSpecified = 0;
+    if (isModeAcceptingRoles()) ++numAttrsSpecified;
+    if (isModeDenyingRoles())  ++numAttrsSpecified;
+    if (isModeLogin()) ++numAttrsSpecified;
+    
+    if(numAttrsSpecified != 1) {
+      String message = "Please specify 1 (and only 1) attribute for this tag: ifRole, ifRoleNot, or ifLoggedIn. Page Name: " + getPageName();
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+  }
+  
+  /** See class comment.  */
+  @Override protected String getEmittedText(String aOriginalBody) {
+    boolean showBody = false;
+    Principal user = getRequest().getUserPrincipal();
+    boolean isCurrentlyLoggedIn = (user != null);
+    
+    if (isModeAcceptingRoles() && isCurrentlyLoggedIn){
+      showBody = hasOneOrMoreOfThe(fAcceptedRoles);
+    }
+    else if (isModeDenyingRoles() && isCurrentlyLoggedIn){
+      showBody = ! hasOneOrMoreOfThe(fDeniedRoles);
+    }
+    else if (isModeLogin()){
+      showBody = fIfLoggedIn ? isCurrentlyLoggedIn : ! isCurrentlyLoggedIn; 
+    }
+    return showBody ? aOriginalBody : EMPTY_STRING;
+  }
+  
+  // PRIVATE
+  private List<String> fAcceptedRoles = new ArrayList<String>();
+  private List<String> fDeniedRoles = new ArrayList<String>();
+  /** The null-ity of this field is needed to indicate the 'unspecified' state. */
+  private Boolean fIfLoggedIn; 
+  
+  private static final String DELIMITER = ",";
+  private static final Logger fLogger = Util.getLogger(ShowForRole.class);
+  
+  private List<String> getRoles(String aRawRoles){
+    List<String> result = new ArrayList<String>();
+    StringTokenizer parser = new StringTokenizer( aRawRoles, DELIMITER);
+    while ( parser.hasMoreTokens() ) {
+      result.add( parser.nextToken().trim() );
+    }
+    return result;
+  }
+  
+  private boolean isModeAcceptingRoles(){
+    return ! fAcceptedRoles.isEmpty();
+  }
+  
+  private boolean isModeDenyingRoles(){
+    return ! fDeniedRoles.isEmpty();
+  }
+  
+  private boolean isModeLogin(){
+    return fIfLoggedIn != null;
+  }
+
+  private boolean hasOneOrMoreOfThe(List<String> aRoles){
+    boolean result = FAILS;
+    for (String role: aRoles){
+      if ( getRequest().isUserInRole(role) ) {
+        result = PASSES;
+        break;
+      }
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/TESTFormPopulator.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,1121 @@
+package hirondelle.web4j.ui.tag;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.TESTAll.DateConverterImpl;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.request.DateConverter;
+import hirondelle.web4j.request.Formats;
+import hirondelle.web4j.ui.tag.Populate.Style;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import javax.servlet.ServletException;
+import javax.servlet.jsp.JspException;
+
+import junit.framework.TestCase;
+
+
+/**
+ (UNPUBLISHED) <a href="http://www.junit.org">JUnit</a> test cases for 
+ {@link PopulateHelper}. 
+ 
+ TO RUN THIS CLASS, some temporary adjustments need to be made to this class and the Formats class.
+ - 1 change to this class
+ - 2 changes to Formats
+ See TESTAll as well! 
+*/
+public final class TESTFormPopulator extends TestCase {
+
+  /** Run the test cases.  */
+   public static void main(String args[]) throws FileNotFoundException, ServletException {
+     fLogger.setLevel(Level.OFF);
+     String[] testCaseName = { TESTFormPopulator.class.getName() };
+     BuildImpl.adHocImplementationAdd(DateConverter.class,  DateConverterImpl.class);
+     junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTFormPopulator (String aName) {
+   super(aName);
+  }
+
+  // TEST CASES //
+
+  public void testSingleParamsWithInputTags() throws JspException, IOException {
+    //test no alteration of tag which is not related to input
+    testSingleParam("<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", "<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", "LoginName", "Strummer");
+    //test no alteration of tag whose type(submit) is not supported 
+    testSingleParam("<input type=\"submit\" value='REGISTER'>", "<input type=\"submit\" value='REGISTER'>", "LoginName", "Strummer");
+    //text 
+    testSingleParam("<input name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+    //caps, spacing, quotes
+    testSingleParam("<INPUT    NAME='LoginName' TYPE='TEXT' SIZE='30'>", "<INPUT value=\"Strummer\"    NAME='LoginName' TYPE='TEXT' SIZE='30'>", "LoginName", "Strummer");
+    //additional extraneous attr's
+    testSingleParam("<input name='LoginName' type='text' size='30' class='blah'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30' class='blah'>", "LoginName", "Strummer");
+    //password
+    testSingleParam("<input name='LoginPassword' type='password' size='30'>", "<input value=\"clash\" name='LoginPassword' type='password' size='30'>", "LoginPassword", "clash");
+    //hidden
+    testSingleParam("<input name='LoginPassword' type='hidden'>", "<input value=\"clash\" name='LoginPassword' type='hidden'>", "LoginPassword", "clash");
+    //search
+    testSingleParam("<input name='Bob' type='search'>", "<input value=\"clash\" name='Bob' type='search'>", "Bob", "clash");
+    //email
+    testSingleParam("<input name='Bob' type='email'>", "<input value=\"joe&#064;clash&#046;com\" name='Bob' type='email'>", "Bob", "joe@clash.com");
+    //url
+    testSingleParam("<input name='Bob' type='url'>", "<input value=\"http&#058;&#047;&#047;www&#046;date4j&#046;net\" name='Bob' type='url'>", "Bob", "http://www.date4j.net");
+    //tel
+    testSingleParam("<input name='Bob' type='tel'>", "<input value=\"1&#045;800&#045;549&#045;BLAH\" name='Bob' type='tel'>", "Bob", "1-800-549-BLAH");
+    //number
+    testSingleParam("<input name='Bob' type='number'>", "<input value=\"3&#046;14\" name='Bob' type='number'>", "Bob", "3.14");
+    //color
+    testSingleParam("<input name='Bob' type='color'>", "<input value=\"&#035;001122\" name='Bob' type='color'>", "Bob", "#001122");
+    //range
+    testSingleParam("<input name='Bob' type='range'>", "<input value=\"56\" name='Bob' type='range'>", "Bob", "56");
+    //radio with match
+    testSingleParam("<INPUT type='RADIO' name=\"StarRating\" value='1'>", "<INPUT checked type='RADIO' name=\"StarRating\" value='1'>", "StarRating", "1");
+    //radio with no match
+    testSingleParam("<INPUT type='RADIO' name=\"StarRating\" value='2'>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", "StarRating", "1");
+    //checkbox with match 
+    testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "<input checked type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "SendCard", "true");
+    //checkbox with no match
+    testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "SendCard", "false");
+    
+    //text with default is overwritten
+    testSingleParam("<input name='LoginName' value='Joe' type='text' size='30'>", "<input name='LoginName' value=\"Mick\" type='text' size='30'>", "LoginName", "Mick");
+    //as above, but with hidden
+    testSingleParam("<input name='LoginName' value='Joe' type='hidden'>", "<input name='LoginName' value=\"Mick\" type='hidden'>", "LoginName", "Mick");
+    //as above, but with search
+    testSingleParam("<input name='LoginName' value='Joe' type='search'>", "<input name='LoginName' value=\"Mick\" type='search'>", "LoginName", "Mick");
+    //as above, but with number
+    testSingleParam("<input name='LoginName' value='3.14' type='number'>", "<input name='LoginName' value=\"7&#046;95\" type='number'>", "LoginName", "7.95");
+    //text with default is overwritten even if param is empty
+    testSingleParam("<input name='LoginName' value='Mick' type='text' size='30'>", "<input name='LoginName' value=\"\" type='text' size='30'>", "LoginName", "");
+    //as above, but with hidden
+    testSingleParam("<input name='LoginName' value='Mick' type='hidden'>", "<input name='LoginName' value=\"\" type='hidden'>", "LoginName", "");
+    //as above, but with search
+    testSingleParam("<input name='LoginName' value='Mick' type='search'>", "<input name='LoginName' value=\"\" type='search'>", "LoginName", "");
+    //text with default is overwritten even if default is empty
+    testSingleParam("<input name='LoginName' value='' type='text' size='30'>", "<input name='LoginName' value=\"Mick\" type='text' size='30'>", "LoginName", "Mick");
+    //radio is checked by default, but no match, so is unchecked
+    testSingleParam("<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "<INPUT type='RADIO' name=\"StarRating\" value='1'>", "StarRating", "2");
+    //as previous, but checked attr at end 
+    testSingleParam("<INPUT type='RADIO' name=\"StarRating\" value='1' checked>", "<INPUT type='RADIO' name=\"StarRating\" value='1'>", "StarRating", "2");
+    //radio is checked by default, and matches, so is unchanged
+    testSingleParam("<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "StarRating", "1");
+    //checkbox is checked by default, but no match, so is removed
+    testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" checked value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "SendCard", "false");
+    //checkbox is checked by default, with match, so is retained
+    testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" value='true' checked>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true' checked>", "SendCard", "true");
+  }
+
+  public void testInputTagWithSpecialRegexChar() throws JspException, IOException {
+    //this test failed with the first version of PopulateHelper, since an item 
+    //was not 'escaped' for special regex chars. As well, this case was untested 
+    //in the original test cases.
+    testSingleParam("<input name='DesiredSalary' type='text' size='30'>", "<input value=\"100&#046;00U&#036;\" name='DesiredSalary' type='text' size='30'>", "DesiredSalary", "100.00U$");
+  }
+  
+  public void testBigFailures() throws JspException, IOException {
+    //invalid flavor
+    testBigFailure("<inputt name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+    //compulsory attr not found (type)
+    testBigFailure("<input name='LoginName' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+    //compulsory attr not found (name)
+    testBigFailure("<input type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+    //no param of expected name in scope
+    testBigFailure("<input name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "Login", "Strummer");
+  }
+  
+  public void testModelObjectWithInputTags() throws JspException, IOException, ModelCtorException  {
+    User user = getUser();
+    //text 
+    testModelObject("<input name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", user);
+    //test no alteration of tag which is not related to input
+    testModelObject("<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", "<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", user);
+    //test no alteration of tag whose type(submit) is not supported 
+    testModelObject("<input type=\"submit\" value='REGISTER'>", "<input type=\"submit\" value='REGISTER'>", user);
+    //caps, spacing, quotes
+    testModelObject("<INPUT    NAME='LoginName' TYPE='TEXT' SIZE='30'>", "<INPUT value=\"Strummer\"    NAME='LoginName' TYPE='TEXT' SIZE='30'>", user);
+    //additional extraneous attr's
+    testModelObject("<input name='LoginName' type='text' size='30' class='blah'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30' class='blah'>", user);
+    //password
+    testModelObject("<input name='LoginPassword' type='password' size='30'>", "<input value=\"clash\" name='LoginPassword' type='password' size='30'>", user);
+    //hidden
+    testModelObject("<input name='LoginPassword' type='hidden'>", "<input value=\"clash\" name='LoginPassword' type='hidden'>", user);
+    //search
+    testModelObject("<input name='LoginPassword' type='search'>", "<input value=\"clash\" name='LoginPassword' type='search'>", user);
+    //email
+    testModelObject("<input name='EmailAddress' type='email'>", "<input value=\"joe&#064;clash&#046;com\" name='EmailAddress' type='email'>", user);
+    //number
+    testModelObject("<input name='DesiredSalary' type='number'>", "<input value=\"17500&#046;00\" name='DesiredSalary' type='number'>", user);
+    //range
+    testModelObject("<input name='Range' type='range'>", "<input value=\"17&#046;5\" name='Range' type='range'>", user);
+    //color
+    testModelObject("<input name='Color' type='color'>", "<input value=\"&#035;001122\" name='Color' type='color'>", user);
+    
+    //radio with match
+    testModelObject("<INPUT type='RADIO' name=\"StarRating\" value='1'>", "<INPUT checked type='RADIO' name=\"StarRating\" value='1'>", user);
+    //radio with no match
+    testModelObject("<INPUT type='RADIO' name=\"StarRating\" value='2'>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", user);
+    //checkbox with match 
+    testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" value='false'>", "<input checked type=\"CHECKBOX\" name=\"SendCard\" value='false'>", user);
+    //checkbox with no match
+    testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", user);
+    
+    //text with default is overwritten
+    testModelObject("<input name='LoginName' value='Mick' type='text' size='30'>", "<input name='LoginName' value=\"Strummer\" type='text' size='30'>", user);
+    //as above, but for hidden
+    testModelObject("<input name='LoginName' value='Mick' type='hidden'>", "<input name='LoginName' value=\"Strummer\" type='hidden'>", user);
+    //text with default is overwritten even if default is empty
+    testModelObject("<input name='LoginName' value='' type='text' size='30'>", "<input name='LoginName' value=\"Strummer\" type='text' size='30'>", user);
+    //as above, but with hidden
+    testModelObject("<input name='LoginName' value='' type='hidden'>", "<input name='LoginName' value=\"Strummer\" type='hidden'>", user);
+    //as above, but with search
+    testModelObject("<input name='LoginName' value='' type='search'>", "<input name='LoginName' value=\"Strummer\" type='search'>", user);
+    //radio is checked by default, but no match, so is unchecked
+    testModelObject("<INPUT type='RADIO' checked name=\"StarRating\" value='2'>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", user);
+    //as previous, but checked attr at end 
+    testModelObject("<INPUT type='RADIO' name=\"StarRating\" value='2' checked>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", user);
+    //radio is checked by default, and matches, so is unchanged
+    testModelObject("<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", user);
+    //checkbox is checked by default, but no match, so is removed
+    testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" checked value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", user);
+    //checkbox is checked by default, with match, so is retained
+    testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" value='false' checked>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='false' checked>", user);
+
+    //basic sanity for Locale and TimeZone
+    testModelObject("<input name='Country' type='text' size='30'>", "<input value=\"en&#095;ca\" name='Country' type='text' size='30'>", user);
+    testModelObject("<input name='TZ' type='text' size='30'>", "<input value=\"Canada&#047;Atlantic\" name='TZ' type='text' size='30'>", user);
+  }
+  
+  public void testFailBeanMethod() throws JspException, IOException, ModelCtorException {
+    try {
+      //name of tag does not match any bean getXXX method
+      testModelObject("<input name='LoginNameB' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", getUser());
+    }
+    catch(Throwable ex){
+      return;
+    }
+    fail("Should have failed.");
+  }
+  
+  public void testParamsWithSelectTags() throws JspException, IOException {
+    //basic prepop
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "<select name='FavoriteTheory'>"+NL+
+       " <option selected>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+    //extra attr's
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "<select name='FavoriteTheory'>"+NL+
+       " <option selected class='blah'>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+    //with value attr and selected
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "<select name='FavoriteTheory'>"+NL+
+       " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+       " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+     );
+    //with value attr and not selected
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "<select name='FavoriteTheory'>"+NL+
+       " <option selected class='blah'>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+    //caps, spaces, and quotes
+    testSelect(
+      "<SELECT   NAME=\"FavoriteTheory\">"+NL+
+       " <OPTION   class=\"blah\" >Quantum Electrodynamics</OPTION>"+NL+
+       " <OPTION   value=\"Quantum Flavordynamics\" >QFD</OPTION>"+NL+
+       " <OPTION >Quantum Chromodynamics</OPTION>"+NL+
+       "</SELECT>", 
+
+      "<SELECT   NAME=\"FavoriteTheory\">"+NL+
+       " <OPTION   class=\"blah\" >Quantum Electrodynamics</OPTION>"+NL+
+       " <OPTION   value=\"Quantum Flavordynamics\" >QFD</OPTION>"+NL+
+       " <OPTION selected >Quantum Chromodynamics</OPTION>"+NL+
+       "</SELECT>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+     );
+    //retain default selected
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+     );
+    //retain default selected when other attr's present
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics' selected class='blah'>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics' selected class='blah'>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+     );
+    //remove default selected when no match
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics' selected class='blah'>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics' class='blah'>Quantum Flavordynamics</option>"+NL+
+       " <option selected>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+     );
+    //extra attrs in select tag
+    testSelect(
+      "<select class='blah' name='FavoriteTheory' size='30'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select class='blah' name='FavoriteTheory' size='30'>"+NL+
+       " <option selected>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+    //test leading and trailing spaces in body of option tag
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option> Quantum Electrodynamics </option>"+NL+
+       " <option>  Quantum Flavordynamics    </option>"+NL+
+       " <option> Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select name='FavoriteTheory'>"+NL+
+       " <option selected> Quantum Electrodynamics </option>"+NL+
+       " <option>  Quantum Flavordynamics    </option>"+NL+
+       " <option> Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+    //as above, with different selected
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option> Quantum Electrodynamics </option>"+NL+
+       " <option>  Quantum Flavordynamics    </option>"+NL+
+       " <option> Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select name='FavoriteTheory'>"+NL+
+       " <option> Quantum Electrodynamics </option>"+NL+
+       " <option selected>  Quantum Flavordynamics    </option>"+NL+
+       " <option> Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+     );
+    //as above, with different selected
+    testSelect(
+      "<select name='FavoriteTheory'>"+NL+
+       " <option> Quantum Electrodynamics </option>"+NL+
+       " <option>  Quantum Flavordynamics    </option>"+NL+
+       " <option> Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select name='FavoriteTheory'>"+NL+
+       " <option> Quantum Electrodynamics </option>"+NL+
+       " <option>  Quantum Flavordynamics    </option>"+NL+
+       " <option selected> Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+     );
+    //multiple selects
+    testSelect(
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option selected>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+    );
+    //special regex chars in extra attrs in option tag
+    testSelect(
+      "<select class='blah' name='FavoriteTheory'>"+NL+
+       " <option title='Aliases [utf-1-1-unicode]'>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select class='blah' name='FavoriteTheory'>"+NL+
+       " <option selected title='Aliases [utf-1-1-unicode]'>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+  }
+  
+  public void testFailedSelects() throws JspException, IOException, ModelCtorException {
+    testFailSelect(
+      "<select name='FaveTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select name='FaveTheory'>"+NL+
+       " <option selected>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FaveTheory", new Object[] {"Quantum Electrodynamics"}
+     );
+  }
+  
+  
+  public void testMultipleParamsWithSelectTags() throws JspException, IOException {
+    //basic 
+    testSelect(
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option>Quantum Flavordynamics</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected>Quantum Flavordynamics</option>"+NL+
+       " <option selected>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+     );
+     //value attr
+    testSelect(
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option selected>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+     );
+     //retain default selected
+    testSelect(
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option selected>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+     );
+     //remove default selected
+    testSelect(
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option selected>Quantum Electrodynamics</option>"+NL+
+       " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+      "<select multiple name='FavoriteTheory'>"+NL+
+       " <option>Quantum Electrodynamics</option>"+NL+
+       " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+       " <option selected>Quantum Chromodynamics</option>"+NL+
+       "</select>", 
+
+       "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+     );
+  }
+  
+  public void testBeanWithSelectTags() throws JspException, IOException, ModelCtorException  {
+    //basic prepop
+    testModelObject(
+      "<select name='FavoriteTheory'>"+NL+
+      " <option>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select name='FavoriteTheory'>"+NL+
+      " <option selected>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      getUser()
+    );
+    //value attr, but not selected
+    testModelObject(
+      "<select name='FavoriteTheory'>"+NL+
+      " <option>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+      "</select>", 
+       
+      "<select name='FavoriteTheory'>"+NL+
+      " <option selected>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+      "</select>", 
+      
+      getUser()
+    );
+    //value attr, and selected
+    testModelObject(
+      "<select name='FavoriteTheory'>"+NL+
+      " <option value='Quantum Electrodynamics'>QED</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+      "</select>", 
+       
+      "<select name='FavoriteTheory'>"+NL+
+      " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+      "</select>", 
+      
+      getUser()
+    );
+    //retain default selected
+    testModelObject(
+      "<select name='FavoriteTheory'>"+NL+
+      " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select name='FavoriteTheory'>"+NL+
+      " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      getUser()
+    );
+    //remove default selected
+    testModelObject(
+      "<select name='FavoriteTheory'>"+NL+
+      " <option value='Quantum Electrodynamics'>QED</option>"+NL+
+      " <option selected>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select name='FavoriteTheory'>"+NL+
+      " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      getUser()
+    );
+  }
+  
+  public void testBeanWithMultiSelectTags() throws JspException, IOException, ModelCtorException {
+    //User is taken as a bean which does not return a Set, but a String - this can 
+    //of course cause only one item to be selected
+    testModelObject(
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option selected>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      getUser()
+    );
+    //bean which returns a Set
+    //this class the bean whose getFavoriteTheory method returns a Set
+    //(A fake bean was attempted, but PopulateHelper barfs on nested classes.)
+    testModelObject(
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option selected>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option selected>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      this
+    );
+    //defaults retained and removed
+    testModelObject(
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option selected>Quantum Electrodynamics</option>"+NL+
+      " <option selected>Quantum Flavordynamics</option>"+NL+
+      " <option selected>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+       
+      "<select multiple name='FavoriteTheory'>"+NL+
+      " <option selected>Quantum Electrodynamics</option>"+NL+
+      " <option>Quantum Flavordynamics</option>"+NL+
+      " <option selected>Quantum Chromodynamics</option>"+NL+
+      "</select>", 
+      
+      this
+    );
+    
+  }
+  
+  /*
+   See above method.
+  */
+  public Set getFavoriteTheory(){
+    Set result = new LinkedHashSet();
+    result.add("Quantum Electrodynamics");
+    result.add("Quantum Chromodynamics");
+    return result;
+  }
+  
+  public void testArgsWithTextAreaTag() throws JspException, IOException, ModelCtorException {
+    //with default text
+    testSingleParam("<textarea name='Comment'> This is a comment.  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    //add other attr's
+    testSingleParam("<textarea name='Comment' wrap='virtual' rows=20 cols=50> This is a comment.  </textarea>", "<textarea name='Comment' wrap='virtual' rows=20 cols=50>The Clash</textarea>", "Comment", "The Clash");
+    //caps, spacing, quotes
+    testSingleParam("<TEXTAREA    NAME='Comment'> This is a comment.  </TEXTAREA>", "<TEXTAREA    NAME='Comment'>The Clash</TEXTAREA>", "Comment", "The Clash");
+    testSingleParam("<textarea NAME='Comment'> This is a comment.  </TEXTAREA>", "<textarea NAME='Comment'>The Clash</TEXTAREA>", "Comment", "The Clash");
+    //without default text
+    testSingleParam("<textarea name='Comment'> </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'></textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'>   </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    //with new lines and white space
+    testSingleParam("<textarea name='Comment'>" +Consts.NEW_LINE+ " This is a comment. " +Consts.NEW_LINE+ " </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'>" +Consts.NEW_LINE+  "</textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'> " +Consts.NEW_LINE+  "</textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'> " +Consts.NEW_LINE+  " </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'>  " +Consts.NEW_LINE+  "  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    //special regex chars in default body
+    testSingleParam("<textarea name='Comment'> $This is a comment.  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'> This is a comment.$  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'> $This is a comment.$  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'> Thi$s is a comment.  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    testSingleParam("<textarea name='Comment'> \\This is a comment.  </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+    //special regex chars in replacememt
+    testSingleParam("<textarea name='Comment'>This is a comment.  </textarea>", "<textarea name='Comment'>The Cla&#036;h</textarea>", "Comment", "The Cla$h");
+    testSingleParam("<textarea name='Comment'>This is a comment.  </textarea>", "<textarea name='Comment'>&#036;The Clash&#036;</textarea>", "Comment", "$The Clash$");
+    testSingleParam("<textarea name='Comment'>This is a comment.  </textarea>", "<textarea name='Comment'>&#092;The Clash</textarea>", "Comment", "\\The Clash");
+  }
+
+  public void testBeanWithTextAreaTag() throws JspException, IOException, ModelCtorException {
+    User user = getUser();
+    //with default text
+    testModelObject("<textarea name='LoginName'>This is a comment</textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+    //with other attr's
+    testModelObject("<textarea name='LoginName' cols='20' rows='35'>This is a comment</textarea>", "<textarea name='LoginName' cols='20' rows='35'>Strummer</textarea>", user);
+    //caps, spacing, quotes
+    testModelObject("<TEXTAREA   NAME='LoginName'>This is a comment</TEXTAREA>", "<TEXTAREA   NAME='LoginName'>Strummer</TEXTAREA>", user);
+    testModelObject("<textarea   NAME='LoginName'>This is a comment</TEXTAREA>", "<textarea   NAME='LoginName'>Strummer</TEXTAREA>", user);
+    //without default text
+    testModelObject("<textarea name='LoginName'></textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+    //with new lines and white space
+    testModelObject("<textarea name='LoginName'>" +Consts.NEW_LINE+ "</textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+    testModelObject("<textarea name='LoginName'> " +Consts.NEW_LINE+ "This is a comment" + Consts.NEW_LINE + " </textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+  }
+  
+  // FIXTURE //
+
+  protected void setUp(){
+    //empty
+  }
+
+  protected void tearDown() {
+    //empty
+  }
+
+  // PRIVATE  //
+  
+  private static final String NL = Consts.NEW_LINE;
+  
+  private User getUser(){
+    User result = null;
+    try {
+      result = new User(
+        "Strummer", 
+        "clash", 
+        "joe@clash.com", 
+        new Integer(1), 
+        "Quantum Electrodynamics", 
+        Boolean.FALSE, 
+        new Integer(42),
+        new BigDecimal("17500.00"),
+        new Date(0), 
+        new BigDecimal("17.5"),
+        "#001122"
+      );
+    }
+    catch (ModelCtorException ex){
+      fail("Cannot build User object.");
+    }
+    return result;
+  }
+  
+  private static final Logger fLogger = Util.getLogger(TESTFormPopulator.class);
+  private static void log(Object aThing){
+    System.out.println(String.valueOf(aThing));
+  }
+
+  
+  private void testSingleParam(String aIn, String aOut, String aParamName, String aParamValue) throws JspException, IOException {
+    PopulateHelper.Context fakeParam = new FakeSingleParam(aParamName, aParamValue);
+    PopulateHelper populator = new PopulateHelper( fakeParam, aIn, Style.MUST_RECYCLE_PARAMS );
+    String editedBody = populator.getEditedBody();
+    assertTrue( editedBody.equals(aOut) );
+  }
+
+  private void testModelObject(String aIn, String aOut, Object aBean) throws JspException, IOException {
+    PopulateHelper.Context myUserBean = new FakeBeanOnly(aBean);
+    PopulateHelper populator = new PopulateHelper( myUserBean, aIn, Style.USE_MODEL_OBJECT);
+    String populatedBody = populator.getEditedBody();
+    if( ! populatedBody.equals(aOut) ){
+      System.out.println("Populated Body : " + populatedBody);
+    }
+    assertTrue( populator.getEditedBody().equals(aOut) );
+  }
+  
+  private void testSelect(String aIn, String aOut, String aParamName, Object[] aParamValues) throws JspException, IOException {
+    PopulateHelper.Context selectParams = new FakeSelectParams(aParamName, aParamValues);
+    PopulateHelper populator = new PopulateHelper( selectParams, aIn, Style.MUST_RECYCLE_PARAMS);
+    assertTrue( populator.getEditedBody().equals(aOut) );
+  }
+  
+  private void testBigFailure(String aIn, String aOut, String aParamName, String aParamValue) throws JspException, IOException {
+    try {
+      testSingleParam(aIn, aOut, aParamName, aParamValue);
+    }
+    catch(Throwable ex){
+      return;
+    }
+    fail("Should have failed.");
+  }
+  
+  private void testFailSelect(String aIn, String aOut, String aParamName, Object[] aParamValues) throws JspException, IOException {
+    try {
+      testSelect(aIn, aOut, aParamName, aParamValues);
+    }
+    catch(Throwable ex){
+      return;
+    }
+    fail("Select should have failed.");
+  }
+  
+  /** Fake the case where a single request parameter is present.  */ 
+  static private final class FakeSingleParam implements PopulateHelper.Context {
+    FakeSingleParam(String aParamName, String aParamValue){
+      fParamName = aParamName;
+      fParamValue = aParamValue;
+    }
+    public String getReqParamValue(String aName){
+      return aName.equals(fParamName) ? fParamValue : null;
+    }
+    public boolean hasRequestParamNamed(String aParamName) {
+      return fParamName.equals(aParamName);
+    }
+    public Object getModelObject(){
+      return null;
+    }
+    public boolean isModelObjectPresent() {
+      return false;
+    }
+    public Formats getFormats(){
+      return new Formats(new Locale("en"), TimeZone.getTimeZone("GMT"));
+    }
+    public Collection<String> getReqParamValues(String aParamName){
+      Collection<String> result = new ArrayList<String>();
+      result.add(fParamValue);
+      return aParamName.equals(fParamName) ? result : Collections.EMPTY_LIST;
+    }
+    private final String fParamName;
+    private final String fParamValue;
+  }
+  
+  /** Fake the case where a bean is present, but no request parameters.  */ 
+  static private final class FakeBeanOnly implements PopulateHelper.Context {
+    FakeBeanOnly(Object aObject){
+      fBean = aObject;
+    }
+    public String getReqParamValue(String aName){
+      return null;
+    }
+    public boolean hasRequestParamNamed(String aParamName) {
+      return false;
+    }
+    public boolean isModelObjectPresent(){
+      return fBean != null;
+    }
+    public Object getModelObject(){
+      return fBean;
+    }
+    public Formats getFormats(){
+      return new Formats(new Locale("en"), TimeZone.getTimeZone("GMT") );
+    }
+    public Collection<String> getReqParamValues(String aParamName){
+      return null;
+    }
+    private final Object fBean;
+  }
+  
+  /** Fake the case where request parameters are possibly multi-valued.   */ 
+  static private final class FakeSelectParams implements PopulateHelper.Context {
+    FakeSelectParams(String aParamName, Object[] aParamValues){
+      fParamName = aParamName;
+      fParamValues = Collections.unmodifiableCollection( Arrays.asList(aParamValues) );
+    }
+    public String getReqParamValue(String aName){
+      return null;
+    }
+    public boolean hasRequestParamNamed(String aParamName) {
+      return fParamName.equals(aParamName);
+    }
+    public boolean isModelObjectPresent(){
+      return false;
+    }
+    public Object getModelObject(){
+      return null;
+    }
+    public Formats getFormats(){
+      return new Formats(new Locale("en"), TimeZone.getTimeZone("GMT") );
+    }
+    public Collection<String> getReqParamValues(String aParamName){
+      return aParamName.equals(fParamName) ? fParamValues : null;
+    }
+    private final String fParamName;
+    private final Collection fParamValues;
+  }
+  
+  private static final Pattern fLOGIN_NAME_PATTERN = Pattern.compile("(.){3,30}");
+  private static final Pattern fLOGIN_PASSWORD_PATTERN = Pattern.compile("(\\S){3,20}");
+
+  /** 
+   Typical model class stolen from exampleA. 
+   Even though this is repetition, it is the only area where the library 
+   uses the example. It would be a shame to have to link the projects for only 
+   this reason, as it is dangerous to attach the library to an app.
+  */
+  public static final class User  { 
+
+    /**
+     @param aLoginName has trimmed length in range <tt>3..30</tt>.
+     @param aLoginPassword has length in range <tt>3..20</tt>, and contains 
+     no whitespace.
+     @param aEmailAddress satisfies {@link WebUtil#isValidEmailAddress} 
+     @param aStarRating rating of last meal in range <tt>1..4</tt>; 
+     optional - if null, replace with value of <tt>0</tt>.
+     @param aFavoriteTheory favorite quantum field theory ; 
+     optional - may be <tt>null</tt>.
+     @param aSendCard toggle for sending Christmas card to user ; 
+     optional - if <tt>null</tt>, replace with <tt>false</tt>.
+     @param aAge of the user, in range <tt>0..150</tt> ; 
+     optional - may be <tt>null</tt>.
+     @param aDesiredSalary is <tt>0</tt> or more ; optional - may be <tt>null</tt>.
+     @param aBirthDate is not in the future ; optional - may be <tt>null</tt>.
+    */
+    public User(
+      String aLoginName, 
+      String aLoginPassword, 
+      String aEmailAddress, 
+      Integer aStarRating, 
+      String aFavoriteTheory, 
+      Boolean aSendCard,
+      Integer aAge,
+      BigDecimal aDesiredSalary,
+      Date aBirthDate, 
+      BigDecimal aRange, 
+      String aColor
+    ) throws ModelCtorException {
+
+      fLoginName = Util.trimPossiblyNull(aLoginName);
+      fLoginPassword = aLoginPassword;
+      fEmailAddress = Util.trimPossiblyNull(aEmailAddress);
+      
+      fStarRating = Util.replaceIfNull(aStarRating, ZERO);
+      fFavoriteTheory = aFavoriteTheory;
+      fSendCard = Util.replaceIfNull(aSendCard, Boolean.FALSE);
+      
+      fAge = aAge;
+      fDesiredSalary = aDesiredSalary;
+      //defensive copy
+      fBirthDate = aBirthDate == null ? null : new Long(aBirthDate.getTime());
+      
+      fRange = aRange;
+      fColor = aColor;
+      
+      validateState();
+    }
+
+    public String getLoginName() {
+      return fLoginName;
+    }
+
+    public String getLoginPassword() {
+      return fLoginPassword;
+    }
+
+    public String getEmailAddress() {
+      return fEmailAddress;
+    }
+    
+    public Integer getStarRating(){
+      return fStarRating;
+    }
+    
+    public String getFavoriteTheory(){
+      return fFavoriteTheory;
+    }
+    
+    public Boolean getSendCard(){
+      return fSendCard;
+    }
+    
+    public Integer getAge(){
+      return fAge;
+    }
+
+    public BigDecimal getDesiredSalary(){
+      return fDesiredSalary;
+    }
+    
+    public Date getBirthDate(){
+      //defensive copy
+      return fBirthDate == null ? null : new Date(fBirthDate.longValue());
+    }
+    
+    public Locale getCountry(){
+      return new Locale("en_ca");
+    }
+    
+    public TimeZone getTZ(){
+      return TimeZone.getTimeZone("Canada/Atlantic");
+    }
+    
+    public BigDecimal getRange(){
+      return fRange;
+    }
+    
+    public String getColor(){
+      return fColor;
+    }
+    
+    /**
+     Intended for debugging only.
+    */
+    public String toString() {
+      return ModelUtil.toStringFor(this);
+    }
+
+    public boolean equals( Object aThat ) {
+      if ( this == aThat ) return true;
+      if ( !(aThat instanceof User) ) return false;
+      User that = (User)aThat;
+      return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+
+    public int hashCode() {
+      return ModelUtil.hashCodeFor(getSignificantFields());
+    }
+    
+    private Object[] getSignificantFields(){
+      return new Object[]{
+        fLoginName, fLoginPassword, fEmailAddress, fStarRating, fFavoriteTheory, 
+        fSendCard, fAge, fDesiredSalary, fBirthDate
+      };
+    }
+
+    // PRIVATE // 
+    private final String fLoginName;
+    private final String fLoginPassword;
+    private final String fEmailAddress;
+    private final Integer fStarRating;
+    private final String fFavoriteTheory;
+    private final Boolean fSendCard;
+    private final Integer fAge;
+    private final BigDecimal fDesiredSalary;
+    private final Long fBirthDate;
+    private final BigDecimal fRange;
+    private final String fColor;
+    private static final Integer ZERO = new Integer(0);
+    
+    private void validateState() throws ModelCtorException {
+      ModelCtorException ex = new ModelCtorException();
+      if ( ! isValidLoginName(fLoginName) ) {
+        ex.add("Login name must have 3..30 characters");
+      }
+      if ( ! isValidLoginPassword(fLoginPassword) ) {
+        ex.add("Password must have 3..20 characters, with no spaces");
+      }
+      if ( ! isValidEmailAddress(fEmailAddress) ){
+        ex.add("Email address has an invalid form");
+      }
+      if ( ! isValidStarRating(fStarRating) ){
+        ex.add("Star Rating (optional) must be in range 1..4");
+      }
+      //no validation needed for favoriteTheory, sendCard
+      if ( ! isValidAge(fAge) ) {
+        ex.add("Age must be in range 0..150");
+      }
+      if ( ! isValidDesiredSalary(fDesiredSalary) ) {
+        ex.add("Desired Salary must be 0 or more.");
+      }
+      if ( ! isValidBirthDate(fBirthDate) ){
+        ex.add("Birth Date must be in the past.");
+      }
+      if ( ! ex.isEmpty() ) throw ex;
+    }
+    
+    private boolean isValidLoginName(String aLoginName){
+      return Util.matches(fLOGIN_NAME_PATTERN, aLoginName);
+    }
+    
+    private boolean isValidLoginPassword(String aLoginPassword){
+      return Util.matches(fLOGIN_PASSWORD_PATTERN, aLoginPassword);
+    }
+
+    private boolean isValidEmailAddress(String aEmailAddress){
+      return WebUtil.isValidEmailAddress(aEmailAddress);
+    }
+    
+    private boolean isValidStarRating(Integer aStarRating){
+      return Util.isInRange(aStarRating.intValue(), 0, 4);
+    }
+    
+    private boolean isValidAge(Integer aAge){
+      boolean result = true;
+      if ( aAge != null ){
+        if ( ! Util.isInRange(aAge.intValue(), 0, 150) ){
+          result = false;
+        }
+      }
+      return result;
+    }
+    
+    private boolean isValidDesiredSalary(BigDecimal aDesiredSalary){
+      final BigDecimal ZERO_MONEY = new BigDecimal("0");
+      boolean result = true;
+      if ( aDesiredSalary != null ) {
+        if ( aDesiredSalary.compareTo(ZERO_MONEY) < 0 ) {
+          result = false;
+        }
+      }
+      return result;
+    }
+    
+    private boolean isValidBirthDate(Long aBirthDate){
+      boolean result = true;
+      if ( aBirthDate != null ) {
+        if ( aBirthDate.longValue() > System.currentTimeMillis()) {
+          result = false;
+        }
+      }
+      return result;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/TESTHighlightCurrentPage.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,148 @@
+package hirondelle.web4j.ui.tag;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+
+import hirondelle.web4j.util.Consts;
+
+public final class TESTHighlightCurrentPage extends TestCase {
+
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTHighlightCurrentPage.class.getName()};
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTHighlightCurrentPage(String aName) {
+   super( aName );
+  }
+  
+  //href with highlight - match and no match
+  public void testHrefNoHighlight(){
+    HighlightCurrentPage highlight = new HighlightCurrentPage();
+    highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do");
+    
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "Home");
+    testHighlight(highlight, "<a  href='ShowHomePage.do'>Home</a>", "Home");
+    testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "Home");
+    testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "Home");
+    testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " Home");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "Home ");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "Home ");
+    testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", " Home ");
+    //Multiple links 
+    testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a> <a href='ShowContacts.do'>Contacts</a>", " Home  <a href='ShowContacts.do'>Contacts</a>");
+    
+    testNoHighlight(highlight, "<a href='ShowContactPage.do'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+    //Nuisance restriction - HREF must come first : 
+    testNoHighlight(highlight, "<a title='Fred' href='ShowHomePage.do'>Home</a>");
+    //No highlight if session id present in one, but not in the other
+    testNoHighlight(highlight, "<a href='ShowHomePage.do;jsessionid=321651651313'>Home</a>");
+    //Multiple links 
+    testNoHighlight(highlight, " <a href='ShowContacts.do'>Contacts</a> <a href='ShowThis.do'>Contacts</a> ");
+  }
+  
+  public void testHrefWithHighlight(){
+    HighlightCurrentPage highlight = new HighlightCurrentPage();
+    highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do");
+    highlight.setStyleClass("highlight");
+
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a  href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " <span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "<span class=\"highlight\">Home</span> ");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "<span class=\"highlight\">Home </span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", "<span class=\"highlight\"> Home </span>");
+
+    testNoHighlight(highlight, "<a href='ShowContactPage.do'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+    testNoHighlight(highlight, "<a title='Fred' href='ShowHomePage.do'>Home</a>");
+    testNoHighlight(highlight, "<a href='ShowHomePage.do;jsessionid=321651651313'>Home</a>");
+  }
+  
+  public void testTitleNoHighlight(){
+    HighlightCurrentPage highlight = new HighlightCurrentPage();
+    highlight.setUseTitle(Boolean.TRUE);
+    highlight.testSetTitleParam("Home");
+    
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "Home");
+    testHighlight(highlight, "<a  href='ShowHomePage.do'>Home</a>", "Home");
+    testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "Home");
+    testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "Home");
+    testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " Home");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "Home ");
+    //Will trim the link body before testing for match
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "Home ");
+    testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", " Home ");
+    //HREF target not relevant here - will match any HREF
+    testHighlight(highlight, "<a href='ShowContact.do'>Home</a>", "Home");
+
+    testNoHighlight(highlight, "<a href='ShowContactPage.do'>My Home</a>");
+    testNoHighlight(highlight, "<a href='ShowContactPage.do'>Acceuil</a>");
+  }
+  
+  public void testTitleWithHighlight(){
+    HighlightCurrentPage highlight = new HighlightCurrentPage();
+    highlight.setUseTitle(Boolean.TRUE);
+    highlight.setStyleClass("highlight");
+    highlight.testSetTitleParam("Home");
+
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a  href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "<span class=\"highlight\">Home</span>");
+    testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " <span class=\"highlight\">Home</span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "<span class=\"highlight\">Home</span> ");
+    testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "<span class=\"highlight\">Home </span>");
+    testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", "<span class=\"highlight\"> Home </span>");
+    
+    testNoHighlight(highlight, "<a href='ShowContactPage.do'>My Home</a>");
+    testNoHighlight(highlight, "<a href='ShowContactPage.do'>Acceuil</a>");
+  }
+  
+  public void testAmpersandInURL(){
+    HighlightCurrentPage highlight = new HighlightCurrentPage();
+    highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do?X=Y&A=B");
+
+    //Ampersand is escaped correctly in HREF :
+    testHighlight(highlight, "<a href='ShowHomePage.do?X=Y&amp;A=B'>Home</a>", "Home");
+    
+    //Ampersand is NOT escaped correctly in HREF
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y&A=B'>Home</a>");
+    //Missing parameters altogether :
+    testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+  }
+  
+  // FIXTURE //
+  
+  protected void setUp(){
+  }
+  
+  protected void tearDown() {
+    //empty
+  }
+ 
+
+  // PRIVATE //
+  
+  private void testHighlight(HighlightCurrentPage aHighlighter, String aIn, String aOut){
+    String out = aHighlighter.getEmittedText(aIn);
+    if ( ! out.equals(aOut) ) {
+      fail("Out was actually: " + Consts.DOUBLE_QUOTE + out + Consts.DOUBLE_QUOTE);
+    }
+  }
+  
+  private void testNoHighlight(HighlightCurrentPage aHighlighter, String aIn){
+    String out = aHighlighter.getEmittedText(aIn);
+    if ( ! out.equals(aIn) ) {
+      fail("Out was actually: " + Consts.DOUBLE_QUOTE + out + Consts.DOUBLE_QUOTE);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/TagHelper.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,135 @@
+package hirondelle.web4j.ui.tag;
+
+import java.io.*;
+import java.util.logging.*;
+import javax.servlet.Servlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.jsp.JspException;
+import javax.servlet.jsp.tagext.SimpleTagSupport;
+import javax.servlet.jsp.PageContext;
+import javax.servlet.jsp.tagext.JspFragment;
+import javax.servlet.jsp.JspContext;
+
+import hirondelle.web4j.util.Util;
+
+/**
+ Base class for implementing custom JSP tags.
+ 
+ <P>The custom tag can optionally have a body. The <tt>.tld</tt> entry for these tags must 
+ have their <tt>body-content</tt> set to <tt>scriptless</tt>.
+ 
+ <P>Concrete subclasses of this class perform these tasks :
+<ul>
+ <li>implement <tt>setXXX</tt> methods, one for each tag attribute;
+ each <tt>setXXX</tt> should validate its argument.
+ <li>optionally override the {@link #crossCheckAttributes} method, to 
+ perform validations depending on more than one attribute.
+ <li>implement {@link #getEmittedText}, to return the text to be included in markup.
+</ul>
+*/
+public abstract class TagHelper extends SimpleTagSupport {
+  
+  /**
+   <b>Template</b> method which calls {@link #getEmittedText(String)}.
+   
+   <P>The body of this tag is evaluated, passed to {@link #getEmittedText(String)}, 
+   and the result is then written to the JSP output writer. In addition, this method will call
+   {@link #crossCheckAttributes()} at the start of processing.
+  */
+  @Override public final void doTag() throws JspException {
+    try {
+      crossCheckAttributes();
+      getJspContext().getOut().write(getEmittedText(getBody()));
+    }
+    catch (Throwable ex){
+      fLogger.severe("Cannot execute custom tag. " + Util.quote(ex));
+      throw new JspException("Cannot execute custom tag.", ex);
+    }
+  }
+  
+  /** 
+   Return the text this tag will display in the resulting web page.
+   
+   @param aOriginalBody is the evaluated body of this tag. If there is no body, or 
+   if the body is present but empty, then it is <tt>null</tt>.
+   @return the text to display in the resulting web page. 
+  */
+  abstract protected String getEmittedText(String aOriginalBody) throws JspException, IOException;
+   
+  /**
+   Perform validations that apply to more than one attribute.
+  
+   <P>This default implementation does nothing.
+   
+   <P>Validations that apply to a single attribute should be performed in its 
+   corresponding <tt>setXXX</tt> method.
+  
+   <P>If a problem is detected, subclasses must emit a <tt>RuntimeException</tt> 
+   describing the problem. If all validations apply to only to a single attribute, 
+   then this method should not be overridden.
+  */
+  protected void crossCheckAttributes() {
+    //do nothing in this default impl
+  }
+  
+  /** Return the underlying {@link HttpServletRequest}.  */
+  protected final HttpServletRequest getRequest(){
+    return (HttpServletRequest)getPageContext().getRequest();
+  }
+  
+  /** Return the underlying {@link HttpServletResponse}.  */
+  protected final HttpServletResponse getResponse(){
+    return (HttpServletResponse)getPageContext().getResponse();
+  }
+  
+  /** Return the underlying {@link PageContext}.  */
+  protected final PageContext getPageContext(){
+    JspContext jspContext = getJspContext();
+    return (PageContext)jspContext;
+  }
+
+  /** 
+   Return the name of the JSP implementation class. 
+   <P>Intended for debugging only. 
+  */
+  protected final String getPageName(){
+    Servlet servlet = (Servlet)getPageContext().getPage();
+    return servlet.getClass().getName();
+  }
+  
+  /**
+   Verify that an attribute value has content. 
+   
+   <P>If no content, then log at <tt>SEVERE</tt> and throw an unchecked exception.
+  */
+  protected final void checkForContent(String aAttributeName, String aAttributeValue){
+    if( ! Util.textHasContent(aAttributeValue) ){
+      String message = Util.quote(aAttributeName) + " attribute must have a value.";
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+  }
+
+  // PRIVATE //
+  
+  private static final Logger fLogger = Util.getLogger(TagHelper.class);
+  
+  /** 
+   Return the evaluated body of this tag.  
+   
+   <P>The body of this tag cannot contain scriptlets or scriptlet expressions.
+   If this tag has no body, or has an empty body, then <tt>null</tt> is returned.
+  */
+  private String getBody() throws IOException, JspException {
+    String result = null;
+    JspFragment body = getJspBody();
+    if( body != null ){
+      StringWriter writer = new StringWriter();
+      getJspBody().invoke(writer);
+      writer.flush();
+      result = writer.toString();
+    }
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/tag/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,14 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Custom Tags</title>
+</head>
+<body>
+Custom JSP tags of general utility. The 
+{@link hirondelle.web4j.ui.tag.TagHelper} base class is useful for creating a 
+wide number of custom tags.
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/Messages.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,83 @@
+package hirondelle.web4j.ui.translate;
+
+import java.util.logging.*;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.request.LocaleSource;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.model.AppResponseMessage;
+import hirondelle.web4j.model.MessageList;
+import hirondelle.web4j.ui.tag.TagHelper;
+
+import java.util.*;
+
+/**
+ Custom tag for rendering a {@link hirondelle.web4j.model.MessageList}.
+ 
+  <P>Example use case :
+ <PRE>
+ &lt;c:if test="${not empty web4j_key_for_messages}"&gt; 
+  Message(s) :
+  &lt;w:messages name="web4j_key_for_messages"&gt;
+   &lt;span class="message"&gt;placeholder&lt;/span&gt;&lt;br&gt;
+  &lt;/w:messages&gt;
+  &lt;c:remove var="web4j_key_for_messages" scope="session"/&gt;
+ &lt;/c:if&gt;
+ </PRE>
+ 
+ <P>The body of this tag is a simple template for emitting each element of the named  
+ {@link MessageList}. The special <tt>placeholder</tt> text is the location where 
+ each item returned by {@link MessageList#getMessages()} is displayed.
+*/
+public final class Messages  extends TagHelper  {
+  
+  /**
+   Key for a {@link MessageList} (in any scope).
+   
+   <P>Required attribute. The {@link hirondelle.web4j.action.ActionImpl} class creates two such items, identified 
+   with {@link hirondelle.web4j.action.ActionImpl#ERRORS} and {@link hirondelle.web4j.action.ActionImpl#MESSAGES}. 
+  */
+  public void setName(String aMessageListName){
+    Object object = getPageContext().findAttribute(aMessageListName);
+    if ( object == null ) {
+      String message = "No object named " + Util.quote(aMessageListName) + " found in any scope.";
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+    fMessageList = (MessageList)object;
+  }
+  
+  /**
+   Emit given template text for each item in the {@link MessageList}.
+    
+   <P>Emit the text in this tag's body (which acts as a template), by replacing the 
+   special {@link #PLACEHOLDER} text with the content of each message. The configured 
+   implementations of both {@link LocaleSource} and {@link Translator} are used to render 
+   each item. (This performs "last-second localization".)
+  */
+  @Override protected String getEmittedText(String aOriginalBody){
+    StringBuilder result = new StringBuilder();
+    Locale locale = BuildImpl.forLocaleSource().get(getRequest());
+    TimeZone timeZone = BuildImpl.forTimeZoneSource().get(getRequest());
+    List<AppResponseMessage> messages = fMessageList.getMessages();
+    for(AppResponseMessage message: messages){
+      String messageText = message.getMessage(locale, timeZone);
+      String fullText = replacePlaceholder(aOriginalBody, messageText);
+      result.append(fullText);
+    }
+    return result.toString();
+  }
+
+  /**
+   Special text used by this class to conveniently identify where message text 
+   is placed. 
+  */
+  public static final String PLACEHOLDER = "placeholder";
+   
+  // PRIVATE 
+  private MessageList fMessageList;
+  private static final Logger fLogger = Util.getLogger(Messages.class);
+  
+  private String replacePlaceholder(String aOriginalBody, String aMessageText){
+    return Util.replace(aOriginalBody, PLACEHOLDER, aMessageText); 
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/TESTText.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,88 @@
+package hirondelle.web4j.ui.translate;
+
+import junit.framework.TestCase;
+
+/** Test the processing of wiki markup. */
+public final class TESTText  extends TestCase {
+
+  public static void main(String args[]) {
+    String[] testCaseName = {TESTText.class.getName()};
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTText(String aName) {
+    super(aName);
+  }
+
+  // TEST CASES 
+  
+  public void testEcho() {
+    Text text = new Text();
+    String orig = "This is a test value";
+    String result = text.processMarkup(orig);
+    assertTrue(orig.equals(result));
+  }
+
+  public void testLink() {
+    testOutput("This is a [link:http://www.blah.com blah]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com'>blah</a>");
+    testOutput("This is a [link:http://www.blah.com blah1 blah2]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com'>blah1 blah2</a>");
+    testOutput("This is a [link:http://www.blah.com blah1 (blah2)]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com'>blah1 " + POPEN + "blah2" + PCLOSE + "</a>");
+    testOutput("This is a [link:http://www.blah.com/this_thing blah]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com" + FSLASH + "this" + UNDERLINE + "thing'>blah</a>");
+    testOutput("This is a [link:http://www.blah.com/this_thing_here blah]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com" + FSLASH + "this" + UNDERLINE + "thing" + UNDERLINE + "here'>blah</a>");
+  }
+  
+  public void testBold() {
+    testOutput("This is a test *bold* item", "This is a test <b>bold</b> item");
+    testOutput("*This* is a test", "<b>This</b> is a test");
+    testOutput("This is a *test*", "This is a <b>test</b>");
+    testOutput("This is a *test*.", "This is a <b>test</b>" + PERIOD);
+    testOutput("This is a *test*. And", "This is a <b>test</b>" + PERIOD + " And");
+    testOutput("This is a *test* here", "This is a <b>test</b> here");
+    testOutput("This is a * test* here", "This is a <b> test</b> here");
+    testOutput("This is a *test * here", "This is a <b>test </b> here");
+    testOutput("This is a * test * here", "This is a <b> test </b> here");
+    testOutput("This is a *  test  * here", "This is a <b>  test  </b> here");
+    testOutput("This is a *test of this* here", "This is a <b>test of this</b> here");
+    testOutput("This is a * test of this * here", "This is a <b> test of this </b> here");
+    testOutput("This is a *  test of this  * here", "This is a <b>  test of this  </b> here");
+    testOutput("This is a *test* of *reluctant* here", "This is a <b>test</b> of <b>reluctant</b> here");
+    testOutput("This is a *test of* the *reluctant aspect* here", "This is a <b>test of</b> the <b>reluctant aspect</b> here");
+  }
+  
+  public void testItalic() {
+    testOutput("This is a test _italic_ item", "This is a test <em>italic</em> item");
+    testOutput("_This_ is a test", "<em>This</em> is a test"); 
+    testOutput("This is a _test_", "This is a <em>test</em>");
+    testOutput("This is a _test_.", "This is a <em>test</em>" + PERIOD);
+    testOutput("This is a _test_. And", "This is a <em>test</em>" + PERIOD + " And");
+    testOutput("This is a _test_ here", "This is a <em>test</em> here");
+    testOutput("This is a _ test_ here", "This is a <em> test</em> here");
+    testOutput("This is a _test _ here", "This is a <em>test </em> here");
+    testOutput("This is a _ test _ here", "This is a <em> test </em> here");
+    testOutput("This is a _  test  _ here", "This is a <em>  test  </em> here");
+    testOutput("This is a _test of this_ here", "This is a <em>test of this</em> here");
+    testOutput("This is a _ test of this _ here", "This is a <em> test of this </em> here");
+    testOutput("This is a _  test of this  _ here", "This is a <em>  test of this  </em> here");
+    testOutput("This is a _test_ of _reluctant_ here", "This is a <em>test</em> of <em>reluctant</em> here");
+    testOutput("This is a _test of_ the _reluctant aspect_ here", "This is a <em>test of</em> the <em>reluctant aspect</em> here");
+  }
+
+  // PRIVATE
+  
+  private static final String STAR = "&#042;";
+  private static final String UNDERLINE = "&#095;";
+  private static final String PERIOD =   "&#046;";
+  private static final String COLON = "&#058;";  
+  private static final String FSLASH = "&#047;";
+  private static final String POPEN = "&#040;";
+  private static final String PCLOSE = "&#041;";
+  
+  private void testOutput(String aInput, String aExpected) {
+    Text text = new Text();
+    text.setWikiMarkup(true);
+    String result = text.processMarkup(aInput);
+    if(! aExpected.equals(result)){
+      fail("Expected '" + aExpected + "' but received '" + result +"'");
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/TESTTranslateTextFlow.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,49 @@
+package hirondelle.web4j.ui.translate;
+
+import java.util.regex.*;
+
+import junit.framework.*;
+
+public final class TESTTranslateTextFlow extends TestCase {
+
+  public static void main(String args[]) {
+    String[] testCaseName = {TESTTranslateTextFlow.class.getName()};
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTTranslateTextFlow(String aName) {
+    super(aName);
+  }
+
+  // TEST CASES //
+  
+  public void testMatches(){
+    showGroups("1Yes");
+    showGroups(" 2Yes  ");
+    showGroups("3Yes ");
+    showGroups(" 4Yes");
+    showGroups("  5Yes  ");
+    showGroups("  Yes No Maybe  ");
+  }
+
+  // FIXTURE //
+
+  protected void setUp() {
+  }
+
+  protected void tearDown() {
+  }
+
+  // PRIVATE //
+  
+  private void showGroups(String aInput){
+    Matcher matcher = TextFlow.TRIMMED_TEXT.matcher(aInput);
+    while ( matcher.find() ){ 
+      for (int idx=0; idx <= matcher.groupCount(); ++idx) {
+        //System.out.println("Group " + idx + ": \"" + matcher.group(idx) + "\"");
+      }
+    }
+  }
+  
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/TESTTranslateTooltip.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,117 @@
+package hirondelle.web4j.ui.translate;
+
+import java.util.regex.*;
+import junit.framework.*;
+import hirondelle.web4j.util.Util;
+
+public final class TESTTranslateTooltip extends TestCase {
+
+  public static void main(String args[]) {
+    String[] testCaseName = { TESTTranslateTooltip.class.getName() };
+    junit.textui.TestRunner.main(testCaseName);
+  }
+
+  public TESTTranslateTooltip( String aName) {
+    super(aName);
+  }
+
+  // TEST CASES //
+  
+  public void testSuccess(){
+    testSuccessfulMatches("text flow <input title='Yes' name='bogus'> text flow" );
+    testSuccessfulMatches("text flow <input title=Yes name='bogus'> text flow" );
+    testSuccessfulMatches("text flow <input title=\"Yes\" name='bogus'> text flow" );
+    testSuccessfulMatches("text flow <input title='Yes' name='bogus'> text flow" );
+    testSuccessfulMatches("text flow <input   title='Yes' name='bogus'> text flow" );
+    testSuccessfulMatches("<input title='Yes' name='bogus'> text flow" );
+    testSuccessfulMatches("<input title='Yes' name='bogus'>" );
+    testSuccessfulMatches("text flow <img alt='Yes' name='bogus'> text flow" );
+    testSuccessfulMatches("text flow <img alt=\"Yes\" name='bogus'> text flow" );
+    testSuccessfulMatches("text flow <img alt=Yes name='bogus'> text flow" );
+    
+    //testSuccessfulMatches("text flow <img alt=Yes name='bogus'> text flow <form title='Yes'>" );
+  }
+  
+  public void testSuccessSubmitButton(){
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value='add.button'>");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=add.button>");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\" >");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\"  >");
+    testSuccessfulMatchesSubmitButton("<input type=\"SUBMIT\" value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" VALUE=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<INPUT TYPE=\"SUBMIT\" VALUE=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("text flow <input type=\"submit\" value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\"> text flow");
+    testSuccessfulMatchesSubmitButton("text flow <input type=\"submit\" value=\"add.button\"> text flow");
+    
+    testSuccessfulMatchesSubmitButton("<input  type=\"submit\" value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input   type=\"submit\" value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\"  value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\"   value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\"    >");
+    
+    testSuccessfulMatchesSubmitButton("<input type='submit' value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type=submit value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type='submit'  value=\"add.button\">");
+    testSuccessfulMatchesSubmitButton("<input type='submit'  value=\"add.button\" title='xyz'>");
+    
+ }
+  
+  public void testFailureSubmitButton(){
+    testNoMatchesSubmitButton("< input type=\"submit\" value='add.button'>");
+    testNoMatchesSubmitButton("<  input type=\"submit\" value='add.button'>");
+    testNoMatchesSubmitButton("<input value='add.button' type=\"submit\" >"); //swap order fails
+    testNoMatchesSubmitButton("<input type=\"checkbox\" value='add.button'>");
+    testNoMatchesSubmitButton("<input type=\"password\" value='add.button'>");
+    testNoMatchesSubmitButton("<select type=\"submit\" value='add.button'>");
+    testNoMatchesSubmitButton("<input type=\"submit\" >");
+    testNoMatchesSubmitButton("<input  value='add.button'>");
+  }
+  
+  // FIXTURE //
+  
+  protected void setUp(){
+  }
+
+  protected void tearDown() {
+  }
+
+  // PRIVATE  //
+  
+  /** Group 2 must be the text 'add.button'.  */
+  private void testSuccessfulMatches(String aInput){
+    Matcher matcher = Tooltips.TOOLTIP.matcher(aInput);
+    if (matcher.find()) {
+      String groupTwo = matcher.group(2);
+      //System.out.println("Group Two: " + groupTwo);
+      groupTwo = Util.removeQuotes(groupTwo);
+      assertTrue(groupTwo.equalsIgnoreCase("Yes"));
+    }
+    else {
+      //System.out.println("Not found.");
+    }
+  }
+  
+  /** Group 2 must be the text 'add.button'.  */
+  private void testSuccessfulMatchesSubmitButton(String aInput){
+    Matcher matcher = Tooltips.SUBMIT_BUTTON.matcher(aInput);
+    if (matcher.find()) {
+      String groupTwo = matcher.group(2);
+      //System.out.println("Group Two: " + groupTwo);
+      groupTwo = Util.removeQuotes(groupTwo);
+      assertTrue(groupTwo.equalsIgnoreCase("add.button"));
+    }
+    else {
+      fail("No match found for " + aInput);
+    }
+  }
+  
+  private void testNoMatchesSubmitButton(String aInput){
+    Matcher matcher = Tooltips.SUBMIT_BUTTON.matcher(aInput);
+    if (matcher.find()) {
+      fail("Did not expect to find match for : " + aInput);
+    }
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/Text.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,350 @@
+package hirondelle.web4j.ui.translate;
+
+import java.util.Locale;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+import javax.servlet.jsp.JspException;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.ui.tag.TagHelper;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Consts;
+
+/**
+ Custom tag for translating base language text (or a "coder key") into a localized form, and applying 
+ "wiki-style" formatting.
+ 
+ <P>This tag uses {@link hirondelle.web4j.ui.translate.Translator} and 
+ {@link hirondelle.web4j.request.LocaleSource} to localize text.
+ 
+ <P>There are several use cases for this tag. In general, the attributes control: 
+ <ul>
+ <li><a href="#BaseText">specifying base text</a> to be translated
+ <li><a href="#WikiStyleFormatting">formatting</a> of the translated result 
+ <li><a href="#TurningOffTranslation">turning off translation</a> altogether
+ </ul> 
+ 
+ <P><b><a name="BaseText">Specifying Base Text</a></b>
+ <P>The base text may be specified simply as the tag body, or as the <tt>value</tt> attribute.
+ <P>Example 1 : <br>
+ <PRE>
+ &lt;w:txt&gt;
+  Of prayers, I am the prayer of silence.
+  Of things that move not, I am the Himalayas.
+ &lt;/w:txt&gt;
+ </PRE>
+ 
+ Example 2 uses a "coder key" : <br>
+ <PRE>
+ &lt;w:txt value="quotation.from.bhagavad.gita" /&gt;
+ </PRE>
+ 
+ <P>This second form is intended especially for translating items appearing 
+ inside a tag.
+  
+ <P>These two use cases are mutually exclusive : either a body must be specified, or 
+ the <tt>value</tt> attribute must be specified, <em>but not both</em>.
+ 
+ <P>Here is another example, combining the two styles : 
+ <PRE>
+ &lt;span title='&lt;w:txt value="quotation.from.bhagavad.gita" /&gt;'&gt;
+  &lt;w:txt&gt;
+   Of prayers, I am the prayer of silence.
+   Of things that move not, I am the Himalayas.
+  &lt;/w:txt&gt;
+ &lt;/span&gt;
+ </PRE>
+ 
+ <P>In either case, the item to be translated may be either some text in the 
+ application's base language, or it may be a 'coder key' (see {@link Translator}).
+ 
+ <P><a name="WikiStyleFormatting"><b>Formatting of the Result</b>
+ <P>By default, this tag will escape all special characters using {@link EscapeChars#forHTML(String)}.
+ Occasionally, it is desirable to allow some limited formatting of text input by the user. Even simple  
+ effects such as bold and italic can measurably increase legibility and clarity, and allowing links 
+ is also very useful. Most wikis allow such simple formatting. This tag uses the following special 
+ characters to denote various effects :
+<ul>
+ <li> *bold* for <b>bold</b> (needs intial space before first '*')
+ <li> _italic_ for <em>italic</em> (needs initial space before first '_')
+ <li>^preserve formatting^ for preserving whitespace (&lt;PRE&gt;)
+ <li>~~~~ on an otherwise blank line for a horizontal rule
+ <li>[link:http:\\www.javapractices.com\Topic1.cjp enumerations] produces this link: <a href="http:\\www.javapractices.com\Topic1.cjp">enumerations</a> 
+ <li>one or more empty lines for a paragraph (&lt;P&gt;)
+ <li>a bar | at the end of a line for a line break (&lt;BR&gt;)
+ <li>a line starting with ' ~ ' for a bullet entry in a list
+</ul>
+
+ <P>Example 3 has wiki style formatting:
+ <PRE>
+ &lt;w:txt wikiMarkup="true"&gt;
+ Default Locale |   
+ _Not all Locales are treated equally_ : there *must* be ... 
+ &lt;/w:txt&gt;
+ </PRE>
+ 
+ Is rendered as :
+<P>Default Locale<br>
+<em>Not all Locales are treated equally</em> : there <b>must</b> be ...
+  
+ <P>To allow the above rules to be interpreted as HTML by this tag, {@link #setWikiMarkup(boolean)} to <tt>true</tt>.
+ 
+ <P><b><a name="TurningOffTranslation">Turning Off Translation</a></b>
+ <P>There are two cases in which it is useful to turn off translation altogether:
+<ul>
+ <li>using only the formatting services of this tag. For example, a message board application may want to 
+ provide basic wiki-style formatting, without any translation. 
+ <li>as a workaround for database issues regarding large text. Sometimes databases treat large 
+ text differently from small text. For example in MySQL, it is not possible for a <tt>TEXT</tt> field to be  
+ assigned a <tt>UNIQUE</tt> constraint. This split between large text and small text can be a problem, since 
+ it may mean that blocks of text are treated differently simply according to their length, and workarounds for 
+ large blocks of text are needed.   
+</ul>  
+ 
+ <P>Example 4 has wiki style formatting for untranslated user input:
+ <PRE>
+ &lt;w:txt wikiMarkup="true" translate="false"&gt;
+   ...render some user input with wiki style formatting...
+ &lt;/w:txt&gt;
+ </PRE>
+
+ <P>Example 5 has wiki style formatting for <em>large</em> untranslated text, hard-coded in the JSP.
+ An example of such text may be an extended section of "help" information :
+ <PRE>
+ &lt;w:txt locale="en"&gt;
+   ..<em>large</em> amount of hard-coded text in English...
+ &lt;/w:txt&gt;
+ 
+ &lt;w:txt locale="fr"&gt;
+   ..<em>large</em> amount of hard-coded text in French...
+ &lt;/w:txt&gt;
+ </PRE>
+ 
+ <em>The above style is outside the usual translation mechanism.</em> It does not use the configured 
+ {@link Translator}. It does not translate its content at all. Rather, it will echo the tag content only 
+ when the specified <tt>locale</tt> matches that returned by the {@link hirondelle.web4j.request.LocaleSource}.
+ <span class="highlight">It is recommended that this style be used only when the above-mentioned problem regarding 
+ database text size exists.</span> This style implicitly has <tt>translate="false"</tt>.
+*/
+public final class Text extends TagHelper {
+  
+  /**
+   Set the item to be translated (optional).
+   
+   <P><tt>aTextAsAttr</tt> takes two forms : user-visible text in the base language, 
+   or a coder key (known to the programmer, but not seen by the end user). See {@link Translator} 
+   for more information.
+   
+   <P>If this attribute is set, then the tag must <em>not</em> have a body.
+   
+   @param aTextAsAttr must have content; this value is always trimmed by this method.
+  */
+  public void setValue(String aTextAsAttr){
+    checkForContent("Value", aTextAsAttr);
+    fTextAsAttr = aTextAsAttr.trim();
+  }
+  
+  /** 
+   Toggle translation on and off (optional, default <tt>true</tt>).
+    
+   <P>By default, text will be translated. An example of setting this item to <tt>false</tt> is a 
+   discussion board, where users input in a single language, and 
+   <a href="#WikiStyleFormatting">wiki style formatting</a> is desired. 
+  
+   <P>An example of rendering such text is :
+   <PRE>
+   &lt;w:txt translate="false" wikiMarkup="true"&gt;
+     This is *bold*, and so on...
+   &lt;/w:txt&gt;
+   </PRE>
+  */
+  public void setTranslate(boolean aValue) {
+    fIsTranslating = aValue;
+  }
+  
+  /**
+   Specify an explicit {@link Locale} (optional).
+   
+   <P><span class='highlight'>When this attribute is specified, this tag will never translate its content.</span> 
+   Instead, this tag will simply <em>emit or suppress</em> its content, according to whether <tt>aLocale</tt> matches 
+   that returned by {@link hirondelle.web4j.request.LocaleSource}. 
+  */
+  public void setLocale(String aLocale){
+    fSpecificLocale = Util.buildLocale(aLocale);
+    fIsTranslating = false;
+  }
+
+  /** 
+   Allow <a href="#WikiStyleFormatting">wiki style formatting</a> to be used (optional, default <tt>false</tt>).
+  */
+  public void setWikiMarkup(boolean aValue){
+    fHasWikiMarkup = aValue;
+  }
+  
+  /** Validate attributes against each other.  */
+  protected void crossCheckAttributes() {
+    if( fSpecificLocale != null && fIsTranslating ){
+      String message = "Cannot translate and specify a Locale at the same time. Page : " + getPageName();
+      fLogger.severe(message);
+      throw new IllegalArgumentException(message);
+    }
+  }
+
+  /** See class comment. */
+  @Override protected String getEmittedText(String aOriginalBody) throws JspException {
+    if ( Util.textHasContent(fTextAsAttr) && Util.textHasContent(aOriginalBody) ){
+      throw new JspException(
+        "Please specify text (or key) to be translated as either value attribute or tag body, but not both." +
+        " Value attribute: " +Util.quote(fTextAsAttr) + ". Tag body: " + Util.quote(aOriginalBody) + 
+        " Page Name :" + getPageName() 
+      );
+    }
+    
+    String baseText = Util.textHasContent(fTextAsAttr) ? fTextAsAttr : aOriginalBody;
+    String result = Consts.EMPTY_STRING;
+    if(Util.textHasContent(baseText)){
+      Locale locale = BuildImpl.forLocaleSource().get(getRequest());
+      if(fIsTranslating){
+        Translator translator = BuildImpl.forTranslator();
+        result = translator.get(baseText, locale);
+        fLogger.finest("Translating base text : " + Util.quote(baseText) + " using Locale " + locale  + " into " + Util.quote(result));
+      }
+      else {
+        fLogger.finest("LocaleSource: " + locale + ", locale attribute: " + fSpecificLocale);
+        if (fSpecificLocale == null || fSpecificLocale.equals(locale)){
+          fLogger.finest("Echoing tag content (possibly adding formatting).");
+          result = baseText;
+        }
+        else {
+          fLogger.finest("Suppressing tag content.");
+          result = Consts.EMPTY_STRING;
+        }
+      }
+      result = processMarkup(result);
+    }
+    return result;
+  }
+
+  /**
+   Translate the text into a result. Escapes characters, then optionally changes wiki markup into hypertext.  
+   Made package-private for testing purposes. 
+  */
+  String processMarkup(String aText) {
+    String result = aText;
+    if(Util.textHasContent(result)){
+      result = EscapeChars.forHTML(result);
+      if( fHasWikiMarkup ){
+        result = changePseudoMarkupToHTML(result);
+      }
+    }
+    return result;
+  }
+  
+  // PRIVATE 
+  private String fTextAsAttr;
+  private boolean fIsTranslating = true;
+  private Locale fSpecificLocale;
+  private boolean fHasWikiMarkup = false;
+  private static final Logger fLogger = Util.getLogger(Text.class);
+  
+  /* 
+   The regexes don't refer to the original '*' and so on. Rather, they refer to the 
+   escaped versions thereof - their 'fingerprints', so to speak.
+   
+   Implementation Note:
+   Seeing undesired wiki-formatting of _blah_ text in links. To fix, introduced second, small variation 
+   for *blah* and _blah_, which matches to the beginning of the input.
+   
+   This is a rather hacky, and a more robust implmentation would use javacc.
+  */
+  
+  private static final Pattern PSEUDO_LINK = Pattern.compile("&#091;link&#058;((\\S)*) ((.)+?)\\&#093;");
+  private static final Pattern PSEUDO_BOLD = Pattern.compile("(?: &#042;)((?:.)+?)(?:&#042;)"); 
+  private static final Pattern PSEUDO_BOLD_START_OF_INPUT = Pattern.compile("(?:^&#042;)((?:.)+?)(?:&#042;)"); 
+  private static final Pattern PSEUDO_ITALIC = Pattern.compile("(?: &#095;)((?:.)+?)(?:&#095;)");
+  private static final Pattern PSEUDO_ITALIC_START_OF_INPUT = Pattern.compile("(?:^&#095;)((?:.)+?)(?:&#095;)");
+  private static final Pattern PSEUDO_CODE = Pattern.compile("(?:\\&#094;)((?:.)+?)(?:\\&#094;)", Pattern.MULTILINE | Pattern.DOTALL);
+  private static final Pattern PSEUDO_HR = Pattern.compile("^(?:\\s)*&#126;&#126;&#126;&#126;(?:\\s)*$", Pattern.MULTILINE);
+  private static final Pattern PSEUDO_PARAGRAPH = Pattern.compile("(^(?:\\s)*$)", Pattern.MULTILINE);
+  private static final Pattern PSEUDO_LINE_BREAK = Pattern.compile("\\&#124;(?: )*$", Pattern.MULTILINE);
+  private static final Pattern PSEUDO_LIST = Pattern.compile("^(?: )*(\\&#126;)(?: )", Pattern.MULTILINE);
+
+  private String changePseudoMarkupToHTML(String aText){
+    String result = null;
+    result = addLink(aText);
+    result = addBold(result);
+    result = addBold2(result);
+    result = addItalic(result);
+    result = addItalic2(result);
+    result = addLineBreak(result);
+    result = addParagraph(result);
+    result = addList(result);
+    result = addCode(result);
+    result = addHorizontalRule(result);
+    return result;
+  }
+  
+  private String addBold(String aText){
+    Matcher matcher = PSEUDO_BOLD.matcher(aText);
+    return matcher.replaceAll(" <b>$1</b>"); //extra space
+  }
+
+  private String addBold2(String aText){
+    Matcher matcher = PSEUDO_BOLD_START_OF_INPUT.matcher(aText);
+    return matcher.replaceAll("<b>$1</b>"); //extra space
+  }
+  
+  private String addItalic(String aText){
+    Matcher matcher = PSEUDO_ITALIC.matcher(aText);
+    return matcher.replaceAll(" <em>$1</em>"); //extra space
+  }
+
+  private String addItalic2(String aText){
+    Matcher matcher = PSEUDO_ITALIC_START_OF_INPUT.matcher(aText);
+    return matcher.replaceAll("<em>$1</em>"); //extra space
+  }
+  
+  private String addCode(String aText){
+    Matcher matcher = PSEUDO_CODE.matcher(aText);
+    return matcher.replaceAll("<PRE>$1</PRE>");
+  }
+  
+  private String addHorizontalRule(String aText){
+    Matcher matcher = PSEUDO_HR.matcher(aText);
+    return matcher.replaceAll("<hr>");
+  }
+
+  private String addLink(String aText){
+    Matcher matcher = PSEUDO_LINK.matcher(aText);
+    return matcher.replaceAll("<a href='$1'>$3</a>");
+  }
+
+  private String addParagraph(String aText){
+    Matcher matcher = PSEUDO_PARAGRAPH.matcher(aText);
+    return removeInitialAndFinalParagraphs(matcher.replaceAll("<P>"));
+  }
+
+  /** Bit hacky - cannot find correct regex to take care of this cleanly. */
+  private String removeInitialAndFinalParagraphs(String aText){
+    String result = aText.trim();
+    if (aText.startsWith("<P>")){
+      result = result.substring(3);
+    }
+    if (aText.endsWith("<P>")){
+      result = result.substring(0, result.length()-3);
+    }
+    return result;
+  }
+  
+  private String addLineBreak(String aText){
+    Matcher matcher = PSEUDO_LINE_BREAK.matcher(aText);
+    return matcher.replaceAll("<BR>");
+  }
+  
+  private String addList(String aText){
+    Matcher matcher = PSEUDO_LIST.matcher(aText);
+    return matcher.replaceAll("<br>&nbsp;&nbsp;&#8226; "); //another version of bull has a non-standard number
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/TextFlow.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,232 @@
+package hirondelle.web4j.ui.translate;
+
+import java.text.CharacterIterator;
+import java.text.StringCharacterIterator;
+import java.util.Locale;
+import java.util.regex.*;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.request.LocaleSource;
+import hirondelle.web4j.ui.tag.TagHelper;
+import hirondelle.web4j.util.EscapeChars;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Regex;
+
+/**
+ Custom tag for translating regular text flow in large sections of a web page. 
+ 
+ <P><span class="highlight">This tag treats every piece of free flow text delimited by a  
+ tag as a unit of translatable base text</span>, and passes it to {@link Translator}. 
+ That is, all tags are treated as <em>delimiters</em> of units of translatable text.
+  
+ <P>This tag is suitable for translating most, but not all, of the 
+ regular text flow in a web page. <span class="highlight">It is suitable for translating 
+ markup that contains short, isolated snippets of text, that have no "structure", and 
+ no dynamic data</span>, such as the labels in a form, the column headers in a listing, 
+ and so on. (For many intranet applications, this 
+ makes up most of the free flow text appearing in the application.) Instead of using many 
+ separate <tt>&lt;w:txt&gt;</tt> {@link Text} tags to translate each item one by one,
+ a single <tt>&lt;w:txtFlow&gt;</tt> tag can often be used to do the same thing in a single step.
+ 
+ <P><span class="highlight">Using this class has two strong advantages</span> : 
+<ul>
+ <li>the effort needed to internationalize a page is greatly reduced
+ <li>the markup will be significantly easier to read and maintain, since most of the free flow text 
+ remains unchanged from the single-language case
+</ul>
+ 
+ <P>
+ This tag is <em>not suitable</em> when the base text to be translated : 
+<ul>
+ <li>contains markup
+ <li>has dynamic data of any sort
+ <li>contains a <tt>TEXTAREA</tt> with a <em>non-empty</em> body. Such text will be seen as a translatable 
+ unit, which is usually undesired, since such text is usually not fixed, but dynamic (that is, from the database). 
+ (To avoid this, simply nest this tag <em>inside</em> the <tt>&lt;w:populate&gt;</tt> tag surrounding the 
+ form that contains the <tt>TEXTAREA</tt>. This ensures that the population is not affected by the action of this 
+ tag.)
+</ul>
+  
+ <P>For example, given this text containing markup :
+ <PRE>The &lt;EM&gt;raison-d'etre&lt;/EM&gt; for this...</PRE>
+ then this tag will split the text into three separate pieces, delimited by the <tt>EM</tt> tags.
+ Then, each piece will be translated. For such items, this is almost always undesirable. Instead, 
+ one must use a <tt>&lt;w:txt&gt;</tt> {@link Text} tag, which can treat such items as 
+ a single unit of translatable text, without chopping it up into three pieces. 
+ 
+ <P><b>Example</b><br>
+ Here, all of the <tt>LABEL</tt> tags in this form will have their content translated by the 
+ <tt>&lt;w:txtFlow&gt;</tt> tag :
+ <PRE>
+&lt;w:populate style='edit' using="myUser"&gt;
+&lt;w:txtFlow&gt;
+&lt;form action='blah.do' method='post' class="user-input"&gt;
+&lt;table align="center"&gt;
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;label class="mandatory"&gt;Email&lt;/label&gt;
+ &lt;/td&gt;
+ &lt;td&gt;
+  &lt;input type="text" name="Email Address" size='30'&gt;
+ &lt;/td&gt; 
+&lt;/tr&gt;
+
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;label&gt;Age&lt;/label&gt;
+ &lt;/td&gt;
+ &lt;td&gt;
+  &lt;input type="text" name="Age" size="30"&gt;
+ &lt;/td&gt; 
+&lt;/tr&gt;
+
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;label&gt;Desired Salary&lt;/label&gt;
+ &lt;/td&gt;
+ &lt;td&gt;
+  &lt;input type="text" name="Desired Salary" size="30"&gt;
+ &lt;/td&gt; 
+&lt;/tr&gt;
+
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;label&gt; Birth Date &lt;/label&gt;
+ &lt;/td&gt;
+ &lt;td&gt;
+  &lt;input type="text" name="Birth Date" size="30"&gt;
+ &lt;/td&gt; 
+&lt;/tr&gt;
+
+&lt;tr&gt;
+ &lt;td&gt;
+  &lt;input type='submit' value='UPDATE'&gt; 
+ &lt;/td&gt;
+&lt;/tr&gt;
+&lt;/table&gt;
+&lt;/form&gt;
+&lt;/w:txtFlow&gt;
+&lt;/w:populate&gt;
+</PRE>
+*/
+public final class TextFlow extends TagHelper {
+
+  /**
+   By default, this tag will escape any special characters appearing in the 
+   text flow, using {@link EscapeChars#forHTML(String)}. To change that default  
+   behaviour, set this value to <tt>false</tt>.
+   
+   <P><span class="highlight">Exercise care that text is not doubly escaped.</span> 
+   For instance, if the text already contains 
+   character entities, and <tt>setEscapeChars</tt> is true, then the text <tt>&amp;amp;</tt>
+   will be emitted by this tag as <tt>&amp;amp;amp;</tt>, for example.
+  */
+  public void setEscapeChars(boolean aValue){
+    fEscapeChars = aValue;
+  }
+  
+  /**
+   Translate each piece of free flow text appearing in <tt>aOriginalBody</tt>.
+   
+   <P>Each piece of text is delimited by one or more tags, and is translated using the configured 
+   {@link Translator}. Leading or trailing white space is preserved.
+  */
+  @Override protected String getEmittedText(String aOriginalBody) {
+    final StringBuffer result = new StringBuffer();
+    final StringBuffer snippet = new StringBuffer();
+    boolean isInsideTag = false;
+    
+    final StringCharacterIterator iterator = new StringCharacterIterator(aOriginalBody);
+    char character =  iterator.current();
+    while (character != CharacterIterator.DONE ){
+      if (character == '<') {
+        doStartTag(result, snippet, character);
+        isInsideTag = true;
+      }
+      else if (character == '>') {
+        doEndTag(result, character);
+        isInsideTag = false;
+      }
+      else {
+        doRegularCharacter(result, snippet, isInsideTag, character);
+      }
+      character = iterator.next();
+    }
+    if( Util.textHasContent(snippet.toString()) ) {
+      appendTranslation(snippet, result);
+    }
+    return result.toString();
+  }
+  
+  // PRIVATE //
+  static Pattern TRIMMED_TEXT = Pattern.compile("((?:\\S(?:.)*\\S)|(?:\\S))");
+  
+  private boolean fEscapeChars = true;
+  private LocaleSource fLocaleSource = BuildImpl.forLocaleSource();
+  private Translator fTranslator = BuildImpl.forTranslator();
+  
+  private void doStartTag(StringBuffer aResult, StringBuffer aSnippet, char aCharacter) {
+    if (Util.textHasContent(aSnippet.toString()) ){ 
+      appendTranslation(aSnippet, aResult);
+    }
+    else {
+      //often contains just spaces and/or new lines, which are just appended
+      aResult.append(aSnippet.toString());
+    }
+    aSnippet.setLength(0);
+    aResult.append(aCharacter);
+  }
+  
+  private void doEndTag(StringBuffer aResult, char aCharacter) {
+    aResult.append(aCharacter);
+  }
+  
+  private void doRegularCharacter(StringBuffer aResult, StringBuffer aSnippet, boolean aIsInsideTag, char aCharacter) {
+    if( aIsInsideTag ){
+      aResult.append(aCharacter);
+    }
+    else {
+      aSnippet.append(aCharacter);
+    }
+    //fLogger.fine("Snippet : " + aSnippet);
+  }
+
+  /**
+   The snippet may contain leading or trailing white space, or control chars (new lines), 
+   which must be preserved. 
+  */
+  private void appendTranslation(StringBuffer aSnippet, StringBuffer aResult){
+    if( Util.textHasContent(aSnippet.toString()) ) {
+      StringBuffer translatedSnippet = new StringBuffer();
+      
+      Matcher matcher = TRIMMED_TEXT.matcher(aSnippet.toString());
+      while ( matcher.find() ) {
+        matcher.appendReplacement(translatedSnippet, getReplacement(matcher));
+      }
+      matcher.appendTail(translatedSnippet);
+      
+      if( fEscapeChars ) {
+        aResult.append(EscapeChars.forHTML(translatedSnippet.toString()));
+      }
+      else {
+        aResult.append(translatedSnippet);
+      }
+    }
+    else {
+      aResult.append(aSnippet.toString());
+    }
+  }
+  
+  private String getReplacement(Matcher aMatcher){
+    String result = null;
+    String baseText = aMatcher.group(Regex.FIRST_GROUP);
+    if (Util.textHasContent(baseText)){
+      Locale locale = fLocaleSource.get(getRequest());
+      result = fTranslator.get(baseText, locale);
+    }
+    else {
+      result = baseText;
+    }
+    return EscapeChars.forReplacementString(result);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/Tooltips.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,140 @@
+package hirondelle.web4j.ui.translate;
+
+import java.util.Locale;
+import java.util.logging.Logger;
+import java.util.regex.*;
+
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.request.LocaleSource;
+import hirondelle.web4j.ui.tag.TagHelper;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.Regex;
+import hirondelle.web4j.util.EscapeChars;
+
+/**
+ Custom tag for translating 
+ <a href="http://www.w3.org/TR/html4/struct/global.html#adef-title"><tt>TITLE</tt></a>,  
+ <a href="http://www.w3.org/TR/html4/struct/objects.html#adef-alt"><tt>ALT</tt></a> 
+ and submit-button 
+ <a href="http://www.w3.org/TR/html4/interact/forms.html#adef-value-INPUT"><tt>VALUE</tt></a>
+ attributes in markup.
+ 
+ <P><span class="highlight">By using this custom tag <em>once</em> in a template JSP, 
+ it is often possible to translate <em>all</em> of the <tt>TITLE</tt>, 
+ <tt>ALT</tt>, and submit-button <tt>VALUE</tt> attributes appearing in an entire application.</span>
+ 
+ <P>The <tt>VALUE</tt> attribute is translated only for <tt>SUBMIT</tt> controls. (This 
+ <tt>VALUE</tt> attribute isn't really a tooltip, of course : it is rendered as the button text. 
+ For this custom tag, the distinction is not very important.)
+ 
+ <P>The <tt>TITLE</tt> attribute applies to a large number of HTML tags, and
+ the <tt>ALT</tt> attribute applies to several tags. In both cases, these 
+ items generate pop-up "tool tips", which are visible to the end user. If the application is 
+ multilingual, then they require translation.
+ 
+ <P>This custom tag accepts HTML markup for its body, and will do a search and 
+ replace on its content, replacing the values of all <tt>TITLE</tt>, <tt>ALT</tt> and 
+ submit-button <tt>VALUE</tt> attributes (that have visible content) with translated values. 
+ The translations are provided by the configured implementations of 
+ {@link Translator} and {@link LocaleSource}.
+*/
+public final class Tooltips extends TagHelper {
+  
+  /**
+   Control the escaping of special characters.
+    
+   <P>By default, this tag will escape any special characters appearing in the 
+   <tt>TITLE</tt> or <tt>ALT</tt> attribute, using {@link EscapeChars#forHTML(String)}. 
+   To override this default behaviour, set this value to <tt>false</tt>. 
+  */
+  public void setEscapeChars(boolean aValue){
+    fEscapeChars = aValue;
+  }
+  
+  /**
+   Scan the body of this tag, and translate the values of all <tt>TITLE</tt>, <tt>ALT</tt>, 
+   and submit-button <tt>VALUE</tt> attributes.
+   
+  <P>Uses the configured {@link Translator} and {@link LocaleSource}.
+   
+   <P>In addition, this method uses {@link EscapeChars#forHTML(String)} to ensure that 
+   any special characters are escaped. This behavior can be overridden using {@link #setEscapeChars(boolean)}. 
+  */
+  @Override protected String getEmittedText(String aOriginalBody) {
+    //fLogger.finest("Original Body: " + aOriginalBody);
+    String result = translateTooltips(aOriginalBody);
+    result = translateSubmitButtons(result);
+    //fLogger.finest("TranslateTooltips translated : " + result.toString());
+    return result;
+  }
+
+  /**
+   Pattern which returns the value of <tt>TITLE</tt> and <tt>ALT</tt> attributes (including any quotes), 
+   as group 2, which is to be translated. 
+  */
+  static final Pattern TOOLTIP = Pattern.compile(
+    "(<[^>]* (?:title=|alt=))" + Regex.ATTR_VALUE + "([^>]*>)",  
+    Pattern.CASE_INSENSITIVE
+  );
+  
+  /**
+   Pattern which returns the value of <tt>VALUE</tt> attribute (including any quotes) of a <tt>SUBMIT</tt>
+   control, as group 2, which is to be translated.
+   
+   <P>Small nuisance restriction : the general order of items must follow this style, where <tt>type</tt>
+   and <tt>value</tt> precede all other attributes, and <tt>type</tt> precedes <tt>value</tt>:
+   <PRE>
+    &lt;input type="submit" value="Add" [any other attributes go here]&gt;
+   </PRE>
+  */
+  static final Pattern SUBMIT_BUTTON = Pattern.compile(
+    "(<input(?:\\s)* (?:type=\"submit\"|type='submit'|type=submit)(?:\\s)* value=)" + Regex.ATTR_VALUE + "([^>]*>)", 
+    Pattern.CASE_INSENSITIVE
+  );
+  
+  // PRIVATE //
+  
+  private static final Logger fLogger = Util.getLogger(Tooltips.class);
+  private boolean fEscapeChars = true;
+  private LocaleSource fLocaleSource = BuildImpl.forLocaleSource();
+  private Translator fTranslator = BuildImpl.forTranslator();
+  
+  private String translateTooltips(String aInput){
+    return scanAndReplace(TOOLTIP, aInput);
+  }
+  
+  private String translateSubmitButtons(String aInput){
+    return scanAndReplace(SUBMIT_BUTTON, aInput);
+  }
+  
+  private String scanAndReplace(Pattern aPattern, String aInput){
+    StringBuffer result = new StringBuffer();
+    Matcher matcher = aPattern.matcher(aInput);
+    while ( matcher.find() ) {
+      matcher.appendReplacement(result, getReplacement(matcher));
+    }
+    matcher.appendTail(result);
+    return result.toString();
+  }
+  
+  private String getReplacement(Matcher aMatcher){
+    String result = null;
+    String baseText = Util.removeQuotes(aMatcher.group(Regex.SECOND_GROUP));
+    if (Util.textHasContent(baseText)){
+      String start = aMatcher.group(Regex.FIRST_GROUP);
+      String end = aMatcher.group(Regex.THIRD_GROUP);
+      Locale locale = fLocaleSource.get(getRequest());
+      String translatedText = fTranslator.get(baseText, locale);
+      if ( fEscapeChars ){
+        translatedText = EscapeChars.forHTML(translatedText);
+      }
+      result = start + Util.quote(translatedText) + end;
+    }
+    else {
+      result = aMatcher.group(Regex.ENTIRE_MATCH);
+    }
+    result = EscapeChars.forReplacementString(result);
+    fLogger.finest("TITLE/ALT base Text: " + Util.quote(baseText) + " has replacement text : " + Util.quote(result));
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/Translation.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,294 @@
+package hirondelle.web4j.ui.translate;
+
+import java.util.*;
+import hirondelle.web4j.model.ModelCtorException;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.model.Check;
+import hirondelle.web4j.security.SafeText;
+
+/**
+ Model Object for a translation.
+ 
+ <P>This class is provided as a convenience. Implementations of {@link Translator} are not required to 
+ use this class.
+ 
+ <P>As one of its {@link hirondelle.web4j.StartupTasks}, a typical implementation of 
+ {@link Translator} may fetch a {@code List<Translation>} from some source 
+ (usually a database, perhaps some properties files), and keep a cache in memory.
+ 
+ <P><a name="MapStructure"></a>
+ For looking up translations, the following nested {@link Map} structure is useful : 
+ <PRE>
+   Map[BaseText, Map[Locale, Translation]]
+ </PRE>
+ Here, <tt>BaseText</tt> and <tt>Translation</tt> are ordinary <em>unescaped</em> 
+ Strings, not {@link SafeText}. This is because the various translation tags in this 
+ package always <em>first</em> perform translation using ordinary unescaped Strings, and 
+ <em>then</em> perform any necessary escaping on the result of the translation.
+ 
+ <P>(See {@link Translator} for definition of 'base text'.)
+ 
+ <P>The {@link #asNestedMap(Collection)} method will modify a {@code List<Translation>} into just such a 
+ structure. As well, {@link #lookUp(String, Locale, Map)} provides a simple <em>default</em> method for 
+ performing the typical lookup with such a structure, given base text and target locale.
+
+ <h3>Usually String, but sometimes SafeText</h3>
+ The following style will remain consistent, and will not escape special characters twice :
+<ul>
+ <li>unescaped : translations stored in the database. 
+ <li>escaped : Translation objects (since they use {@link SafeText}). This allows end users 
+ to edit such objects just like any other data, with no danger of scripts executing in their browser.  
+ <li>unescaped : in-memory data, extracted from N <tt>Translation</tt> objects 
+ using {@link SafeText#getRawString()}. This in-memory data implements a  
+ <tt>Translator</tt>. Its data is not rendered <em>directly</em>
+ in a JSP, so it can remain as String.
+ <li>escaped : the various translation tags always perform the needed escaping on the raw String. 
+</ul> 
+
+ The translation text usually remains as a String, yet {@link SafeText} is available  
+ when working with the data directly in a web page, in a form or listing.
+*/
+public final class Translation implements Comparable<Translation> {
+  
+  /**
+   Constructor with no explicit foreign keys.
+   
+   @param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'. 
+   @param aLocale target locale for the translation (required) 
+   @param aTranslation translation of the base text into the target locale (required)
+  */
+  public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation) throws ModelCtorException {
+    fBaseText = aBaseText;
+    fLocale = aLocale;
+    fTranslation = aTranslation;
+    validateState();
+  }
+
+  /**
+   Constructor with explict foreign keys.
+   
+   <P>This constructor allows carrying the foreign keys directly, instead of performing lookup later on. 
+   (If the database does not support subselects, then use of this constructor will likely reduce 
+   trivial lookup operations.)
+    
+   @param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'. 
+   @param aLocale target locale for the translation (required) 
+   @param aTranslation translation of the base text into the target locale (required)
+   @param aBaseTextId foreign key representing a <tt>BaseText</tt> item, <tt>1..50</tt> characters (optional)
+   @param aLocaleId foreign key representing a <tt>Locale</tt>, <tt>1..50</tt> characters (optional)
+  */
+  public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation, Id aBaseTextId, Id aLocaleId) throws ModelCtorException {
+    fBaseText = aBaseText;
+    fLocale = aLocale;
+    fTranslation = aTranslation;
+    fBaseTextId = aBaseTextId;
+    fLocaleId = aLocaleId;
+    validateState();
+  }
+  
+  /** Return the base text passed to the constructor.  */
+  public SafeText getBaseText() {
+    return fBaseText;
+  }
+
+  /** Return the locale passed to the constructor.  */
+  public Locale getLocale() {
+    return fLocale;
+  }
+
+  /** Return the localized translation passed to the constructor.  */
+  public SafeText getTranslation() {
+    return fTranslation;
+  }
+  
+  /** Return the base text id passed to the constructor.  */
+  public Id getBaseTextId(){
+    return fBaseTextId;
+  }
+  
+  /** Return the locale id passed to the constructor.  */
+  public Id getLocaleId(){
+    return fLocaleId;
+  }
+  
+  /**
+   Return a {@link Map} having a <a href="#MapStructure">structure</a> 
+   typically needed for looking up translations.
+   
+   <P>The caller will use the returned {@link Map} to look up first using <tt>BaseText</tt>, 
+   and then using <tt>Locale</tt>. See {@link #lookUp(String, Locale, Map)}.
+   
+   @param aTranslations {@link Collection} of {@link Translation} objects.
+   @return {@link Map} of a <a href="#MapStructure">structure suitable for looking up translations</a>.
+  */
+  public static Map<String, Map<String, String>> asNestedMap(Collection<Translation> aTranslations){
+     Map<String, Map<String, String>> result = new LinkedHashMap<String, Map<String, String>>();
+     String currentBaseText = null;
+     Map<String, String> currentTranslations = null;
+     for (Translation trans: aTranslations){
+       if ( trans.getBaseText().getRawString().equals(currentBaseText) ){
+         currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString());
+       }
+       else {
+         //finish old
+         if (currentBaseText != null) {
+           result.put(currentBaseText, currentTranslations);
+         }
+         //start new
+         currentBaseText = trans.getBaseText().getRawString();
+         currentTranslations = new LinkedHashMap<String, String>();
+         currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString());
+       }
+     }
+     //ensure last one is added
+     if(currentBaseText != null && currentTranslations != null){
+       result.put(currentBaseText, currentTranslations);
+     }
+     return result;
+  }
+  
+  /**
+   Look up a translation using a simple policy.
+   
+   <P>If <tt>aBaseText</tt> is not known, or if there is no <em>explicit</em> translation for 
+   the exact {@link Locale}, then return <tt>aBaseText</tt> as is, without translation or 
+   alteration.
+   
+   <P>The policy used here is simple. It may not be desirable for some applications.
+   In particular, if there is a need to implement a "best match" to <tt>aLocale</tt> 
+   (after the style of {@link ResourceBundle}), then this method cannot be used. 
+    
+   @param aBaseText text to be translated. See {@link Translator} for a definition of 'base text'.
+   @param aLocale whose <tt>toString</tt> result will be used to find the localized 
+   translation of <tt>aBaseText</tt>.
+   @param aTranslations has the <a href="#MapStructure">structure suitable for look up</a>.
+   @return {@link LookupResult} carrying the text of the successful translation, or, in the case of a failed lookup, information
+   about the nature of the failure.
+  */
+  public static LookupResult lookUp(String aBaseText, Locale aLocale, Map<String, Map<String, String>> aTranslations) {
+    LookupResult result = null;
+    Map<String, String> allTranslations = aTranslations.get(aBaseText);
+    if ( allTranslations == null ) {
+      result = LookupResult.UNKNOWN_BASE_TEXT;
+    }
+    else {
+      String translation = allTranslations.get(aLocale.toString());
+      result = (translation != null) ? new LookupResult(translation): LookupResult.UNKNOWN_LOCALE; 
+    }
+    return result;
+  }
+  
+  /** 
+   The result of {@link Translation#lookUp(String, Locale, Map)}.
+   <P>Encapsulates both the species of success/fail and the actual 
+   text of the translation, if any.
+   
+  <P>Example of a typical use case :
+  <PRE>
+    String text = null;
+    LookupResult lookup = Translation.lookUp(aBaseText, aLocale, fTranslations);
+    if( lookup.hasSucceeded() ){ 
+      text = lookup.getText();
+    }
+    else {
+      text = aBaseText;
+      if(LookupResult.UNKNOWN_BASE_TEXT == lookup){
+        addToListOfUnknowns(aBaseText);
+      }
+      else if (LookupResult.UNKNOWN_LOCALE == lookup){
+        //do nothing in this implementation
+      }
+    }
+  </PRE>
+  */
+  public static final class LookupResult {
+    /** <tt>BaseText</tt> is unknown. */
+    public static final LookupResult UNKNOWN_BASE_TEXT = new LookupResult(); 
+    /** <tt>BaseText</tt> is known, but no translation exists for the specified <tt>Locale</tt>*/
+    public static final LookupResult UNKNOWN_LOCALE = new LookupResult();
+    /** Returns <tt>true</tt> only if a specific translation exists for <tt>BaseText</tt> and <tt>Locale</tt>.  */
+    public boolean hasSucceeded(){ return fTranslationText != null; }
+    /**
+     Return the text of the successful translation.  
+     Returns <tt>null</tt> only if {@link #hasSucceeded()} is <tt>false</tt>. 
+    */
+    public String getText(){ return fTranslationText; }
+    LookupResult(String aTranslation){ 
+      fTranslationText = aTranslation; 
+    }
+    private final String fTranslationText;
+    private LookupResult(){  
+      fTranslationText = null;
+    }
+  }
+
+  /** Intended for debugging only. */
+  @Override public String toString(){
+    return ModelUtil.toStringFor(this);
+  }
+
+  public int compareTo(Translation aThat) {
+    final int EQUAL = 0;
+    if ( this == aThat ) return EQUAL;
+    
+    int comparison = this.fBaseText.compareTo(aThat.fBaseText);
+    if ( comparison != EQUAL ) return comparison;
+    
+    comparison = this.fLocale.toString().compareTo(aThat.fLocale.toString());
+    if ( comparison != EQUAL ) return comparison;    
+    
+    comparison = this.fTranslation.compareTo(aThat.fTranslation);
+    if ( comparison != EQUAL ) return comparison;    
+    
+    return EQUAL;
+  }
+  
+  @Override public boolean equals(Object aThat){
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ) {
+      Translation that = (Translation)aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;
+  }
+  
+  @Override public int hashCode() {
+    if ( fHashCode == 0 ){
+      fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
+    }
+    return fHashCode;
+  }
+  
+  // PRIVATE //
+  private final SafeText fBaseText;
+  private final Locale fLocale;
+  private final SafeText fTranslation;
+  private Id fLocaleId; 
+  private Id fBaseTextId;
+  private int fHashCode;
+  
+  private void validateState() throws ModelCtorException {
+    ModelCtorException ex = new ModelCtorException();
+    if( ! Check.required(fBaseText) ) {
+      ex.add("Base Text must have content.");      
+    }
+    if( ! Check.required(fLocale) ) {
+      ex.add("Locale must have content.");      
+    }
+    if( ! Check.required(fTranslation) ) {
+      ex.add("Translation must have content.");
+    }
+    if( ! Check.optional(fLocaleId, Check.min(1), Check.max(50)) ){
+      ex.add("LocaleId optional, 1..50 characters.");
+    }
+    if( ! Check.optional(fBaseTextId, Check.min(1), Check.max(50)) ){
+      ex.add("BaseTextId optional, 1..50 characters.");
+    }
+    if( ex.isNotEmpty() )  throw ex;
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[] {fBaseText, fLocale, fTranslation};
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/Translator.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,127 @@
+package hirondelle.web4j.ui.translate;
+
+import hirondelle.web4j.request.LocaleSource;
+
+import java.util.Locale;
+
+/**
+ Translate base text into a localized form. 
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forTranslator()} returns the configured implementation of this interface.
+ 
+ <P>Here, "<b>base text</b>" refers to either :
+ <ul>
+ <li>a snippet of user-presentable text in the language being used to build the application
+ <li>a "coder key", such as <tt>"image.title"</tt> or <tt>"button.label"</tt>. 
+ Coder keys are never presented to the end user, and serve as an alias or shorthand 
+ for something else. They are intended for the exclusive use of the programmer, and may 
+ be thought of as being in a special "language" understood only by the programmer.
+ </ul>
+
+ <P><span class="highlight">In code, it is likely best to use user-presentable text 
+ instead of coder keys, whenever possible - since lookups are never needed, it makes 
+ code clearer at the point of call.</span>
+ 
+ <P>Please see the package <a href="package-summary.html">overview</a> for an interesting way of 
+ evolving the implementation of this interface during development, allowing applications to assist in their 
+ own translation.
+
+ <P>One might validly object to a framework which requires an implementation of this 
+ interface even for single-language applications. However, arguments in favor of 
+ such a style include : 
+<ul>
+ <li>providing a do-nothing implementation for a single-language application is trivial.
+ <li>changing from a single language to multiple languages is a common requirement for 
+ web applications. The presence of this implementation makes it clear to the maintainer 
+ where such changes are made, if they become necessary. 
+ <li>some programmers might prefer to name items as <tt>'emailAddr'</tt> instead of 
+ <tt>'Email Address'</tt>, for instance. In this case, a {@link Translator} can also  
+ be used, even in a single-language application, to translate from such 'coder keys' 
+ to user-presentable text. 
+</ul> 
+ 
+ <P>The recommended style is to implement this interface with a database, and to 
+ <a href="http://www.javapractices.com/Topic208.cjp">avoid <tt>ResourceBundle</tt></a>.
+  
+ <P><b>Guidance For Implementing With A Database</b><br>
+ Example of a web application with the following particular requirements 
+ (see the example application for further illustration): 
+ <ul>
+ <li>English is the base development language, used by the programmers and development team
+ <li>the user interface needs to be in both English and French
+ <li>in source code, the programmers use both regular text snippets in  
+ user-presentable English, and (occasionally) 'coder keys', according to the needs of each particular case 
+ </ul> 
+ 
+ Here is a style of <tt>ResultSet</tt> that can be used to implement this interface :
+ <P><table border=1 cellspacing=0 cellpadding=3>
+  <tr>
+   <th>BaseText</th><th>Locale</th><th>Translation</th> 
+  </tr>
+  <tr><td>Fish And Chips</td><td>en</td><td>Fish And Chips</td></tr>
+  <tr><td>Fish And Chips</td><td>fr</td><td>Poisson et Frites</td></tr>
+  <tr><td>delete</td><td>en</td><td>delete</td></tr>
+  <tr><td>delete</td><td>fr</td><td>supprimer</td></tr>
+  <tr><td>add.edit.button</td><td>en</td><td>Add/Edit</td></tr>
+  <tr><td>add.edit.button</td><td>fr</td><td>Ajouter/Changer</td></tr>
+ </table> 
+ 
+ <P>Only the last two rows use a "coder key".
+ The <tt>BaseText</tt> column holds either the coder key, or the user-presentable English. 
+ The <tt>BaseText</tt> is the lookup key. The fact that there is 
+ repetition of data between the <tt>BaseText</tt> and <tt>English</tt> columns is not a real duplication problem, 
+ since <em>this is a <tt>ResultSet</tt>, not a table</em> - the underlying tables will not have such 
+ repetition (if designed properly, of course). 
+  
+ <P>For example, such a <tt>ResultSet</tt> can be constructed from three underlying tables - 
+ <tt>BaseText</tt>, <tt>Locale</tt>, and <tt>Translation</tt>. <tt>Translation</tt> is a cross-reference 
+ table, with foreign keys to both <tt>BaseText</tt> and <tt>Locale</tt>. When such a scheme is 
+ normalized, it will have no repeated data. (In underlying queries, however, the base 
+ language will be necessarily treated differently than the other languages.)
+ 
+ <P>Upon startup, the tables are read, the above <tt>ResultSet</tt> is created and  
+ stored in a <tt>private static Map</tt>, represented schematically as 
+ <tt>Map[BaseText, Map[Locale, Translation]]</tt>. When a translation is required, 
+ this <tt>Map</tt> is used as an in-memory lookup table. This avoids 
+ repeated fetching from the database of trivial data that rarely changes. 
+
+ <P><b>Note on Thread Safety</b>
+ <br>In the suggested implementation style, the <tt>private static Map</tt> that stores translation data is 
+ <tt>static</tt>, and thus shared by multiple threads. Implementations are free to implement 
+ any desired policy regarding thread safety, from relaxed to strict. A relaxed implementation 
+ might completely ignore the possibility of a mistaken read/write, since no writes are expected in 
+ production, or simply because the consequences are usually trivial. A strict implementation would 
+ take the opposite approach, and demand that the <tt>private static Map</tt> be used in an fully 
+ thread-safe manner.   
+*/
+public interface Translator {
+  
+  /**
+   Translate <tt>aBaseText</tt> into text appropriate for <tt>aLocale</tt>.
+   
+   <P><tt>aBaseText</tt> is either user-presentable text in the base language of 
+   development, or a "coder key" known only to the programmer.
+   
+   <P>If <tt>aBaseText</tt> is unknown, then the implementation is free to define 
+   any desired behavior. For example, this method might return  
+  <ul>
+   <li><tt>aBaseText</tt> passed to this method, in its raw, untranslated form
+   <li><tt>aBaseText</tt> with special surrounding text, such as <tt>???some text???</tt>, with 
+   leading and trailing characters
+   <li>a fixed <tt>String</tt> such as <tt>'???'</tt> or <tt>'?untranslated?'</tt>
+   <li>it is also possible for an implementation to throw a {@link RuntimeException} when 
+   an item is missing, but this is not recommended for applications in production
+  </ul>
+  
+   <P>If <tt>aBaseText</tt> is known, but there is no explicit translation for the 
+   given {@link Locale}, then an implementation might choose to mimic the behavior of 
+   {@link java.util.ResourceBundle}, and choose a translation from the "nearest available" 
+   {@link Locale} instead.
+  
+   @param aBaseText is not <tt>null</tt>, but may be empty
+   @param aLocale comes from the configured {@link LocaleSource}.
+  */
+  String get(String aBaseText, Locale aLocale);
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/ui/translate/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,86 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Translate</title>
+</head>
+<body>
+Translation of text for multilingual applications.
+ 
+ <h3>Last-Second Localization</h3>
+WEB4J's goal is to make the task of changing a single-language application into a 
+ multilingual one as <em>painless</em> and as <em>focused</em> as possible, with minimal 
+ ripple effects. To support this idea, WEB4J has two main design goals :
+ <ul>
+ <li><span class="highlight">Model Objects, actions, and Data Access Objects should be 
+ exactly the same</span> regardless of whether the application is multilingual or not.
+ <li><span class="highlight">Markup in a multilingual application should appear almost the same</span> 
+ as in a single language application.
+ </ul>
+ 
+ <P>
+To achieve the first goal, WEB4J uses a policy of "last-second localization", 
+whereby <span class="highlight">translations are performed outside of normal code, and only in Java Server Pages, 
+using custom tags</span>. This corresponds to the idea that a translation is "just a view". 
+ 
+<h3>Overview</h3> 
+The various items are : 
+<ul>
+ <li>{@link hirondelle.web4j.request.LocaleSource} defines how an application deduces 
+ a {@link java.util.Locale} from an incoming request (using a policy defined by your application).
+ <li>the <tt>Locale</tt> obtained from {@link hirondelle.web4j.request.LocaleSource} is passed to 
+ a {@link hirondelle.web4j.ui.translate.Translator}, which defines how an application translates
+ base text into a given target language. 
+ <li>the {@link hirondelle.web4j.ui.translate.Text} tag uses the above to perform the translation 
+"at the last second", in a JSP.
+<li>the {@link hirondelle.web4j.ui.translate.TextFlow} tag translates all the text flow (the text appearing 
+outside of tags) appearing in its body. This tag is suitable for translating large sections
+of markup whose text flow is simple and non-structured.
+<li>the {@link hirondelle.web4j.ui.translate.Tooltips} tag translates all <tt>TITLE</tt>
+and <tt>ALT</tt> attributes appearing in its body (it also translates the text of <tt>SUBMIT</tt> buttons). 
+If placed in a high-level template, it 
+translates those attributes across a large number of pages - perhaps even the whole site.
+<li>the {@link hirondelle.web4j.ui.translate.Messages} tag translates the 
+{@link hirondelle.web4j.model.AppResponseMessage}s emitted by {@link hirondelle.web4j.action.Action}s.
+ </ul> 
+
+<h3>Translatable Items</h3> 
+ Translatable items include :
+ <ul>
+ <li>static text in a web page
+ <li>tooltips -  the <tt>TITLE</tt> and <tt>ALT</tt> attributes of tags
+ <li>links whose target is dependent on <tt>Locale</tt>
+ <li>dynamic messages generated by the application
+ </ul>
+   
+ <h3>Suggested Development Style</h3> 
+ For a new multilingual application, one may proceed as follows :
+ <ul>
+ <li>start development with a do-nothing {@link hirondelle.web4j.ui.translate.Translator}, 
+ that simply returns the base text, unchanged and untranslated
+ <li>use the 
+ {@link hirondelle.web4j.ui.translate.Text},
+ {@link hirondelle.web4j.ui.translate.Tooltips},
+ {@link hirondelle.web4j.ui.translate.TextFlow},
+ and {@link hirondelle.web4j.ui.translate.Messages} tags during development, 
+ to internationalize JSPs from the beginning. (The text is not localized at this point.)
+ <li>when nearing completion, begin localization by changing the {@link hirondelle.web4j.ui.translate.Translator}
+ implementation, to always record the base text in some way (save to the database, or to an in-memory <tt>List</tt>, 
+ for example). Thus, <span class="highlight">by simply exercising all parts of the application 
+ (including all possible error messages), one may generate a listing of all items that need translation.</span>
+ <li>finally, translate all items, and provide the "correct" {@link hirondelle.web4j.ui.translate.Translator}
+ implementation. (See the example application for illustration.)
+ </ul>
+
+<P>If an existing application needs to be changed from a single-language style to a multilingual style, 
+then a similar technique may be used.
+
+<h3>Database versus {@link java.util.ResourceBundle} </h3>
+Although a {@link hirondelle.web4j.ui.translate.Translator} may be backed by a 
+<tt>ResourceBundle</tt>, the recommended style is to use a database instead. 
+<a href="http://www.javapractices.com/Topic208.cjp"><tt>ResourceBundle</tt> is a 
+mediocre tool</a> for web applications. 
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/Args.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,112 @@
+package hirondelle.web4j.util;
+
+import java.util.regex.*;
+
+/**
+ Utility methods for common argument validations.
+
+<P>Replaces <tt>if</tt> statements at the start of a method with 
+ more compact method calls.
+ 
+ <P>Example use case.
+ <P>Instead of :
+ <PRE>
+ public void doThis(String aText){
+   if (!Util.textHasContent(aText)){
+     throw new IllegalArgumentException();
+   }
+   //..main body elided
+ }
+ </PRE>
+ <P>One may instead write :
+ <PRE>
+ public void doThis(String aText){
+   Args.checkForContent(aText);
+   //..main body elided
+ }
+ </PRE>
+*/
+public final class Args {
+  
+  /**
+   If <code>aText</code> does not satisfy {@link Util#textHasContent}, then 
+   throw an <code>IllegalArgumentException</code>.
+  
+   <P>Most text used in an application is meaningful only if it has visible content.
+  */
+  public static void checkForContent(String aText){
+    if( ! Util.textHasContent(aText) ){
+      throw new IllegalArgumentException("Text has no visible content");
+    }
+  }
+
+  /**
+   If {@link Util#isInRange} returns <code>false</code>, then 
+   throw an <code>IllegalArgumentException</code>. 
+  
+   @param aLow is less than or equal to <code>aHigh</code>.
+  */
+  public static void checkForRange(int aNumber, int aLow, int aHigh) {
+    if ( ! Util.isInRange(aNumber, aLow, aHigh) ) {
+      throw new IllegalArgumentException(aNumber + " not in range " + aLow + ".." + aHigh);
+    }
+  }
+
+  /**
+   If <tt>aNumber</tt> is less than <tt>1</tt>, then throw an 
+   <tt>IllegalArgumentException</tt>.
+  */
+  public static void checkForPositive(int aNumber) {
+    if (aNumber < 1) {
+      throw new IllegalArgumentException(aNumber + " is less than 1");
+    }
+  }
+
+  /**
+   If {@link Util#matches} returns <tt>false</tt>, then 
+   throw an <code>IllegalArgumentException</code>. 
+  */
+  public static void checkForMatch(Pattern aPattern, String aText){
+    if (! Util.matches(aPattern, aText)){
+      throw new IllegalArgumentException(
+        "Text " + Util.quote(aText) + " does not match '" +aPattern.pattern()+ "'"
+      );
+    }
+  }
+  
+  /**
+   If <code>aObject</code> is null, then throw a <code>NullPointerException</code>.
+  
+   <P>Use cases :
+  <pre>
+   doSomething( Football aBall ){
+     //1. call some method on the argument : 
+     //if aBall is null, then exception is automatically thrown, so 
+     //there is no need for an explicit check for null.
+     aBall.inflate();
+    
+     //2. assign to a corresponding field (common in constructors): 
+     //if aBall is null, no exception is immediately thrown, so 
+     //an explicit check for null may be useful here
+     Args.checkForNull( aBall );
+     fBall = aBall;
+     
+     //3. pass on to some other method as parameter : 
+     //it may or may not be appropriate to have an explicit check 
+     //for null here, according the needs of the problem
+     Args.checkForNull( aBall ); //??
+     fReferee.verify( aBall );
+   }
+   </pre>
+  */
+  public static void checkForNull(Object aObject) {
+    if (aObject == null) {
+      throw new NullPointerException();
+    }
+  }
+  
+  // PRIVATE 
+  private Args(){
+    //empty - prevent construction
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/Consts.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,55 @@
+package hirondelle.web4j.util;
+
+/**
+ Collected constants of general utility.
+
+<P>All members of this class are immutable. 
+
+ <P>(This is an example of 
+ <a href='http://www.javapractices.com/Topic2.cjp'>class for constants</a>.)
+*/
+public final class Consts  {
+
+  /** Opposite of {@link #FAILS}.  */
+  public static final boolean PASSES = true;
+  /** Opposite of {@link #PASSES}.  */
+  public static final boolean FAILS = false;
+  
+  /** Opposite of {@link #FAILURE}.  */
+  public static final boolean SUCCESS = true;
+  /** Opposite of {@link #SUCCESS}.  */
+  public static final boolean FAILURE = false;
+
+  /** 
+   Useful for {@link String} operations, which return an index of <tt>-1</tt> when 
+   an item is not found. 
+  */
+  public static final int NOT_FOUND = -1;
+  
+  /** System property - <tt>line.separator</tt>*/
+  public static final String NEW_LINE = System.getProperty("line.separator");
+  /** System property - <tt>file.separator</tt>*/
+  public static final String FILE_SEPARATOR = System.getProperty("file.separator");
+  /** System property - <tt>path.separator</tt>*/
+  public static final String PATH_SEPARATOR = System.getProperty("path.separator");
+  
+  public static final String EMPTY_STRING = "";
+  public static final String SPACE = " ";
+  public static final String TAB = "\t";
+  public static final String SINGLE_QUOTE = "'";
+  public static final String PERIOD = ".";
+  public static final String DOUBLE_QUOTE = "\"";
+
+  // PRIVATE //
+
+  /**
+   The caller references the constants using <tt>Consts.EMPTY_STRING</tt>, 
+   and so on. Thus, the caller should be prevented from constructing objects of 
+   this class, by declaring this private constructor. 
+  */
+  private Consts(){
+    //this prevents even the native class from 
+    //calling this ctor as well :
+    throw new AssertionError();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/EscapeChars.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,535 @@
+package hirondelle.web4j.util;
+
+import java.net.URLEncoder;
+import java.io.UnsupportedEncodingException;
+import java.text.CharacterIterator;
+import java.text.StringCharacterIterator;
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.ui.translate.Text;
+import hirondelle.web4j.ui.translate.Tooltips;
+import hirondelle.web4j.ui.translate.TextFlow;
+import hirondelle.web4j.ui.tag.Populate;
+import hirondelle.web4j.database.Report;
+
+/**
+ Convenience methods for escaping special characters related to HTML, XML, 
+ and regular expressions.
+ 
+ <P>To keep you safe by default, WEB4J goes to some effort to escape 
+ characters in your data when appropriate, such that you <em>usually</em>
+ don't need to think too much about escaping special characters. Thus, you
+  shouldn't need to <em>directly</em> use the services of this class very often. 
+ 
+ <P><span class='highlight'>For Model Objects containing free form user input, 
+ it is highly recommended that you use {@link SafeText}, not <tt>String</tt></span>.
+ Free form user input is open to malicious use, such as
+ <a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>Cross Site Scripting</a>
+ attacks. 
+ Using <tt>SafeText</tt> will protect you from such attacks, by always escaping 
+ special characters automatically in its <tt>toString()</tt> method.   
+ 
+ <P>The following WEB4J classes will automatically escape special characters 
+ for you, when needed : 
+ <ul>
+ <li>the {@link SafeText} class, used as a building block class for your 
+ application's Model Objects, for modeling all free form user input
+ <li>the {@link Populate} tag used with forms
+ <li>the {@link Report} class used for creating quick reports
+ <li>the {@link Text}, {@link TextFlow}, and {@link Tooltips} custom tags used 
+ for translation
+ </ul> 
+*/
+public final class EscapeChars {
+
+  /**
+    Escape characters for text appearing in HTML markup.
+    
+    <P>This method exists as a defence against Cross Site Scripting (XSS) hacks.
+    The idea is to neutralize control characters commonly used by scripts, such that
+    they will not be executed by the browser. This is done by replacing the control
+    characters with their escaped equivalents.  
+    See {@link hirondelle.web4j.security.SafeText} as well.
+    
+    <P>The following characters are replaced with corresponding 
+    HTML character entities :
+    <table border='1' cellpadding='3' cellspacing='0'>
+    <tr><th> Character </th><th>Replacement</th></tr>
+    <tr><td> &lt; </td><td> &amp;lt; </td></tr>
+    <tr><td> &gt; </td><td> &amp;gt; </td></tr>
+    <tr><td> &amp; </td><td> &amp;amp; </td></tr>
+    <tr><td> " </td><td> &amp;quot;</td></tr>
+    <tr><td> \t </td><td> &amp;#009;</td></tr>
+    <tr><td> ! </td><td> &amp;#033;</td></tr>
+    <tr><td> # </td><td> &amp;#035;</td></tr>
+    <tr><td> $ </td><td> &amp;#036;</td></tr>
+    <tr><td> % </td><td> &amp;#037;</td></tr>
+    <tr><td> ' </td><td> &amp;#039;</td></tr>
+    <tr><td> ( </td><td> &amp;#040;</td></tr> 
+    <tr><td> ) </td><td> &amp;#041;</td></tr>
+    <tr><td> * </td><td> &amp;#042;</td></tr>
+    <tr><td> + </td><td> &amp;#043; </td></tr>
+    <tr><td> , </td><td> &amp;#044; </td></tr>
+    <tr><td> - </td><td> &amp;#045; </td></tr>
+    <tr><td> . </td><td> &amp;#046; </td></tr>
+    <tr><td> / </td><td> &amp;#047; </td></tr>
+    <tr><td> : </td><td> &amp;#058;</td></tr>
+    <tr><td> ; </td><td> &amp;#059;</td></tr>
+    <tr><td> = </td><td> &amp;#061;</td></tr>
+    <tr><td> ? </td><td> &amp;#063;</td></tr>
+    <tr><td> @ </td><td> &amp;#064;</td></tr>
+    <tr><td> [ </td><td> &amp;#091;</td></tr>
+    <tr><td> \ </td><td> &amp;#092;</td></tr>
+    <tr><td> ] </td><td> &amp;#093;</td></tr>
+    <tr><td> ^ </td><td> &amp;#094;</td></tr>
+    <tr><td> _ </td><td> &amp;#095;</td></tr>
+    <tr><td> ` </td><td> &amp;#096;</td></tr>
+    <tr><td> { </td><td> &amp;#123;</td></tr>
+    <tr><td> | </td><td> &amp;#124;</td></tr>
+    <tr><td> } </td><td> &amp;#125;</td></tr>
+    <tr><td> ~ </td><td> &amp;#126;</td></tr>
+    </table>
+    
+    <P>Note that JSTL's {@code <c:out>} escapes <em>only the first 
+    five</em> of the above characters.
+   */
+   public static String forHTML(String aText){
+     final StringBuilder result = new StringBuilder();
+     final StringCharacterIterator iterator = new StringCharacterIterator(aText);
+     char character =  iterator.current();
+     while (character != CharacterIterator.DONE ){
+       if (character == '<') {
+         result.append("&lt;");
+       }
+       else if (character == '>') {
+         result.append("&gt;");
+       }
+       else if (character == '&') {
+         result.append("&amp;");
+      }
+       else if (character == '\"') {
+         result.append("&quot;");
+       }
+       else if (character == '\t') {
+         addCharEntity(9, result);
+       }
+       else if (character == '!') {
+         addCharEntity(33, result);
+       }
+       else if (character == '#') {
+         addCharEntity(35, result);
+       }
+       else if (character == '$') {
+         addCharEntity(36, result);
+       }
+       else if (character == '%') {
+         addCharEntity(37, result);
+       }
+       else if (character == '\'') {
+         addCharEntity(39, result);
+       }
+       else if (character == '(') {
+         addCharEntity(40, result);
+       }
+       else if (character == ')') {
+         addCharEntity(41, result);
+       }
+       else if (character == '*') {
+         addCharEntity(42, result);
+       }
+       else if (character == '+') {
+         addCharEntity(43, result);
+       }
+       else if (character == ',') {
+         addCharEntity(44, result);
+       }
+       else if (character == '-') {
+         addCharEntity(45, result);
+       }
+       else if (character == '.') {
+         addCharEntity(46, result);
+       }
+       else if (character == '/') {
+         addCharEntity(47, result);
+       }
+       else if (character == ':') {
+         addCharEntity(58, result);
+       }
+       else if (character == ';') {
+         addCharEntity(59, result);
+       }
+       else if (character == '=') {
+         addCharEntity(61, result);
+       }
+       else if (character == '?') {
+         addCharEntity(63, result);
+       }
+       else if (character == '@') {
+         addCharEntity(64, result);
+       }
+       else if (character == '[') {
+         addCharEntity(91, result);
+       }
+       else if (character == '\\') {
+         addCharEntity(92, result);
+       }
+       else if (character == ']') {
+         addCharEntity(93, result);
+       }
+       else if (character == '^') {
+         addCharEntity(94, result);
+       }
+       else if (character == '_') {
+         addCharEntity(95, result);
+       }
+       else if (character == '`') {
+         addCharEntity(96, result);
+       }
+       else if (character == '{') {
+         addCharEntity(123, result);
+       }
+       else if (character == '|') {
+         addCharEntity(124, result);
+       }
+       else if (character == '}') {
+         addCharEntity(125, result);
+       }
+       else if (character == '~') {
+         addCharEntity(126, result);
+       }
+       else {
+         //the char is not a special one
+         //add it to the result as is
+         result.append(character);
+       }
+       character = iterator.next();
+     }
+     return result.toString();
+  }
+  
+
+  /**
+   Escape all ampersand characters in a URL. 
+    
+   <P>Replaces all <tt>'&amp;'</tt> characters with <tt>'&amp;amp;'</tt>.
+   
+  <P>An ampersand character may appear in the query string of a URL.
+   The ampersand character is indeed valid in a URL.
+   <em>However, URLs usually appear as an <tt>HREF</tt> attribute, and 
+   such attributes have the additional constraint that ampersands 
+   must be escaped.</em>
+   
+   <P>The JSTL &lt;c:url&gt; tag does indeed perform proper URL encoding of 
+   query parameters. But it does not, in general, produce text which 
+   is valid as an <tt>HREF</tt> attribute, simply because it does 
+   not escape the ampersand character. This is a nuisance when 
+   multiple query parameters appear in the URL, since it requires a little 
+   extra work.
+  */
+  public static String forHrefAmpersand(String aURL){
+    return aURL.replace("&", "&amp;");
+  }
+   
+  /**
+    Synonym for <tt>URLEncoder.encode(String, "UTF-8")</tt>.
+   
+    <P>Used to ensure that HTTP query strings are in proper form, by escaping
+    special characters such as spaces.
+   
+    <P>It is important to note that if a query string appears in an <tt>HREF</tt>
+    attribute, then there are two issues - ensuring the query string is valid HTTP
+    (it is URL-encoded), and ensuring it is valid HTML (ensuring the 
+    ampersand is escaped).
+   */
+   public static String forURL(String aURLFragment){
+     String result = null;
+     try {
+       result = URLEncoder.encode(aURLFragment, "UTF-8");
+     }
+     catch (UnsupportedEncodingException ex){
+       throw new RuntimeException("UTF-8 not supported", ex);
+     }
+     return result;
+   }
+
+  /**
+   Escape characters for text appearing as XML data, between tags.
+   
+   <P>The following characters are replaced with corresponding character entities :
+   <table border='1' cellpadding='3' cellspacing='0'>
+   <tr><th> Character </th><th> Encoding </th></tr>
+   <tr><td> &lt; </td><td> &amp;lt; </td></tr>
+   <tr><td> &gt; </td><td> &amp;gt; </td></tr>
+   <tr><td> &amp; </td><td> &amp;amp; </td></tr>
+   <tr><td> " </td><td> &amp;quot;</td></tr>
+   <tr><td> ' </td><td> &amp;#039;</td></tr>
+   </table>
+   
+   <P>Note that JSTL's {@code <c:out>} escapes the exact same set of 
+   characters as this method. <span class='highlight'>That is, {@code <c:out>}
+    is good for escaping to produce valid XML, but not for producing safe 
+    HTML.</span>
+  */
+  public static String forXML(String aText){
+    final StringBuilder result = new StringBuilder();
+    final StringCharacterIterator iterator = new StringCharacterIterator(aText);
+    char character =  iterator.current();
+    while (character != CharacterIterator.DONE ){
+      if (character == '<') {
+        result.append("&lt;");
+      }
+      else if (character == '>') {
+        result.append("&gt;");
+      }
+      else if (character == '\"') {
+        result.append("&quot;");
+      }
+      else if (character == '\'') {
+        result.append("&#039;");
+      }
+      else if (character == '&') {
+         result.append("&amp;");
+      }
+      else {
+        //the char is not a special one
+        //add it to the result as is
+        result.append(character);
+      }
+      character = iterator.next();
+    }
+    return result.toString();
+  }
+  
+  /**
+   Escapes characters for text appearing as data in the 
+   <a href='http://www.json.org/'>Javascript Object Notation</a>
+   (JSON) data interchange format.
+   
+   <P>The following commonly used control characters are escaped :
+   <table border='1' cellpadding='3' cellspacing='0'>
+   <tr><th> Character </th><th> Escaped As </th></tr>
+   <tr><td> " </td><td> \" </td></tr>
+   <tr><td> \ </td><td> \\ </td></tr>
+   <tr><td> / </td><td> \/ </td></tr>
+   <tr><td> back space </td><td> \b </td></tr> 
+   <tr><td> form feed </td><td> \f </td></tr>
+   <tr><td> line feed </td><td> \n </td></tr>
+   <tr><td> carriage return </td><td> \r </td></tr>
+   <tr><td> tab </td><td> \t </td></tr>
+   </table>
+   
+   <P>See <a href='http://www.ietf.org/rfc/rfc4627.txt'>RFC 4627</a> for more information.
+  */
+  public static String forJSON(String aText){
+    final StringBuilder result = new StringBuilder();
+    StringCharacterIterator iterator = new StringCharacterIterator(aText);
+    char character = iterator.current();
+    while (character != StringCharacterIterator.DONE){
+      if( character == '\"' ){
+        result.append("\\\"");
+      }
+      else if(character == '\\'){
+        result.append("\\\\");
+      }
+      else if(character == '/'){
+        result.append("\\/");
+      }
+      else if(character == '\b'){
+        result.append("\\b");
+      }
+      else if(character == '\f'){
+        result.append("\\f");
+      }
+      else if(character == '\n'){
+        result.append("\\n");
+      }
+      else if(character == '\r'){
+        result.append("\\r");
+      }
+      else if(character == '\t'){
+        result.append("\\t");
+      }
+      else {
+        //the char is not a special one
+        //add it to the result as is
+        result.append(character);
+      }
+      character = iterator.next();
+    }
+    return result.toString();    
+  }
+
+  /**
+   Return <tt>aText</tt> with all <tt>'&lt;'</tt> and <tt>'&gt;'</tt> characters
+   replaced by their escaped equivalents.
+  */
+  public static String toDisableTags(String aText){
+    final StringBuilder result = new StringBuilder();
+    final StringCharacterIterator iterator = new StringCharacterIterator(aText);
+    char character =  iterator.current();
+    while (character != CharacterIterator.DONE ){
+      if (character == '<') {
+        result.append("&lt;");
+      }
+      else if (character == '>') {
+        result.append("&gt;");
+      }
+      else {
+        //the char is not a special one
+        //add it to the result as is
+        result.append(character);
+      }
+      character = iterator.next();
+    }
+    return result.toString();
+  }
+  
+
+  /**
+   Replace characters having special meaning in regular expressions
+   with their escaped equivalents, preceded by a '\' character.
+  
+   <P>The escaped characters include :
+  <ul>
+  <li>.
+  <li>\
+  <li>?, * , and +
+  <li>&
+  <li>:
+  <li>{ and }
+  <li>[ and ]
+  <li>( and )
+  <li>^ and $
+  </ul>
+  */
+  public static String forRegex(String aRegexFragment){
+    final StringBuilder result = new StringBuilder();
+
+    final StringCharacterIterator iterator = 
+      new StringCharacterIterator(aRegexFragment)
+    ;
+    char character =  iterator.current();
+    while (character != CharacterIterator.DONE ){
+      /*
+       All literals need to have backslashes doubled.
+      */
+      if (character == '.') {
+        result.append("\\.");
+      }
+      else if (character == '\\') {
+        result.append("\\\\");
+      }
+      else if (character == '?') {
+        result.append("\\?");
+      }
+      else if (character == '*') {
+        result.append("\\*");
+      }
+      else if (character == '+') {
+        result.append("\\+");
+      }
+      else if (character == '&') {
+        result.append("\\&");
+      }
+      else if (character == ':') {
+        result.append("\\:");
+      }
+      else if (character == '{') {
+        result.append("\\{");
+      }
+      else if (character == '}') {
+        result.append("\\}");
+      }
+      else if (character == '[') {
+        result.append("\\[");
+      }
+      else if (character == ']') {
+        result.append("\\]");
+      }
+      else if (character == '(') {
+        result.append("\\(");
+      }
+      else if (character == ')') {
+        result.append("\\)");
+      }
+      else if (character == '^') {
+        result.append("\\^");
+      }
+      else if (character == '$') {
+        result.append("\\$");
+      }
+      else {
+        //the char is not a special one
+        //add it to the result as is
+        result.append(character);
+      }
+      character = iterator.next();
+    }
+    return result.toString();
+  }
+  
+  /**
+   Escape <tt>'$'</tt> and <tt>'\'</tt> characters in replacement strings.
+   
+   <P>Synonym for <tt>Matcher.quoteReplacement(String)</tt>.
+   
+   <P>The following methods use replacement strings which treat 
+   <tt>'$'</tt> and <tt>'\'</tt> as special characters:
+   <ul>
+   <li><tt>String.replaceAll(String, String)</tt>
+   <li><tt>String.replaceFirst(String, String)</tt>
+   <li><tt>Matcher.appendReplacement(StringBuffer, String)</tt>
+   </ul>
+   
+   <P>If replacement text can contain arbitrary characters, then you 
+   will usually need to escape that text, to ensure special characters 
+   are interpreted literally.
+  */
+  public static String forReplacementString(String aInput){
+    return Matcher.quoteReplacement(aInput);
+  }
+  
+  /**
+   Disable all <tt>&lt;SCRIPT&gt;</tt> tags in <tt>aText</tt>.
+   
+   <P>Insensitive to case.
+  */  
+  public static String forScriptTagsOnly(String aText){
+    String result = null;
+    Matcher matcher = SCRIPT.matcher(aText);
+    result = matcher.replaceAll("&lt;SCRIPT&gt;");
+    matcher = SCRIPT_END.matcher(result);
+    result = matcher.replaceAll("&lt;/SCRIPT&gt;");
+    return result;
+  }
+  
+  // PRIVATE //
+  
+  private EscapeChars(){
+    //empty - prevent construction
+  }
+  
+  private static final Pattern SCRIPT = Pattern.compile(
+    "<SCRIPT>", Pattern.CASE_INSENSITIVE
+   );
+  private static final Pattern SCRIPT_END = Pattern.compile(
+    "</SCRIPT>", Pattern.CASE_INSENSITIVE
+  );
+  
+  private static void addCharEntity(Integer aIdx, StringBuilder aBuilder){
+    String padding = "";
+    if( aIdx <= 9 ){
+       padding = "00";
+    }
+    else if( aIdx <= 99 ){
+      padding = "0";
+    }
+    else {
+      //no prefix
+    }
+    String number = padding + aIdx.toString();
+    aBuilder.append("&#" + number + ";");
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/Regex.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,312 @@
+package hirondelle.web4j.util;
+
+/**
+ (UNPUBLISHED) Simple collection of commonly used regular expressions, as <tt>String</tt>s.
+
+ <P>Regular expressions are both cryptic and error-prone. Reuse of 
+ the regular expressions in this class should increase legibility in the caller, and 
+ reduce testing time. 
+
+<P>(These are presented here as <tt>String</tt>s, and not as <tt>Pattern</tt>s, to 
+ aid in constructing complex expressions out of simpler ones.)
+
+<P>Some items follow the style of <em>Mastering Regular Expressions</em>, by 
+ Jeffrey Friedl (0-596-00289-0).
+
+ <P>Grouping represents a problem for this class, since the caller may or 
+ may not desire a particular element to be a capturing group.
+*/
+public final class Regex {
+
+  /**
+   Not a well-formed regex, but a symbolic name for alternation, intended 
+   simply to improve legibility of regexes.
+  */
+  public static final String OR = "|";
+
+  /**
+   Whitespace.
+  */
+  public static final String WS = "\\s*";
+  public static final String DOT = "\\.";
+  public static final String ANY_CHARS = ".*"; 
+  public static final String START_TAG = "<";
+  public static final String END_TAG = WS + ">";
+  public static final String ALL_BUT_END_OF_TAG = "[^>]*";
+  public static final String ALL_BUT_START_OF_TAG = "[^<]*";
+  public static final String QUOTE = "(?:'|\")"; 
+  public static final String NO_SPECIAL_HTML_CHAR = "[^<>'\"]";
+  
+  /** Group 1 returns the attribute value.  */
+  public static final String QUOTED_ATTR = QUOTE+"((?:"+NO_SPECIAL_HTML_CHAR+")*)"+QUOTE;
+  
+  /** Group 1 returns the trimmed text.  */
+  public static final String TRIMMED_TEXT = "(?:\\s)*((?:\\S(?:.)*\\S)|(?:\\S))(?:\\s)*";
+  
+  //Might be used for unquoted attributes:
+  //public static final String fNO_SPECIAL_HTML_CHARS_OR_SPACES = "[^<>'\"\\s]";
+  
+  public static final int ENTIRE_MATCH = 0;
+  public static final int FIRST_GROUP = 1;
+  public static final int SECOND_GROUP = 2;
+  public static final int THIRD_GROUP = 3;
+  public static final int FOURTH_GROUP = 4;
+  
+  // NEW items added after web4j 1.3.0 :
+  
+  public static final String SINGLE_QUOTED_ATTR = "'[^']*'";
+  public static final String DOUBLE_QUOTED_ATTR = "\"[^\"]*\"";
+  public static final String UNQUOTED_ATTR = "[-.:\\w]+";
+  
+  /**
+   Group 1 is the attribute value, <em>with</em> quotes.
+   <P>Use {@link Util#removeQuotes(String)} to remove any quotes, if needed.
+  <P>The content of an HTML tag attribute is specified at 
+   http://www.w3.org/TR/html4/intro/sgmltut.html#attributes
+  */
+  public static final String ATTR_VALUE = 
+    "(" + 
+       SINGLE_QUOTED_ATTR + 
+       OR +
+       DOUBLE_QUOTED_ATTR + 
+       OR +
+       UNQUOTED_ATTR + 
+    ")"
+  ;
+  
+  public static final String ATTR_NAME = "[a-zA-Z]+";
+  public static final String TAG_NAME = "[a-zA-Z]+";
+  public static final String ATTR = "\\s+" + ATTR_NAME + WS + "=" + WS + ATTR_VALUE;
+  public static final String FIRST_TAG = "<" + TAG_NAME + "(?:" + ATTR + ")*" + WS +  ">";
+  public static final String SECOND_TAG = "</" + TAG_NAME + ">";
+  public static final String TAG_BODY = "(.*?)";
+  public static final String ENTIRE_TAG = FIRST_TAG + TAG_BODY + SECOND_TAG;
+  
+  public static final String BLANK_LINE = "^\\s*$";
+  
+  /**
+   Finds positions where a comma should be placed in a number, in the style
+   <tt>1,000,000</tt>. Intended for integers, but can also handle up to 3 decimal 
+   places. For example, <tt>10000.001</tt> gives <tt>10,000.001</tt>.
+  */
+  public static final String COMMA_INSERTION = "(?<=\\d)(?=(\\d\\d\\d)+(?!\\d))";
+  
+  /**
+   Either integer or floating point number. Has the following properties 
+  <ul>
+   <li>digits with possible decimal point
+   <li>possible leading plus or minus sign
+   <li>no grouping delimiters (such as a comma)
+   <li>no leading or trailing whitespace
+  </ul>
+   <P>Example matches: 1, 100, 2.3, -2.3, +2.3, -272.13, -.0, 2.
+   <P>Example mismatches: '1,000', '123 ', ' 123'.
+  */
+  public static final String NUMBER = 
+    "(?:-|\\+)?" + 
+    "(" + 
+       "[0-9]+" + "(" + DOT + "[0-9]*)?" + 
+       OR + 
+       DOT + "[0-9]+" + 
+    ")"
+  ;
+  
+  /**
+   Similar to {@link #NUMBER}, except the number of decimals, if present, is always 2.
+   
+   <P>Example matches : <tt>1000, 100.25, .25, 0.25, -.13, -0.13</tt>
+   <P>Example mismatches : <tt>1,000.00, 100., 100.0, 100.123, -56.000, .123</tt>
+  */
+  public static final String DOLLARS = 
+    "(?:-|\\+)?" + 
+    "(" + 
+       "[0-9]+" + "(" + Regex.DOT + "[0-9]{2})?" + 
+       Regex.OR + 
+       Regex.DOT + "[0-9]{2}" + 
+    ")"
+  ;
+  
+  /**
+   An amount in any currency.
+   
+   <P>There are two permitted decimal separators: <tt>'.'</tt> and <tt>','</tt>.  The 
+   permitted number of decimals is <tt>0,2,3</tt>.
+      
+   <P>Example matches : <tt>'1000', '100.25', '.253', '0.25', '-.13', '-0.00'</tt>
+   <P>Example mismatches : <tt>'1,000.00', '100.', '100.0', '100.1234', ',1', ',1234'</tt>
+   
+   <P>Note as well that <tt>'1,000'</tt> matches as well! A grouping separator 
+   in one <tt>Locale</tt> is a decimal separator in others.
+   
+   <P>Any <tt>String</tt> that matches this pattern will be accepted by 
+   {@link java.math.BigDecimal#BigDecimal(String)}.
+  */
+  public static final String MONEY = 
+    "(?:-|\\+)?" + 
+    "(" + 
+       "[0-9]+((?:\\.|,)[0-9]{2,3})?" + 
+       Regex.OR + 
+        "(?:\\.|,)[0-9]{2,3}" + 
+    ")"
+  ;
+  
+  /**
+   An arbitrary number of digits. Has the following properties 
+   <ul>
+   <li>value greater than or equal to <tt>0</tt>
+   <li>possible leading zeros, as in <tt>0100</tt>, or <tt>0002</tt>
+   <li>no decimal point
+   <li>no leading plus or minus sign
+   <li>no leading or trailing whitespace
+  </ul>
+  
+   <P><em>Design Note:</em><br>
+   Allowing leading zeros is not a problem for creating <tt>Integer</tt> objects, 
+   since the <tt>Integer(String)</tt> constructor allows them.
+  
+   <P>Example matches: 0, 1, 2, 9, 10, 99, 789, 010, 0018.<br>
+   Example mismatches: -1, +1, 2.0, ' 0', '2 '.
+  */
+  public static final String DIGITS = "(\\d)+";
+  
+  /**
+   Return a regular expression corresponding to <tt>DIGITS</tt>, but having 
+   number of digits in range <tt>1..aMaxNumDigits</tt>.
+  
+   @param aMaxNumDigits must be <tt>1</tt> or more.
+  */
+  public static String forNDigits(int aMaxNumDigits){
+    Args.checkForRange(aMaxNumDigits, 1, Integer.MAX_VALUE);
+    return "(\\d){1," + aMaxNumDigits + "}";
+  }
+  
+  /**
+   Email address.
+  
+   <P>The {@link javax.mail.internet.InternetAddress} class permits validation of an 
+   email address. See also 
+   {@link hirondelle.web4j.util.WebUtil#isValidEmailAddress(String)}. Thus, this 
+   regex <em>should be used only when those classes are not available</em>.
+  */
+  public static final String EMAIL_ADDR = 
+    "\\w[-.\\w]*@" +  
+    "[a-z0-9]+(\\.[a-z0-9]+)*" + DOT + 
+    "(com|org|net|edu|gov|int|mil|biz|info|name|museum|coop|aero|[a-z][a-z])"
+  ;
+
+  /**
+   Matches numbers in the range 0-255.
+   <P>Example: 1, 001, 010, 199, 255.
+  */
+  private static final String IP_ADDR_ITEM = 
+    "(?:[01]?\\d\\d?" + 
+    OR + 
+    "2[0-4]\\d" + 
+    OR + 
+    "25[0-5])"
+  ;
+  
+  /**
+   IP addresses. 
+   <P>Example match: 1.01.001.255
+  */
+  public static final String IP_ADDR = 
+    "(?<![\\w.])" + 
+    IP_ADDR_ITEM + DOT + 
+    IP_ADDR_ITEM + DOT + 
+    IP_ADDR_ITEM + DOT + 
+    IP_ADDR_ITEM +
+    "(?![\\w.])"
+  ;
+
+
+  /**
+   A positional regex which returns the position where lower case text is 
+   immediately followed by upper case text. 
+  
+   <P>Intended for manipulation of text in camel hump style, which looks like this :
+   BlahBlahBlah, LoginName, EmailAddress.
+  
+   <P>Example:<br>
+   To change 'LoginName' into the more user-friendly 'Login Name' (with an added space), 
+   replace the matches returned by this regex with the replacement string <tt>' $1'</tt>.
+  */
+  public static final String CAMEL_HUMP_TEXT = "(?<=[a-z0-9])([A-Z])";
+  
+  /**
+   Simple identifier. 
+   One or more letters/underscores, with possible trailing digits. 
+   Matching examples include : 
+  <ul>
+   <li><tt>blah</tt>
+   <li><tt>blah42</tt>
+   <li><tt>blah_42</tt>
+   <li><tt>BlahBlah</tt>
+   <li><tt>BLAH_BLAH</tt>
+  </ul>
+  */
+  public static final String SIMPLE_IDENTIFIER = "([a-zA-Z_]+(?:\\d)*)";
+
+  /**
+   Scoped identifier.
+   
+   <P>Either two {@link #SIMPLE_IDENTIFIER}s separated by a period, or a single 
+   {@link #SIMPLE_IDENTIFIER}. The item before the period represents an <em>optional</em> 
+   scoping qualifier. (This style is used by SQL statement identifiers, where the  
+   scoping qualifier represents the target database.) 
+  */
+  public static final String SIMPLE_SCOPED_IDENTIFIER = "(?:[a-zA-Z_]+(?:\\d)*\\.)?(?:[a-zA-Z_]+(?:\\d)*)";
+
+  /**
+   A link or anchor tag.
+   
+   <P>Here, <tt>HREF</tt> must be the first attribute to appear in the tag. 
+   The following groups are defined : 
+  <ul>
+   <li> group 1 - value of the HREF attr
+   <li> group 2 - all text after the HREF attr, but still inside the tag - the "remainder"
+   attributes
+   <li> group 3 - the body of the A tag
+  </ul>
+  */
+  public static final String LINK = 
+    "<a href=" + Regex.QUOTED_ATTR + "(" + Regex.ALL_BUT_END_OF_TAG + ")" + Regex.END_TAG
+    + Regex.TRIMMED_TEXT + "</a>"
+  ;
+
+  /**
+   Month in the Gregorian calendar: <tt>01..12</tt>.
+  */
+  public static final String MONTH =
+    "(01|02|03|04|05|06|07|08|09|10|11|12)"
+  ;
+  
+  /**
+   Day of the month in the Gregorian calendar: <tt>01..31</tt>.
+  */
+  public static final String DAY_OF_MONTH = 
+    "(01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30|31)"
+  ;
+  
+  /** Hours in the day <tt>00..23</tt>.  */
+  public static final String HOURS = 
+    "(00|01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|20|21|22|23)"
+  ;
+  
+  /** Minutes in an hour <tt>00..59</tt>.  */
+  public static final String MINUTES = 
+    "((0|1|2|3|4|5)\\d)"  
+  ;
+  
+  /** Hours and minutes, in the form <tt>00:59</tt>.  */
+  public static final String HOURS_AND_MINUTES = 
+    HOURS + ":" + MINUTES
+  ;
+  
+  // PRIVATE //
+  
+  /** Prevents instantiation of this class.  */
+  private Regex(){
+    //emtpy
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/Stopwatch.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,124 @@
+package hirondelle.web4j.util;
+
+import java.math.BigDecimal;
+
+/**
+ Allows timing of the execution of any block of code.
+
+ Example use case:
+<PRE>
+Stopwatch stopwatch = new Stopwatch();
+stopwatch.start();
+//..perform operations
+stopwatch.stop();
+
+//timed in nanos, but toString is expressed in millis,
+//to three decimal places, to reflect the real resolution of most systems:
+System.out.println("The reading on the stopwatch is: " + stopwatch);
+
+//reuse the same stopwatch again
+//Note that there is no need to call a reset method.
+stopwatch.start();
+//..perform operations
+stopwatch.stop();
+
+//perform a numeric comparison, using raw nanoseconds:
+if ( stopwatch.toValue() > 5 ) {
+  System.out.println("The reading is high: " + stopwatch);
+}
+</PRE>
+
+ <P>The value on the stopwatch may be inspected at any time using the  
+ {@link #toString} (millis) and {@link #toValue} (nanos) methods.
+ 
+ <P><b>Example 2</b>
+ <br>To time the various steps in a long startup or initialization task, your code may take the form:
+<PRE>
+Stopwatch stopwatch = new Stopwatch();
+stopwatch.start();
+//..perform operation 1
+log("Step 1 completed " + stopwatch + " after start.");
+
+//perform operation 2
+log("Step 2 completed " + stopwatch + " after start.");
+
+//perform the last operation, operation 3
+stopwatch.stop();
+log("Final Step 3 completed " + stopwatch + " after start") ;
+</PRE>
+
+<P><i>Implementation Note:</i></br>
+ This class uses {@link System#nanoTime()}, not {@link System#currentTimeMillis()}, to 
+ perform its timing operations.
+*/
+public final class Stopwatch {
+
+  /**
+   Start the stopwatch.
+  
+   <P>You cannot call this method if the stopwatch is already started.
+  */
+  public void start(){
+    if (fIsRunning) {
+      throw new IllegalStateException("Must stop before calling start again.");
+    }
+    //reset both start and stop
+    fStart = getCurrentTime();
+    fStop = 0;
+    fIsRunning = true;
+  }
+
+  /**
+   Stop the stopwatch.
+  
+   <P>You can only call this method if the stopwatch has been started.
+  */
+  public void stop() {
+    if (!fIsRunning) {
+      throw new IllegalStateException("Cannot stop if not currently running.");
+    }
+    fStop = getCurrentTime();
+    fIsRunning = false;
+  }
+
+  /**
+   Return the current "reading" on the stopwatch, in milliseconds, in a format suitable 
+   typical use cases.
+   
+   <P>Example return value : '<tt>108.236 ms</tt>'. The underlying timing is in nanos, 
+   but it's expressed here in millis, for two reasons: the resolution on most systems is 
+   in the microsecond range, and the full 9 digits are less easy to reader. If you need 
+   the raw nanos, just use {@link #toValue()} instead.
+   
+   <P>Ref: https://blogs.oracle.com/dholmes/entry/inside_the_hotspot_vm_clocks
+  */
+  @Override public String toString() {
+    StringBuilder result = new StringBuilder();
+    BigDecimal value = new BigDecimal(toValue());//scale is zero
+    //millis, with 3 decimals:
+    value = value.divide(MILLION, 3, BigDecimal.ROUND_HALF_EVEN);
+    result.append(value);
+    result.append(" ms");
+    return result.toString();    
+  }
+
+  /**
+   Return the current "reading" on the stopwatch in raw nanoseconds, as a numeric type.
+  */
+  public long toValue() {
+    long result = fStop == 0 ? (getCurrentTime() - fStart) : (fStop -fStart);
+    return result;
+  }
+
+  // PRIVATE 
+  private long fStart;
+  private long fStop;
+  private boolean fIsRunning;
+  
+  /** Converts from nanos to millis. */
+  private static final BigDecimal MILLION = new BigDecimal("1000000");
+  
+  private long getCurrentTime(){
+    return System.nanoTime();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/TESTEscapeChars.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,231 @@
+package hirondelle.web4j.util;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+//import junit.swingui.TestRunner;
+import java.util.regex.*;
+import java.util.logging.*;
+import hirondelle.web4j.util.Util;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> test cases for {@link EscapeChars}. 
+*/
+public final class TESTEscapeChars extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = { TESTEscapeChars.class.getName()};
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+
+  public TESTEscapeChars(String aName) {
+   super(aName);
+  }
+
+  // TEST CASES //
+
+  public void testForRegex() {
+
+    //escaped regex does not equal original literal, 
+    //yet literal is a match for its own escaped pattern
+    testForRegexHasMatch("a.b");
+    testForRegexHasMatch("a\\b");
+    testForRegexHasMatch("a?b");
+    testForRegexHasMatch("a*b");
+    testForRegexHasMatch("a+b");
+    testForRegexHasMatch("a&b");
+    testForRegexHasMatch("{ab}");
+    testForRegexHasMatch("ab{}");
+    testForRegexHasMatch("a{b}");
+    testForRegexHasMatch("{ab");
+    testForRegexHasMatch("ab}");
+    testForRegexHasMatch("[ab]");
+    testForRegexHasMatch("ab]");
+    testForRegexHasMatch("[ab");
+    testForRegexHasMatch("a[]b");
+    testForRegexHasMatch("a^b");
+    testForRegexHasMatch("^ab");
+    testForRegexHasMatch("ab^");
+    testForRegexHasMatch("a$b");
+    testForRegexHasMatch("$ab");
+    testForRegexHasMatch("ab$");
+    testForRegexHasMatch("ab:");
+    testForRegexHasMatch(":b:");
+    testForRegexHasMatch("a::");
+    testForRegexHasMatch("(ab)");
+    testForRegexHasMatch("(ab");
+    testForRegexHasMatch("ab)");
+    testForRegexHasMatch("a(b)");
+    testForRegexHasMatch("()ab");
+    testForRegexHasMatch("a()b");
+    testForRegexHasMatch(".\\?*+&:{}[]()^$");
+  }
+  
+  public void testForHTMLFlow(){
+    testHTMLFlow("this", "this", Consts.SUCCESS);
+    testHTMLFlow("this ", "this ", Consts.SUCCESS);
+    testHTMLFlow(" this", " this", Consts.SUCCESS);
+    testHTMLFlow(" ", " ", Consts.SUCCESS);
+    testHTMLFlow("This is a test", "This is a test", Consts.SUCCESS);
+    testHTMLFlow("This is > test", "This is &gt; test", Consts.SUCCESS);
+    testHTMLFlow("This is < test", "This is &lt; test", Consts.SUCCESS);
+    testHTMLFlow("This is & test", "This is &amp; test", Consts.SUCCESS);
+    testHTMLFlow("This is a 'test'", "This is a &#039;test&#039;", Consts.SUCCESS);
+    testHTMLFlow("This is a \"test\"", "This is a &quot;test&quot;", Consts.SUCCESS);
+    testHTMLFlow("This is a (test)", "This is a &#040;test&#041;", Consts.SUCCESS);
+    testHTMLFlow("This is # test", "This is &#035; test", Consts.SUCCESS);
+    testHTMLFlow("This is % test", "This is &#037; test", Consts.SUCCESS);
+    testHTMLFlow("This is ; test", "This is &#059; test", Consts.SUCCESS);
+    testHTMLFlow("This is + test", "This is &#043; test", Consts.SUCCESS);
+    testHTMLFlow("This is - test", "This is &#045; test", Consts.SUCCESS);
+
+    testHTMLFlow("This is ! test", "This is &#033; test", Consts.SUCCESS);
+    //testHTMLFlow("This is \n test", "This is &#010; test", Consts.SUCCESS); //this char no longer escaped
+    //testHTMLFlow("This is \r test", "This is &#013; test", Consts.SUCCESS); //this char no longer escaped
+    testHTMLFlow("This is $ test", "This is &#036; test", Consts.SUCCESS);
+    testHTMLFlow("This is * test", "This is &#042; test", Consts.SUCCESS);
+    testHTMLFlow("This is . test", "This is &#046; test", Consts.SUCCESS);
+    testHTMLFlow("This is / test", "This is &#047; test", Consts.SUCCESS);
+    testHTMLFlow("This is : test", "This is &#058; test", Consts.SUCCESS);
+    testHTMLFlow("This is = test", "This is &#061; test", Consts.SUCCESS);
+    testHTMLFlow("This is ? test", "This is &#063; test", Consts.SUCCESS);
+    testHTMLFlow("This is @ test", "This is &#064; test", Consts.SUCCESS);
+    testHTMLFlow("This is [ test", "This is &#091; test", Consts.SUCCESS);
+    testHTMLFlow("This is \\ test", "This is &#092; test", Consts.SUCCESS);
+    testHTMLFlow("This is ] test", "This is &#093; test", Consts.SUCCESS);
+    testHTMLFlow("This is ^ test", "This is &#094; test", Consts.SUCCESS);
+    testHTMLFlow("This is _ test", "This is &#095; test", Consts.SUCCESS);
+    testHTMLFlow("This is ` test", "This is &#096; test", Consts.SUCCESS);
+    testHTMLFlow("This is { test", "This is &#123; test", Consts.SUCCESS);
+    testHTMLFlow("This is ~ test", "This is &#126; test", Consts.SUCCESS);
+
+    //first twelve 
+    testHTMLFlow("<>&\"'()#%+-;", "&lt;&gt;&amp;&quot;&#039;&#040;&#041;&#035;&#037;&#043;&#045;&#059;", Consts.SUCCESS);
+    //remainder
+    //testHTMLFlow("\t\n\r!$*./:=?@[\\]^_`{|}~", "&#009;&#010;&#013;&#033;&#036;&#042;&#046;&#047;&#058;&#061;&#063;&#064;&#091;&#092;&#093;&#094;&#095;&#096;&#123;&#124;&#125;&#126;", Consts.SUCCESS);
+    //testHTMLFlow("This is a test \t\n\r!$*./:=?@[\\]^_`{|}~ This is a test", "This is a test &#009;&#010;&#013;&#033;&#036;&#042;&#046;&#047;&#058;&#061;&#063;&#064;&#091;&#092;&#093;&#094;&#095;&#096;&#123;&#124;&#125;&#126; This is a test", Consts.SUCCESS);
+
+    testHTMLFlow("\t!$*./:=?@[\\]^_`{|}~", "&#009;&#033;&#036;&#042;&#046;&#047;&#058;&#061;&#063;&#064;&#091;&#092;&#093;&#094;&#095;&#096;&#123;&#124;&#125;&#126;", Consts.SUCCESS);
+    testHTMLFlow("This is a test \t!$*./:=?@[\\]^_`{|}~ This is a test", "This is a test &#009;&#033;&#036;&#042;&#046;&#047;&#058;&#061;&#063;&#064;&#091;&#092;&#093;&#094;&#095;&#096;&#123;&#124;&#125;&#126; This is a test", Consts.SUCCESS);
+    
+    testHTMLFlow("This is a test", "This is a test ", Consts.FAILS);
+    testHTMLFlow("This is a test", " This is a test", Consts.FAILS);
+    testHTMLFlow("This is a test", "This  is a test", Consts.FAILS);
+    testHTMLFlow("This is a test", " This is a test ", Consts.FAILS);
+    testHTMLFlow("This is > test", "This is > test", Consts.FAILS);
+    testHTMLFlow("This is > test", "This is &gt test", Consts.FAILS);
+    testHTMLFlow("This is > test", "This is gt; test", Consts.FAILS);
+    testHTMLFlow("This is + test", "This is &043; test", Consts.FAILS);
+    testHTMLFlow("This is + test", "This is &#043 test", Consts.FAILS);
+    testHTMLFlow("This is + test", "This is &#043;  test", Consts.FAILS);
+    testHTMLFlow("This is ~ test", "This is &#127; test", Consts.FAILS);
+ }
+
+  public void testForReplacementString(){
+    testReplacementString("abc", "abc", Consts.SUCCESS);
+    testReplacementString("a$b", "a\\$b", Consts.SUCCESS);
+    testReplacementString("$1", "\\$1", Consts.SUCCESS);
+    testReplacementString("a\\b", "a\\\\b", Consts.SUCCESS);
+    testReplacementString("[ab]", "[ab]", Consts.SUCCESS);
+    testReplacementString("ab{}*&*()%#^@", "ab{}*&*()%#^@", Consts.SUCCESS);
+    
+    testReplacementString("abc", "abcd", Consts.FAILS);
+    testReplacementString("a$b", "a$b", Consts.FAILS);
+    testReplacementString("$1", "$1", Consts.FAILS);
+    testReplacementString("a\\b", "a\\b", Consts.FAILS);
+  }
+  
+  public void testForJson(){
+    testJson("abc", "abc", Consts.SUCCESS);
+    testJson(" \"abc\" ", " \\\"abc\\\" ", Consts.SUCCESS);
+    testJson("This is one 'New York Ago'", "This is one 'New York Ago'", Consts.SUCCESS);
+    testJson("Joel" + '\t' + "Plaskett", "Joel\\tPlaskett", Consts.SUCCESS);
+    testJson("Joel" + '\f' + "Plaskett", "Joel\\fPlaskett", Consts.SUCCESS);
+    testJson("JoelPlaskett" + '\b', "JoelPlaskett\\b", Consts.SUCCESS);
+    testJson("JoelPlaskett" + '\n', "JoelPlaskett\\n", Consts.SUCCESS);
+    testJson("Joel Plaskett" + '\r', "Joel Plaskett\\r", Consts.SUCCESS);
+    testJson("Joel Plaskett" + '\r' + '\n' , "Joel Plaskett\\r\\n", Consts.SUCCESS);
+    testJson("My name is Flann O'Brien", "My name is Flann O'Brien", Consts.SUCCESS);
+    testJson("My \"name\" is Flann O'Brien", "My \\\"name\\\" is Flann O'Brien", Consts.SUCCESS);
+    testJson("I am \\ slash", "I am \\\\ slash", Consts.SUCCESS);
+    testJson("I am / backslash", "I am \\/ backslash", Consts.SUCCESS);
+    testJson("This is \"the\" most \\complex\\ test /of/ all; it has lot's going in inside" + '\n' + '\r', "This is \\\"the\\\" most \\\\complex\\\\ test \\/of\\/ all; it has lot's going in inside\\n\\r" , Consts.SUCCESS);
+  }
+
+  // FIXTURE //
+
+   protected void setUp(){
+     //empty
+   }
+
+   /**
+    Re-set test objects.
+   */
+   protected void tearDown() {
+     //empty
+   }
+
+   // PRIVATE  //
+   
+  private static final Logger fLogger = Util.getLogger(TESTEscapeChars.class);  
+   
+   private void testForRegexHasMatch(String aUnescapedRegex){
+     Pattern pattern = Pattern.compile(EscapeChars.forRegex(aUnescapedRegex));
+     assertTrue(! pattern.pattern().equals(aUnescapedRegex) );
+     Matcher matcher = pattern.matcher(aUnescapedRegex);
+     fLogger.fine("Escaped pattern: " + pattern.pattern());
+     if ( !matcher.matches() ){
+       fail(aUnescapedRegex + " does not match pattern " + pattern.pattern());
+     }
+   }
+   
+   private void testHTMLFlow(String aInput, String aExpectedOutput, Boolean aSeekingSuccess){
+     String output = EscapeChars.forHTML(aInput);
+     if ( aSeekingSuccess ) {
+       if ( ! output.equals(aExpectedOutput) ){
+         fail("Fail: Output was " + Util.quote(output) + ", expected successful match with " + Util.quote(aExpectedOutput));
+       }
+     }
+     else {
+       if ( output.equals(aExpectedOutput) ){
+         fail("Fail: Output was " + Util.quote(output) + ", expected failed match with " + Util.quote(aExpectedOutput));
+       }
+     }
+   }
+   
+   private void testReplacementString(String aInput, String aExpectedOutput, Boolean aSeekingSuccess){
+     String output = EscapeChars.forReplacementString(aInput);
+     if ( aSeekingSuccess ) {
+       if( ! output.equals(aExpectedOutput)) {
+         fail("Fail: Output was " + Util.quote(output) + ", but expected " + Util.quote(aExpectedOutput));
+       }
+     }
+     else {
+       if ( output.equals(aExpectedOutput) ){
+         fail("Fail: Output was " + Util.quote(output) + ", but expected failure to match " + Util.quote(aExpectedOutput));
+       }
+     }
+   }
+   
+   private void testJson(String aInput, String aExpectedOutput, Boolean aSeekingSuccess){
+     String output = EscapeChars.forJSON(aInput);
+     if(aSeekingSuccess){
+       if( !output.equals(aExpectedOutput) ) {
+         fail("Fail: Output was " + Util.quote(output) + ", but expected " + Util.quote(aExpectedOutput));
+       }
+     }
+     else {
+       if( output.equals(aExpectedOutput) ){
+         fail("Fail: Output was " + Util.quote(output) + ", but expected failure to match " + Util.quote(aExpectedOutput));
+       }
+     }
+   }
+}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/TESTRegex.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,580 @@
+package hirondelle.web4j.util;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+import java.util.regex.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> test cases for {@link Regex}. 
+*/
+public final class TESTRegex extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = {TESTRegex.class.getName()};
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+
+  public TESTRegex(String aName) {
+    super(aName);
+  }
+
+  // TEST CASES //
+
+  public void testWhitespace() {
+    testIsMatch(Regex.WS, "");
+    testIsMatch(Regex.WS, " ");
+    testIsMatch(Regex.WS, Consts.NEW_LINE);
+    testIsMatch(Regex.WS, Consts.TAB);
+    
+    testIsNotMatch(Regex.WS, "A");
+    testIsNotMatch(Regex.WS, "0");
+    testIsNotMatch(Regex.WS, "  0 ");
+  }
+
+  public void testAnyCharsExceptNewLine(){
+    testIsMatch(Regex.ANY_CHARS, "");
+    testIsMatch(Regex.ANY_CHARS, " ");
+    testIsMatch(Regex.ANY_CHARS, ".");
+    testIsMatch(Regex.ANY_CHARS, "A");
+    testIsMatch(Regex.ANY_CHARS, "9");
+    testIsMatch(Regex.ANY_CHARS, "@#$%@#%@#*%@#(%@)_#(%");
+    testIsMatch(Regex.ANY_CHARS, Consts.TAB);
+    
+    testIsNotMatch(Regex.ANY_CHARS, Consts.NEW_LINE);
+  }
+  
+  public void testStartTag(){
+    testIsMatch(Regex.START_TAG, "<");
+    
+    testIsNotMatch(Regex.START_TAG, "< ");
+    testIsNotMatch(Regex.START_TAG, " <");
+    testIsNotMatch(Regex.START_TAG, ">");
+  }
+
+  public void testEndTag(){
+    testIsMatch(Regex.END_TAG, ">");
+    testIsMatch(Regex.END_TAG, " >");
+    
+    testIsNotMatch(Regex.END_TAG, "> ");
+    testIsNotMatch(Regex.END_TAG, "<");
+  }
+  
+  public void testAllButEndOfTag(){
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, "");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, " ");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, "value='blah'");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, " value='blah' ");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, " value=blah ");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, " value=\"blah\" ");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, "checked value='blah'");
+    testIsMatch(Regex.ALL_BUT_END_OF_TAG, " checked value='blah' ");
+    
+    testIsNotMatch(Regex.ALL_BUT_END_OF_TAG, "value='blah'>" );
+    testIsNotMatch(Regex.ALL_BUT_END_OF_TAG, " checked value='blah' >" );
+    testIsNotMatch(Regex.ALL_BUT_END_OF_TAG, " checked value='blah' > " );
+  }
+  
+  public void testQuote(){
+    testIsMatch(Regex.QUOTE, "\"");
+    testIsMatch(Regex.QUOTE, "'");
+    
+    testIsNotMatch(Regex.QUOTE, "");
+    testIsNotMatch(Regex.QUOTE, " ");
+    testIsNotMatch(Regex.QUOTE, "A");
+    testIsNotMatch(Regex.QUOTE, "'A'");
+    testIsNotMatch(Regex.QUOTE, "\"A\"");
+  }
+  
+  public void testNoSpecialHtmlChars(){
+    testIsMatch(Regex.NO_SPECIAL_HTML_CHAR, "p");
+    testIsMatch(Regex.NO_SPECIAL_HTML_CHAR, "#");
+    testIsMatch(Regex.NO_SPECIAL_HTML_CHAR, " ");
+    
+    testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "<");
+    testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, ">");
+    testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "'");
+    testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "\"");
+    testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "blah"); // >1 char
+  }
+  
+  public void testQuotedAttr(){
+    testIsMatch(Regex.QUOTED_ATTR, "'blah'");
+    testIsMatch(Regex.QUOTED_ATTR, "'blah blah'");
+    testIsMatch(Regex.QUOTED_ATTR, "'blah '");
+    testIsMatch(Regex.QUOTED_ATTR, "' blah'");
+    testIsMatch(Regex.QUOTED_ATTR, "' blah '");
+    testIsMatch(Regex.QUOTED_ATTR, "\"blah\"");
+    testIsMatch(Regex.QUOTED_ATTR, "\"blah \"");
+    testIsMatch(Regex.QUOTED_ATTR, "\"blah blah\"");
+    testIsMatch(Regex.QUOTED_ATTR, "\" blah \"");
+    testIsNotMatch(Regex.QUOTED_ATTR, "blah");
+    testIsNotMatch(Regex.QUOTED_ATTR, " blah ");
+    testIsNotMatch(Regex.QUOTED_ATTR, "blah blah");
+    testIsNotMatch(Regex.QUOTED_ATTR, "'<blah>'");
+    testIsNotMatch(Regex.QUOTED_ATTR, "'blah''");
+    testGroupOneIsMatch(Regex.QUOTED_ATTR, "'blah'", "blah");
+    testGroupOneIsMatch(Regex.QUOTED_ATTR, "' blah'", " blah");
+    testGroupOneIsMatch(Regex.QUOTED_ATTR, "'blah '", "blah ");
+    testGroupOneIsMatch(Regex.QUOTED_ATTR, "' blah '", " blah ");
+    testGroupOneIsMatch(Regex.QUOTED_ATTR, "'blah blah'", "blah blah");
+  }
+  
+  public void testTrimmedText(){
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " blah ", "blah");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, "blah ", "blah");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " blah ", "blah");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " blah blah ", "blah blah");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, "#", "#");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " #", "#");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " # ", "#");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, "this is good", "this is good");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good", "this is good");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, "  this is good", "this is good");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good ", "this is good");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good  ", "this is good");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good  ", "this is good");
+    testGroupOneIsMatch(Regex.TRIMMED_TEXT, "  this is good ", "this is good");
+  }
+  
+  public void testAttrValue(){
+    testIsMatch(Regex.UNQUOTED_ATTR, "blah");
+    testIsMatch(Regex.UNQUOTED_ATTR, "-_.:blah");
+    testIsMatch(Regex.UNQUOTED_ATTR, "BLAH");
+    testIsMatch(Regex.UNQUOTED_ATTR, "0Blah9");
+    testIsNotMatch(Regex.UNQUOTED_ATTR, " ");
+    testIsNotMatch(Regex.UNQUOTED_ATTR, "");
+    testIsNotMatch(Regex.UNQUOTED_ATTR, "blah!");
+    testIsNotMatch(Regex.UNQUOTED_ATTR, "blah{");
+    testIsNotMatch(Regex.UNQUOTED_ATTR, "blah*");
+    
+    testIsMatch(Regex.SINGLE_QUOTED_ATTR, "'blah'");
+    testIsMatch(Regex.SINGLE_QUOTED_ATTR, "''");
+    testIsMatch(Regex.SINGLE_QUOTED_ATTR, "'blah blah < > '");
+    testIsMatch(Regex.SINGLE_QUOTED_ATTR, "'This is not a \"bongo\" drum.'");
+    testIsMatch(Regex.SINGLE_QUOTED_ATTR, EscapeChars.forRegex("'!@#$!@#$%@$^&*()'") );
+    testIsNotMatch(Regex.SINGLE_QUOTED_ATTR, "'This isn't a match'");
+    
+    testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"blah\"");
+    testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"\"");
+    testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is Billy's bongo <drum>.\"");
+    testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is  <> :; - . _ ! @ \"");
+    testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is a 'so-called' quote.\"");
+    testIsNotMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is not a \"so-called\" match.\"");
+    
+    testIsMatch(Regex.ATTR_VALUE, "\"blah\"");
+    testIsMatch(Regex.ATTR_VALUE, "\"This is Billy's bongo.\"");
+    testIsMatch(Regex.ATTR_VALUE, "\"This is a 'so-called' quote.\"");
+    testIsMatch(Regex.ATTR_VALUE, "\"This is a ! (thing) of 2.5.% _- , !?:;{}\"");
+    testIsMatch(Regex.ATTR_VALUE, "\"\"");
+    
+    testIsMatch(Regex.ATTR_VALUE, "'blah'");
+    testIsMatch(Regex.ATTR_VALUE, "''");
+    testIsMatch(Regex.ATTR_VALUE, "'This is \"quoted\" like so.'");
+    testIsMatch(Regex.ATTR_VALUE, "'This is a ! (thing) of 2.5.% _- , !?:;{}'");
+    
+    testIsMatch(Regex.ATTR_VALUE, "blah");
+    testIsMatch(Regex.ATTR_VALUE, "-._:BLAH09");
+  }
+  
+  public void testCommaInsertion(){
+    testCommas("1", "1");
+    testCommas("10", "10");
+    testCommas("100", "100");
+    testCommas("1000", "1,000");
+    testCommas("10000", "10,000");
+    testCommas("100000", "100,000");
+    testCommas("1000000", "1,000,000");
+    testCommas("0", "0");
+    testCommas("0.0", "0.0");
+    testCommas("0.01", "0.01");
+    testCommas("0.001", "0.001");
+    testCommas("100000.001", "100,000.001");
+  }
+  
+  public void testEmailAddress(){
+    testIsMatch(Regex.EMAIL_ADDR, "bob@blah.com");
+    testIsMatch(Regex.EMAIL_ADDR, "bob@blah.blah.com");
+    testIsMatch(Regex.EMAIL_ADDR, "bob-.@blah.com");
+    testIsMatch(Regex.EMAIL_ADDR, "bob@blah.org");
+    testIsMatch(Regex.EMAIL_ADDR, "bob@blah.aero");
+    testIsMatch(Regex.EMAIL_ADDR, "bob@blah.uk");
+    testIsMatch(Regex.EMAIL_ADDR, "bob@blah.boo.uk");
+    
+    testIsNotMatch(Regex.EMAIL_ADDR, "-.bob@blah.com");
+    testIsNotMatch(Regex.EMAIL_ADDR, "bob");
+    testIsNotMatch(Regex.EMAIL_ADDR, "");
+    testIsNotMatch(Regex.EMAIL_ADDR, " ");
+    testIsNotMatch(Regex.EMAIL_ADDR, "bob@");
+    testIsNotMatch(Regex.EMAIL_ADDR, "bob@blah");
+    testIsNotMatch(Regex.EMAIL_ADDR, "bob@blah.");
+    testIsNotMatch(Regex.EMAIL_ADDR, "bob@blah.joe");
+  }
+  
+  public void testIpAddress(){
+    testIsMatch(Regex.IP_ADDR, "0.0.0.0");
+    testIsMatch(Regex.IP_ADDR, "10.0.0.0");
+    testIsMatch(Regex.IP_ADDR, "100.0.0.0");
+    testIsMatch(Regex.IP_ADDR, "255.0.0.0");
+    testIsMatch(Regex.IP_ADDR, "219.64.54.0");
+    testIsMatch(Regex.IP_ADDR, "127.0.0.1");
+    testIsMatch(Regex.IP_ADDR, "255.255.255.255");
+    testIsMatch(Regex.IP_ADDR, "220.230.240.250");
+    
+    testIsNotMatch(Regex.IP_ADDR, "");
+    testIsNotMatch(Regex.IP_ADDR, " ");
+    testIsNotMatch(Regex.IP_ADDR, "1");
+    testIsNotMatch(Regex.IP_ADDR, "255");
+    testIsNotMatch(Regex.IP_ADDR, "255.");
+    testIsNotMatch(Regex.IP_ADDR, "0.0.0.");
+    testIsNotMatch(Regex.IP_ADDR, "0.0.0.0.");
+    testIsNotMatch(Regex.IP_ADDR, ".0.0.0.0");
+  }
+  
+  public void testNumber(){
+    testIsMatch(Regex.NUMBER, "0");
+    testIsMatch(Regex.NUMBER, "123");
+    testIsMatch(Regex.NUMBER, "+123");
+    testIsMatch(Regex.NUMBER, "-123");
+    testIsMatch(Regex.NUMBER, "3.14159");
+    testIsMatch(Regex.NUMBER, "-3.14159");
+    testIsMatch(Regex.NUMBER, "+0");
+    testIsMatch(Regex.NUMBER, "-0");
+    testIsMatch(Regex.NUMBER, "+0.0");
+    testIsMatch(Regex.NUMBER, "-0.0");
+    testIsMatch(Regex.NUMBER, ".2310");
+    testIsMatch(Regex.NUMBER, "-.2310");
+    testIsMatch(Regex.NUMBER, "+.235");
+    testIsMatch(Regex.NUMBER, "+0.2561");
+    testIsMatch(Regex.NUMBER, "-0.21365");
+    testIsMatch(Regex.NUMBER, "2.");
+    
+    testIsNotMatch(Regex.NUMBER, "");
+    testIsNotMatch(Regex.NUMBER, " ");
+    testIsNotMatch(Regex.NUMBER, "A");
+    testIsNotMatch(Regex.NUMBER, "A123");
+    testIsNotMatch(Regex.NUMBER, "123 ");
+    testIsNotMatch(Regex.NUMBER, " 123");
+    testIsNotMatch(Regex.NUMBER, "1.2 ");
+    testIsNotMatch(Regex.NUMBER, "1,000");
+    testIsNotMatch(Regex.NUMBER, "1,000.00");
+    testIsNotMatch(Regex.NUMBER, "+ 123");
+    testIsNotMatch(Regex.NUMBER, "- 123");
+  }
+
+  /*
+      * <P>Example matches : <tt>1000, 100.25, .25, 0.25, -.13, -0.13</tt>
+   <P>Example mismatches : <tt>1,000.00, 100., 100.0, 100.123, -56.000, .123</tt>
+
+   */
+  public void testDollars(){
+    testIsMatch(Regex.DOLLARS, "1000");
+    testIsMatch(Regex.DOLLARS, "+1000");
+    testIsMatch(Regex.DOLLARS, "-1000");
+    testIsMatch(Regex.DOLLARS, "100.25");
+    testIsMatch(Regex.DOLLARS, "+100.25");
+    testIsMatch(Regex.DOLLARS, "-100.25");
+    testIsMatch(Regex.DOLLARS, ".25");
+    testIsMatch(Regex.DOLLARS, "+.25");
+    testIsMatch(Regex.DOLLARS, "-.25");
+    testIsMatch(Regex.DOLLARS, "0.25");
+    testIsMatch(Regex.DOLLARS, "+0.25");
+    testIsMatch(Regex.DOLLARS, "-0.25");
+    testIsMatch(Regex.DOLLARS, ".13");
+    testIsMatch(Regex.DOLLARS, "+.13");
+    testIsMatch(Regex.DOLLARS, "-.13");
+    
+    testIsNotMatch(Regex.DOLLARS, "1000 ");
+    testIsNotMatch(Regex.DOLLARS, " 1000");
+    testIsNotMatch(Regex.DOLLARS, "1,000");
+    testIsNotMatch(Regex.DOLLARS, "1,0000.00");
+    testIsNotMatch(Regex.DOLLARS, "100.");
+    testIsNotMatch(Regex.DOLLARS, "100.0");
+    testIsNotMatch(Regex.DOLLARS, "100.123");
+    testIsNotMatch(Regex.DOLLARS, "-56.000");
+    testIsNotMatch(Regex.DOLLARS, ".123");
+    testIsNotMatch(Regex.DOLLARS, ".2");
+    testIsNotMatch(Regex.DOLLARS, ".222");
+    testIsNotMatch(Regex.DOLLARS, ".22A");
+  }
+  
+  public void testMoney(){
+    testIsMatch(Regex.MONEY, "1000");
+    testIsMatch(Regex.MONEY, "+1000");
+    testIsMatch(Regex.MONEY, "-1000");
+    testIsMatch(Regex.MONEY, "100.01");
+    testIsMatch(Regex.MONEY, "+100.01");
+    testIsMatch(Regex.MONEY, "-100.01");
+    testIsMatch(Regex.MONEY, "100,12");
+    testIsMatch(Regex.MONEY, "100,123");
+    testIsMatch(Regex.MONEY, "1000,123");
+    testIsMatch(Regex.MONEY, ".253");
+    testIsMatch(Regex.MONEY, "0.25");
+    testIsMatch(Regex.MONEY, "-.13");
+    testIsMatch(Regex.MONEY, "-0.000");
+    
+    testIsNotMatch(Regex.MONEY, "1,000.00");
+    testIsNotMatch(Regex.MONEY, "1.000,00");
+    testIsNotMatch(Regex.MONEY, "+1 000");
+    //testIsNotMatch(Regex.MONEY, "-1,000"); //passes! maybe this is ok... an English 1,000 is a French 1.000
+    //testIsNotMatch(Regex.MONEY, "-1,00"); //passes!
+    testIsNotMatch(Regex.MONEY, "-1,000.00");
+    testIsNotMatch(Regex.MONEY, "1 000");
+    testIsNotMatch(Regex.MONEY, "100.");
+    testIsNotMatch(Regex.MONEY, "100,");
+    testIsNotMatch(Regex.MONEY, "100.0");
+    testIsNotMatch(Regex.MONEY, "100,0");
+    testIsNotMatch(Regex.MONEY, "100.1234");
+    testIsNotMatch(Regex.MONEY, "-100,1234");
+    testIsNotMatch(Regex.MONEY, ",1");
+    testIsNotMatch(Regex.MONEY, ",1234");
+  }
+  
+  public void testOrdinal(){
+    testIsMatch(Regex.DIGITS, "0");
+    testIsMatch(Regex.DIGITS, "1");
+    testIsMatch(Regex.DIGITS, "9");
+    testIsMatch(Regex.DIGITS, "10");
+    testIsMatch(Regex.DIGITS, "11");
+    testIsMatch(Regex.DIGITS, "19");
+    testIsMatch(Regex.DIGITS, "20");
+    testIsMatch(Regex.DIGITS, "1000");
+    testIsMatch(Regex.DIGITS, "987988");
+    testIsMatch(Regex.DIGITS, "128568521657894");
+    testIsMatch(Regex.DIGITS, "01");
+    testIsMatch(Regex.DIGITS, "09");
+    testIsMatch(Regex.DIGITS, "018");
+    testIsMatch(Regex.DIGITS, "0018");
+    testIsMatch(Regex.DIGITS, "00018");
+    
+    testIsNotMatch(Regex.DIGITS, "");
+    testIsNotMatch(Regex.DIGITS, " ");
+    testIsNotMatch(Regex.DIGITS, "A");
+    testIsNotMatch(Regex.DIGITS, "-1");
+    testIsNotMatch(Regex.DIGITS, " 1");
+    testIsNotMatch(Regex.DIGITS, "1 ");
+    testIsNotMatch(Regex.DIGITS, "+1");
+    testIsNotMatch(Regex.DIGITS, "-0");
+    testIsNotMatch(Regex.DIGITS, "+0");
+    testIsNotMatch(Regex.DIGITS, "0.0");
+    testIsNotMatch(Regex.DIGITS, "2.0");
+    testIsNotMatch(Regex.DIGITS, "27.15");
+  }
+  
+  public void testOrdinalOfLimitedLength(){
+    testIsMatch(Regex.forNDigits(1), "0");
+    testIsMatch(Regex.forNDigits(1), "1");
+    testIsMatch(Regex.forNDigits(1), "9");
+    
+    testIsMatch(Regex.forNDigits(2), "0");
+    testIsMatch(Regex.forNDigits(2), "10");
+    testIsMatch(Regex.forNDigits(2), "01");
+    testIsMatch(Regex.forNDigits(2), "99");
+    
+    testIsMatch(Regex.forNDigits(3), "0");
+    testIsMatch(Regex.forNDigits(3), "1");
+    testIsMatch(Regex.forNDigits(3), "11");
+    testIsMatch(Regex.forNDigits(3), "01");
+    testIsMatch(Regex.forNDigits(3), "100");
+    testIsMatch(Regex.forNDigits(3), "001");
+    testIsMatch(Regex.forNDigits(3), "568");
+    testIsMatch(Regex.forNDigits(3), "999");
+    
+    testIsNotMatch(Regex.forNDigits(1), "");
+    testIsNotMatch(Regex.forNDigits(1), " ");
+    testIsNotMatch(Regex.forNDigits(1), "a");
+    testIsNotMatch(Regex.forNDigits(1), "-1");
+    testIsNotMatch(Regex.forNDigits(1), "+1");
+    testIsNotMatch(Regex.forNDigits(1), "2.0");
+    testIsNotMatch(Regex.forNDigits(1), "0.0");
+    testIsNotMatch(Regex.forNDigits(1), "01");
+    testIsNotMatch(Regex.forNDigits(1), "10");
+    
+    testIsNotMatch(Regex.forNDigits(2), "a");
+    testIsNotMatch(Regex.forNDigits(2), "");
+    testIsNotMatch(Regex.forNDigits(2), " ");
+    testIsNotMatch(Regex.forNDigits(2), "-1");
+    testIsNotMatch(Regex.forNDigits(2), "+1");
+    testIsNotMatch(Regex.forNDigits(2), "2.0");
+    testIsNotMatch(Regex.forNDigits(2), "0.0");
+    testIsNotMatch(Regex.forNDigits(2), "100");
+    testIsNotMatch(Regex.forNDigits(2), "001");
+    
+    testIsNotMatch(Regex.forNDigits(3), "2.0");
+    testIsNotMatch(Regex.forNDigits(3), "1000");
+    testIsNotMatch(Regex.forNDigits(3), "0111");
+    testIsNotMatch(Regex.forNDigits(3), " 111");
+    testIsNotMatch(Regex.forNDigits(3), "111 ");
+    
+    //test that param cannot be less than one
+    try {
+      Regex.forNDigits(0);
+    }
+    catch (RuntimeException ex){
+      return;
+    }
+    fail("Should not be able to create regex for ordinal of length 0.");
+  }
+  
+  public void testCamelHump(){
+    testCamelHump(fSUCCESS, "LoginName", "Login Name");
+    testCamelHump(fSUCCESS, "EmailAddress", "Email Address");
+    testCamelHump(fSUCCESS, "BlahBlahBlahBlah", "Blah Blah Blah Blah");
+    testCamelHump(fSUCCESS, " LoginName", " Login Name");
+    testCamelHump(fSUCCESS, "LoginName ", "Login Name ");
+    testCamelHump(fSUCCESS, "Login6Name", "Login6 Name");
+    
+    testCamelHump(fFAIL, "LoginName", "LoginNAme");
+    testCamelHump(fFAIL, "LoginName", "Login NAme");
+    testCamelHump(fFAIL, "LoginName", "Login Name ");
+    testCamelHump(fFAIL, "LoginName", " Login Name");
+  }
+  
+  public void testSimpleScopedIdentifier(){
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah42");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah_42");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "BlahBlah");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "BLAH_BLAH");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "BLAH_BLAH_42");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.sola");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "TRANSLATE.FETCH_ALL_TRANSLATIONS");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "SECURITY.fetch_users_and_roles");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "SECURITY_BLAH.fetch_users_and_roles");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "SECURITY_42.fetch_users_and_roles_42");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "_");
+    testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "b");
+    
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.sola.foin");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah..sola");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, ".blah.sola");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.sola.");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "*.blah");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.*");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, " ");
+    testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "  ");
+  }
+  
+  public void testHours(){
+    testIsMatch(Regex.HOURS, "00");
+    testIsMatch(Regex.HOURS, "01");
+    testIsMatch(Regex.HOURS, "02");
+    testIsMatch(Regex.HOURS, "03");
+    testIsMatch(Regex.HOURS, "04");
+    testIsMatch(Regex.HOURS, "05");
+    testIsMatch(Regex.HOURS, "06");
+    testIsMatch(Regex.HOURS, "07");
+    testIsMatch(Regex.HOURS, "08");
+    testIsMatch(Regex.HOURS, "09");
+    testIsMatch(Regex.HOURS, "10");
+    testIsMatch(Regex.HOURS, "11");
+    testIsMatch(Regex.HOURS, "12");
+    testIsMatch(Regex.HOURS, "13");
+    testIsMatch(Regex.HOURS, "19");
+    testIsMatch(Regex.HOURS, "20");
+    testIsMatch(Regex.HOURS, "23");
+    
+    testIsNotMatch(Regex.HOURS, "0");
+    testIsNotMatch(Regex.HOURS, "00 ");
+    testIsNotMatch(Regex.HOURS, " 00");
+    testIsNotMatch(Regex.HOURS, "12:00");
+  }
+  
+  public void testMinutes(){
+    testIsMatch(Regex.MINUTES, "00");
+    testIsMatch(Regex.MINUTES, "01");
+    testIsMatch(Regex.MINUTES, "09");
+    testIsMatch(Regex.MINUTES, "10");
+    testIsMatch(Regex.MINUTES, "59");
+    
+    testIsNotMatch(Regex.MINUTES, "0");
+    testIsNotMatch(Regex.MINUTES, "1");
+    testIsNotMatch(Regex.MINUTES, "60");
+    testIsNotMatch(Regex.MINUTES, "-1");
+    testIsNotMatch(Regex.MINUTES, "-00");
+    testIsNotMatch(Regex.MINUTES, "100");
+  }
+  
+  public void testHoursAndMinutes(){
+    testIsMatch(Regex.HOURS_AND_MINUTES, "00:00");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "23:59");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "01:00");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "00:01");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "00:59");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "23:00");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "23:01");
+    testIsMatch(Regex.HOURS_AND_MINUTES, "12:45");
+    
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, "0:00");
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, " 00:00");
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, "00:00 ");
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, "1:00");
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, "3:00");
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, "00:60");
+    testIsNotMatch(Regex.HOURS_AND_MINUTES, "24:00");
+  }
+  
+  // FIXTURE //
+
+   protected void setUp(){
+   }
+
+   protected void tearDown() {
+   }
+
+   // PRIVATE  //
+   
+   private static final boolean fSUCCESS = true;
+   private static final boolean fFAIL = false;
+   
+   private void testIsMatch(String aPattern, String aInput){
+     Pattern pattern = Pattern.compile(aPattern);
+     Matcher matcher = pattern.matcher(aInput);
+     assertTrue(matcher.matches());
+   }
+   
+   private void testIsNotMatch(String aPattern, String aInput){
+     Pattern pattern = Pattern.compile(aPattern);
+     Matcher matcher = pattern.matcher(aInput);
+     assertTrue(!matcher.matches());
+   }
+   
+   private void testGroupOneIsMatch(String aPattern, String aInput, String aGroupOne){
+     Pattern pattern = Pattern.compile(aPattern);
+     Matcher matcher = pattern.matcher(aInput);
+     assertTrue(matcher.matches());
+     assertTrue(matcher.group(Regex.FIRST_GROUP).equals(aGroupOne));
+   }
+   
+  private void testCommas(String aText, String aOutput){
+    Pattern commas = Pattern.compile(Regex.COMMA_INSERTION);
+    Matcher matchForCommas = commas.matcher(aText);
+    StringBuffer result = new StringBuffer();
+    while ( matchForCommas.find() ){
+      matchForCommas.appendReplacement(result, ",");
+    }
+    matchForCommas.appendTail(result);
+    assertTrue(result.toString().equals(aOutput));
+  }
+  
+  private void testCamelHump(boolean aSuccessDesired, String aIn, String aOut){
+    String replacement = " $1";
+    
+    Pattern pattern = Pattern.compile(Regex.CAMEL_HUMP_TEXT);
+    Matcher matcher = pattern.matcher(aIn);
+    if (aSuccessDesired) {
+      assertTrue( matcher.replaceAll(replacement).equals(aOut) );    
+    }
+    else {
+      assertTrue( ! matcher.replaceAll(replacement).equals(aOut) );    
+    }
+  }
+   
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/TESTUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,335 @@
+package hirondelle.web4j.util;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+import java.util.logging.*;
+import java.util.*;
+import java.math.BigDecimal;
+import hirondelle.web4j.util.Util;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> test cases for {@link Util}. 
+*/
+public final class TESTUtil extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = {TESTUtil.class.getName()};
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+  
+  public TESTUtil(String aName) {
+   super(aName);
+  }
+  
+  // TEST CASES //
+  
+  public void testReplace(){
+    testReplaceError(null, null, null);
+    testReplaceError(null, SUBSTR, REPLACEMENT);
+    testReplaceError(INPUT, null, REPLACEMENT);
+    testReplaceError(INPUT, SUBSTR, null);
+    
+    testReplaceSuccess(INPUT, SUBSTR, REPLACEMENT, RESULT);
+    testReplaceSuccess("this is silly this is silly", SUBSTR, REPLACEMENT, "this *IS* silly this *IS* silly");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *IS*  silly");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this  *IS* silly");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this  *IS*  silly");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *IS* silly ");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, " this *IS* silly");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *is* silly");
+    testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this IS silly");
+  }
+  
+  public void testLogOnePerLine(){
+    List list = new ArrayList();
+    list.add(null); 
+    list.add("Zebra"); 
+    list.add("aardvark");
+    list.add("Banana");
+    list.add("");
+    list.add("aardvark");
+    list.add(new BigDecimal("5.00"));
+    String listResult =  "(7) {" + NL + 
+    "  ''" + NL +
+    "  '5.00'" + NL +
+    "  'aardvark'" + NL +
+    "  'aardvark'" + NL +
+    "  'Banana'" + NL +
+    "  'null'" + NL + 
+    "  'Zebra'" + NL + 
+    "}";
+    testLogOnePerLineSucceed(list, listResult);
+    list = new ArrayList();
+    listResult =  "(0) {" + NL + 
+    "}";
+    testLogOnePerLineSucceed(list, listResult);
+    
+    Map map = new LinkedHashMap();
+    map.put("b", "blah");
+    map.put("a", new BigDecimal("5.00"));
+    map.put("Z", null);
+    map.put(null, new Integer(3));
+    String mapResult =  "(4) {" + NL + 
+    "  'a' = '5.00'" + NL +
+    "  'b' = 'blah'" + NL + 
+    "  'null' = '3'" + NL + 
+    "  'Z' = 'null'" + NL + 
+    "}";
+    testLogOnePerLineSucceed(map, mapResult);
+    
+    map = new LinkedHashMap();
+    mapResult =  "(0) {" + NL + 
+    "}";
+    testLogOnePerLineSucceed(map, mapResult);
+  }
+  
+  public void testRemoveQuotes(){
+    assertTrue(Util.removeQuotes("Yes").equals("Yes"));
+    assertTrue(Util.removeQuotes("'Yes'").equals("Yes")); 
+    assertTrue(Util.removeQuotes("'Yes").equals("Yes"));
+    assertTrue(Util.removeQuotes("'Yes").equals("Yes"));
+    assertTrue(Util.removeQuotes("Yes'").equals("Yes"));
+    assertTrue(Util.removeQuotes("\"Yes\"").equals("Yes"));
+    assertTrue(Util.removeQuotes("\"Yes").equals("Yes"));
+    assertTrue(Util.removeQuotes("Yes\"").equals("Yes"));
+    
+    assertTrue(Util.removeQuotes(" Yes").equals(" Yes"));
+    assertTrue(Util.removeQuotes(" 'Yes").equals(" 'Yes"));
+    assertTrue(Util.removeQuotes(" 'Yes'").equals(" 'Yes"));
+  }
+  
+  public void testNumDecimals(){
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("0"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("0.0"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("0.00"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("1"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("100"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("1.0"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("1.12"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("100.1"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("100.23"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("-100"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("-1.0"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("-1.12"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("-100.1"), 2));
+    assertTrue(Util.hasMaxDecimals(new BigDecimal("-100.23"), 2));
+    
+    assertFalse(Util.hasMaxDecimals(new BigDecimal("-100.234"), 2));
+    assertFalse(Util.hasMaxDecimals(new BigDecimal("1.234"), 2));
+    assertFalse(Util.hasMaxDecimals(new BigDecimal("1.2345"), 2));
+    assertFalse(Util.hasMaxDecimals(new BigDecimal("0.000"), 2));
+    assertFalse(Util.hasMaxDecimals(new BigDecimal("-0.000"), 2));
+    assertFalse(Util.hasMaxDecimals(new BigDecimal("-0.0000"), 2));
+
+    boolean hasThrownEx = false;
+    try {
+      Util.hasMaxDecimals(new BigDecimal("23.23"), 0);
+    }
+    catch (Throwable ex){
+      hasThrownEx = true;
+    }
+    assertTrue(hasThrownEx);
+  }
+  
+  public void testHasNumDecimals(){
+    testHasNumDecimals("100", 0, PASS);
+    testHasNumDecimals("999.82", 2, PASS);
+    testHasNumDecimals("100.1", 1, PASS);
+    testHasNumDecimals("-89", 0, PASS);
+    testHasNumDecimals("-89.56321", 5, PASS);
+    testHasNumDecimals("8.1234567890", 10, PASS); 
+    
+    testHasNumDecimals("-89.56321", 4, FAIL);
+  }
+  
+  public void testWithNoSpaces(){
+    testWithNoSpacesSuccess("Is Coder Key", "IsCoderKey");
+  }
+  
+  public void testBuildTimeZone(){
+    testTimeZoneSuccess("GMT");
+    testTimeZoneSuccess("America/Halifax");
+    testTimeZoneSuccess(" GMT ");
+    testTimeZoneSuccess("PST");
+    
+    testTimeZoneFailure("Pacific");
+    testTimeZoneFailure("");
+    testTimeZoneFailure(null);
+  }
+  
+  public void testInitialCapital(){
+    testCap("blah", "Blah");
+    testCap(" blah", " blah");
+    testCap(" blah ", " blah ");
+    testCap("Blah", "Blah");
+    testCap("b", "B");
+    testCap("b ", "B ");
+    
+    //failures
+    testCapFails("blah", "blah");
+    testCapFails("blah", "BLAH");
+    testCapFails("blah", "Blah ");
+    testCapFails("blah", " Blah");
+    
+    //testCapFails(" ", "B "); fails 
+  }
+  
+  public void testQuote(){
+    assertTrue(Util.quote("blah").equals("'blah'"));
+    assertTrue(Util.quote("").equals("''"));
+    assertTrue(Util.quote(null).equals("'null'"));
+    assertTrue(Util.quote(" ").equals("' '"));
+    
+    char[] word = null;
+    assertTrue(Util.quote(word).equals("'null'"));
+    char[] word2  = {'a', 'b'};
+    assertTrue(Util.quote(word2).startsWith("'[C@")); //arrays of primitives don't work well
+    
+    String thing = null;
+    assertTrue(Util.quote(thing).equals("'null'"));
+    
+    List<String> names = Arrays.asList("blah", null);
+    assertTrue(Util.quote(names).equals("'[blah, null]'"));
+  }
+  
+  // FIXTURE //
+  protected void setUp(){
+  }
+
+  /**
+   Re-set test objects.
+  */
+  protected void tearDown() {
+  }
+
+  // PRIVATE  //
+  private static final String INPUT = "this is silly";
+  private static final String SUBSTR = " is ";
+  private static final String REPLACEMENT = " *IS* ";
+  private static final String RESULT = "this *IS* silly";
+  private static final String NL = Consts.NEW_LINE;
+  private static final boolean PASS = true;
+  private static final boolean FAIL = false;
+  
+  private static final Logger fLogger = Util.getLogger(TESTUtil.class);
+  
+  /**
+   Tests conditions which should generate an exception. 
+  */
+  private void testReplaceError(String aInput, String aOld, String aNew){
+    boolean hasError = false;
+    try {
+      Util.replace(aInput, aOld, aNew);
+    }
+    catch (Throwable ex){
+      hasError = true;
+    }
+    if ( ! hasError ){
+      fail(
+        "Input : " + Util.quote(aInput) + 
+        " Old : " + Util.quote(aOld) + 
+        " New : " + Util.quote(aNew) 
+      );
+    }
+  }
+  
+  private void testReplaceSuccess(
+    String aInput, String aSubstr, String aReplacement, String aExpectedResult
+  ){
+    String result = Util.replace(aInput, aSubstr, aReplacement);
+    if ( ! result.equals(aExpectedResult) ) {
+      fail(
+        Util.quote(result) + " does not equal expected result : " + 
+        Util.quote(aExpectedResult)
+      );
+    }
+  }
+
+  private void testReplaceFailure(
+    String aInput, String aSubstr, String aReplacement, String aExpectedMismatch
+  ){
+    String result = Util.replace(aInput, aSubstr, aReplacement);
+    if ( result.equals(aExpectedMismatch) ) {
+      fail(
+        Util.quote(result) + " equals, but expected mismatch : " + 
+        Util.quote(aExpectedMismatch)
+      );
+    }
+  }
+  
+  private void testLogOnePerLineSucceed(Collection aInput, String aExpectedMatch){
+    String result = Util.logOnePerLine(aInput);
+    if( ! result.equals(aExpectedMatch) ){
+      fail(
+        Util.quote(result) + " doesn't equal, but expected match : " + 
+        Util.quote(aExpectedMatch)
+      );
+    }
+  }
+
+  private void testLogOnePerLineSucceed(Map aInput, String aExpectedMatch){
+    String result = Util.logOnePerLine(aInput);
+    if( ! result.equals(aExpectedMatch) ){
+      fail(
+        Util.quote(result) + " doesn't equal, but expected match : " + 
+        Util.quote(aExpectedMatch)
+      );
+    }
+  }
+  
+  private void testWithNoSpacesSuccess(String aInput, String aExpectedMatch){
+    String result = Util.withNoSpaces(aInput);
+    if( ! result.equals(aExpectedMatch) ){
+      fail(
+        Util.quote(result) + " doesn't equal, but expected match : " + 
+        Util.quote(aExpectedMatch)
+      );
+    }
+  }
+  
+  private void testTimeZoneSuccess(String aTimeZone){
+    Util.buildTimeZone(aTimeZone);
+  }
+  
+  private void testTimeZoneFailure(String aTimeZone){
+    try {
+      Util.buildTimeZone(aTimeZone);
+      fail("Expected failure for time zone " + Util.quote(aTimeZone));
+    }
+    catch (Throwable ex){
+      //expected failure
+    }
+  }
+  
+  private void testCap(String aInput, String aExpectedValue){
+    String value = Util.withInitialCapital(aInput);
+    assertTrue( value.equals(aExpectedValue));
+  }
+  
+  private void testCapFails(String aInput, String aExpectedValue){
+    String value = Util.withInitialCapital(aInput);
+    assertFalse( value.equals(aExpectedValue));
+  }
+  
+  private void testHasNumDecimals(String aAmount, int aNumDecimals, boolean aSucceed){
+    BigDecimal amount = new BigDecimal(aAmount);
+    if (aSucceed){
+      assertTrue(Util.hasNumDecimals(amount, aNumDecimals));
+    }
+    else {
+      assertFalse(Util.hasNumDecimals(amount, aNumDecimals));
+    }
+  }
+  
+  private static void log(Object aObject){
+    System.out.println(String.valueOf(aObject));
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/TESTWebUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,108 @@
+package hirondelle.web4j.util;
+
+import junit.framework.*;
+//import junit.ui.TestRunner;
+//import junit.textui.TestRunner;
+//import junit.swingui.TestRunner;
+//import java.util.logging.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> tests for {@link WebUtil}.
+*/
+public final class TESTWebUtil extends TestCase {
+
+  /**
+   Run the test cases.
+  */
+   public static void main(String args[]) {
+     String[] testCaseName = { TESTWebUtil.class.getName()};
+     //Select one of several types of interfaces.
+     junit.textui.TestRunner.main(testCaseName);
+     //junit.swingui.TestRunner.main(testCaseName);
+     //junit.ui.TestRunner.main(testCaseName);
+  }
+
+  /**
+   Canonical form of constructor.
+  */
+  public TESTWebUtil( String aName) {
+   super( aName );
+  }
+
+  // TEST CASES //
+  
+  public void testSetQueryParam() {
+    testForSetQueryParam("blah.do", "artist", "Tom Thomson", "blah.do?artist=Tom+Thomson");
+    testForSetQueryParam("blah.do", "Fav Artist", "Tom Thomson", "blah.do?Fav+Artist=Tom+Thomson");
+    testForSetQueryParam("blah.do?artist=Tom+Thomson", "artist", "A Y Jackson", "blah.do?artist=A+Y+Jackson");
+    testForSetQueryParam("blah.do?Fav+Artist=Tom+Thomson", "Fav Artist", "A Y Jackson", "blah.do?Fav+Artist=A+Y+Jackson");
+    testForSetQueryParam("blah.do?country=Canada&artist=Tom+Thomson", "artist", "A Y Jackson", "blah.do?country=Canada&artist=A+Y+Jackson");
+    testForSetQueryParam("blah.do?country=Canada&artist=Tom+Thomson&color=Purple", "artist", "A Y Jackson", "blah.do?country=Canada&artist=A+Y+Jackson&color=Purple");
+  }
+  
+
+  public void testValidEmail(){
+    assertTrue( WebUtil.isValidEmailAddress("blah@blah.com") );
+    assertTrue( WebUtil.isValidEmailAddress("blah@blah.net") );
+    assertTrue( WebUtil.isValidEmailAddress("blah@blah.org") );
+    assertTrue( WebUtil.isValidEmailAddress("blah@blah.x") );
+    assertTrue( WebUtil.isValidEmailAddress("b@b.com") );
+    assertTrue( WebUtil.isValidEmailAddress("blah@blah.blah.com") );
+    assertTrue( WebUtil.isValidEmailAddress("blah@blah.blah.blah.com") );
+  }
+  
+  public void testInvalidEmail(){
+    //assertTrue( ! WebUtil.isValidEmailAddress(null) );  //not allowed
+    assertTrue( ! WebUtil.isValidEmailAddress("") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress(" ") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("@blah") );
+    assertTrue( ! WebUtil.isValidEmailAddress("blah@blah@blah") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("@blah.com") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress(" @blah.com") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("blah@") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("blah@ ") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("blah@@blah.com") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("@blah@blah.com") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("blah;@blah.org") ); 
+    assertTrue( ! WebUtil.isValidEmailAddress("blah") );
+  }
+  
+  public void testFileExtension(){
+    testFileExt("http://localhost:8081/predict/pub/search/SearchAction.show", "show");
+    testFileExt("http://localhost:8081/predict/pub/search/SearchAction.show;jsessionid=464653E", "show");
+    testFileExt("http://localhost:8081/predict/pub/search/SearchAction.list;jsessionid=464653E?Id=3&Text=Blah", "list");
+    testFileExt("http://localhost:8081/predict/pub/search/SearchAction.list?Id=3&Text=Blah", "list");
+    testFileExt("http://www.bazoodi.com/main/backup/ZipAllLists.zip?OpSys=Win&Style=FixedWidth", "zip");
+  }
+  
+  // FIXTURE 
+
+   protected void setUp(){
+     //empty
+   }
+
+   protected void tearDown() {
+     //empty
+   }
+
+  // PRIVATE  //
+   
+  //private static final Logger fLogger = Util.getLogger(TESTWebUtil.class);  
+  
+  private void testForSetQueryParam(String aInputURL, String aParamName, String aParamValue, String aOutputURL){
+    String newURL = WebUtil.setQueryParam(aInputURL, aParamName, aParamValue);
+    if ( ! newURL.equals(aOutputURL) ) {
+      fail(
+        "URL does not match. Expected : '" + aOutputURL + 
+        "' but computed '" + newURL + "'"
+      );
+    }
+  }
+  
+  private void testFileExt(String aURLInput, String aExpectedResult){
+    String result = WebUtil.getFileExtension(aURLInput);
+    if(!result.equals(aExpectedResult)) {
+      fail("Extension does not match. Expected : '" + aExpectedResult + "' but computed : '" + result + "'");
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/TimeSource.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,33 @@
+package hirondelle.web4j.util;
+
+import java.util.TimeZone;
+
+/**
+ Return a possibly-fake value for the system clock.
+ 
+ <P>When testing, it is often useful to use a 
+ <a href='http://www.javapractices.com/topic/TopicAction.do?Id=234'>fake system clock</a>, 
+ in order to exercise code that uses date logic. When you implement this interface, 
+ you are instructing WEB4J classes on what time value they should use as the system clock. 
+ This allows your application to <i>share</i> its fake system clock with the framework, 
+ so that they can both use the exact same clock.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for instructions on how to configure an implementation 
+ of this interface. 
+   
+   <P>The following WEB4J framework classes use <tt>TimeSource</tt> :
+   <ul>
+   <li>{@link hirondelle.web4j.model.DateTime#now(TimeZone)} - returns the current date-time  
+   <li>{@link hirondelle.web4j.ui.tag.ShowDate} - displays the current date-time  
+   <li>{@link hirondelle.web4j.webmaster.LoggingConfigImpl} - both the name of the logging 
+   file and the date-time attached to each logging record are affected
+   <li>{@link hirondelle.web4j.webmaster.TroubleTicket} - uses the current date-time
+   <li>{@link hirondelle.web4j.Controller} - upon startup, it places the current date-time in application scope
+   </ul>
+*/
+public interface TimeSource {
+  
+  /** Return the possibly-fake system time, in milliseconds.  */
+  long currentTimeMillis();
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/TimeSourceImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,17 @@
+package hirondelle.web4j.util;
+
+/**
+ Default implementation of {@link TimeSource}.
+ 
+<P> Simply returns the normal system time, without alteration. 
+ If you don't define your own {@link TimeSource}, then this 
+ default implementation will automatically be used by WEB4J. 
+*/
+public final class TimeSourceImpl  implements TimeSource {
+
+  /** Return {@link System#currentTimeMillis()}, with no alteration. */
+  public long currentTimeMillis() {
+    return System.currentTimeMillis();
+  }
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/Util.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,817 @@
+package hirondelle.web4j.util;
+
+import java.util.*;
+import java.util.logging.*;
+import java.util.regex.*;
+import java.math.BigDecimal;
+import java.text.CharacterIterator;
+import java.text.StringCharacterIterator; 
+import java.lang.reflect.Array;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import hirondelle.web4j.security.SafeText;
+import hirondelle.web4j.model.Decimal;
+
+/**
+ Static convenience methods for common tasks, which eliminate code duplication.
+
+ <P>{@link Args} wraps certain methods of this class into a form suitable for checking arguments.
+ 
+  <P>{@link hirondelle.web4j.util.WebUtil} includes utility methods particular to web applications.
+*/
+public final class Util {    
+
+  /**
+   Return true only if <tt>aNumEdits</tt> is greater than <tt>0</tt>.
+   
+   <P>This method is intended for database operations.
+  */
+  public static boolean isSuccess(int aNumEdits){
+    return aNumEdits > 0;
+  }
+
+  /**
+    Return <tt>true</tt> only if <tt>aText</tt> is not null,
+    and is not empty after trimming. (Trimming removes both
+    leading/trailing whitespace and ASCII control characters. See {@link String#trim()}.)
+   
+    <P> For checking argument validity, {@link Args#checkForContent} should 
+    be used instead of this method.
+   
+    @param aText possibly-null.
+   */
+   public static boolean textHasContent(String aText) {
+     return (aText != null) && (aText.trim().length() > 0);
+   }
+   
+   /**
+     Return <tt>true</tt> only if <tt>aText</tt> is not null,
+     and if its raw <tt>String</tt> is not empty after trimming. (Trimming removes both
+     leading/trailing whitespace and ASCII control characters. See {@link String#trim()}.)
+     
+     @param aText possibly-null.
+    */
+   public static boolean textHasContent(SafeText aText){
+     return (aText != null) && (aText.getRawString().trim().length() > 0);
+   }
+
+  /**
+   If <tt>aText</tt> is null, return null; else return <tt>aText.trim()</tt>.
+  
+   This method is especially useful for Model Objects whose <tt>String</tt>
+   parameters to its constructor can take any value whatsoever, including 
+   <tt>null</tt>. Using this method lets <tt>null</tt> params remain
+   <tt>null</tt>, while trimming all others.
+  
+   @param aText possibly-null.
+  */
+   public static String trimPossiblyNull(String aText){
+     return aText == null ? null : aText.trim();
+   }
+   
+  /**
+   Return <tt>true</tt> only if <tt>aNumber</tt> is in the range 
+   <tt>aLow..aHigh</tt> (inclusive).
+  
+   <P> For checking argument validity, {@link Args#checkForRange} should 
+   be used instead of this method.
+  
+   @param aLow less than or equal to <tt>aHigh</tt>.
+  */
+  static public boolean isInRange( int aNumber, int aLow, int aHigh ){
+    if (aLow > aHigh) {
+      throw new IllegalArgumentException(
+        "Low: " + aLow + " is greater than High: " + aHigh
+      );
+    }
+    return (aLow <= aNumber && aNumber <= aHigh);
+  }
+  
+  /**
+   Return <tt>true</tt> only if the number of decimal places in <tt>aAmount</tt> is in the range 
+   0..<tt>aMaxNumDecimalPlaces</tt> (inclusive).
+   
+   @param aAmount any amount, positive or negative..
+   @param aMaxNumDecimalPlaces is <tt>1</tt> or more.
+  */
+  static public boolean hasMaxDecimals(BigDecimal aAmount, int aMaxNumDecimalPlaces){
+    Args.checkForPositive(aMaxNumDecimalPlaces);
+    int numDecimals  = aAmount.scale();
+    return 0 <= numDecimals && numDecimals <= aMaxNumDecimalPlaces;
+  }
+
+  /**
+   Return <tt>true</tt> only if the number of decimal places in <tt>aAmount</tt> is in the range 
+   0..<tt>aMaxNumDecimalPlaces</tt> (inclusive).
+   
+   @param aAmount any amount, positive or negative..
+   @param aMaxNumDecimalPlaces is <tt>1</tt> or more.
+  */
+  static public boolean hasMaxDecimals(Decimal aAmount, int aMaxNumDecimalPlaces){
+    return hasMaxDecimals(aAmount.getAmount(), aMaxNumDecimalPlaces);
+  }
+  
+  /**
+   Return <tt>true</tt> only if <tt>aAmount</tt> has exactly the number 
+   of specified decimals.
+   
+    @param aNumDecimals is 0 or more.
+  */
+  public static boolean hasNumDecimals(BigDecimal aAmount, int aNumDecimals){
+    if( aNumDecimals < 0 ){
+      throw new IllegalArgumentException("Number of decimals must be 0 or more: " + quote(aNumDecimals));
+    }
+    return aAmount.scale() == aNumDecimals;
+  }
+  
+  /**
+   Return <tt>true</tt> only if <tt>aAmount</tt> has exactly the number 
+   of specified decimals.
+   
+    @param aNumDecimals is 0 or more.
+  */
+  public static boolean hasNumDecimals(Decimal aAmount, int aNumDecimals){
+    if( aNumDecimals < 0 ){
+      throw new IllegalArgumentException("Number of decimals must be 0 or more: " + quote(aNumDecimals));
+    }
+    return aAmount.getAmount().scale() == aNumDecimals;
+  }
+  
+  /**
+   Parse text commonly used to denote booleans into a {@link Boolean} object.
+   
+   <P>The parameter passed to this method is first trimmed (if it is non-null), 
+   and then compared to the following Strings, ignoring case :
+   <ul>
+   <li>{@link Boolean#TRUE} : 'true', 'yes', 'on'
+   <li>{@link Boolean#FALSE} : 'false', 'no', 'off'
+   </ul>
+   
+   <P>Any other text will cause a <tt>RuntimeException</tt>. (Note that this behavior 
+   is different from that of {@link Boolean#valueOf(String)}).
+  
+   <P>(This method is clearly biased in favor of English text. It is hoped that this is not too inconvenient for the caller.)
+   
+   @param aBooleanAsText possibly-null text to be converted into a {@link Boolean}; if null, then the 
+   return value is null. 
+  */
+  public static Boolean parseBoolean(String aBooleanAsText){
+    Boolean result = null;
+    String value = trimPossiblyNull(aBooleanAsText);
+    if ( value == null ) {
+      //do nothing - return null
+    }
+    else if ( value.equalsIgnoreCase("false") || value.equalsIgnoreCase("no") || value.equalsIgnoreCase("off") ) {
+      result = Boolean.FALSE;
+    }
+    else if ( value.equalsIgnoreCase("true") || value.equalsIgnoreCase("yes") || value.equalsIgnoreCase("on") ) {
+      result =  Boolean.TRUE;
+    }
+    else {
+      throw new IllegalArgumentException(
+        "Cannot parse into Boolean: " + quote(aBooleanAsText) + ". Accepted values are: true/false/yes/no/on/off"
+      );
+    }
+    return result;
+  }
+  
+  /**
+   Coerce a possibly-<tt>null</tt> {@link Boolean}  value into {@link Boolean#FALSE}.
+   
+   <P>This method is usually called in Model Object constructors that have two-state <tt>Boolean</tt> fields.
+    
+   <P>This method is supplied specifically for request parameters that may be <em>missing</em> from the request, 
+   during normal operation of the program.
+   <P>Example : a form has a checkbox for 'yes, send me your newsletter', and the data is modeled has having two states - 
+   <tt>true</tt> and <tt>false</tt>. If the checkbox is <em>not checked</em> , however, the browser will 
+   likely not POST any corresponding request parameter - it will be <tt>null</tt>. In that case, calling this method 
+   will coerce such <tt>null</tt> parameters into {@link Boolean#FALSE}.
+   
+   <P>There are other cases in which data is modeled as having not two states, but <em>three</em> : 
+   <tt>true</tt>, <tt>false</tt>, and <tt>null</tt>. The <tt>null</tt> value usually means 'unknown'. 
+   In that case, this method should <em>not</em> be called.
+  */
+  public static Boolean nullMeansFalse(Boolean aBoolean){
+    return aBoolean == null ? Boolean.FALSE : aBoolean;
+  }
+  
+  /**
+   Create a {@link Pattern} corresponding to a <tt>List</tt>.
+  
+   Example: if the {@link List} contains "cat" and "dog", then the returned 
+   <tt>Pattern</tt> will correspond  to the regular expression "(cat|dog)".
+  
+   @param aList is not empty, and contains objects whose <tt>toString()</tt>
+   value represents each item in the pattern.
+  */
+  public static final Pattern getPatternFromList( List<?> aList ){
+    if ( aList.isEmpty() ){
+      throw new IllegalArgumentException();
+    }
+    StringBuilder regex = new StringBuilder("(");
+    Iterator<?> iter = aList.iterator();
+    while (iter.hasNext()){
+      Object item = iter.next();
+      regex.append( item.toString() );
+      if ( iter.hasNext() ) { 
+        regex.append( "|" );
+      }
+    }
+    regex.append(")");
+    return Pattern.compile( regex.toString() );
+  }
+
+  /**
+   Return <tt>true</tt> only if <tt>aMoney</tt> equals <tt>0</tt>
+   or <tt>0.00</tt>.
+  */
+  public static boolean isZeroMoney( BigDecimal aMoney ){
+    final BigDecimal ZERO_MONEY = new BigDecimal("0");
+    final BigDecimal ZERO_MONEY_WITH_DECIMAL = new BigDecimal("0.00");
+    return 
+      aMoney.equals(ZERO_MONEY) || 
+      aMoney.equals(ZERO_MONEY_WITH_DECIMAL)
+    ;
+  }
+  
+  /**
+   Return true only if <tt>aText</tt> is non-null, and matches 
+   <tt>aPattern</tt>.
+  
+   <P>Differs from {@link Pattern#matches} and {@link String#matches},
+   since the regex argument is a compiled {@link Pattern}, not a 
+   <tt>String</tt>.
+  */
+  public static boolean matches(Pattern aPattern, String aText){
+    /*
+     Implementation Note:
+     Patterns are thread-safe, while Matchers are not. Thus, a Pattern may 
+     be compiled by a class once upon startup, then reused safely in a 
+     multi-threaded environment. 
+    */
+    if (aText == null) return false;
+    Matcher matcher = aPattern.matcher(aText);
+    return matcher.matches();
+  }
+  
+  /**
+   Return true only if <tt>aText</tt> is non-null, and contains 
+   a substring that matches <tt>aPattern</tt>.
+  */
+  public static boolean contains(Pattern aPattern, String aText){
+    if (aText == null) return false;
+    Matcher matcher = aPattern.matcher(aText);
+    return matcher.find();
+  }
+
+  /**
+   If <tt>aPossiblyNullItem</tt> is <tt>null</tt>, then return <tt>aReplacement</tt> ; 
+   otherwise return <tt>aPossiblyNullItem</tt>.
+   
+   <P>Intended mainly for occasional use in Model Object constructors. It is used to 
+   coerce <tt>null</tt> items into a more appropriate default value. 
+  */
+  public static <E> E replaceIfNull(E aPossiblyNullItem, E aReplacement){
+    return aPossiblyNullItem == null ? aReplacement : aPossiblyNullItem;
+  }
+  
+  /**
+   <P>Convert end-user input into a form suitable for {@link BigDecimal}. 
+  
+   <P>The idea is to allow a wide range of user input formats for monetary amounts. For 
+   example, an amount may be input as <tt>'$1,500.00'</tt>, <tt>'U$1500.00'</tt>, 
+   or <tt>'1500.00 U$'</tt>. These entries can all be converted into a 
+   <tt>BigDecimal</tt> by simply stripping out all characters except for digits 
+   and the decimal character.
+  
+   <P>Removes all characters from <tt>aCurrencyAmount</tt> which are not digits or 
+   <tt>aDecimalSeparator</tt>. Finally, if <tt>aDecimalSeparator</tt> is not 
+   a period (expected by <tt>BigDecimal</tt>) then it is replaced with a period.
+  
+   @param aDecimalSeparator must have content, and must have length of <tt>1</tt>.
+  */
+  static public String trimCurrency(String aCurrencyAmount, String aDecimalSeparator){
+    Args.checkForContent(aDecimalSeparator);
+    if ( aDecimalSeparator.length() != 1) {
+      throw new IllegalArgumentException(
+        "Decimal separator is not a single character: " + Util.quote(aDecimalSeparator)
+      );
+    }
+    
+    StringBuilder result = new StringBuilder();
+    StringCharacterIterator iter = new StringCharacterIterator(aCurrencyAmount);
+    char character = iter.current();
+    while (character != CharacterIterator.DONE){
+      if ( Character.isDigit(character) ){
+        result.append(character);
+      }
+      else if (aDecimalSeparator.charAt(0) == character){
+        result.append(Consts.PERIOD.charAt(0));
+      }
+      else {
+        //do not append any other chars
+      }
+      character = iter.next();
+    }
+    return result.toString();
+  }
+
+  /**
+   Return a {@link Logger} whose name follows a specific naming convention.
+  
+   <P>The conventional logger names used by WEB4J are taken as   
+   <tt>aClass.getPackage().getName()</tt>.
+   
+   <P>Logger names appearing in the <tt>logging.properties</tt> config file
+   must match the names returned by this method.
+  
+   <P>If an application requires an alternate naming convention, then an  
+   alternate implementation can be easily constructed. Alternate naming conventions 
+   might account for :
+  <ul>
+   <li>pre-pending the logger name with the name of the application (this is useful 
+   where log handlers are shared between different applications)
+   <li>adding version information
+  </ul>
+  */
+  public static Logger getLogger(Class<?> aClass){
+    return Logger.getLogger(aClass.getPackage().getName());  
+  }
+  
+  /**
+   Call {@link String#valueOf(Object)} on <tt>aObject</tt>, and place the result in single quotes.
+   <P>This method is a bit unusual in that it can accept a <tt>null</tt>
+   argument : if <tt>aObject</tt> is <tt>null</tt>, it will return <tt>'null'</tt>.
+   
+   <P>This method reduces errors from leading and trailing spaces, by placing 
+   single quotes around the returned text. Such leading and trailing spaces are 
+   both easy to create and difficult to detect (a bad combination).
+   
+   <P>Note that such quotation is likely needed only for <tt>String</tt> data, 
+   since trailing or leading spaces will not occur for other types of data.
+  */
+  public static String quote(Object aObject){
+    String result = "EXCEPTION OCCURED";
+    try {
+      result = Consts.SINGLE_QUOTE + String.valueOf(aObject) + Consts.SINGLE_QUOTE; 
+    }
+    catch (Throwable ex){
+      //errors were seen in this branch - depends on the impl of the passed object/array
+      //do nothing - use the default 
+    }
+    return result;
+  }
+  
+  /**
+   Remove any initial or final quote characters from <tt>aText</tt>, either a single quote or 
+   a double quote.
+   
+   <P>This method will not trim the text passed to it. Furthermore, it will examine only
+   the very first character and the very last character. It will remove the first or last character, 
+   but only if they are a single quote or a double quote.
+    
+   <P>If <tt>aText</tt> has no content, then it is simply returned by this method, as is, 
+   including possibly-<tt>null</tt> values.
+   @param aText is possibly <tt>null</tt>, and is not trimmed by this method
+  */
+  public static String removeQuotes(String aText){
+    String result = null;
+    if ( ! textHasContent(aText)) {
+      result = aText;
+    }
+    else {
+      int length = aText.length();
+      String firstChar = aText.substring(0,1);
+      String lastChar = aText.substring(length-1);
+      boolean startsWithQuote = firstChar.equalsIgnoreCase("\"") || firstChar.equalsIgnoreCase("'"); 
+      boolean endsWithQuote = lastChar.equalsIgnoreCase("\"") || lastChar.equalsIgnoreCase("'");
+      int startIdx = startsWithQuote ? 1 : 0;
+      int endIdx = endsWithQuote ? length-1 : length;
+      result = aText.substring(startIdx, endIdx);
+    }
+    return result;
+  }
+  
+  /**
+   Ensure the initial character of <tt>aText</tt> is capitalized.
+   
+   <P>Does not trim <tt>aText</tt>.
+   
+   @param aText has content.
+  */
+  public static String withInitialCapital(String aText) {
+    Args.checkForContent(aText);
+    final int FIRST = 0;
+    final int ALL_BUT_FIRST = 1;
+    StringBuilder result = new StringBuilder();
+    result.append( Character.toUpperCase(aText.charAt(FIRST)) );
+    result.append(aText.substring(ALL_BUT_FIRST));
+    return result.toString();
+  }
+  
+  /**
+   Ensure <tt>aText</tt> contains no spaces.
+   
+   <P>Along with {@link #withInitialCapital(String)}, this method is useful for 
+   mapping request parameter names into corresponding <tt>getXXX</tt> methods. 
+   For example, the text <tt>'Email Address'</tt> and <tt>'emailAddress'</tt>
+   can <em>both</em> be mapped to a method named <tt>'getEmailAddress()'</tt>, by using :
+   <PRE> 
+   String methodName = "get" + Util.withNoSpaces(Util.withInitialCapital(name));
+   </PRE>
+   
+   @param aText has content
+  */
+  public static String withNoSpaces(String aText){
+    Args.checkForContent(aText);
+    return replace(aText.trim(), Consts.SPACE, Consts.EMPTY_STRING);
+  }
+  
+  /**
+   Replace every occurrence of a fixed substring with substitute text. 
+    
+   <P>This method is distinct from {@link String#replaceAll}, since it does not use a 
+   regular expression.
+  
+   @param aInput may contain substring <tt>aOldText</tt>; satisfies {@link #textHasContent(String)}
+   @param aOldText substring which is to be replaced; possibly empty, but never null
+   @param aNewText replacement for <tt>aOldText</tt>; possibly empty, but never null
+  */
+  public static String replace(String aInput, String aOldText, String aNewText){
+    if ( ! textHasContent(aInput) ) {
+      throw new IllegalArgumentException("Input must have content.");
+    }
+    if ( aNewText == null ) {
+      throw new NullPointerException("Replacement text may be empty, but never null.");
+    }
+    final StringBuilder result = new StringBuilder();
+    //startIdx and idxOld delimit various chunks of aInput; these
+    //chunks always end where aOldText begins
+    int startIdx = 0;
+    int idxOld = 0;
+    while ((idxOld = aInput.indexOf(aOldText, startIdx)) >= 0) {
+      //grab a part of aInput which does not include aOldPattern
+      result.append( aInput.substring(startIdx, idxOld) );
+      //add replacement text
+      result.append( aNewText );
+      //reset the startIdx to just after the current match, to see
+      //if there are any further matches
+      startIdx = idxOld + aOldText.length();
+    }
+    //the final chunk will go to the end of aInput
+    result.append( aInput.substring(startIdx) );
+    return result.toString();
+  }
+
+  /**
+   Return a <tt>String</tt> suitable for logging, having one item from <tt>aCollection</tt>
+   per line. 
+   
+   <P>For the <tt>Collection</tt> containing <br>
+   <tt>[null, "Zebra", "aardvark", "Banana", "", "aardvark", new BigDecimal("5.00")]</tt>,
+   
+   <P>the return value is :
+   <PRE>
+   (7) {
+     ''
+     '5.00'
+     'aardvark'
+     'aardvark'
+     'Banana'
+     'null'
+     'Zebra'
+   }
+   </PRE>
+   
+   <P>The text for each item is generated by calling {@link #quote}, and by appending a new line.
+   
+   <P>As well, this method reports the total number of items, <em>and places items in  
+   alphabetical order</em> (ignoring case). (The iteration order of the <tt>Collection</tt> 
+   passed by the caller will often differ from the order of items presented in the return value.) 
+   </PRE>
+  */
+  public static String logOnePerLine(Collection<?> aCollection){
+    int STANDARD_INDENTATION = 1;
+    return logOnePerLine(aCollection, STANDARD_INDENTATION);
+  }
+  
+  /**
+   As in {@link #logOnePerLine(Collection)}, but with specified indentation level. 
+   
+   @param aIndentLevel greater than or equal to 1, acts as multiplier for a 
+   "standard" indentation level of two spaces.
+  */
+  public static String logOnePerLine(Collection<?> aCollection, int aIndentLevel){
+    Args.checkForPositive(aIndentLevel);
+    String indent = getIndentation(aIndentLevel);
+    StringBuilder result = new StringBuilder();
+    result.append("(" + aCollection.size() + ") {" + Consts.NEW_LINE);
+    List<String> lines = new ArrayList<String>(aCollection.size());
+    for (Object item: aCollection){
+      StringBuilder line = new StringBuilder(indent);
+      line.append( quote(item) ); //nulls ok
+      line.append( Consts.NEW_LINE );
+      lines.add(line.toString());
+    }
+    addSortedLinesToResult(result, lines);
+    result.append(getFinalIndentation(aIndentLevel));
+    result.append("}");
+    return result.toString();
+  }
+  
+  /**
+   Return a <tt>String</tt> suitable for logging, having one item from <tt>aMap</tt>
+   per line. 
+   
+   <P>For a <tt>Map</tt> containing <br>
+   <tt>["b"="blah", "a"=new BigDecimal(5.00), "Z"=null, null=new Integer(3)]</tt>,
+   
+   <P>the return value is :
+   <PRE>
+   (4) {
+     'a' = '5.00'
+     'b' = 'blah'
+     'null' = '3'
+     'Z' = 'null'
+   }
+   </PRE>
+   
+   <P>The text for each key and value is generated by calling {@link #quote}, and 
+   appending a new line after each entry.
+   
+   <P>As well, this method reports the total number of items, <em>and places items in  
+   alphabetical order of their keys</em> (ignoring case). (The iteration order of the 
+   <tt>Map</tt> passed by the caller will often differ from the order of items in the
+   return value.) 
+   
+   <P>An attempt is made to suppress the emission of passwords. Values in a Map are 
+   presented as <tt>****</tt> if the following conditions are all true :
+   <ul>
+   <li>{@link String#valueOf(java.lang.Object)} applied to the <em>key</em> contains the word <tt>password</tt> or 
+   <tt>credential</tt> (ignoring case)
+   <li>the <em>value</em> is not an array or a <tt>Collection</tt>
+   </ul>
+  */
+  public static String logOnePerLine(Map<?,?> aMap){
+    StringBuilder result = new StringBuilder();
+    result.append("(" + aMap.size() + ") {" + Consts.NEW_LINE);
+    List<String> lines = new ArrayList<String>(aMap.size());
+    String SEPARATOR = " = ";
+    Iterator iter = aMap.keySet().iterator();
+    while (  iter.hasNext() ){
+      Object key = iter.next();
+      StringBuilder line = new StringBuilder(INDENT);
+      line.append(quote(key)); //nulls ok
+      line.append(SEPARATOR);
+      Object value = aMap.get(key);
+      int MORE_INDENTATION = 2;
+      if ( value != null && value instanceof Collection) {
+        line.append( logOnePerLine((Collection)value, MORE_INDENTATION));
+      }
+      else if ( value != null && value.getClass().isArray() ){
+        List valueItems = Arrays.asList( (Object[])value );
+        line.append( logOnePerLine(valueItems, MORE_INDENTATION) );
+      }
+      else {
+        value = suppressPasswords(key, value);
+        line.append(quote(value)); //nulls ok
+      }
+      line.append(Consts.NEW_LINE);
+      lines.add(line.toString());
+    }
+    addSortedLinesToResult(result, lines);
+    result.append("}");
+    return result.toString();
+  }
+  
+  /**
+   Return a {@link Locale} object by parsing <tt>aRawLocale</tt>.
+   
+   <P>The format of <tt>aRawLocale</tt> follows the 
+   <tt>language_country_variant</tt> style used by {@link Locale}. The value is <i>not</i> checked against
+   {@link Locale#getAvailableLocales()}. 
+  */
+  public static Locale buildLocale(String aRawLocale){
+    int language = 0;
+    int country = 1;
+    int variant = 2;
+    Locale result = null;
+    fLogger.finest("Raw Locale: " + aRawLocale);
+    String[] parts = aRawLocale.split("_");
+    if (parts.length == 1) {
+      result = new Locale( parts[language] );
+    }
+    else if (parts.length == 2) {
+      result = new Locale( parts[language], parts[country] );
+    }
+    else if (parts.length == 3 ) {
+      result = new Locale( parts[language], parts[country], parts[variant] );
+    }
+    else {
+      throw new AssertionError("Locale identifer has unexpected format: " + aRawLocale);
+    }
+    fLogger.finest("Parsed Locale : " + Util.quote(result.toString()));
+    return result;
+  }
+  
+  /**
+   Return a {@link TimeZone} corresponding to a given {@link String}.
+   
+   <P>If the given <tt>String</tt> does not correspond to a known <tt>TimeZone</tt> id,
+   as determined by {@link TimeZone#getAvailableIDs()}, then a  
+   runtime exception is thrown. (This differs from the behavior of the 
+   {@link TimeZone} class itself, and is the reason why this method exists.)
+  */
+  public static TimeZone buildTimeZone(String aTimeZone) {
+    TimeZone result = null;
+    List<String> timeZones = Arrays.asList(TimeZone.getAvailableIDs());
+    if( timeZones.contains(aTimeZone.trim()) ) {
+      result = TimeZone.getTimeZone(aTimeZone.trim());
+    }
+    else {
+      fLogger.severe("Unknown Time Zone : " + quote(aTimeZone));
+      //fLogger.severe("Known Time Zones : " + logOnePerLine(timeZones));
+      throw new IllegalArgumentException("Unknown TimeZone Id : " + quote(aTimeZone));
+    }
+    return result;
+  }
+  
+  /**
+   Convenience method for producing a simple textual
+   representation of an array.
+  
+   <P>The format of the returned {@link String} is the same as 
+   {@link java.util.AbstractCollection#toString} : 
+   <ul>
+   <li>non-empty array: <tt>[blah, blah]</tt>
+   <li>empty array: <tt>[]</tt>
+   <li>null array: <tt>null</tt>
+   </ul>
+  
+   <P>Thanks to Jerome Lacoste for improving the implementation of this method.
+   
+   @param aArray is a possibly-null array whose elements are
+   primitives or objects. Arrays of arrays are also valid, in which case
+   <tt>aArray</tt> is rendered in a nested, recursive fashion.
+   
+  */
+  public static String getArrayAsString(Object aArray){
+    final String fSTART_CHAR = "[";
+    final String fEND_CHAR = "]";
+    final String fSEPARATOR = ", ";
+    final String fNULL = "null";
+    
+    if ( aArray == null ) return fNULL;
+    checkObjectIsArray(aArray);
+
+    StringBuilder result = new StringBuilder( fSTART_CHAR );
+    int length = Array.getLength(aArray);
+    for ( int idx = 0 ; idx < length ; ++idx ) {
+      Object item = Array.get(aArray, idx);
+      if ( isNonNullArray(item) ){
+        //recursive call!
+        result.append( getArrayAsString(item) );
+      }
+      else{
+        result.append( item );
+      }
+      if ( ! isLastItem(idx, length) ) {
+        result.append(fSEPARATOR);
+      }
+    }
+    result.append(fEND_CHAR);
+    return result.toString();
+  }
+
+  /**
+   Transform a <tt>List</tt> into a <tt>Map</tt>.
+  
+   <P>This method exists because it is sometimes necessary to transform a 
+   <tt>List</tt> into a lookup table of some sort, using <em>unique</em> keys already
+   present in the <tt>List</tt> data.
+   
+   <P>The <tt>List</tt> to be transformed contains objects having a method named 
+   <tt>aKeyMethodName</tt>, and which returns objects of class <tt>aKeyClass</tt>.
+   Thus, data is extracted from each object to act as its key. Furthermore, the 
+   key must be <em>unique</em>. If any duplicates are detected, then an 
+   exception is thrown. This ensures that the returned <tt>Map</tt> will be the 
+   same size as the given <tt>List</tt>, and that no data is silently discarded. 
+   
+   <P>The iteration order of the returned <tt>Map</tt> is identical to the iteration order of 
+   the input <tt>List</tt>.
+  */
+  public static <K,V> Map<K,V> asMap(List<V> aList, Class<K> aClass, String aKeyMethodName){
+    Map<K,V> result = new LinkedHashMap<K,V>();
+    for(V value: aList){
+      K key = getMethodValue(value, aClass, aKeyMethodName);
+      if( result.containsKey(key) ){
+        throw new IllegalArgumentException("Key must be unique. Duplicate detected : " + quote(key));
+      }
+      result.put(key, value);
+    }
+    return result;
+  }
+  
+  /**
+   Reverse the keys and values in a <tt>Map</tt>.
+   
+   <P>This method exists because sometimes a lookup operation needs to be performed in a 
+   style opposite to an existing <tt>Map</tt>.
+   
+   <P>There is an unusual requirement on the <tt>Map</tt> argument: the map <em>values</em> must be 
+   unique. Thus, the returned <tt>Map</tt> will be the same size as the input <tt>Map</tt>. If any 
+   duplicates are detected, then an exception is thrown.
+    
+   <P>The iteration order of the returned <tt>Map</tt> is identical to the iteration order of 
+   the input <tt>Map</tt>.
+  */
+  public static <K,V> Map<V,K> reverseMap(Map<K,V> aMap){
+    Map<V,K> result = new LinkedHashMap<V,K>();
+    for(Map.Entry<K,V> entry: aMap.entrySet()){
+      if( result.containsKey(entry.getValue())){
+        throw new IllegalArgumentException("Value must be unique. Duplicate detected : " + quote(entry.getValue()));
+      }
+      result.put(entry.getValue(), entry.getKey());
+    }
+    return result;
+  }
+  
+  
+  // PRIVATE //
+  
+  private Util(){
+    //empty - prevents construction by the caller.
+  }
+  
+  private static final Logger fLogger = Util.getLogger(Util.class);
+  private static final String INDENT = Consts.SPACE + Consts.SPACE;
+  private static final Pattern PASSWORD = Pattern.compile("(password|credential)", Pattern.CASE_INSENSITIVE); 
+ 
+  private static void addSortedLinesToResult(StringBuilder aResult, List<String> aLines) {
+    Collections.sort(aLines, String.CASE_INSENSITIVE_ORDER);
+    for (String line: aLines){
+      aResult.append( line );
+    }
+  }
+  
+  private static String getIndentation(int aIndentLevel){
+    StringBuilder result = new StringBuilder();
+    for (int idx = 1; idx <= aIndentLevel; ++idx){
+      result.append(INDENT);
+    }
+    return result.toString();
+  }
+  
+  private static String getFinalIndentation(int aIndentLevel){
+    return getIndentation(aIndentLevel - 1);
+  }
+
+  /**
+   Replace likely password values with a fixed string. 
+  */
+  private static Object suppressPasswords(Object aKey, Object aValue){
+    Object result = aValue;
+    String key = String.valueOf(aKey);
+    Matcher matcher = PASSWORD.matcher(key);
+    if ( matcher.find() ){
+      result = "*****";
+    }
+    return result;
+  }
+  
+  private static void checkObjectIsArray(Object aArray){
+    if ( ! aArray.getClass().isArray() ) {
+      throw new IllegalArgumentException("Object is not an array.");
+    }
+  }
+
+  private static boolean isNonNullArray(Object aItem){
+    return aItem != null && aItem.getClass().isArray();
+  }
+
+  private static boolean isLastItem(int aIdx, int aLength){
+    return (aIdx == aLength - 1);
+  }
+  
+  private static <K> K getMethodValue(Object aValue, Class<K> aClass, String aKeyMethodName){
+    K result = null;
+    try {
+      Method method = aValue.getClass().getMethod(aKeyMethodName); //no args
+      result = (K)method.invoke(aValue);
+    }
+    catch (NoSuchMethodException ex){
+      handleInvocationEx(aValue.getClass(), aKeyMethodName);
+    }
+    catch (IllegalAccessException ex){
+      handleInvocationEx(aValue.getClass(), aKeyMethodName);
+    }
+    catch (InvocationTargetException ex){
+      handleInvocationEx(aValue.getClass(), aKeyMethodName);
+    }
+    return result;
+  }
+  
+  private static void handleInvocationEx(Class<?> aClass, String aKeyMethodName){
+    throw new IllegalArgumentException("Cannot invoke method named " + quote(aKeyMethodName) + " on object of class " + quote(aClass));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/WebUtil.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,281 @@
+package hirondelle.web4j.util;
+
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+
+import java.util.regex.*;
+import javax.mail.internet.AddressException;
+import javax.mail.internet.InternetAddress;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletConfig;
+
+/**
+ Static convenience methods for common web-related tasks, which eliminate code duplication.
+
+<P> Similar to {@link hirondelle.web4j.util.Util}, but for methods particular to the web.
+*/
+public final class WebUtil {
+  
+  /** Called only upon startup, by the framework.  */
+  public static void init(ServletConfig aConfig){
+    fContext = aConfig.getServletContext();
+  }
+  
+  /**
+   Validate the form of an email address.
+  
+   <P>Return <tt>true</tt> only if 
+  <ul> 
+   <li> <tt>aEmailAddress</tt> can successfully construct an 
+   {@link javax.mail.internet.InternetAddress} 
+   <li> when parsed with "@" as delimiter, <tt>aEmailAddress</tt> contains 
+   two tokens which satisfy {@link hirondelle.web4j.util.Util#textHasContent}.
+  </ul>
+  
+  <P> The second condition arises since local email addresses, simply of the form
+   "<tt>albert</tt>", for example, are valid for {@link javax.mail.internet.InternetAddress}, 
+   but almost always undesired.
+  */
+  public static boolean isValidEmailAddress(String aEmailAddress){
+    if (aEmailAddress == null) return false;
+    boolean result = true;
+    try {
+      InternetAddress emailAddr = new InternetAddress(aEmailAddress);
+      if ( ! hasNameAndDomain(aEmailAddress) ) {
+        result = false;
+      }
+    }
+    catch (AddressException ex){
+      result = false;
+    }
+    return result;
+  }
+
+  /**
+   Ensure a particular name-value pair is present in a URL.
+    
+   <P>If the parameter does not currently exist in the URL, then the name-value
+   pair is appended to the URL; if the parameter is already present in the URL,
+   however, then its value is changed.
+  
+   <P>Any number of query parameters can be added to a URL, one after the other.
+   Any special characters in <tt>aParamName</tt> and <tt>aParamValue</tt> will be 
+   escaped by this method using {@link EscapeChars#forURL}.
+  
+   <P>This method is intended for cases in which an <tt>Action</tt> requires
+   a redirect after processing, and the redirect in turn requires <em>dynamic</em> 
+   query parameters. (With a redirect, this is the only way to 
+   pass data to the destination page. Items placed in request scope for the 
+   original request will no longer be available to the second request caused 
+   by the redirect.)
+  
+   <P>Example 1, where a new parameter is added :<P>
+   <tt>setQueryParam("blah.do", "artist", "Tom Thomson")</tt>
+  <br>
+   returns the value :<br> <tt>blah.do?artist=Tom+Thomson</tt>
+  
+   <P>Example 2, where an existing parameter is updated :<P>
+   <tt>setQueryParam("blah.do?artist=Tom+Thomson", "artist", "A Y Jackson")</tt>
+  <br>
+   returns the value :<br> <tt>blah.do?artist=A+Y+Jackson</tt>
+   
+   <P>Example 3, with a parameter name of slightly different form :<P>
+   <tt>setQueryParam("blah.do?Favourite+Artist=Tom+Thomson", "Favourite Artist", "A Y Jackson")</tt>
+  <br>
+   returns the value :<br> <tt>blah.do?Favourite+Artist=A+Y+Jackson</tt>
+   
+   @param aURL a base URL, with <em>escaped</em> parameter names and values
+   @param aParamName <em>unescaped</em> parameter name
+   @param aParamValue <em>unescaped</em> parameter value
+  */
+  public static String setQueryParam(String aURL, String aParamName, String aParamValue){
+    String result = null;
+    if ( aURL.indexOf(EscapeChars.forURL(aParamName) + "=") == -1) {
+      result = appendParam(aURL, aParamName, aParamValue);
+    }
+    else {
+      result = replaceParam(aURL, aParamName, aParamValue);
+    }
+    return result;
+  }
+  
+  /**
+   Return {@link HttpServletRequest#getRequestURL}, optionally concatenated with 
+   <tt>?</tt> and {@link HttpServletRequest#getQueryString}.
+   
+   <P>Query parameters are added only if they are present.
+   
+   <P>If the underlying method is a <tt>GET</tt> which does NOT edit the database, 
+   then presenting the return value of this method in a link is usually acceptable. 
+   
+   <P>If the underlying method is a <tt>POST</tt>, or if it is a <tt>GET</tt> which 
+   (erroneously) edits the database, it is recommended that the return value of 
+   this method NOT be placed in a link. 
+   
+   <P><em>Warning</em> : if this method is called in JSP or custom tag, then it 
+   is likely that the original query string has been overwritten by the server, 
+   as result of an internal <tt>forward</tt> operation.
+  */
+  public static String getURLWithQueryString(HttpServletRequest aRequest){
+    StringBuilder result = new StringBuilder();
+    result.append(aRequest.getRequestURL());
+    String queryString = aRequest.getQueryString();
+    if ( Util.textHasContent(queryString) ) {
+      result.append("?");
+      result.append(queryString);
+    }
+    return result.toString();
+  }
+  
+  /**
+   Return the original, complete URL submitted by the browser.
+   
+   <P>Session id is included in the return value.
+   
+   <P>Somewhat frustratingly, the original client request is not directly available from 
+   the Servlet API. 
+   <P>This implementation is based on an example in the 
+   <a href="http://www.exampledepot.com/egs/javax.servlet/GetReqUrl.html">Java Almanac</a>.
+  */
+  public static String getOriginalRequestURL(HttpServletRequest aRequest, HttpServletResponse aResponse){
+    String result = null;
+    //http://hostname.com:80/mywebapp/servlet/MyServlet/a/b;c=123?d=789
+    String scheme = aRequest.getScheme();             // http
+    String serverName = aRequest.getServerName();     // hostname.com
+    int serverPort = aRequest.getServerPort();        // 80
+    String contextPath = aRequest.getContextPath();   // /mywebapp
+    String servletPath = aRequest.getServletPath();   // /servlet/MyServlet
+    String pathInfo = aRequest.getPathInfo();         // /a/b;c=123
+    String queryString = aRequest.getQueryString();   // d=789
+
+    // Reconstruct original requesting URL
+    result = scheme + "://" + serverName + ":" + serverPort + contextPath + servletPath;
+    if (Util.textHasContent(pathInfo)) {
+        result = result + pathInfo;
+    }
+    if (Util.textHasContent(queryString)) {
+        result = result + "?" + queryString;
+    }
+    return aResponse.encodeURL(result);    
+  }
+  
+  /**
+   Find an attribute by searching request scope, session scope (if it exists), and application scope (in that order).
+   
+   <P>If there is no session, then this method will not create one. 
+   
+   <P>If no <tt>Object</tt> corresponding to <tt>aKey</tt> is found, then <tt>null</tt> is returned. 
+  */
+  public static Object findAttribute(String aKey, HttpServletRequest aRequest){
+    //This method is similar to {@link javax.servlet.jsp.JspContext#findAttribute(java.lang.String)}
+    Object result = null;
+    result = aRequest.getAttribute(aKey);
+    if( result == null ) {
+      HttpSession session = aRequest.getSession(DO_NOT_CREATE);
+      if( session != null ) {
+        result = session.getAttribute(aKey);
+      }
+    }
+    if( result == null ) {
+      result = fContext.getAttribute(aKey);
+    }
+    return result;
+  }
+  
+  /**
+   Returns the 'file extension' for a given URL. 
+   
+   <P>Some example return values for this method :
+   <table  border='1' cellpadding='3' cellspacing='0'>
+    <tr><th>URL</th><th>'File Extension'</th></tr>
+    <tr>
+      <td>.../VacationAction.do</td>
+      <td>do</td>
+    </tr>
+    <tr>
+      <td>.../VacationAction.fetchForChange?Id=103</td>
+      <td>fetchForChange</td>
+    </tr>
+    <tr>
+      <td>.../VacationAction.list?Start=Now&End=Never</td>
+      <td>list</td>
+    </tr>
+    <tr>
+      <td>.../SomethingAction.show;jsessionid=32131?SomeId=123456</td>
+      <td>show</td>
+    </tr>
+   </table>
+   
+   @param aURL has content, and contains a '.' character (which defines the start of the 'file extension'.)
+  */
+  public static String getFileExtension(String aURL) {
+    String result = null;
+    int lastPeriod = aURL.lastIndexOf(".");
+    if( lastPeriod == NOT_FOUND ) {
+      throw new RuntimeException("Cannot find '.' character in URL: " + Util.quote(aURL));
+    }
+    int jsessionId = aURL.indexOf(";jsessionid");
+    int firstQuestionMark = aURL.indexOf("?");
+    if( jsessionId != NOT_FOUND){
+      result = aURL.substring(lastPeriod + 1, jsessionId);
+    }
+    else if( firstQuestionMark != NOT_FOUND){
+      result = aURL.substring(lastPeriod + 1, firstQuestionMark);
+    }
+    else {
+      result = aURL.substring(lastPeriod + 1);
+    }
+    return result;
+  }
+  
+  // PRIVATE 
+  
+  private WebUtil(){
+    //empty - prevent construction
+  }
+  
+  /** Needed for searching application scope for attributes. */
+  private static ServletContext fContext;
+  
+  private static final boolean DO_NOT_CREATE = false;
+  
+  private static String appendParam(String aURL, String aParamName, String aParamValue){
+    StringBuilder result = new StringBuilder(aURL);
+    if (aURL.indexOf("?") == -1) {
+      result.append("?");
+    }
+    else {
+      result.append("&");
+    }
+    result.append( EscapeChars.forURL(aParamName) );
+    result.append("=");
+    result.append( EscapeChars.forURL(aParamValue) );
+    return result.toString();
+  }
+
+  private static String replaceParam(String aURL, String aParamName, String aParamValue){
+    String regex = "(\\?|\\&)(" + EscapeChars.forRegex(EscapeChars.forURL(aParamName)) + "=)([^\\&]*)";
+    StringBuffer result = new StringBuffer();
+    Pattern pattern = Pattern.compile( regex );
+    Matcher matcher = pattern.matcher(aURL);
+    while ( matcher.find() ) {
+      matcher.appendReplacement(result, getReplacement(matcher, aParamValue));
+    }
+    matcher.appendTail(result);
+    return result.toString();
+  }
+  
+  private static String getReplacement(Matcher aMatcher, String aParamValue){
+    return aMatcher.group(1) + aMatcher.group(2) + EscapeChars.forURL(aParamValue);
+  }
+  
+  private static boolean hasNameAndDomain(String aEmailAddress){
+    String[] tokens = aEmailAddress.split("@");
+    return 
+     tokens.length == 2 &&
+     Util.textHasContent( tokens[0] ) && 
+     Util.textHasContent( tokens[1] ) ;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/util/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,13 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Utilities</title>
+</head>
+<body>
+General utility classes, useful for many applications.
+<br>&nbsp;
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/BadResponseDetector.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,203 @@
+package hirondelle.web4j.webmaster;
+
+import static hirondelle.web4j.util.Consts.FAILS;
+import java.util.regex.Pattern;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.Check;
+import hirondelle.web4j.model.ModelCtorException;
+import java.util.logging.*;
+
+import hirondelle.web4j.util.Util;
+import java.util.*;
+import java.io.*;
+import java.net.MalformedURLException;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.net.URLConnection;
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+import static hirondelle.web4j.util.Consts.EMPTY_STRING;
+import static hirondelle.web4j.util.Consts.NEW_LINE;
+import hirondelle.web4j.webmaster.TroubleTicket;
+import hirondelle.web4j.util.Stopwatch;
+
+/**  Detects problems by pinging a target URL every few minutes. */
+final class BadResponseDetector extends TimerTask {
+  
+  /**
+   Build a <tt>BadResponseDetector</tt> using a setting of the same name in 
+   <tt>web.xml</tt>.
+   
+   These settings are simply passed to the regular 
+   {@link #BadResponseDetector(String, int, int)} constructor.
+  */
+  static BadResponseDetector getInstanceUsing(String aConfig){
+    Scanner parser = new Scanner(aConfig);
+    parser.useDelimiter(",\\s*");
+    String targetURL = parser.next();
+    int pingFrequency = parser.nextInt();
+    int timeout = parser.nextInt();
+    return new BadResponseDetector(targetURL, pingFrequency, timeout);
+  }
+  
+  /**
+   Constructor.
+    
+   @param aTargetURL the URL to be tested periodically. Required, starts with 'http://'. Must be able 
+   to form a {@link java.net.URL}.
+   @param aPingFrequency number of <em>minutes</em> between each test (1..60). Required.
+   @param aTimeout number of <i>seconds</i> to wait for a response (1..60). Required.  
+  */
+  BadResponseDetector(String aTargetURL, int aPingFrequency, int aTimeout) {
+    fTargetURL = aTargetURL;
+    fPingFrequency = aPingFrequency;
+    fTimeout = aTimeout;
+    validateState();
+  }
+  
+  /** 
+    Ping the target URL by attempting to fetch its HTTP header. 
+    Email the webmaster if the response status code in the header is 400 or more, or if the ping times out.  
+  */
+  @Override public void run() {
+    fLogger.fine("Pinging the target URL " + Util.quote(fTargetURL));
+    Stopwatch stopwatch = new Stopwatch();
+    String problem = EMPTY_STRING;
+    String pageContent = null;
+    int statusCode = 0;
+    URLConnection connection = null;
+    try {
+      stopwatch.start();
+      URL target = new URL(fTargetURL);
+      connection = target.openConnection();
+      connection.setConnectTimeout(fTimeout*1000);
+      connection.connect();
+      statusCode = extractStatusCode(connection);
+      pageContent = getEntireContent(connection);
+      if( statusCode == NOT_FOUND ) {
+        problem = "Cannot extract status code for : " + fTargetURL;
+      }
+      else if ( isError(statusCode) ) {
+        problem = "URL " + fTargetURL + " is returning an error status code : " + statusCode; 
+      }
+    }
+    catch(MalformedURLException ex){
+      fLogger.fine("Malformed URL : " + fTargetURL); //this is checked in ctor
+    }
+    catch(SocketTimeoutException ex){
+      problem = "Connection timed out.";
+    }
+    catch (IOException ex) {
+      problem = "Cannot open connection to the URL : " + fTargetURL;
+    }
+    
+    stopwatch.stop();
+    long BILLION = 1000 * 1000 * 1000;
+    if( stopwatch.toValue() > (fTimeout * BILLION)) { //nanos
+      problem = problem + "Response took too long : " + stopwatch;
+    }
+    
+    if( Util.textHasContent(problem) ) {
+      mailTroubleTicket(problem);
+    }
+    else {
+      fLogger.info("No problem detected. Status code : " + statusCode + ". Response time : " + stopwatch + ". Content length: " + pageContent.length());
+    }
+  }
+
+  /** Return the target URL passed to the constructor. */
+  String getTargetURL(){  return fTargetURL;  }
+  
+  /** Return the ping frequency passed to the constructor. */
+  int getPingFrequency(){   return fPingFrequency; }
+  
+  /** Return the timeout passed to the constructor. */
+  int getTimeout(){   return fTimeout; }
+  
+  // PRIVATE //
+  private final String fTargetURL;
+  private final int fPingFrequency;
+  private final int fTimeout;
+  private static final Pattern HTTP_TARGET = Pattern.compile("http://.*");
+  private static final int ENTIRE_FIRST_LINE = 0;
+  private static final int STATUS_CODE = 1;
+  private static final String END_OF_INPUT = "\\Z";  
+  //note the user of a logger attached to a public class
+  private static final Logger fLogger = Util.getLogger(PerformanceMonitor.class);
+
+  private void validateState(){
+    ModelCtorException ex = new ModelCtorException();
+    
+    if ( FAILS == Check.required(fTargetURL, Check.pattern(HTTP_TARGET)) ) {
+      ex.add("Target URL is required, must start with 'http://'.");
+    }
+    if( Util.textHasContent(fTargetURL)) {
+      try {
+        URL testUrl = new URL(fTargetURL);
+      }
+      catch(MalformedURLException exception){
+        ex.add("Target URL is malformed.");
+      }
+    }
+    
+    if ( FAILS == Check.required(fPingFrequency, Check.range(1,60))) {
+      ex.add("Ping Frequency is required, must be in range 1..60 minutes.");
+    }
+    if ( FAILS == Check.required(fTimeout, Check.range(1,60))) {
+      ex.add("Timeout is required, must be in range 1..60 seconds.");
+    }
+    
+    if ( ! ex.isEmpty() ) {
+      throw new IllegalArgumentException("Cannot construct BadResponseDetector : " + ex.toString(), ex);
+    }
+  }
+  
+  /** Returns -1 if no status code can be detected.  */
+  private int extractStatusCode(URLConnection aURLConnection){
+    //Typical value, first line of response : 'HTTP/1.1 200 OK'
+    int result = -1;
+    String firstLine = aURLConnection.getHeaderField(ENTIRE_FIRST_LINE);
+    StringTokenizer parser = new StringTokenizer(firstLine, " ");
+    List<String> items = new ArrayList<String>();
+    while (parser.hasMoreTokens()) {
+      items.add(parser.nextToken());
+    }
+    String status = items.get(STATUS_CODE);
+    if( Util.textHasContent(status) ) {
+      try {
+        result = Integer.valueOf(status);
+      }
+      catch (NumberFormatException ex){
+        //do nothing - return value will reflect the inability to detect status code
+      }
+    }
+    return result;
+  }
+  
+  private boolean isError(int aStatusCode) {
+    return aStatusCode >= 400;
+  }
+  
+  private void mailTroubleTicket(String aProblem) {
+    StringBuilder problem = new StringBuilder();
+    problem.append("Bad response detected." + NEW_LINE);
+    problem.append(NEW_LINE);
+    problem.append("URL : " + fTargetURL  + NEW_LINE);
+    problem.append("Problem : " + aProblem);
+    fLogger.severe(problem.toString());
+    TroubleTicket ticket = new TroubleTicket(problem.toString());
+    try {
+      ticket.mailToRecipients();
+    }
+    catch(AppException ex){
+      fLogger.severe("Cannot send email regarding bad response.");
+    }
+  }
+  
+  private String getEntireContent(URLConnection aConnection) throws IOException {
+    String result = null;
+    Scanner scanner = new Scanner(aConnection.getInputStream());
+    scanner.useDelimiter(END_OF_INPUT);
+    result = scanner.next();
+    return result;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/Emailer.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,41 @@
+package hirondelle.web4j.webmaster;
+
+import java.util.*;
+
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.util.WebUtil;
+
+/**
+ Send a simple email from the webmaster to a list of receivers.
+ 
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forEmailer()} 
+ returns the configured implementation of this interface.
+ 
+ <P>Implementations of this interface will be called by the framework when it needs 
+ to send an email. For example, {@link TroubleTicket} uses such an implementation to 
+ send diagnostic information to the webmaster. The from-address is taken from the 
+ webmaster email configured in <tt>web.xml</tt>. 
+ 
+ <P><b>Sending Email on a Separate Thread</b><br>
+ Some implementations might send emails on a separate worker thread.
+ However, such implementations can be tricky. If your application creates a thread on its own, then
+ the Servlet Container will likely not be able to shut down in the regular way. The thread 
+ will be unknown to the Container, and will likely prevent it from shutting down. 
+ 
+ <P>The {@link EmailerImpl} default implementation of this interface doesn't use a 
+ separate worker thread. See <tt>web.xml</tt> in the example applications for more details. 
+*/
+public interface Emailer {
+  
+  /**
+   Send an email from the webmaster to a list of receivers. 
+  
+   @param aToAddresses contains email addresses of the receivers, as a List of Strings that satisfy 
+   {@link WebUtil#isValidEmailAddress(String)}
+   @param aSubject satisfies {@link hirondelle.web4j.util.Util#textHasContent}
+   @param aBody satisfies {@link hirondelle.web4j.util.Util#textHasContent}
+  */
+  void sendFromWebmaster(List<String> aToAddresses, String aSubject, String aBody) throws AppException;
+  
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/EmailerImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,202 @@
+package hirondelle.web4j.webmaster;
+
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.StringTokenizer;
+import java.util.logging.Logger;
+
+import javax.mail.Authenticator;
+import javax.mail.Message;
+import javax.mail.PasswordAuthentication;
+import javax.mail.Session;
+import javax.mail.Transport;
+import javax.mail.internet.InternetAddress;
+import javax.mail.internet.MimeMessage;
+
+/**
+ Default implementation of {@link Emailer}.
+ 
+ <P>Uses these <tt>init-param</tt> settings in <tt>web.xml</tt>:
+ <ul>
+ <li><tt>Webmaster</tt> : the email address of the webmaster.
+ <li><tt>MailServerConfig</tt> : configuration data to be passed to the mail server, as a list of name=value pairs.
+ Each name=value pair appears on a single line by itself. Used for <tt>mail.host</tt> settings, and so on. 
+ The special value <tt>NONE</tt> indicates that emails are suppressed, and will not be sent. 
+ <li><tt>MailServerCredentials</tt> : user name and password for access to the outgoing mail server. 
+ The user name is separated from the password by a pipe character '|'.
+ The special value <tt>NONE</tt> means that no credentials are needed (often the case when the wep app
+ and the outgoing mail server reside on the same network). 
+ </ul> 
+ 
+ <P>Example <tt>web.xml</tt> settings, using a Gmail account:
+<PRE>    &lt;init-param&gt;
+      &lt;param-name&gt;Webmaster&lt;/param-name&gt;
+      &lt;param-value&gt;myaccount@gmail.com&lt;/param-value&gt; 
+    &lt;/init-param&gt;
+    
+    &lt;init-param&gt;
+      &lt;param-name&gt;MailServerConfig&lt;/param-name&gt;
+      &lt;param-value&gt;
+        mail.smtp.host=smtp.gmail.com       
+        mail.smtp.auth=true
+        mail.smtp.port=465
+        mail.smtp.socketFactory.port=465
+        mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
+      &lt;/param-value&gt; 
+    &lt;/init-param&gt;
+
+    &lt;init-param&gt;
+      &lt;param-name&gt;MailServerCredentials&lt;/param-name&gt;
+      &lt;param-value&gt;myaccount@gmail.com|mypassword&lt;/param-value&gt; 
+    &lt;/init-param&gt;
+  </pre>
+*/
+public final class EmailerImpl implements Emailer {
+
+  public void sendFromWebmaster(List<String> aToAddresses, String aSubject, String aBody) throws AppException {
+    if (isMailEnabled()) {
+      validateState(getWebmasterEmailAddress(), aToAddresses, aSubject, aBody);
+      fLogger.fine("Sending email using request thread.");
+      sendEmail(getWebmasterEmailAddress(), aToAddresses, aSubject, aBody);
+    }
+    else {
+      fLogger.fine("Mailing is disabled, since mail server is configured as " + Util.quote(Config.NONE));
+    }
+  }
+
+  // PRIVATE 
+
+  private Config fConfig = new Config();
+  private static final Logger fLogger = Util.getLogger(EmailerImpl.class);
+
+  private boolean isMailEnabled() {
+    return  fConfig.isEnabled(fConfig.getMailServerConfig());
+  }
+  
+  private boolean areCredentialsEnabled() {
+    return fConfig.isEnabled(fConfig.getMailServerCredentials());
+  }
+
+  /** Return the mail server config in the form of a Properties object. */
+  private Properties getMailServerConfigProperties() {
+    Properties result = new Properties();
+    String rawValue = fConfig.getMailServerConfig();
+    /*  Example data: mail.smtp.host = smtp.blah.com */
+    if(Util.textHasContent(rawValue)){
+      List<String> lines = getAsLines(rawValue);
+      for(String line : lines){
+        int delimIdx = line.indexOf("=");
+        String name = line.substring(0,delimIdx);
+        String value = line.substring(delimIdx+1);
+        if(isMissing(name) || isMissing(value)){
+          throw new RuntimeException(
+            "This line for the MailServerConfig setting in web.xml does not have the expected form: " + Util.quote(line)
+          );
+        }
+        result.put(name.trim(), value.trim());
+      }
+    }
+    return result;
+  }
+  
+  private List<String> getAsLines(String aRawValue){
+    List<String> result = new  ArrayList<String>();
+    StringTokenizer parser = new StringTokenizer(aRawValue, "\n\r");
+    while ( parser.hasMoreTokens() ) {
+      result.add( parser.nextToken().trim() );
+    }
+    return result;
+  }
+  
+  private static boolean isMissing(String aText){
+    return ! Util.textHasContent(aText);
+  }
+  
+  private String getWebmasterEmailAddress() {
+    return fConfig.getWebmaster();
+  }
+
+  private void validateState(String aFrom, List<String> aToAddresses, String aSubject, String aBody) throws AppException {
+    AppException ex = new AppException();
+    if (!WebUtil.isValidEmailAddress(aFrom)) {
+      ex.add("From-Address is not a valid email address.");
+    }
+    if (!Util.textHasContent(aSubject)) {
+      ex.add("Email subject has no content.");
+    }
+    if (!Util.textHasContent(aBody)) {
+      ex.add("Email body has no content.");
+    }
+    if (aToAddresses.isEmpty()){
+      ex.add("To-Address is empty.");
+    }
+    for(String email: aToAddresses){
+      if (!WebUtil.isValidEmailAddress(email)) {
+        ex.add("To-Address is not a valid email address: " + Util.quote(email));
+      }
+    }
+    if (ex.isNotEmpty()) {
+      fLogger.severe("Cannot send email : " + ex);
+      throw ex;
+    }
+  }
+
+  private void sendEmail(String aFrom, List<String> aToAddresses, String aSubject, String aBody) throws AppException {
+    fLogger.fine("Sending mail from " + Util.quote(aFrom));
+    fLogger.fine("Sending mail to " + Util.quote(aToAddresses));
+    Properties props = getMailServerConfigProperties();
+    //fLogger.fine("Properties: " + props);
+    try {
+      Authenticator auth = getAuthenticator();
+      //fLogger.fine("Authenticator: " + auth);
+      Session session = Session.getDefaultInstance(props, auth);
+      //session.setDebug(true);
+      MimeMessage message = new MimeMessage(session);
+      message.setFrom(new InternetAddress(aFrom));
+      for(String toAddr: aToAddresses){
+        message.addRecipient(Message.RecipientType.TO, new InternetAddress(toAddr));
+      }
+      message.setSubject(aSubject);
+      message.setText(aBody);
+      Transport.send(message); // thread-safe? throttling makes the question irrelevant 
+    }
+    catch (Throwable ex) {
+      fLogger.severe("CANNOT SEND EMAIL: " + ex);
+      throw new AppException("Cannot send email", ex);
+    }
+    fLogger.fine("Mail is sent.");
+  }
+
+  private Authenticator getAuthenticator(){
+    Authenticator  result = null;
+    if( areCredentialsEnabled() ){
+      result = new SMTPAuthenticator();
+    }
+    return result;
+  }
+  
+  private static final class SMTPAuthenticator extends Authenticator {
+    SMTPAuthenticator() {}
+    public PasswordAuthentication getPasswordAuthentication()   {
+      PasswordAuthentication result = null;
+      /** Format is pipe separated : bob|passwd. */
+      String rawValue = new Config().getMailServerCredentials();
+      int delimIdx = rawValue.indexOf("|");
+      if(delimIdx != -1){
+        String userName = rawValue.substring(0,delimIdx);
+        String password = rawValue.substring(delimIdx+1);
+        result = new PasswordAuthentication(userName, password);
+      }
+      else {
+        throw new RuntimeException("Missing pipe separator between user name and password: " + rawValue);
+      }
+      return result;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/LoggingConfig.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,41 @@
+package hirondelle.web4j.webmaster;
+
+import hirondelle.web4j.model.AppException;
+
+import java.util.Map;
+
+/**
+ Configure the logging system used in your application.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. 
+ {@link hirondelle.web4j.BuildImpl#forLoggingConfig()} 
+ returns the configured implementation of this interface.
+ 
+ <P>Here, implementations configure the logging system using <em>code</em>, not a configuration file. 
+ WEB4J itself uses JDK logging. Your application may also use JDK logging, or any other logging system.
+ 
+ <P>If your application does not require any logging config performed in code, 
+ then just set the <tt>LoggingDirectory</tt> in <tt>web.xml</tt> set to <tt>'NONE'</tt>.  
+ 
+ <P>Implementations of this interface are called by the framework only <em>once</em>, upon startup.
+ 
+ <P><b>Independent Logging On Servers</b><br>
+ In the JDK <tt>logging.properties</tt> config file, it is important to remember that  
+ the <tt>handlers</tt> setting creates Handlers and <em>attaches them to the root logger</em>. 
+ In general, those default handlers will be <em>shared</em> by all applications running 
+ in that JRE. This is not appropriate for most server environments. 
+ In a servlet environment, however, each application uses a <em>private class loader</em>. 
+ This means that each application can perform its own custom logging 
+ config in <em>code</em>, instead of in <tt>logging.properties</tt>, and <em>retain independence</em> 
+ from other applications running in the same JRE. 
+*/
+public interface LoggingConfig {
+  
+  /** 
+   Configure application logging.
+   @param  aConfig - in a servlet environment, this map will hold the <tt>init-param</tt> items for the servlet, 
+   as stated in <tt>web.xml</tt>.   
+  */
+  void setup(Map<String, String> aConfig) throws AppException;
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/LoggingConfigImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,210 @@
+package hirondelle.web4j.webmaster;
+
+import static hirondelle.web4j.util.Consts.FILE_SEPARATOR;
+import static hirondelle.web4j.util.Consts.NOT_FOUND;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.util.TimeSource;
+import hirondelle.web4j.util.Util;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.logging.FileHandler;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.logging.SimpleFormatter;
+
+/**
+ Default implementation of {@link LoggingConfig}, to set up simple logging.
+ 
+ <P>This implementation uses JDK logging, and appends logging output to a single file, 
+ with no size limit on the file. It uses two settings in <tt>web.xml</tt>:
+<ul>
+ <li><tt>LoggingDirectory</tt> - the absolute directory which will hold the logging 
+ output file. This class will always use a file name using the system date/time, as 
+ returned by {@link DateTime#now(TimeZone)} using the <tt>DefaultUserTimeZone</tt> setting in 
+ <tt>web.xml</tt>, in the form <tt>2007_12_31_59_59.txt</tt>. If the directory does not exist, WEB4J will 
+ attempt to create it upon startup. If set to the special value of <tt>'NONE'</tt>, then 
+ this class will not configure JDK logging in any way.
+ <li><tt>LoggingLevels</tt> - a comma-separated list of logger names and their corresponding 
+ levels. To verify operation, this class will emit test logging entries for each of these loggers, 
+ at the stated logging levels. 
+</ul>
+*/
+public final class LoggingConfigImpl implements LoggingConfig {
+
+  /** See class comment.   */
+  public void setup(Map<String, String> aConfig) throws AppException {
+    /* This impl uses the unpublished Config class, not the given map. Custom impls will need the given map. */
+    logStdOut("Logging directory from web.xml : " + Util.quote(fConfig.getLoggingDirectory()));
+    logStdOut("Logging levels from web.xml : " + Util.quote(fConfig.getLoggingLevels()));
+    if( isTurnedOff() ) {
+      logStdOut("Default logging config is turned off, since directory is set to " + Util.quote(NONE));
+    }
+    else {
+      logStdOut("Setting up logging config...");
+      validateDirectorySetting();
+      parseLoggers();
+      createFileHandler();
+      attachLoggersToFileHandler();
+      tryTestMessages();
+      fLogger.config("Logging to directory : " + Util.quote(fConfig.getLoggingDirectory()));
+      DateTime now = DateTime.now(fConfig.getDefaultUserTimeZone());
+      fLogger.config("Current date-time: " + now.format("YYYY-MM-DD hh:mm:ss.fffffffff") + " (uses your TimeSource implementation and the DefaultUserTimeZone setting in web.xml)");
+      fLogger.config("Raw value of System.currentTimeMillis(): " + System.currentTimeMillis());
+      showLoggerLevels();
+    }
+  }
+
+  // PRIVATE
+  private Config fConfig = new Config();
+  private static final int NO_SIZE_LIMIT = 0;
+  private static final int MAX_BYTES = NO_SIZE_LIMIT;
+  private static final int NUM_FILES = 1;
+  private static final boolean APPEND_TO_EXISTING = true;
+  private static final String NONE = "NONE";
+  private static final String SEPARATOR = "=";
+  
+  /** List of loggers. Each Logger stores its own Level as part of its state.  */
+  private final List<Logger> fLoggers = new ArrayList<Logger>();
+  private FileHandler fHandler;
+  private static final Logger fLogger = Util.getLogger(LoggingConfigImpl.class);
+  
+  private boolean isTurnedOff(){
+    return NONE.equalsIgnoreCase(fConfig.getLoggingDirectory());
+  }
+  
+  private void validateDirectorySetting() {
+    if( ! fConfig.getLoggingDirectory().endsWith(FILE_SEPARATOR) ){
+      String message = "*** PROBLEM *** LoggingDirectory setting in web.xml does not end in with a directory separator : " + Util.quote(fConfig.getLoggingDirectory());
+      logStdOut(message);
+      throw new IllegalArgumentException(message);
+    }
+    if( ! targetDirectoryExists() ){
+      String message = "LoggingDirectory setting in web.xml does not refer to an existing, writable directory. Will attempt to create directory : " + Util.quote(fConfig.getLoggingDirectory());
+      logStdOut(message);
+      File directory = new File(fConfig.getLoggingDirectory());
+      boolean success = directory.mkdirs();
+      if (success) {
+        logStdOut("Directory created successfully");
+      }
+      else {
+        logStdOut("*** PROBLEM *** : Unable to create LoggingDirectory specified in web.xml! Permissions problem? Directory already exists, but not writable?");
+      }
+    }
+  }
+  
+  private void parseLoggers(){
+    for(String logLevel : fConfig.getLoggingLevels()){
+      int separator = logLevel.indexOf(SEPARATOR);
+      String logger = logLevel.substring(0, separator).trim();
+      String level = logLevel.substring(separator + 1).trim();
+      addLogger(removeSuffix(logger), level);
+    }
+  }
+  
+  private String removeSuffix(String aLogger){
+    int suffix = aLogger.indexOf(".level");
+    if ( suffix == NOT_FOUND ) {
+      throw new IllegalArgumentException("*** PROBLEM *** LoggingLevels setting in web.xml does not end with '.level'");
+    }
+    return aLogger.substring(0, suffix);
+  }
+  
+  private void addLogger(String aLogger, String aLevel){
+    if( ! Util.textHasContent(aLogger) ){
+      throw new IllegalArgumentException("Logger name specified in web.xml has no content.");
+    }
+    Logger logger = Logger.getLogger(aLogger); //creates Logger if does not yet exist
+    logger.setLevel(Level.parse(aLevel));
+    fLogger.config("Adding Logger " + Util.quote(logger.getName() ) + " with level " + Util.quote(logger.getLevel()) );
+    fLoggers.add(logger);
+  }
+  
+  private void createFileHandler() throws AppException {
+    try {
+      fHandler = new FileHandler(getFileName(), MAX_BYTES, NUM_FILES, APPEND_TO_EXISTING);
+      fHandler.setLevel(Level.FINEST);
+      fHandler.setFormatter(new TimeSensitiveFormatter());
+    }
+    catch (IOException ex){
+      throw new AppException("Cannot create FileHandler: " + ex.toString() , ex);
+    }
+  }
+  
+  private void attachLoggersToFileHandler(){
+    for (Logger logger: fLoggers){
+      if( hasNoFileHandler(logger) ){
+        logger.addHandler(fHandler);
+      }
+    }
+  }
+  
+  private boolean hasNoFileHandler(Logger aLogger){
+    boolean result = true;
+    Handler[] handlers = aLogger.getHandlers();
+    fLogger.config("Logger " + aLogger.getName() + " has this many existing handlers: " + handlers.length);
+    for (int idx = 0; idx < handlers.length; ++idx){
+      if ( FileHandler.class.isAssignableFrom(handlers[idx].getClass()) ){
+        fLogger.config("FileHandler already exists for Logger " + Util.quote(aLogger.getName()) + ". Will not add a new one.");
+        result = false;
+        break;
+      }
+    }
+    return result;
+  }
+  
+  /** Log a test message at each logger's configured level. */
+  private void tryTestMessages(){
+    logStdOut("Sending test messages to configured loggers. Please confirm output to above log file.");
+    for(Logger logger: fLoggers){
+      logger.log(logger.getLevel(), "This is a test message for Logger " + Util.quote(logger.getName()));
+    }
+  }
+
+  /**
+   Return the complete name of the logging file.
+   Example file name : <tt>C:\log\fish_and_chips\2007_12_31_23_59.txt</tt>
+  */
+  private String getFileName(){
+    String result = null;
+    DateTime now = DateTime.now(fConfig.getDefaultUserTimeZone());
+    result = fConfig.getLoggingDirectory() + now.format("YYYY|_|MM|_|DD|_|hh|_|mm");
+    result = result + ".txt";
+    logStdOut("Logging file name : " + Util.quote(result));
+    return result;
+  }
+  
+  private boolean targetDirectoryExists(){
+    File directory = new File(fConfig.getLoggingDirectory());
+    return directory.exists() && directory.isDirectory() && directory.canWrite();
+  }
+  
+  private void logStdOut(Object aObject){
+    String message = String.valueOf(aObject);
+    System.out.println(message);
+  }
+  
+  private void showLoggerLevels() {
+    for(Logger logger : fLoggers){
+      fLogger.config("Logger " + logger.getName() + " has level " + logger.getLevel());
+    }
+  }
+  
+  private static final class TimeSensitiveFormatter extends SimpleFormatter {
+    public TimeSensitiveFormatter() { }
+    @Override public String format(LogRecord aLogRecord) {
+      aLogRecord.setMillis(fTimeSource.currentTimeMillis());
+      return super.format(aLogRecord);
+    }
+    private TimeSource fTimeSource = BuildImpl.forTimeSource();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/PerformanceMonitor.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,220 @@
+package hirondelle.web4j.webmaster;
+
+import hirondelle.web4j.util.Stopwatch;
+import hirondelle.web4j.util.Util;
+import hirondelle.web4j.util.WebUtil;
+
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.logging.Logger;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ Compile simple performance statistics, and use periodic pings to detect trouble.
+
+ <P>See <tt>web.xml</tt> for more information on how to configure this {@link Filter}.
+ 
+ <h3>Performance Statistics</h3> 
+ This class stores a <tt>Collection</tt> of {@link PerformanceSnapshot} objects in 
+ memory (not in a database).
+
+ <P>The presentation of these performance statistics in a JSP is always "one behind" this class. 
+ This {@link Filter} examines the response time of each <em>fully processed</em> 
+ request. Any JSP presenting the response times, however, is not fully processed <i>from the 
+ point of view of this filter</i>, and has not yet contributed to the statistics.  
+
+ <P><span class="highlight">It is important to note that {@link Filter} objects 
+ must be designed to operate safely in a multi-threaded environment</span>.  
+ Using the <a href='http://www.javapractices.com/Topic48.cjp'>nomenclature</a> of 
+ <em>Effective Java</em>, this class is 'conditionally thread safe' : the responsibility 
+ for correct operation in a multi-threaded environment is <em>shared</em> between 
+ this class and its caller. See {@link #getPerformanceHistory} for more information.
+ 
+<P> If desired, you can use also external tools such as 
+<a href='http://www.siteuptime.com/'>SiteUptime.com</a> to monitor your site. 
+*/
+public final class PerformanceMonitor implements Filter {
+  
+  /**
+   Read in the configuration of this filter from <tt>web.xml</tt>.
+   
+   <P>The config is validated, gathering of statistics is begun, and 
+   any periodic ping operations are initialized.
+  */
+  public void init(FilterConfig aFilterConfig) {
+    /*
+     The logging performed here is not showing up in the expected manner. 
+    */
+    Enumeration items = aFilterConfig.getInitParameterNames();
+    while ( items.hasMoreElements() ) {
+      String name = (String)items.nextElement();
+      String value = aFilterConfig.getInitParameter(name);
+      fLogger.fine("Filter param " + name + " = " + Util.quote(value));
+    }
+    
+    fEXPOSURE_TIME = new Integer( aFilterConfig.getInitParameter(EXPOSURE_TIME) );
+    fNUM_PERFORMANCE_SNAPSHOTS = new Integer( 
+      aFilterConfig.getInitParameter(NUM_PERFORMANCE_SNAPSHOTS) 
+    );
+    validateConfigParamValues();
+    
+    fPerformanceHistory.addFirst(new PerformanceSnapshot(fEXPOSURE_TIME));
+  }
+  
+  /** This implementation does nothing.  */
+  public void destroy() {
+    //do nothing
+  }
+  
+  /** Calculate server response time, and store relevant statistics in memory.   */
+  public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException {
+    fLogger.fine("START PerformanceMonitor Filter.");
+    
+    Stopwatch stopwatch = new Stopwatch();
+    stopwatch.start();
+    
+    aChain.doFilter(aRequest, aResponse);
+    
+    stopwatch.stop();
+    long millis = stopwatch.toValue()/(1000*1000);
+    addResponseTime(millis, aRequest);
+    fLogger.fine("END PerformanceMonitor Filter. Response Time: " + stopwatch);
+  }
+
+  /**
+   Return statistics on recent application performance.
+  
+   <P>A static method is the only way an {@link hirondelle.web4j.action.Action} 
+   can access this data, since it has no access to the {@link Filter} object 
+   itself (which is built by the container).
+   
+   <P>The typical task for the caller is iteration over the return value. The caller  
+   <b>must</b> synchronize this iteration, by obtaining the lock on the return value. 
+   The typical use case of this method is :
+   <PRE>
+   List history = PerformanceMonitor.getPerformanceHistory();
+   synchronized(history) {
+     for(PerformanceSnapshot snapshot : history){
+       //..elided
+     }
+   }
+   </PRE>
+  */
+  public static List<PerformanceSnapshot> getPerformanceHistory(){
+    /*
+     Note that using Collections.synchronizedList here is not possible : the 
+     API for that method states that when used, the returned reference must be used 
+     for ALL interactions with the backing list. 
+    */
+    return fPerformanceHistory;
+  }
+  
+  // PRIVATE 
+  
+  /**
+   Holds queue of {@link PerformanceSnapshot} objects, including the "current" one.
+  
+  <P>The queue grows until it reaches a configured maximum length, after which stale 
+   items are removed when new ones are added.
+  
+   <P>This mutable item must always have synchronized access, to ensure thread-safety.
+  */
+  private static final LinkedList<PerformanceSnapshot> fPerformanceHistory = new LinkedList<PerformanceSnapshot>();
+  
+  private static Integer fEXPOSURE_TIME;
+  private static Integer fNUM_PERFORMANCE_SNAPSHOTS;
+  
+  /*
+   Names of configuration parameters.
+  */
+  private static final String NUM_PERFORMANCE_SNAPSHOTS = "NumPerformanceSnapshots";
+  private static final String EXPOSURE_TIME = "ExposureTime";
+  
+  private static final Logger fLogger = Util.getLogger(PerformanceMonitor.class);
+  
+  /**
+   Validate the configured parameter values.
+  */
+  private void validateConfigParamValues(){
+    StringBuilder message = new StringBuilder();
+    if ( ! Util.isInRange(fNUM_PERFORMANCE_SNAPSHOTS, 1, 1000) ) {
+      message.append(
+        "web.xml: " + NUM_PERFORMANCE_SNAPSHOTS + " has value of " + 
+        fNUM_PERFORMANCE_SNAPSHOTS + ", which is outside the accepted range of 1..1000."
+      );
+    }
+    int exposure = fEXPOSURE_TIME;
+    if ( exposure != 10 && exposure != 20 && exposure != 30 && exposure != 60){
+      message.append(
+        " web.xml: " + EXPOSURE_TIME + " has a value of " + exposure + "." + 
+        " The only accepted values are 10, 20, 30, and 60."
+      );
+    }
+    if ( Util.textHasContent(message.toString()) ){
+      throw new IllegalArgumentException(message.toString());
+    }
+  }
+  
+  private static void addResponseTime(long aResponseTime /*millis*/, ServletRequest aRequest){
+    long now = System.currentTimeMillis();
+    HttpServletRequest request = (HttpServletRequest)aRequest;
+    String url = WebUtil.getURLWithQueryString(request);
+    //this single synchronization block implements the *internal* thread-safety 
+    //responsibilities of this class
+    synchronized( fPerformanceHistory ){
+      if ( now > getCurrentSnapshot().getEndTime().getTime() ){
+        //start a new 'current' snapshot
+        addToPerformanceHistory( new PerformanceSnapshot(fEXPOSURE_TIME) );
+      }
+      updateCurrentSnapshotStats(aResponseTime, url);
+    }
+  }
+  
+  private static void addToPerformanceHistory(PerformanceSnapshot aNewSnapshot){
+    while ( hasGap(aNewSnapshot, getCurrentSnapshot() ) ) {
+      fLogger.fine("Gap detected. Adding empty snapshot.");
+      PerformanceSnapshot filler = PerformanceSnapshot.forGapInActivity(getCurrentSnapshot());
+      addNewSnapshot(filler);
+    }
+    addNewSnapshot(aNewSnapshot);
+  }
+
+  private static boolean hasGap(PerformanceSnapshot aNewSnapshot, PerformanceSnapshot aCurrentSnapshot){
+    return aNewSnapshot.getEndTime().getTime() - aCurrentSnapshot.getEndTime().getTime() > fEXPOSURE_TIME*60*1000;
+  }
+  
+  private static void addNewSnapshot(PerformanceSnapshot aNewSnapshot){
+    fPerformanceHistory.addFirst(aNewSnapshot);
+    ensureSizeRemainsLimited();
+  }
+  
+  private static void ensureSizeRemainsLimited() {
+    if ( fPerformanceHistory.size() > fNUM_PERFORMANCE_SNAPSHOTS ){
+      fPerformanceHistory.removeLast();
+    }
+  }
+  
+  private static PerformanceSnapshot getCurrentSnapshot(){
+    return fPerformanceHistory.getFirst();
+  }
+  
+  private static void updateCurrentSnapshotStats(long aResponseTime /*millis*/, String aURL){
+    PerformanceSnapshot updatedSnapshot = getCurrentSnapshot().addResponseTime(
+      aResponseTime, aURL
+    );
+    //this style is needed only because the PerfomanceSnapshot objects are immutable.
+    //Immutability is advantageous, since it guarantees that the caller 
+    //cannot change the internal state of this class.
+    fPerformanceHistory.removeFirst();
+    fPerformanceHistory.addFirst(updatedSnapshot);
+  }
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/PerformanceSnapshot.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,219 @@
+package hirondelle.web4j.webmaster;
+
+import java.util.*;
+import java.util.logging.*;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.model.ModelUtil;
+import hirondelle.web4j.util.Util;
+
+/**
+ Statistics on server response time.
+ 
+ <P>This class uses the metaphor of a 'photographic exposure' of, say, 10 minutes, whereby response times
+ for a specific time interval are grouped together in a single bin, and average and maximum response times for 
+ that interval are derived for that group.
+ 
+ <P>A particular <tt>PerformanceSnapshot</tt> is used only if its 'exposure time' 
+ has not yet ended. A typical 'exposure' lasts a few minutes. 
+ (See {@link hirondelle.web4j.webmaster.PerformanceMonitor} and the <tt>web.xml</tt> 
+ of the example application for more information.) 
+ By inspecting the return value of {@link #getEndTime}, <em>the caller 
+ determines if a <tt>PerformanceSnapshot</tt> object can still be used</em>, or if a new 
+ <tt>PerformanceSnapshot</tt> object must be created for the 'next exposure'.
+
+ <P>This class is immutable. In particular, {@link #addResponseTime} returns a new object, 
+ instead of changing the state of an existing one.
+*/
+public final class PerformanceSnapshot {
+  
+  /**
+   @param aExposureTime number of minutes to gather statistics ; see <tt>web.xml</tt>
+   for more information.
+  */
+  public PerformanceSnapshot(Integer aExposureTime){
+    fMaxUrl = Consts.EMPTY_STRING;
+    fAvgResponseTime = 0L;
+    fMaxResponseTime = 0L;
+    fNumRequests = 0;
+    fExposureTime = aExposureTime.intValue();
+    fEndTime = calcEndTime();
+  }
+
+  /**
+   Return a <tt>PerformanceSnapshot</tt> having no activity. 
+   Such objects are used to explicitly 'fill in the gaps' during periods of no activity.
+   
+   <P>The returned object has the same exposure time as <tt>aCurrentSnapshot</tt>.
+   Its end time is taken as <tt>aCurrentSnapshot.getEndTime()</tt>, plus the exposure time.
+   All other items are <tt>0</tt> or empty. 
+  */
+  public static PerformanceSnapshot forGapInActivity(PerformanceSnapshot aCurrentSnapshot){
+    return new PerformanceSnapshot(
+      aCurrentSnapshot.getEndTime().getTime() + aCurrentSnapshot.getExposureTime()*60*1000, 
+      Consts.EMPTY_STRING, 
+      0L, 
+      0L, 
+      0,
+      aCurrentSnapshot.getExposureTime()
+    );
+  }
+
+  /**
+   Return a new <tt>PerformanceSnapshot</tt> whose state reflects an additional  
+   data point.
+  
+   @param aResponseTime response time of a particular server request.
+   @param aURL URL of the underlying request.
+  */
+  public PerformanceSnapshot addResponseTime(long aResponseTime, String aURL){
+    long maxResponseTime = 
+      aResponseTime > fMaxResponseTime ? aResponseTime : fMaxResponseTime
+    ;
+    String maxUrl = aResponseTime > fMaxResponseTime ? aURL : fMaxUrl;
+    int numRequests = fNumRequests + 1;
+    return new PerformanceSnapshot(
+      fEndTime, 
+      maxUrl, 
+      getNewAvgResponseTime(aResponseTime), 
+      maxResponseTime, 
+      numRequests,
+      fExposureTime
+    );
+  }
+
+  /**
+   Return the time that this snapshot will 'end'. After this time, 
+   a new <tt>PerformanceSnapshot</tt> must be created by the caller (using the  
+   constructor).
+  */
+  public Date getEndTime(){
+    return new Date(fEndTime);
+  }
+  
+  /**
+   Return the number of server requests this snapshot has recorded. 
+  
+   <P>If a page contains two images, for example, the server will likely count 
+   3 requests, not 1 (one page and two images). 
+  */
+  public Integer getNumRequests(){
+    return new Integer(fNumRequests);
+  }
+  
+  /** Return the average response time recorded during this snapshot.  */
+  public Long getAvgResponseTime(){
+    return new Long(fAvgResponseTime);
+  }
+  
+  /** Return the maximum response time recorded during this snapshot.  */
+  public Long getMaxResponseTime(){
+    return new Long(fMaxResponseTime);
+  }
+  
+  /** Return the exposure time in minutes, as passed to the constructor.  */
+  public Integer getExposureTime(){
+    return new Integer(fExposureTime);
+  }
+  
+  /** Return the URL of the request responsible for {@link #getMaxResponseTime}.  */
+  public String getURLWithMaxResponseTime(){
+    return fMaxUrl;
+  }
+  
+  /** Intended for debugging only.  */
+  @Override public String toString(){
+    return ModelUtil.toStringFor(this);
+  }
+  
+  @Override public boolean equals(Object aThat){
+    if ( this == aThat ) return true;
+    if ( !(aThat instanceof PerformanceSnapshot) ) return false;
+    PerformanceSnapshot that = (PerformanceSnapshot)aThat;
+    return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+  }
+  
+  @Override public int hashCode(){
+    return ModelUtil.hashCodeFor(getSignificantFields());
+  }
+  
+  // PRIVATE //
+  private final int fNumRequests;
+  private final long fEndTime;
+  private final long fAvgResponseTime;
+  private final long fMaxResponseTime;
+  private final int fExposureTime;
+  private final String fMaxUrl;
+  
+  private static final Logger fLogger = Util.getLogger(PerformanceSnapshot.class);  
+  
+  private PerformanceSnapshot(
+    long aEndTime, 
+    String aMaxUrl, 
+    long aAvgResponseTime, 
+    long aMaxResponseTime, 
+    int aNumRequests,
+    int aExposureTime
+  ){
+    fEndTime = aEndTime;
+    fMaxUrl = aMaxUrl;
+    fAvgResponseTime = aAvgResponseTime;
+    fMaxResponseTime = aMaxResponseTime;
+    fNumRequests = aNumRequests;
+    fExposureTime = aExposureTime;
+  }
+  
+  /**
+   Return a new average response time, rounded to the nearest millisecond.
+  */
+  private long getNewAvgResponseTime(long aNewResponseTime){
+    //use integer division to do the rounding
+    //here, all previous requests are treated as having the same average response time
+    long numerator = (fAvgResponseTime * fNumRequests) + aNewResponseTime;
+    long denominator = fNumRequests + 1;
+    return numerator/denominator;
+  }
+  
+  /**
+   Return the time this snapshot's exposure will end.
+  
+   <P>The end time is the next 'round' minute of the hour in agreement with the 
+   configured exposure time. For example, if the exposure time is 20 minutes, and 
+   the current time is 32 minutes past the hour, the end time will be 40 minutes past 
+   the hour. (If the current time is 40 minutes past the hour, the end time will be 
+   60 minutes past the hour.)
+  */
+  private long calcEndTime(){
+    //this item is used as a 'workspace', and its state is changed to find the 
+    //desired end point :
+    final Calendar result = new GregorianCalendar();
+    //leniency will increment hour, day, and so on, if necessary :
+    result.setLenient(true);
+    result.setTimeInMillis(System.currentTimeMillis());
+    //these items are not relevant to the result, since we need to return an even minute
+    result.set(Calendar.MILLISECOND, 0);
+    result.set(Calendar.SECOND, 0);
+    fLogger.finest("Initial calendar, minus seconds : " + result.toString());
+    //increase the minutes until the desired end point is reached
+    //note this item in non-final
+    int minute = result.get(Calendar.MINUTE); 
+    fLogger.finest("Initial minute: " + minute);
+    
+    //Note that the minute is always incremented at least once
+    //This avoids error when the new Snapshot is created at a 'whole' minute (a 
+    //common occurrence).
+    do {
+      ++minute;
+    }
+    while (minute % fExposureTime != 0);
+    fLogger.finest("Final minute : " + minute);
+    
+    result.set(Calendar.MINUTE, minute);
+    return result.getTimeInMillis();
+  }
+  
+  private Object[] getSignificantFields(){
+    return new Object[]{
+      fNumRequests, fEndTime, fAvgResponseTime, fMaxResponseTime, fExposureTime, fMaxUrl 
+   };
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/TroubleTicket.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,628 @@
+package hirondelle.web4j.webmaster;
+
+import hirondelle.web4j.ApplicationInfo;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.model.AppException;
+import hirondelle.web4j.model.DateTime;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.util.Args;
+import hirondelle.web4j.util.Consts;
+import hirondelle.web4j.util.Util;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+/**
+ Email diagnostic information to support staff when an error occurs. 
+ 
+ <P>Uses the following settings in <tt>web.xml</tt>:
+<ul>
+ <li> <tt>Webmaster</tt> - the 'from' address.
+ <li> <tt>TroubleTicketMailingList</tt> - the 'to' addresses for the support staff.
+ <li> <tt>PoorPerformanceThreshold</tt> - when the response time exceeds this level, then 
+ a <tt>TroubleTicket</tt> is sent
+ <li>  <tt>MinimumIntervalBetweenTroubleTickets</tt> - throttles down emission of 
+ <tt>TroubleTicket</tt>s, where many might be emitted in rapid succession, from the 
+ same underlying cause
+</ul>
+
+ <P>The {@link hirondelle.web4j.Controller} will create and send a <tt>TroubleTicket</tt> when: 
+ <ul>
+ <li>an unexpected problem (a bug) occurs. The bug corresponds to an unexpected 
+ {@link Throwable} emitted by either the application or the framework. 
+ <li>the response time exceeds the <tt>PoorPerformanceThreshold</tt> configured in 
+ <tt>web.xml</tt>.
+ </ul> 
+
+ <P><em>Warning</em>: some e-mail spam filters may incorrectly treat the default content of these trouble 
+ tickets (example below) as spam.
+ 
+ <P>Example content of a <tt>TroubleTicket</tt>, as returned by {@link #toString()}:
+<PRE>
+{@code
+Error for web application Fish And Chips Club/4.6.2.0
+*** java.lang.RuntimeException: Testing application behavior upon failure. ***
+--------------------------------------------------------
+Time of error : 2011-09-12 19:59:32
+Occurred for user : blah
+Web application Build Date: Sat Jul 09 00:00:00 ADT 2011
+Web application Author : Hirondelle Systems
+Web application Link : http://www.web4j.com/
+Web application Message : Uses web4j.jar version 4.6.2
+
+Request Info:
+--------------------------------------------------------
+HTTP Method: GET
+Context Path: /fish
+ServletPath: /webmaster/testfailure/ForceFailure.do
+URI: /fish/webmaster/testfailure/ForceFailure.do
+URL: http://localhost:8081/fish/webmaster/testfailure/ForceFailure.do
+Header accept = text/html,application/xhtml+xml,application/xml;q=0.9
+Header accept-charset = UTF-8,*
+Header accept-encoding = gzip,deflate
+Header accept-language = en-us,en;q=0.5
+Header connection = keep-alive
+Header cookie = JSESSIONID=2C326412C32F6F823673A5FBD1C883A7
+Header host = localhost:8081
+Header keep-alive = 115
+Header referer = http://localhost:8081/fish/webmaster/performance/ShowPerformance.do
+Header user-agent = Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2.22) ..[elided]..
+Cookie JSESSIONID=2C326412C32F6F823673A5FBD1C883A7
+
+Client Info:
+--------------------------------------------------------
+User IP: 127.0.0.1
+User hostname: 127.0.0.1
+
+Session Info
+--------------------------------------------------------
+Logged in user name : blah
+Timeout : 900 seconds.
+Session Attributes javax.servlet.jsp.jstl.fmt.request.charset = UTF-8
+Session Attributes web4j_key_for_form_source_id = 214c125310311a6e4eda5aa6448b3c47d0a85d31
+Session Attributes web4j_key_for_locale = en
+Session Attributes web4j_key_for_previous_form_source_id = f2d6ae8487e555f90ae7c186f370b05bcf46f0d0
+
+Memory Info:
+--------------------------------------------------------
+JRE Memory
+ total:  66,650,112
+ used:    7,515,624 (11%)
+ free:   59,134,488 (89%)
+
+
+Server And Servlet Info:
+--------------------------------------------------------
+Name: localhost
+Port: 8081
+Info: Apache Tomcat/6.0.10
+JRE default TimeZone: America/Halifax
+JRE default Locale: English (Canada)
+awt.toolkit: sun.awt.windows.WToolkit
+catalina.base: C:\johanley\Projects\TomcatInstance
+catalina.home: C:\Program Files\Tomcat6
+catalina.useNaming: true
+common.loader: ${catalina.home}/lib,${catalina.home}/lib/*.jar
+file.encoding: UTF-8
+file.encoding.pkg: sun.io
+file.separator: \
+java.awt.graphicsenv: sun.awt.Win32GraphicsEnvironment
+java.awt.printerjob: sun.awt.windows.WPrinterJob
+java.class.path: .;C:\Program Files\Java\jre1.6.0_07\lib\ext\QTJava.zip;C:\Program Files\Tomcat6\bin\bootstrap.jar
+java.class.version: 49.0
+java.endorsed.dirs: C:\Program Files\Tomcat6\endorsed
+java.ext.dirs: C:\jdk1.5.0\jre\lib\ext
+java.home: C:\jdk1.5.0\jre
+java.io.tmpdir: C:\johanley\Projects\TomcatInstance\temp
+java.library.path: C:\jdk1.5.0\bin;.;C:\WINDOWS\system32;C:\WINDOWS;C:\jdk1.5.0\bin;..e[lided]..
+java.naming.factory.initial: org.apache.naming.java.javaURLContextFactory
+java.naming.factory.url.pkgs: org.apache.naming
+java.runtime.name: Java(TM) 2 Runtime Environment, Standard Edition
+java.runtime.version: 1.5.0_07-b03
+java.specification.name: Java Platform API Specification
+java.specification.vendor: Sun Microsystems Inc.
+java.specification.version: 1.5
+java.util.logging.config.file: C:\johanley\Projects\TomcatInstance\conf\logging.properties
+java.util.logging.manager: org.apache.juli.ClassLoaderLogManager
+java.vendor: Sun Microsystems Inc.
+java.vendor.url: http://java.sun.com/
+java.vendor.url.bug: http://java.sun.com/cgi-bin/bugreport.cgi
+java.version: 1.5.0_07
+java.vm.info: mixed mode, sharing
+java.vm.name: Java HotSpot(TM) Client VM
+java.vm.specification.name: Java Virtual Machine Specification
+java.vm.specification.vendor: Sun Microsystems Inc.
+java.vm.specification.version: 1.0
+java.vm.vendor: Sun Microsystems Inc.
+java.vm.version: 1.5.0_07-b03
+line.separator: 
+
+os.arch: x86
+os.name: Windows XP
+os.version: 5.1
+package.access: sun.,org.apache.catalina.,org.apache.coyote.,org.apache.tomcat.,org.apache.jasper.,sun.beans.
+package.definition: sun.,java.,org.apache.catalina.,org.apache.coyote.,org.apache.tomcat.,org.apache.jasper.
+path.separator: ;
+server.loader: 
+shared.loader: 
+sun.arch.data.model: 32
+sun.boot.class.path: C:\jdk1.5.0\jre\lib\rt.jar;...[elided]
+sun.boot.library.path: C:\jdk1.5.0\jre\bin
+sun.cpu.endian: little
+sun.cpu.isalist: 
+sun.desktop: windows
+sun.io.unicode.encoding: UnicodeLittle
+sun.jnu.encoding: Cp1252
+sun.management.compiler: HotSpot Client Compiler
+sun.os.patch.level: Service Pack 3
+tomcat.util.buf.StringCache.byte.enabled: true
+user.country: CA
+user.dir: C:\johanley\Projects\TomcatInstance
+user.home: C:\Documents and Settings\John
+user.language: en
+user.name: John
+user.timezone: America/Halifax
+user.variant: 
+java.class.path: 
+.
+C:\Program Files\Java\jre1.6.0_07\lib\ext\QTJava.zip
+C:\Program Files\Tomcat6\bin\bootstrap.jar
+Servlet : Controller
+Servlet init-param:  AccessControlDbConnectionString = java:comp/env/jdbc/fish_access
+Servlet init-param:  AllowStringAsBuildingBlock = YES
+Servlet init-param:  BigDecimalDisplayFormat = #,##0.00
+Servlet init-param:  BooleanFalseDisplayFormat = <input type='checkbox' name='false' value='false' readonly notab>
+Servlet init-param:  BooleanTrueDisplayFormat = <input type='checkbox' name='true' value='true' checked readonly notab>
+Servlet init-param:  CharacterEncoding = UTF-8
+Servlet init-param:  DateTimeFormatForPassingParamsToDb = YYYY-MM-DD^hh:mm:ss^YYYY-MM-DD hh:mm:ss
+Servlet init-param:  DecimalSeparator = PERIOD
+Servlet init-param:  DecimalStyle = HALF_EVEN,2
+Servlet init-param:  DefaultDbConnectionString = java:comp/env/jdbc/fish
+Servlet init-param:  DefaultLocale = en
+Servlet init-param:  DefaultUserTimeZone = America/Halifax
+Servlet init-param:  EmptyOrNullDisplayFormat = -
+Servlet init-param:  ErrorCodeForDuplicateKey = 1062
+Servlet init-param:  ErrorCodeForForeignKey = 1216,1217,1451,1452
+Servlet init-param:  FetchSize = 25
+Servlet init-param:  FullyValidateFileUploads = ON
+Servlet init-param:  HasAutoGeneratedKeys = true
+Servlet init-param:  IgnorableParamValue = 
+Servlet init-param:  ImplementationFor.hirondelle.web4j.security.LoginTasks = hirondelle.fish.all.preferences.Login
+Servlet init-param:  ImplicitMappingRemoveBasePackage = hirondelle.fish
+Servlet init-param:  IntegerDisplayFormat = #,###
+Servlet init-param:  IsSQLPrecompilationAttempted = true
+Servlet init-param:  LoggingDirectory = C:\log\fish\
+Servlet init-param:  LoggingLevels = hirondelle.fish.level=FINE, hirondelle.web4j.level=FINE
+Servlet init-param:  MailServerConfig = mail.host=mail.blah.com
+Servlet init-param:  MailServerCredentials = NONE
+Servlet init-param:  MaxFileUploadRequestSize = 1048576
+Servlet init-param:  MaxHttpRequestSize = 51200
+Servlet init-param:  MaxRequestParamValueSize = 51200
+Servlet init-param:  MaxRows = 300
+Servlet init-param:  MinimumIntervalBetweenTroubleTickets = 30
+Servlet init-param:  PoorPerformanceThreshold = 20
+Servlet init-param:  SpamDetectionInFirewall = OFF
+Servlet init-param:  SqlEditorDefaultTxIsolationLevel = DATABASE_DEFAULT
+Servlet init-param:  SqlFetcherDefaultTxIsolationLevel = DATABASE_DEFAULT
+Servlet init-param:  TimeZoneHint = NONE
+Servlet init-param:  TranslationDbConnectionString = java:comp/env/jdbc/fish_translation
+Servlet init-param:  TroubleTicketMailingList = blah@blah.com
+Servlet init-param:  Webmaster = blah@blah.com
+
+Stack Trace:
+--------------------------------------------------------
+java.lang.RuntimeException: Testing application behavior upon failure.
+  at hirondelle.fish.webmaster.testfailure.ForceFailure.execute(ForceFailure.java:29)
+  at hirondelle.web4j.Controller.checkOwnershipThenExecuteAction(Unknown Source)
+  at hirondelle.web4j.Controller.processRequest(Unknown Source)
+  at hirondelle.web4j.Controller.doGet(Unknown Source)
+  ...[elided]...
+ }
+</PRE>
+*/
+public final class TroubleTicket {
+
+  /** Called by the framework upon startup, to save the name of the server (Servlet 3.0 wouldn't need this). */
+ public static void init(ServletConfig aConfig){
+   fConfig = aConfig;
+ }
+  
+  /**
+   Constructor.
+   
+   @param aException has caused the problem.
+   @param aRequest original underlying HTTP request.
+  */
+  public TroubleTicket(Throwable aException, HttpServletRequest aRequest){
+    fException = aException;
+    fRequest = aRequest;
+    buildBodyOfMessage();
+  }
+  
+  /**
+   Constuctor sets custom content for the body of the email.
+   
+   <P>When using this constructor, the detailed information shown in the class 
+   comment is not generated. 
+   @param aCustomBody the desired body of the email.
+  */
+  public TroubleTicket(String aCustomBody) {
+    Args.checkForContent(aCustomBody);
+    fRequest = null;
+    fException = null;
+    fBody.append(aCustomBody);
+  }
+  
+  /**
+   Return extensive listing of items which may be useful in solving the problem.
+  
+   <P>See example in the class comment.
+  */
+  @Override public String toString(){
+    return fBody.toString();
+  }
+
+  /**
+   Send an email to the <tt>TroubleTicketMailingList</tt> recipients configured in 
+   <tt>web.xml</tt>.
+    
+   <P>If sufficient time has passed since the last email of a <tt>TroubleTicket</tt>, 
+   then send an email to the webmaster whose body is {@link #toString}; otherwise do 
+   nothing.
+  
+   <P>Here, "sufficient time" is defined by a setting in <tt>web.xml</tt> named 
+   <tt>MinimumIntervalBetweenTroubleTickets</tt>. The intent is to throttle down on 
+   emails which likely have the same cause. 
+  */
+  public void mailToRecipients() throws AppException {
+    if ( hasEnoughTimePassedSinceLastEmail() ) {
+      sendEmail();
+      updateMostRecentTime();
+    }
+  }
+  
+  // PRIVATE
+  private static ServletConfig fConfig;
+  private final HttpServletRequest fRequest;
+  private final Throwable fException;
+  private final ApplicationInfo fAppInfo = BuildImpl.forApplicationInfo();
+  
+  private static final boolean DO_NOT_CREATE_SESSION = false;
+  private static final Pattern PASSWORD_PATTERN = Pattern.compile(
+    "password", Pattern.CASE_INSENSITIVE
+  );
+  
+  /**
+   The text which contains all relevant information which may be useful in solving 
+   the problem.
+  */
+  private StringBuilder fBody = new StringBuilder(); 
+  
+  /**
+   The time of the last send of a TroubleTicket email, expressed in 
+   milliseconds since the Java epoch.
+  
+   This static data is shared among requests, and all access to this 
+   field must be synchronized. This synchronization should not be a problem in practice,
+   since this class is used only when there's a problem.
+  */
+  private static long fTimeLastEmail;
+  
+  /** Build fBody from its various parts.  */
+  private void buildBodyOfMessage() {
+    addExceptionSummary();
+    addRequestInfo();
+    addClientInfo();
+    addSessionInfo();
+    addMemoryInfo();
+    addServerInfo();
+    addStackTrace();
+  }
+  
+  private void addLine(String aLine){
+    fBody.append(aLine + Consts.NEW_LINE);
+  }
+  
+  private void addStartOfSection(String aHeader){
+    addLine(Consts.EMPTY_STRING);
+    addLine(aHeader);
+    addLine("--------------------------------------------------------");
+  }
+  
+  private void addExceptionSummary(){
+    addStartOfSection(
+      "Error for web application " + fAppInfo.getName() + "/" + fAppInfo.getVersion() + 
+      "." + Consts.NEW_LINE + "*** "  + fException.toString() + " ***"
+    );
+    long nowMillis = BuildImpl.forTimeSource().currentTimeMillis();
+    TimeZone tz = new Config().getDefaultUserTimeZone();
+    DateTime now = DateTime.forInstant(nowMillis, tz);
+    addLine("Time of error : " + now.format("YYYY-MM-DD hh:mm:ss"));
+    addLine("Occurred for user : " + getLoggedInUser() );
+    addLine("Web application Build Date: " + fAppInfo.getBuildDate());
+    addLine("Web application Author : " + fAppInfo.getAuthor());
+    addLine("Web application Link : " + fAppInfo.getLink());
+    addLine("Web application Message : " + fAppInfo.getMessage());
+    if ( fException instanceof AppException ) {
+      AppException appEx = (AppException)fException;
+      Iterator errorsIter = appEx.getMessages().iterator();
+      while ( errorsIter.hasNext() ) {
+        addLine( errorsIter.next().toString() );
+      }
+    }
+  }
+  
+  private void addRequestInfo(){
+    addStartOfSection("Request Info:");
+    addLine("HTTP Method: " + fRequest.getMethod());
+    addLine("Context Path: " + fRequest.getContextPath());
+    addLine("ServletPath: " + fRequest.getServletPath());
+    addLine("URI: " + fRequest.getRequestURI());
+    addLine("URL: " + fRequest.getRequestURL().toString());
+    addRequestParams();
+    addRequestHeaders();
+    addCookies();
+  }
+  
+  private void addClientInfo(){
+    addStartOfSection("Client Info:");
+    addLine("User IP: " + fRequest.getRemoteAddr());
+    addLine("User hostname: " + fRequest.getRemoteHost());
+  }
+  
+  private void addServerInfo(){
+    addStartOfSection("Server And Servlet Info:");
+    addLine("Name: " + fRequest.getServerName());
+    addLine("Port: " + fRequest.getServerPort());
+    addLine("Info: " + fConfig.getServletContext().getServerInfo()); //in Servlet 3.0 this is attached to the request
+    addLine("JRE default TimeZone: " + TimeZone.getDefault().getID());
+    addLine("JRE default Locale: " + Locale.getDefault().getDisplayName());
+    
+    addAllSystemProperties();
+    addClassPath();
+    addLine("Servlet : " + fConfig.getServletName()); 
+    
+    Map<String, String> servletParams = new Config().getRawMap();
+    servletParams = sortMap(servletParams);
+    addMap(servletParams, "Servlet init-param: ");
+  }
+  
+  private void addAllSystemProperties(){
+    Map properties = sortMap(System.getProperties());
+    Set props = properties.entrySet();
+    Iterator iter = props.iterator();
+    while ( iter.hasNext() ) {
+      Map.Entry entry = (Map.Entry)iter.next();
+      addLine(entry.getKey() + ": " + entry.getValue());
+    }
+  }
+
+  /**
+   Since this item tends to be very long, it is useful to place each entry 
+   on a separate line.
+  */
+  private void addClassPath(){
+    String JAVA_CLASS_PATH = "java.class.path";
+    String classPath = System.getProperty(JAVA_CLASS_PATH);
+    List pathElements = Arrays.asList( classPath.split(Consts.PATH_SEPARATOR) );
+    StringBuilder result = new StringBuilder(Consts.NEW_LINE);
+    Iterator pathElementsIter = pathElements.iterator();
+    while ( pathElementsIter.hasNext() ) {
+      String pathElement = (String)pathElementsIter.next();
+      result.append(pathElement);
+      if ( pathElementsIter.hasNext() ) {
+        result.append(Consts.NEW_LINE);
+      }
+    }
+    addLine(JAVA_CLASS_PATH + ": " + result.toString());
+  }
+  
+  private void addStackTrace(){
+    addStartOfSection("Stack Trace:");
+    addLine( getStackTrace(fException) );
+  }
+  
+  private void  addRequestParams(){
+    Map paramMap = new HashMap();
+    Enumeration namesEnum = fRequest.getParameterNames();
+    while ( namesEnum.hasMoreElements() ){
+      String name = (String)namesEnum.nextElement();
+      String values = Util.getArrayAsString( fRequest.getParameterValues(name) );
+      if( isPassword(name)){
+        paramMap.put(name, "***(masked)***");
+      }
+      else {
+        paramMap.put(name, values);
+      }
+    }
+    paramMap = sortMap(paramMap);
+    addMap(paramMap, "Req Param");
+  }
+  
+  private void addMemoryInfo(){
+    addStartOfSection("Memory Info:");
+    addLine(getMemory());
+  }
+  
+  private String getMemory(){
+    return getTotalUsedFree(Runtime.getRuntime().totalMemory(), Runtime.getRuntime().freeMemory(), "JRE Memory");
+  }
+  
+  private String getTotalUsedFree(long aTotal, long aFree, String aDescription){
+    StringBuilder result = new StringBuilder();
+    BigDecimal total = new BigDecimal(aTotal);
+    BigDecimal used = new BigDecimal(aTotal - aFree);
+    BigDecimal free = new BigDecimal(aFree);
+
+    BigDecimal percentUsed = percent(used, total);
+    BigDecimal percentFree = percent(free, total);
+    
+    result.append(aDescription + Consts.NEW_LINE) ;
+    String totalText = format(total);
+    String format = "%-9s%" + totalText.length() + "s";
+    result.append(String.format(format, " total: ", totalText) + Consts.NEW_LINE) ;
+    result.append(String.format(format, " used: ", format(used)) +  " (" + percentUsed.intValue() + "%)" + Consts.NEW_LINE) ;
+    result.append(String.format(format, " free: ", format(free)) + " (" + percentFree.intValue() + "%)" + Consts.NEW_LINE) ;
+    return result.toString();
+  }
+
+  private BigDecimal percent(BigDecimal aA, BigDecimal aB){
+    BigDecimal ZERO = new BigDecimal("0");
+    BigDecimal result = ZERO;
+    BigDecimal HUNDRED = new BigDecimal("100");
+    if( aB.compareTo(ZERO) != 0) {
+      result = aA.divide(aB, 2, RoundingMode.HALF_EVEN);
+      result = result.multiply(HUNDRED);
+    }
+    return result;
+  }
+  
+  private String format(BigDecimal aNumber){
+    DecimalFormat format = new DecimalFormat("#,###");
+    return format.format(aNumber); 
+  }
+  
+  private boolean isPassword(String aName){
+    return Util.contains(PASSWORD_PATTERN, aName);
+  }
+  
+  private void addRequestHeaders(){
+    Map headerMap = new HashMap();
+    Enumeration namesEnum = fRequest.getHeaderNames();
+    while ( namesEnum.hasMoreElements() ) {
+      String name = (String) namesEnum.nextElement();
+      Enumeration valuesEnum = fRequest.getHeaders(name);
+      while ( valuesEnum.hasMoreElements() ) {
+        String value = (String)valuesEnum.nextElement();
+        headerMap.put(name, value);
+      }
+    }
+    headerMap = sortMap(headerMap);
+    addMap(headerMap, "Header");
+  }
+  
+  private void addCookies(){
+    if (fRequest.getCookies() == null) return;
+    
+    List cookies = Arrays.asList(fRequest.getCookies());
+    Iterator cookiesIter = cookies.iterator();
+    while ( cookiesIter.hasNext() ) {
+      Cookie cookie = (Cookie)cookiesIter.next();
+      addLine("Cookie " + cookie.getName() + "=" + cookie.getValue());
+    }
+  }
+  
+  private String getStackTrace( Throwable aThrowable ) {
+    final Writer result = new StringWriter();
+    final PrintWriter printWriter = new PrintWriter( result );
+    aThrowable.printStackTrace( printWriter );
+    return result.toString();
+  }  
+  
+  private void addSessionInfo(){
+    addStartOfSection("Session Info");
+    
+    HttpSession session = fRequest.getSession(DO_NOT_CREATE_SESSION);
+    if ( session == null ){
+      addLine("No session existed for this request.");
+    }
+    else {
+      addLine("Logged in user name : " + getLoggedInUser());
+      addLine("Timeout : " + session.getMaxInactiveInterval() + " seconds.");
+      Map<String, String> sessionMap = new HashMap<String, String>();
+      Enumeration sessionAttrs = session.getAttributeNames();
+      while (sessionAttrs.hasMoreElements()){
+        String name = (String)sessionAttrs.nextElement();
+        Object value = session.getAttribute(name);
+        if( isPassword(name) ){
+          sessionMap.put(name, "***(masked)***");
+        }
+        else {
+          sessionMap.put(name, value.toString());
+        }
+      }
+      sessionMap = sortMap(sessionMap);
+      addMap(sessionMap, "Session Attributes");
+    }
+  }
+  
+  private String getLoggedInUser(){
+    String result = null;
+    if (fRequest.getUserPrincipal() != null) {
+      result = fRequest.getUserPrincipal().getName();
+    }
+    else {
+      result = "NONE"; 
+    }
+    return result;
+  }
+  
+  private static synchronized boolean hasEnoughTimePassedSinceLastEmail(){
+    return (System.currentTimeMillis()-fTimeLastEmail >getMinimumIntervalBetweenEmails());
+  }
+
+  private static synchronized void updateMostRecentTime(){
+    fTimeLastEmail = System.currentTimeMillis();
+  }
+
+  private void sendEmail() throws AppException {
+    Emailer emailer = BuildImpl.forEmailer();
+    emailer.sendFromWebmaster(new Config().getTroubleTicketMailingList(), getSubject(), toString());
+  }
+  
+  /** Text to appear in all TroubleTicket emails as the "Subject" of the email. */
+  private String getSubject(){
+    return 
+      "Servlet Error. Application : " + fAppInfo.getName() + "" +
+      "/" + fAppInfo.getVersion()
+    ;
+  }
+      
+  /**
+   Convert the number of minutes configured in web.xml into milliseconds.
+  */
+  private static long getMinimumIntervalBetweenEmails(){
+    final long MILLISECONDS_PER_SECOND = 1000;
+    final int SECONDS_PER_MINUTE = 60;
+    Long MINIMUM_INTERVAL_BETWEEN_TICKETS = new Config().getMinimumIntervalBetweenTroubleTickets();
+    return 
+      MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE *
+      MINIMUM_INTERVAL_BETWEEN_TICKETS
+    ;
+  }
+  
+  private void addMap(Map aMap, String aLineHeader){
+    Iterator iter = aMap.keySet().iterator();
+    while (iter.hasNext()){
+      String name = (String)iter.next();
+      String value = (String)aMap.get(name);
+      addLine(aLineHeader + " " + name + " = " + value);
+    }
+  }
+  
+  private Map sortMap(Map aInput){
+    Map result = new TreeMap(String.CASE_INSENSITIVE_ORDER);
+    result.putAll(aInput);
+    return result;
+  }
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/webmaster/package.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,12 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>Webmaster</title>
+</head>
+<body>
+Administrative items for support staff.
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/overview.html	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,26 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+   <meta name="Author" content="Hirondelle Systems">
+   <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+   <title>WEB4J</title>
+</head>
+<body>
+<b>WEB4J</b> is a full stack framework for connecting a browser to a database, using Java.
+It was created by Hirondelle Systems (John O'Hanley).
+
+<div class='opening-quote'>
+    <b>"Great software requires a fanatical devotion to beauty."</b>
+     <div class='author'>- Paul Graham</div>
+</div>
+    
+<P>For a comprehensive overview, please see <a href='http://www.web4j.com/Java_Web_Application_Framework_Overview.jsp'>web4j.com</a>.
+
+<P><a href="hirondelle/web4j/doc-files/VersionHistory.html">Version History</a>
+
+<P>WEB4J is open source software. 
+It is released under a <a href="hirondelle/web4j/doc-files/LICENSE.txt">BSD license</a>.
+
+</body>
+</html>
Binary file lib/activation.jar has changed
Binary file lib/jsp-api.jar has changed
Binary file lib/junit.jar has changed
Binary file lib/mail.jar has changed
Binary file lib/servlet-api.jar has changed