Sunday, March 6, 2011

Locale aware formatting in LiveCode using Java

Recently on the Use-LiveCode mailing list, Peter Haworth asked how he could get a hold of the date/times/number/currency format preferences to ensure that data display corresponded to the user locale. For date and time, you can use 'the system date' and 'the system time' for quick formatting, and 'the dateFormat' to figure out some of the details. But formatting numbers is quite a different challenge. And even if you can get the 'system' information, what to do if you're building a text that shouldn't be in English or user formatting?

At the risk of sounding like a broken record: let's use Java - it has powerful formatting capabilities for all the required items, and its concept of Locale is robust. To avoid startup overhead and improve response time, we'll use the same approach as for the Zeroconf example: process communication to start a helper process, interact with it, and finally close it when we're done.

So let's write a helper class in Java that will (a) give us the default Locale, (b) list the available locales, (c) allows us to format a LiveCode number as currency, integer or decimal number, applying a given or the default locale, (d) allows us to format a LiveCode date as short, medium, long or full date, applying a given or the default locale, and (e) allows us to format a LiveCode time as short, medium, long or full time, applying a given or the default locale. Sounds like a lot to do, but it's actually quite straightforward to implement:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.text.DateFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public class LiveCodeLocaleHelper {

private final static String EXIT_COMMAND = "exit";
private final static String LIST_COMMAND = "list";
private final static String DEFAULT_COMMAND = "default";
private final static String CURRENCY_COMMAND = "currency";
private final static String INTEGER_COMMAND = "integer";
private final static String NUMBER_COMMAND = "number";
private final static String DATE_COMMAND = "date";
private final static String TIME_COMMAND = "time";

private static final Map LOCALE_MAP;
private static final DateFormat LC_DATE_FORMAT;
private static final DateFormat LC_TIME_FORMAT;

static {
Locale en_US_locale = null;
LOCALE_MAP = new HashMap();
for (Locale locale : Locale.getAvailableLocales()) {
if ("en".equals(locale.getLanguage()) && "US".equals(locale.getCountry())) {
en_US_locale = locale;
}
LOCALE_MAP.put(locale.toString(), locale);
}
LC_DATE_FORMAT = DateFormat.getDateInstance(DateFormat.SHORT, en_US_locale);
LC_TIME_FORMAT = DateFormat.getTimeInstance(DateFormat.SHORT, en_US_locale);
}

public static void main(final String[] args) throws IOException {
String commandLine = ""; // the last received command line
System.out.println("LiveCodeLocaleHelper started. Type the command you wish to execute (type 'exit' to stop):");
final InputStreamReader isr = new InputStreamReader(System.in);
final BufferedReader bir = new BufferedReader(isr);

while(!EXIT_COMMAND.equalsIgnoreCase(commandLine)) {
commandLine = bir.readLine();
final String[] commandParts = commandLine.split("\t");
final String commandName = commandParts[0];
if (EXIT_COMMAND.equalsIgnoreCase(commandName)) {
System.out.println("LiveCodeLocaleHelper is exiting");
} else if (LIST_COMMAND.equalsIgnoreCase(commandName)) {
handleListCommand(commandParts);
} else if (DEFAULT_COMMAND.equalsIgnoreCase(commandName)) {
handleDefaultCommand(commandParts);
} else if (CURRENCY_COMMAND.equalsIgnoreCase(commandName)) {
handleCurrencyCommand(commandParts);
} else if (INTEGER_COMMAND.equalsIgnoreCase(commandName)) {
handleIntegerCommand(commandParts);
} else if (NUMBER_COMMAND.equalsIgnoreCase(commandName)) {
handleNumberCommand(commandParts);
} else if (DATE_COMMAND.equalsIgnoreCase(commandName)) {
handleDateCommand(commandParts);
} else if (TIME_COMMAND.equalsIgnoreCase(commandName)) {
handleTimeCommand(commandParts);
} else {
System.out.println("Unrecognized command: " + commandLine);
}
}
}

private static void handleListCommand(final String[] commandParts) {
final List localeIdentifiers = new ArrayList(LOCALE_MAP.keySet());
Collections.sort(localeIdentifiers);
for (String localeIdentifier : localeIdentifiers) {
System.out.println(localeIdentifier);
}
System.out.println(".");
}

private static void handleDefaultCommand(final String[] commandParts) {
System.out.println(Locale.getDefault().toString());
}

private static void handleCurrencyCommand(final String[] commandParts) {
double number;
try {
number = Double.parseDouble(commandParts[1]);
} catch (NumberFormatException e) {
System.out.println("Invalid number: " + commandParts[1]);
return;
}
NumberFormat currencyFormat = null;
if (commandParts.length > 2) {
final String localeIdentifier = commandParts[2];
currencyFormat = NumberFormat.getCurrencyInstance(LOCALE_MAP.get(localeIdentifier));
} else {
currencyFormat = NumberFormat.getCurrencyInstance();
}
System.out.println(currencyFormat.format(number));
}

private static void handleIntegerCommand(final String[] commandParts) {
double number;
try {
number = Double.parseDouble(commandParts[1]);
} catch (NumberFormatException e) {
System.out.println("Invalid number: " + commandParts[1]);
return;
}
NumberFormat integerFormat = null;
if (commandParts.length > 2) {
final String localeIdentifier = commandParts[2];
integerFormat = NumberFormat.getIntegerInstance(LOCALE_MAP.get(localeIdentifier));
} else {
integerFormat = NumberFormat.getIntegerInstance();
}
System.out.println(integerFormat.format(number));
}

private static void handleNumberCommand(final String[] commandParts) {
double number;
try {
number = Double.parseDouble(commandParts[1]);
} catch (NumberFormatException e) {
System.out.println("Invalid number: " + commandParts[1]);
return;
}
NumberFormat numberFormat = null;
if (commandParts.length > 2) {
final String localeIdentifier = commandParts[2];
numberFormat = NumberFormat.getNumberInstance(LOCALE_MAP.get(localeIdentifier));
} else {
numberFormat = NumberFormat.getNumberInstance();
}
System.out.println(numberFormat.format(number));
}

private static void handleDateCommand(final String[] commandParts) {
Date date = null;
try {
date = LC_DATE_FORMAT.parse(commandParts[1]);
} catch (ParseException e) {
System.out.println("Invalid date: " + commandParts[1]);
return;
}
int dateSize = DateFormat.SHORT;
final String dateFormatString = commandParts[2];
if ("medium".equalsIgnoreCase(dateFormatString)) {
dateSize = DateFormat.MEDIUM;
} else if ("long".equalsIgnoreCase(dateFormatString)) {
dateSize = DateFormat.LONG;
} else if ("full".equalsIgnoreCase(dateFormatString)) {
dateSize = DateFormat.FULL;
}
DateFormat dateFormat = null;
if (commandParts.length > 3) {
final String localeIdentifier = commandParts[3];
dateFormat = DateFormat.getDateInstance(dateSize, LOCALE_MAP.get(localeIdentifier));
} else {
dateFormat = DateFormat.getDateInstance(dateSize);
}
System.out.println(dateFormat.format(date));
}

private static void handleTimeCommand(final String[] commandParts) {
Date date = null;
try {
date = LC_TIME_FORMAT.parse(commandParts[1]);
} catch (ParseException e) {
System.out.println("Invalid time: " + commandParts[1]);
return;
}
int timeSize = DateFormat.SHORT;
final String dateFormatString = commandParts[2];
if ("medium".equalsIgnoreCase(dateFormatString)) {
timeSize = DateFormat.MEDIUM;
} else if ("long".equalsIgnoreCase(dateFormatString)) {
timeSize = DateFormat.LONG;
} else if ("full".equalsIgnoreCase(dateFormatString)) {
timeSize = DateFormat.FULL;
}
DateFormat dateFormat = null;
if (commandParts.length > 3) {
final String localeIdentifier = commandParts[3];
dateFormat = DateFormat.getTimeInstance(timeSize, LOCALE_MAP.get(localeIdentifier));
} else {
dateFormat = DateFormat.getTimeInstance(timeSize);
}
System.out.println(dateFormat.format(date));
}

}

