hirondelle.date4j.DateTimeFormatter Maven / Gradle / Ivy
package hirondelle.date4j;
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)}.
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.
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.
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 aMonths, List aWeekdays, List aAmPmIndicators){
fFormat = aFormat;
fLocale = null;
fCustomLocalization = new CustomLocalization(aMonths, aWeekdays, aAmPmIndicators);
validateState();
}
/** Format a {@link DateTime}. */
String format(DateTime aDateTime){
fEscapedRanges = new ArrayList();
fInterpretedRanges = new ArrayList();
findEscapedRanges();
interpretInput(aDateTime);
return produceFinalOutput();
}
// PRIVATE
private final String fFormat;
private final Locale fLocale;
private Collection fInterpretedRanges;
private Collection 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> fMonths = new LinkedHashMap>();
/**
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> fWeekdays = new LinkedHashMap>();
/**
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> fAmPm = new LinkedHashMap>();
private final CustomLocalization fCustomLocalization;
private final class CustomLocalization{
CustomLocalization(List aMonths, List aWeekdays, List 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 Months;
List Weekdays;
List 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 TOKENS = new ArrayList();
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 months = new ArrayList();
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 weekdays = new ArrayList();
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 indicators = new ArrayList();
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.");
}
}
}