With the Java helper class written, we can turn our attention to the LiveCode side. Let's create a new stack:



As you can see, we have two buttons at the top for starting/stopping the helper process, along with an option menu to list the available locales and a field to show the default locale. Now put the following into the stack script:
local sProcess

on Locale_StartHelper
if sProcess is empty then
local tDefaultFolder, tStackFolder
put Locale_StackFolder() into tStackFolder
put the defaultFolder into tDefaultFolder
set the defaultFolder to tStackFolder
put "java LiveCodeLocaleHelper" into sProcess
open process sProcess for update
set the defaultFolder to tDefaultFolder
--> make sure we're speaking with the right helper
read from process sProcess for 1 line
if it begins with "LiveCodeLocaleHelper started." then
disable button "Start"
enable button "Stop"
else
close process sProcess
put empty into sProcess
end if
end if
end Locale_StartHelper

function Locale_AvailableLocales
local tLocaleList
write ("list") & return to process sProcess
repeat forever
read from process sProcess for 1 line
if it begins with "." then exit repeat
put it after tLocaleList
end repeat
delete char -1 of tLocaleList
return tLocaleList
end Locale_AvailableLocales

function Locale_DefaultLocale
write ("default") & return to process sProcess
read from process sProcess for 1 line
return it
end Locale_DefaultLocale

function Locale_FormatCurrency pAmount, pLocale
if pLocale is empty then
write ("currency" & tab & pAmount) & return to process sProcess
else
write ("currency" & tab & pAmount & tab & pLocale) & return to process sProcess
end if
read from process sProcess for 1 line
return line 1 of it
end Locale_FormatCurrency

function Locale_FormatInteger pAmount, pLocale
if pLocale is empty then
write ("integer" & tab & pAmount) & return to process sProcess
else
write ("integer" & tab & pAmount & tab & pLocale) & return to process sProcess
end if
read from process sProcess for 1 line
return line 1 of it
end Locale_FormatInteger

function Locale_FormatNumber pAmount, pLocale
if pLocale is empty then
write ("number" & tab & pAmount) & return to process sProcess
else
write ("number" & tab & pAmount & tab & pLocale) & return to process sProcess
end if
read from process sProcess for 1 line
return line 1 of it
end Locale_FormatNumber

function Locale_FormatDate pDate, pSize, pLocale
if pLocale is empty then
write ("date" & tab & pDate & tab & pSize) & return to process sProcess
else
write ("date" & tab & pDate & tab & pSize & tab & pLocale) & return to process sProcess
end if
read from process sProcess for 1 line
return line 1 of it
end Locale_FormatDate

function Locale_FormatTime pTime, pSize, pLocale
if pLocale is empty then
write ("time" & tab & pTime & tab & pSize) & return to process sProcess
else
write ("time" & tab & pTime & tab & pSize & tab & pLocale) & return to process sProcess
end if
read from process sProcess for 1 line
return line 1 of it
end Locale_FormatTime

on Locale_StopHelper
write ("exit") & return to process sProcess
close process sProcess
enable button "Start"
disable button "Stop"
put empty into sProcess
end Locale_StopHelper

function Locale_StackFolder
local tStackFolder
put the effective filename of this stack into tStackFolder
set the itemDelimiter to slash
delete item -1 of tStackFolder
return tStackFolder
end Locale_StackFolder


The script for the Start button is easy enough:
on mouseUp
disable button "Start"
Locale_StartHelper
put Locale_AvailableLocales() into button "AvailableLocales"
put Locale_DefaultLocale() into field "DefaultLocale"
enable button "Stop"
end mouseUp

And the script of the Stop button is equally trivial:
on mouseUp
disable button "Stop"
Locale_StopHelper
enable button "Start"
end mouseUp

The script for the Default button in the Number formatting section looks like this:
on mouseUp
local tNumber
put field "Number" into tNumber
put "Currency:" & tab & Locale_FormatCurrency(tNumber) & return & \
"Integer:" & tab & Locale_FormatInteger(tNumber) & return & \
"Number:" & tab & Locale_FormatNumber(tNumber) \
into field "FormattedNumbers"
end mouseUp

And the script for the Available button in the Number formatting section looks like this:
on mouseUp
local tNumber, tLocale
put field "Number" into tNumber
put the label of button "AvailableLocales" into tLocale
put "Currency:" & tab & Locale_FormatCurrency(tNumber, tLocale) & return & \
"Integer:" & tab & Locale_FormatInteger(tNumber, tLocale) & return & \
"Number:" & tab & Locale_FormatNumber(tNumber, tLocale) \
into field "FormattedNumbers"
end mouseUp

Likewise, the script for the Default button in the Date formatting section becomes:
on mouseUp
local tDate
put field "Date" into tDate
put "Short:" & tab & Locale_FormatDate(tDate, "short") & return & \
"Medium:" & tab & Locale_FormatDate(tDate, "medium") & return & \
"Long:" & tab & Locale_FormatDate(tDate, "long") & return & \
"Full:" & tab & Locale_FormatDate(tDate, "full") \
into field "FormattedDates"
end mouseUp

Hence, the script for the Available button in the Date formatting section becomes:
on mouseUp
local tDate, tLocale
put field "Date" into tDate
put the label of button "AvailableLocales" into tLocale
put "Short:" & tab & Locale_FormatDate(tDate, "short", tLocale) & return & \
"Medium:" & tab & Locale_FormatDate(tDate, "medium", tLocale) & return & \
"Long:" & tab & Locale_FormatDate(tDate, "long", tLocale) & return & \
"Full:" & tab & Locale_FormatDate(tDate, "full", tLocale) \
into field "FormattedDates"
end mouseUp

Almost done - here's the script for the Default button in the Time formatting section:
on mouseUp
local tTime
put field "Time" into tTime
put "Short:" & tab & Locale_FormatTime(tTime, "short") & return & \
"Medium:" & tab & Locale_FormatTime(tTime, "medium") & return & \
"Long:" & tab & Locale_FormatTime(tTime, "long") & return & \
"Full:" & tab & Locale_FormatTime(tTime, "full") \
into field "FormattedTimes"
end mouseUp

Finally, the logical extension for the Available button in the Time formatting section:
on mouseUp
local tTime, tLocale
put field "Time" into tTime
put the label of button "AvailableLocales" into tLocale
put "Short:" & tab & Locale_FormatTime(tTime, "short", tLocale) & return & \
"Medium:" & tab & Locale_FormatTime(tTime, "medium", tLocale) & return & \
"Long:" & tab & Locale_FormatTime(tTime, "long", tLocale) & return & \
"Full:" & tab & Locale_FormatTime(tTime, "full", tLocale) \
into field "FormattedTimes"
end mouseUp

Save the stack, and copy the compiled LiveCodeLocaleHelper.class file into the same directory where you saved the stack. Click the Start button, then play around with the rest of the user interface to enter different numbers, dates and times, clicking the buttons to see the formatting output, switching between the different available locales.

Download it here.