Added branch 5.x.
authorMarco Zanon <info@marcozanon.com>
Thu, 15 Jun 2017 21:15:49 +0000 (21:15 +0000)
committerMarco Zanon <info@marcozanon.com>
Thu, 15 Jun 2017 21:15:49 +0000 (21:15 +0000)
42 files changed:
5.x/LICENSE [new file with mode: 0644]
5.x/build.properties [new file with mode: 0644]
5.x/build.xml [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/MException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/MInformation.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/MObject.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/conversion/MConversionException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/conversion/MDateConverter.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/conversion/MInvalidConversionFormatException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/conversion/MNumberConverter.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnection.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionFailureException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionGenerator.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionPool.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MDatabaseException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MSqlStatementException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MSqlStatementResults.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MSqlTable.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/database/MSqlTransactionException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MInvalidJsonValueException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonArray.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonBoolean.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonNull.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonNumber.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonObject.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonString.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/json/MJsonValue.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLogDatabaseTable.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLogFilter.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLogListener.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLogMessage.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLogPlainTextFile.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLogTarget.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/logging/MLoggingException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MText.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MTextException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MTranslationException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MTranslationFileParsingException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MTranslationValueNotFoundException.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MTranslator.java [new file with mode: 0644]
5.x/src/java/com/marcozanon/macaco/text/MXhtmlUnsafeStringException.java [new file with mode: 0644]

diff --git a/5.x/LICENSE b/5.x/LICENSE
new file mode 100644 (file)
index 0000000..7eacd8f
--- /dev/null
@@ -0,0 +1,25 @@
+Macaco
+Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+
+Released under MIT license:
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+
+Small portions inspired by other projects or web pages.
+See source code for additional information.
diff --git a/5.x/build.properties b/5.x/build.properties
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/5.x/build.xml b/5.x/build.xml
new file mode 100644 (file)
index 0000000..0d7093d
--- /dev/null
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<!--
+  - Macaco
+  - Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+  - Released under MIT license (see LICENSE for details).
+ -->
+
+<project name="macaco" default="clean">
+
+    <property file="build.properties" />
+
+    <target name="clean" description="Cleans up everything.">
+        <delete dir="target/" />
+    </target>
+
+    <target name="createArchive" depends="clean" description="Packs source files into a .tar.gz archive.">
+        <tar compression="gzip" destfile="target/${ant.project.name}.tar.gz" basedir="." excludes="target/" />
+    </target>
+
+    <target name="compile" depends="clean" description="Compiles .java source code to .class bytecode.">
+        <mkdir dir="target/classes/" />
+        <javac target="1.8" source="1.8" srcdir="src/java/" destdir="target/classes/">
+            <compilerarg value="-Xlint" />
+        </javac>
+    </target>
+
+    <target name="createJarFile" depends="compile" description="Creates a .jar file.">
+        <jar destfile="target/${ant.project.name}.jar">
+            <fileset dir="target/classes/" includes="**/*.class" />
+        </jar>
+    </target>
+
+</project>
diff --git a/5.x/src/java/com/marcozanon/macaco/MException.java b/5.x/src/java/com/marcozanon/macaco/MException.java
new file mode 100644 (file)
index 0000000..dcfb946
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco;
+
+public abstract class MException extends Exception {
+
+    /* */
+
+    public MException() {
+        super();
+    }
+
+    public MException(String message) {
+        super(message);
+    }
+
+    public MException(Throwable error) {
+        super(error);
+    }
+
+    public MException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/MInformation.java b/5.x/src/java/com/marcozanon/macaco/MInformation.java
new file mode 100644 (file)
index 0000000..79791ea
--- /dev/null
@@ -0,0 +1,54 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco;
+
+import com.marcozanon.macaco.text.MText;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+public class MInformation extends MObject {
+
+    public static final String MACACO_VERSION = "5.x";
+
+    public static final String TEXT_ENCODING = "UTF-8";
+
+    /* Generic information. */
+
+    public static String getMacacoVersion() {
+        return MInformation.MACACO_VERSION;
+    }
+
+    public static String getMacacoFullName() {
+        return "Macaco " + MInformation.getMacacoVersion();
+    }
+
+    public static String getMacacoCopyrightInformation() {
+        StringBuilder s = new StringBuilder("");
+        s.append(MInformation.getMacacoFullName() + System.getProperty("line.separator"));
+        s.append("Copyright (c) 2009-2017 by Marco Zanon." + System.getProperty("line.separator"));
+        s.append("Released under the MIT license. See LICENSE for additional information." + System.getProperty("line.separator"));
+        s.append("Small portions inspired by other projects or web pages. See source code for additional information.");
+        return s.toString();
+    }
+
+    /* Exceptions. */
+
+    public static String getExceptionAsString(Exception exception) {
+        if (null == exception) {
+            throw new IllegalArgumentException("Invalid 'exception': null.");
+        }
+        //
+        StringWriter sw = new StringWriter();
+        PrintWriter pw = new PrintWriter(sw);
+        exception.printStackTrace(pw);
+        pw.flush();
+        sw.flush();
+        //
+        return sw.toString();
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/MObject.java b/5.x/src/java/com/marcozanon/macaco/MObject.java
new file mode 100644 (file)
index 0000000..ea72cac
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco;
+
+public abstract class MObject {
+
+    /* */
+
+    protected MObject clone() {
+        throw new UnsupportedOperationException("Please provide manually by yourself.");
+    }
+
+    public String toString() {
+        throw new UnsupportedOperationException("Please use appropriate methods (if any).");
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/conversion/MConversionException.java b/5.x/src/java/com/marcozanon/macaco/conversion/MConversionException.java
new file mode 100644 (file)
index 0000000..ab626b2
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.conversion;
+
+import com.marcozanon.macaco.MException;
+
+public abstract class MConversionException extends MException {
+
+    /* */
+
+    public MConversionException() {
+        super();
+    }
+
+    public MConversionException(String message) {
+        super(message);
+    }
+
+    public MConversionException(Throwable error) {
+        super(error);
+    }
+
+    public MConversionException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/conversion/MDateConverter.java b/5.x/src/java/com/marcozanon/macaco/conversion/MDateConverter.java
new file mode 100644 (file)
index 0000000..4461edf
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.conversion;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.text.MText;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.TimeZone;
+
+public class MDateConverter extends MObject {
+
+    protected LinkedHashSet<String> dateFormats = new LinkedHashSet<String>();
+    protected Locale locale = null;
+    protected TimeZone timeZone = null;
+
+    /* */
+
+    public MDateConverter(String defaultDateFormat, Locale locale) {
+        this(defaultDateFormat, locale, TimeZone.getDefault());
+    }
+
+    public MDateConverter(String defaultDateFormat, Locale locale, TimeZone timeZone) {
+        super();
+        //
+        this.addDateFormat(defaultDateFormat);
+        this.setLocale(locale);
+        this.setTimeZone(timeZone);
+    }
+
+    public MDateConverter(LinkedHashSet<String> dateFormats, Locale locale, TimeZone timeZone) {
+        super();
+        //
+        this.setDateFormats(dateFormats);
+        this.setLocale(locale);
+        this.setTimeZone(timeZone);
+    }
+
+    public MDateConverter clone() {
+        return new MDateConverter(this.getDateFormats(), this.getLocale(), this.getTimeZone());
+    }
+
+    /* Date formats. */
+
+    public void setDateFormats(LinkedHashSet<String> dateFormats) {
+        if (null == dateFormats) {
+            throw new IllegalArgumentException("Invalid 'dateFormats': null.");
+        }
+        else {
+            Iterator<String> i = dateFormats.iterator();
+            while (i.hasNext()) {
+                MDateConverter.checkDateFormat(i.next());
+            }
+        }
+        //
+        synchronized (this.dateFormats) {
+            this.dateFormats = dateFormats;
+        }
+    }
+
+    public void addDateFormat(String dateFormat) {
+        MDateConverter.checkDateFormat(dateFormat);
+        //
+        synchronized (this.dateFormats) {
+            this.dateFormats.add(dateFormat);
+        }
+    }
+
+    public LinkedHashSet<String> getDateFormats() {
+        return this.dateFormats;
+    }
+
+    public String getDefaultDateFormat() {
+        return this.getDateFormats().iterator().next();
+    }
+
+    /* Locale. */
+
+    protected void setLocale(Locale locale) {
+        if (null == locale) {
+            throw new IllegalArgumentException("Invalid 'locale': null.");
+        }
+        //
+        this.locale = locale;
+    }
+
+    public Locale getLocale() {
+        return this.locale;
+    }
+
+    /* Time zone. */
+
+    protected void setTimeZone(TimeZone timeZone) {
+        if (null == timeZone) {
+            throw new IllegalArgumentException("Invalid 'timeZone': null.");
+        }
+        //
+        this.timeZone = timeZone;
+    }
+
+    public TimeZone getTimeZone() {
+        return this.timeZone;
+    }
+
+    /* Conversions. */
+
+    protected static void checkDateFormat(String dateFormat) {
+        if (MText.isBlank(dateFormat)) {
+            throw new IllegalArgumentException("Invalid 'dateFormat': null or empty.");
+        }
+        //
+        try {
+            SimpleDateFormat testDateFormat = new SimpleDateFormat(dateFormat);
+        }
+        catch (IllegalArgumentException exception) {
+            throw new IllegalArgumentException(String.format("Invalid 'dateFormat': %s.", dateFormat)); // no need to propagate exception
+        }
+    }
+
+    protected static Date getDateFromStringByParameters(String x, String inputDateFormat, Locale inputLocale, TimeZone inputTimeZone) throws MInvalidConversionFormatException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        MDateConverter.checkDateFormat(inputDateFormat);
+        if (null == inputLocale) {
+            throw new IllegalArgumentException("Invalid 'inputLocale': null.");
+        }
+        if (null == inputTimeZone) {
+            throw new IllegalArgumentException("Invalid 'inputTimeZone': null.");
+        }
+        //
+        Calendar c1 = Calendar.getInstance(inputTimeZone, inputLocale);
+        c1.clear();
+        c1.setLenient(false);
+        SimpleDateFormat sdf = new SimpleDateFormat(inputDateFormat, inputLocale);
+        sdf.setCalendar(c1);
+        Date d1 = null;
+        try {
+            d1 = sdf.parse(x);
+        }
+        catch (ParseException exception) {
+            throw new MInvalidConversionFormatException(String.format("Invalid 'x' or parsing: %s (input format: %s).", x, inputDateFormat)); // no need to propagate exception
+        }
+        Calendar c2 = Calendar.getInstance(inputTimeZone, inputLocale);
+        c2.clear();
+        c2.set(Calendar.YEAR, c1.get(Calendar.YEAR));
+        c2.set(Calendar.MONTH, c1.get(Calendar.MONTH));
+        c2.set(Calendar.DAY_OF_MONTH, c1.get(Calendar.DAY_OF_MONTH));
+        c2.set(Calendar.HOUR_OF_DAY, c1.get(Calendar.HOUR_OF_DAY));
+        c2.set(Calendar.MINUTE, c1.get(Calendar.MINUTE));
+        c2.set(Calendar.SECOND, c1.get(Calendar.SECOND));
+        Date d2 = c2.getTime();
+        if (!x.equals(sdf.format(d2))) {
+            throw new MInvalidConversionFormatException(String.format("Invalid 'x' or parsing: %s (input format: %s).", x, inputDateFormat));
+        }
+        return d2;
+    }
+
+    protected static String getStringFromDateByParameters(Date date, String outputDateFormat, Locale outputLocale, TimeZone outputTimeZone) {
+        if (null == date) {
+            throw new IllegalArgumentException("Invalid 'date': null.");
+        }
+        MDateConverter.checkDateFormat(outputDateFormat);
+        if (null == outputLocale) {
+            throw new IllegalArgumentException("Invalid 'outputLocale': null.");
+        }
+        if (null == outputTimeZone) {
+            throw new IllegalArgumentException("Invalid 'outputTimeZone': null.");
+        }
+        //
+        SimpleDateFormat sdf = new SimpleDateFormat(outputDateFormat, outputLocale);
+        sdf.setCalendar(Calendar.getInstance(outputTimeZone, outputLocale));
+        return sdf.format(date);
+    }
+
+    public Date getDateFromString(String x) throws MInvalidConversionFormatException {
+        Date y = null;
+        for (String dateFormat: this.getDateFormats()) {
+            try {
+                y = MDateConverter.getDateFromStringByParameters(x, dateFormat, this.getLocale(), this.getTimeZone());
+                return y;
+            }
+            catch (MInvalidConversionFormatException exception) {
+            }
+        }
+        if (null == y) {
+            throw new MInvalidConversionFormatException(String.format("Invalid 'x': %s.", x));
+        }
+        return y; // necessary to avoid Java compilation errors
+    }
+
+    public String getStringFromDate(Date x) {
+        return MDateConverter.getStringFromDateByParameters(x, this.getDefaultDateFormat(), this.getLocale(), this.getTimeZone());
+    }
+
+    /* Helpers. */
+
+    public static Date getFlatDate(Date x) {
+        if (null == x) {
+            return x;
+        }
+        //
+        Calendar calendar = new GregorianCalendar();
+        calendar.setTime(x);
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        return calendar.getTime();
+    }
+
+    public static Date getCeilDate(Date x) {
+        if (null == x) {
+            return x;
+        }
+        //
+        Calendar calendar = new GregorianCalendar();
+        calendar.setTime(x);
+        calendar.set(Calendar.HOUR_OF_DAY, 23);
+        calendar.set(Calendar.MINUTE, 59);
+        calendar.set(Calendar.SECOND, 59);
+        calendar.set(Calendar.MILLISECOND, 999);
+        return calendar.getTime();
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/conversion/MInvalidConversionFormatException.java b/5.x/src/java/com/marcozanon/macaco/conversion/MInvalidConversionFormatException.java
new file mode 100644 (file)
index 0000000..98351ac
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.conversion;
+
+@SuppressWarnings("serial")
+public class MInvalidConversionFormatException extends MConversionException {
+
+    /* */
+
+    public MInvalidConversionFormatException() {
+        super();
+    }
+
+    public MInvalidConversionFormatException(String message) {
+        super(message);
+    }
+
+    public MInvalidConversionFormatException(Throwable error) {
+        super(error);
+    }
+
+    public MInvalidConversionFormatException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/conversion/MNumberConverter.java b/5.x/src/java/com/marcozanon/macaco/conversion/MNumberConverter.java
new file mode 100644 (file)
index 0000000..05d3219
--- /dev/null
@@ -0,0 +1,163 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.conversion;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.text.MText;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.text.ParsePosition;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+
+public class MNumberConverter extends MObject {
+
+    protected LinkedHashSet<String> numberFormats = new LinkedHashSet<String>();
+    protected Locale locale = null;
+
+    /* */
+
+    public MNumberConverter(String defaultNumberFormat, Locale locale) {
+        super();
+        //
+        this.addNumberFormat(defaultNumberFormat);
+        this.setLocale(locale);
+    }
+
+    public MNumberConverter(LinkedHashSet<String> numberFormats, Locale locale) {
+        super();
+        //
+        this.setNumberFormats(numberFormats);
+        this.setLocale(locale);
+    }
+
+    public MNumberConverter clone() {
+        return new MNumberConverter(this.getNumberFormats(), this.getLocale());
+    }
+
+    /* Number formats. */
+
+    public void setNumberFormats(LinkedHashSet<String> numberFormats) {
+        if (null == numberFormats) {
+            throw new IllegalArgumentException("Invalid 'numberFormats': null.");
+        }
+        else {
+            Iterator<String> i = numberFormats.iterator();
+            while (i.hasNext()) {
+                MNumberConverter.checkNumberFormat(i.next());
+            }
+        }
+        //
+        synchronized (this.numberFormats) {
+            this.numberFormats = numberFormats;
+        }
+    }
+
+    public void addNumberFormat(String numberFormat) {
+        MNumberConverter.checkNumberFormat(numberFormat);
+        //
+        synchronized (this.numberFormats) {
+            this.numberFormats.add(numberFormat);
+        }
+    }
+
+    public LinkedHashSet<String> getNumberFormats() {
+        return this.numberFormats;
+    }
+
+    public String getDefaultNumberFormat() {
+        return this.getNumberFormats().iterator().next();
+    }
+
+    /* Locale. */
+
+    protected void setLocale(Locale locale) {
+        if (null == locale) {
+            throw new IllegalArgumentException("Invalid 'locale': null.");
+        }
+        //
+        this.locale = locale;
+    }
+
+    public Locale getLocale() {
+        return this.locale;
+    }
+
+    /* Conversions. */
+
+    protected static void checkNumberFormat(String numberFormat) {
+        if (MText.isBlank(numberFormat)) {
+            throw new IllegalArgumentException("Invalid 'numberFormat': null or empty.");
+        }
+        //
+        try {
+            DecimalFormat testNumberFormat = new DecimalFormat(numberFormat);
+        }
+        catch (IllegalArgumentException exception) {
+            throw new IllegalArgumentException(String.format("Invalid 'numberFormat': %s.", numberFormat)); // no need to propagate exception
+        }
+    }
+
+    protected static BigDecimal getNumberFromStringByParameters(String x, String inputNumberFormat, Locale inputLocale) throws MInvalidConversionFormatException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        MNumberConverter.checkNumberFormat(inputNumberFormat);
+        if (null == inputLocale) {
+            throw new IllegalArgumentException("Invalid 'inputLocale': null.");
+        }
+        //
+        DecimalFormatSymbols dfs = new DecimalFormatSymbols(inputLocale);
+        DecimalFormat df = new DecimalFormat(inputNumberFormat, dfs);
+        df.setParseBigDecimal(true);
+        ParsePosition validPosition = new ParsePosition(0);
+        BigDecimal bd = (BigDecimal)df.parse(x, validPosition);
+        if (validPosition.getIndex() < x.length()) {
+            throw new MInvalidConversionFormatException(String.format("Invalid 'x' or parsing: %s (input format: %s).", x, inputNumberFormat));
+        }
+        return bd;
+    }
+
+    protected static String getStringFromNumberByParameters(BigDecimal number, String outputNumberFormat, Locale outputLocale) {
+        if (null == number) {
+            throw new IllegalArgumentException("Invalid 'number': null.");
+        }
+        MNumberConverter.checkNumberFormat(outputNumberFormat);
+        if (null == outputLocale) {
+            throw new IllegalArgumentException("Invalid 'outputLocale': null.");
+        }
+        //
+        DecimalFormatSymbols dfs = new DecimalFormatSymbols(outputLocale);
+        DecimalFormat df = new DecimalFormat(outputNumberFormat, dfs);
+        df.setRoundingMode(RoundingMode.HALF_UP);
+        return df.format(number);
+    }
+
+    public BigDecimal getNumberFromString(String x) throws MInvalidConversionFormatException {
+        BigDecimal y = null;
+        for (String numberFormat: this.getNumberFormats()) {
+            try {
+                y = MNumberConverter.getNumberFromStringByParameters(x, numberFormat, this.getLocale());
+                return y;
+            }
+            catch (MInvalidConversionFormatException exception) {
+            }
+        }
+        if (null == y) {
+            throw new MInvalidConversionFormatException(String.format("Invalid 'x': %s.", x));
+        }
+        return y; // necessary to avoid Java compilation errors
+    }
+
+    public String getStringFromNumber(BigDecimal x) {
+        return MNumberConverter.getStringFromNumberByParameters(x, this.getDefaultNumberFormat(), this.getLocale());
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnection.java b/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnection.java
new file mode 100644 (file)
index 0000000..3445def
--- /dev/null
@@ -0,0 +1,281 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.logging.MLogListener;
+import com.marcozanon.macaco.text.MText;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+
+public class MDatabaseConnection extends MObject {
+
+    public static enum TransactionStatus {
+        CLOSED,
+        SUCCESSFUL,
+        FAILED
+    };
+
+    protected String driver = null;
+    protected String url = null;
+    protected String username = null;
+    protected String password = null;
+    protected MLogListener logListener = null;
+
+    protected Connection connection = null;
+
+    protected MDatabaseConnection.TransactionStatus transactionStatus = MDatabaseConnection.TransactionStatus.CLOSED;
+
+    /* */
+
+    public MDatabaseConnection(String driver, String url, String username, String password, MLogListener logListener) throws MDatabaseConnectionFailureException {
+        super();
+        //
+        if (MText.isBlank(driver)) {
+            throw new IllegalArgumentException("Invalid 'driver': null or empty.");
+        }
+        if (MText.isBlank(url)) {
+            throw new IllegalArgumentException("Invalid 'url': null or empty.");
+        }
+        if (null == username) {
+            throw new IllegalArgumentException("Invalid 'username': null.");
+        }
+        if (null == password) {
+            throw new IllegalArgumentException("Invalid 'password': null.");
+        }
+        //
+        this.driver = driver;
+        this.url = url;
+        this.username = username;
+        this.password = password;
+        this.setLogListener(logListener);
+        // Load driver.
+        try {
+            Class.forName(this.getDriver()).newInstance();
+        }
+        catch (ClassNotFoundException exception) {
+            throw new MDatabaseConnectionFailureException("Could not load driver.", exception);
+        }
+        catch (IllegalAccessException exception) {
+            throw new MDatabaseConnectionFailureException("Could not load driver.", exception);
+        }
+        catch (InstantiationException exception) {
+            throw new MDatabaseConnectionFailureException("Could not load driver.", exception);
+        }
+        // Establish a connection.
+        try {
+            this.connection = DriverManager.getConnection(this.getUrl(), this.getUsername(), this.getPassword());
+        }
+        catch (SQLException exception) {
+            throw new MDatabaseConnectionFailureException("Could not get database connection.", exception);
+        }
+        // Set SQL mode to standard ANSI.
+        try {
+            this.getConnection().createStatement().executeUpdate("SET SQL_MODE='ANSI'");
+            this.logStatement("### SET SQL_MODE='ANSI' ###");
+        }
+        catch (SQLException exception) {
+            throw new MDatabaseConnectionFailureException("Could not set SQL mode to ANSI.", exception);
+        }
+    }
+
+    public void close() throws MDatabaseConnectionFailureException {
+        try {
+            this.getConnection().close();
+        }
+        catch (SQLException exception) {
+            throw new MDatabaseConnectionFailureException("Could not close database connection.", exception);
+        }
+    }
+
+    public boolean isClosed() throws MDatabaseConnectionFailureException {
+        try {
+            return this.getConnection().isClosed();
+        }
+        catch (SQLException exception) {
+            throw new MDatabaseConnectionFailureException("Could not determine the state.", exception);
+        }
+    }
+
+    protected void finalize() {
+        try {
+            this.close();
+        }
+        catch (Exception exception) {
+        }
+    }
+
+    /* Driver. */
+
+    public String getDriver() {
+        return this.driver;
+    }
+
+    /* Url. */
+
+    public String getUrl() {
+        return this.url;
+    }
+
+    /* Username. */
+
+    public String getUsername() {
+        return this.username;
+    }
+
+    /* Password. */
+
+    public String getPassword() {
+        return this.password;
+    }
+
+    /* Connection. */
+
+    protected Connection getConnection() {
+        return this.connection;
+    }
+
+    /* Transactions. */
+
+    protected void setTransactionStatus(MDatabaseConnection.TransactionStatus transactionStatus) {
+        if (null == transactionStatus) {
+            throw new IllegalArgumentException("Invalid 'transactionStatus': null.");
+        }
+        //
+        this.transactionStatus = transactionStatus;
+    }
+
+    public MDatabaseConnection.TransactionStatus getTransactionStatus() {
+        return this.transactionStatus;
+    }
+
+    public void startTransaction() throws MSqlTransactionException {
+        if (MDatabaseConnection.TransactionStatus.CLOSED != this.getTransactionStatus()) {
+            throw new MSqlTransactionException("Nested transactions not allowed.");
+        }
+        try {
+            this.getConnection().setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
+            this.getConnection().setAutoCommit(false);
+            this.setTransactionStatus(MDatabaseConnection.TransactionStatus.SUCCESSFUL);
+            this.logStatement("### BEGIN TRANSACTION ###");
+        }
+        catch (SQLException exception) {
+            throw new MSqlTransactionException("Could not start transaction.", exception);
+        }
+    }
+
+    public void rollBackTransaction() throws MSqlTransactionException {
+        if (MDatabaseConnection.TransactionStatus.CLOSED == this.getTransactionStatus()) {
+            throw new MSqlTransactionException("Transaction not started.");
+        }
+        try {
+            this.getConnection().rollback();
+            this.getConnection().setAutoCommit(true);
+            this.setTransactionStatus(MDatabaseConnection.TransactionStatus.CLOSED);
+            this.logStatement("### ROLLBACK ###");
+        }
+        catch (SQLException exception) {
+            this.setTransactionStatus(MDatabaseConnection.TransactionStatus.FAILED);
+            throw new MSqlTransactionException("Could not roll back transaction.", exception);
+        }
+    }
+
+    public MDatabaseConnection.TransactionStatus commitTransaction() throws MSqlTransactionException {
+        switch (this.getTransactionStatus()) {
+            case SUCCESSFUL:
+                try {
+                    this.getConnection().commit();
+                    this.getConnection().setAutoCommit(true);
+                    this.setTransactionStatus(MDatabaseConnection.TransactionStatus.CLOSED);
+                    this.logStatement("### COMMIT ###");
+                    return MDatabaseConnection.TransactionStatus.SUCCESSFUL;
+                }
+                catch (SQLException exception) {
+                    this.rollBackTransaction();
+                    return MDatabaseConnection.TransactionStatus.FAILED;
+                }
+            case FAILED:
+                this.rollBackTransaction();
+                return MDatabaseConnection.TransactionStatus.FAILED;
+            default: // instead of "case CLOSED:" (necessary to avoid Java compilation errors)
+                throw new MSqlTransactionException("Transaction not started.");
+        }
+    }
+
+    /* Statements. */
+
+    public MSqlStatementResults executePreparedStatement(String statement) throws MSqlStatementException {
+        return this.executePreparedStatement(statement, new LinkedList<Object>());
+    }
+
+    public MSqlStatementResults executePreparedStatement(String statement, LinkedList<Object> parameters) throws MSqlStatementException {
+        if (MText.isBlank(statement)) {
+            throw new IllegalArgumentException("Invalid 'statement': null or empty.");
+        }
+        if (null == parameters) {
+            throw new IllegalArgumentException("Invalid 'parameters': null.");
+        }
+        //
+        MSqlStatementResults results = null;
+        PreparedStatement preparedStatement = null;
+        try {
+            // Prepare the statement.
+            preparedStatement = this.getConnection().prepareStatement(statement, PreparedStatement.RETURN_GENERATED_KEYS);
+            for (int p = 0; parameters.size() > p; p++) {
+                preparedStatement.setObject(p + 1, parameters.get(p));
+            }
+            // Execute the statement and parse the results.
+            preparedStatement.execute();
+            results = new MSqlStatementResults(preparedStatement);
+            this.logStatement(preparedStatement.toString());
+        }
+        catch (SQLException exception) {
+            if (MDatabaseConnection.TransactionStatus.SUCCESSFUL == this.getTransactionStatus()) {
+                this.setTransactionStatus(MDatabaseConnection.TransactionStatus.FAILED);
+            }
+            throw new MSqlStatementException(String.format("Could not execute prepared statement: %s.", preparedStatement), exception);
+        }
+        return results;
+    }
+
+    /* Tables. */
+
+    public MSqlTable getTable(String table, String primaryKey) {
+        return new MSqlTable(this, table, primaryKey);
+    }
+
+    /* Logging. */
+
+    public void setLogListener(MLogListener logListener) {
+        this.logListener = logListener;
+    }
+
+    protected MLogListener getLogListener() {
+        return this.logListener;
+    }
+
+    protected void logStatement(String statement) {
+        MLogListener logListener = this.getLogListener();
+        if (null != logListener) {
+            logListener.onMessageLogging(statement);
+        }
+    }
+
+    /* Engine version. */
+
+    public String getEngineVersion() throws MSqlStatementException {
+        MSqlStatementResults results = this.executePreparedStatement("SELECT VERSION()");
+        LinkedList<LinkedHashMap<String, Object>> resultList = results.getRecords();
+        //
+        return (String)resultList.get(0).get("VERSION()");
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionFailureException.java b/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionFailureException.java
new file mode 100644 (file)
index 0000000..e99c72e
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+@SuppressWarnings("serial")
+public class MDatabaseConnectionFailureException extends MDatabaseException {
+
+    /* */
+
+    public MDatabaseConnectionFailureException() {
+        super();
+    }
+
+    public MDatabaseConnectionFailureException(String message) {
+        super(message);
+    }
+
+    public MDatabaseConnectionFailureException(Throwable error) {
+        super(error);
+    }
+
+    public MDatabaseConnectionFailureException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionGenerator.java b/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionGenerator.java
new file mode 100644 (file)
index 0000000..3e09502
--- /dev/null
@@ -0,0 +1,110 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.logging.MLogListener;
+import com.marcozanon.macaco.text.MText;
+
+public class MDatabaseConnectionGenerator extends MObject {
+
+    protected String driver = null;
+    protected String url = null;
+    protected String username = null;
+    protected String password = null;
+    protected MLogListener logListener = null;
+
+    /* */
+
+    public MDatabaseConnectionGenerator(String driver, String url, String username, String password, MLogListener logListener) {
+        super();
+        //
+        if (MText.isBlank(driver)) {
+            throw new IllegalArgumentException("Invalid 'driver': null or empty.");
+        }
+        if (MText.isBlank(url)) {
+            throw new IllegalArgumentException("Invalid 'url': null or empty.");
+        }
+        if (null == username) {
+            throw new IllegalArgumentException("Invalid 'username': null.");
+        }
+        if (null == password) {
+            throw new IllegalArgumentException("Invalid 'password': null.");
+        }
+        //
+        this.driver = driver;
+        this.url = url;
+        this.username = username;
+        this.password = password;
+        this.setLogListener(logListener);
+    }
+
+    /* Driver. */
+
+    public String getDriver() {
+        return this.driver;
+    }
+
+    /* Url. */
+
+    public String getUrl() {
+        return this.url;
+    }
+
+    /* Username. */
+
+    public String getUsername() {
+        return this.username;
+    }
+
+    /* Password. */
+
+    public String getPassword() {
+        return this.password;
+    }
+
+    /* Log listener. */
+
+    public MLogListener getLogListener() {
+        return this.logListener;
+    }
+
+    public void setLogListener(MLogListener logListener) {
+        this.logListener = logListener;
+    }
+
+    /* Generator. */
+
+    public MDatabaseConnection getNewDatabaseConnection() throws MDatabaseConnectionFailureException {
+        return new MDatabaseConnection(this.getDriver(), this.getUrl(), this.getUsername(), this.getPassword(), this.getLogListener());
+    }
+
+    public boolean isGeneratorFor(MDatabaseConnection databaseConnection) {
+        if (null == databaseConnection) {
+            throw new IllegalArgumentException("Invalid 'databaseConnection': null.");
+        }
+        //
+        if (!databaseConnection.getDriver().equals(this.getDriver())) {
+            return false;
+        }
+        if (!databaseConnection.getUrl().equals(this.getUrl())) {
+            return false;
+        }
+        if (!databaseConnection.getUsername().equals(this.getUsername())) {
+            return false;
+        }
+        if (!databaseConnection.getPassword().equals(this.getPassword())) {
+            return false;
+        }
+        if (databaseConnection.getLogListener() != this.getLogListener()) {
+            return false;
+        }
+        //
+        return true;
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionPool.java b/5.x/src/java/com/marcozanon/macaco/database/MDatabaseConnectionPool.java
new file mode 100644 (file)
index 0000000..d971298
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.logging.MLogListener;
+import java.util.LinkedList;
+
+public class MDatabaseConnectionPool extends MObject {
+
+    protected MDatabaseConnectionGenerator databaseConnectionGenerator = null;
+
+    protected LinkedList<MDatabaseConnection> databaseConnections = new LinkedList<MDatabaseConnection>();
+    protected int minimumSize = 0;
+    protected int maximumSize = 0;
+
+    /* */
+
+    public MDatabaseConnectionPool(String driver, String url, String username, String password, int minimumSize, int maximumSize, MLogListener logListener) throws MDatabaseConnectionFailureException {
+        super();
+        //
+        if (0 > minimumSize) {
+            throw new IllegalArgumentException(String.format("Invalid 'minimumSize': %s.", minimumSize));
+        }
+        if (1 > maximumSize) {
+            throw new IllegalArgumentException(String.format("Invalid 'maximumSize': %s.", maximumSize));
+        }
+        else if (minimumSize > maximumSize) {
+            throw new IllegalArgumentException(String.format("Invalid 'maximumSize': %s < %s ('minimumSize').", maximumSize, minimumSize));
+        }
+        //
+        this.databaseConnectionGenerator = new MDatabaseConnectionGenerator(driver, url, username, password, logListener);
+        this.minimumSize = minimumSize;
+        this.maximumSize = maximumSize;
+        //
+        this.initialize();
+    }
+
+    protected void initialize() throws MDatabaseConnectionFailureException {
+        for (int c = 0; c < this.getMinimumSize(); c++) {
+            MDatabaseConnection databaseConnection = this.getDatabaseConnectionGenerator().getNewDatabaseConnection();
+            this.pushDatabaseConnection(databaseConnection);
+        }
+    }
+
+    /* Database connection generator. */
+
+    protected MDatabaseConnectionGenerator getDatabaseConnectionGenerator() {
+        return this.databaseConnectionGenerator;
+    }
+
+    /* Database connections. */
+
+    protected LinkedList<MDatabaseConnection> getDatabaseConnections() {
+        return this.databaseConnections;
+    }
+
+    protected int getMinimumSize() {
+        return this.minimumSize;
+    }
+
+    protected int getMaximumSize() {
+        return this.maximumSize;
+    }
+
+    public synchronized MDatabaseConnection popDatabaseConnection() throws MDatabaseConnectionFailureException {
+        LinkedList<MDatabaseConnection> databaseConnections = this.getDatabaseConnections();
+        MDatabaseConnection databaseConnection = null;
+        if (0 == databaseConnections.size()) {
+            databaseConnection = this.getDatabaseConnectionGenerator().getNewDatabaseConnection();
+        }
+        else {
+            databaseConnection = databaseConnections.removeLast();
+        }
+        if (this.getMinimumSize() > databaseConnections.size()) {
+            databaseConnections.add(this.getDatabaseConnectionGenerator().getNewDatabaseConnection());
+        }
+        return databaseConnection;
+    }
+
+    public synchronized void pushDatabaseConnection(MDatabaseConnection databaseConnection) throws MDatabaseConnectionFailureException {
+        if (null == databaseConnection) {
+            throw new IllegalArgumentException("Invalid 'databaseConnection': null.");
+        }
+        else if (!this.getDatabaseConnectionGenerator().isGeneratorFor(databaseConnection)) {
+            throw new IllegalArgumentException("Invalid 'databaseConnection': not compatible with this database connection pool.");
+        }
+        else if (databaseConnection.isClosed()) {
+            throw new IllegalArgumentException("Invalid 'databaseConnection': closed.");
+        }
+        //
+        LinkedList<MDatabaseConnection> databaseConnections = this.getDatabaseConnections();
+        if (this.getMaximumSize() >= databaseConnections.size()) {
+            databaseConnections.add(databaseConnection);
+        }
+    }
+
+    /* Logging. */
+
+    public synchronized void setLogListener(MLogListener logListener) {
+        this.getDatabaseConnectionGenerator().setLogListener(logListener);
+        //
+        for (MDatabaseConnection databaseConnection: this.getDatabaseConnections()) {
+            databaseConnection.setLogListener(logListener);
+        }
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MDatabaseException.java b/5.x/src/java/com/marcozanon/macaco/database/MDatabaseException.java
new file mode 100644 (file)
index 0000000..ac13251
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+import com.marcozanon.macaco.MException;
+
+public abstract class MDatabaseException extends MException {
+
+    /* */
+
+    public MDatabaseException() {
+        super();
+    }
+
+    public MDatabaseException(String message) {
+        super(message);
+    }
+
+    public MDatabaseException(Throwable error) {
+        super(error);
+    }
+
+    public MDatabaseException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MSqlStatementException.java b/5.x/src/java/com/marcozanon/macaco/database/MSqlStatementException.java
new file mode 100644 (file)
index 0000000..da8697b
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+@SuppressWarnings("serial")
+public class MSqlStatementException extends MDatabaseException {
+
+    /* */
+
+    public MSqlStatementException() {
+        super();
+    }
+
+    public MSqlStatementException(String message) {
+        super(message);
+    }
+
+    public MSqlStatementException(Throwable error) {
+        super(error);
+    }
+
+    public MSqlStatementException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MSqlStatementResults.java b/5.x/src/java/com/marcozanon/macaco/database/MSqlStatementResults.java
new file mode 100644 (file)
index 0000000..6b2eb49
--- /dev/null
@@ -0,0 +1,128 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.text.MText;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+
+public class MSqlStatementResults extends MObject {
+
+    protected PreparedStatement preparedStatement = null;
+    protected ResultSet resultSet = null;
+
+    protected LinkedList<LinkedHashMap<String, Object>> records = new LinkedList<LinkedHashMap<String, Object>>();
+
+    protected LinkedHashSet<Object> generatedKeys = new LinkedHashSet<Object>();
+
+    /* */
+
+    public MSqlStatementResults(PreparedStatement preparedStatement) throws MSqlStatementException {
+        super();
+        //
+        if (null == preparedStatement) {
+            throw new IllegalArgumentException("Invalid 'preparedStatement': null.");
+        }
+        //
+        this.preparedStatement = preparedStatement;
+        //
+        try {
+            this.resultSet = this.getPreparedStatement().getResultSet();
+            if (null != this.getResultSet()) {
+                while (this.getResultSet().next()) {
+                    ResultSetMetaData resultSetMetaData = this.getResultSet().getMetaData();
+                    LinkedHashMap<String, Object> record = new LinkedHashMap<String, Object>();
+                    for (int f = 0; resultSetMetaData.getColumnCount() > f; f++) {
+                        String field = resultSetMetaData.getColumnLabel(f + 1);
+                        record.put(field, this.getResultSet().getObject(field));
+                    }
+                    this.getRecords().add(record);
+                }
+            }
+        }
+        catch (SQLException exception) {
+            throw new MSqlStatementException("Could not retrieve statement records.", exception);
+        }
+        //
+        try {
+            ResultSet generatedKeysResultSet = this.getPreparedStatement().getGeneratedKeys();
+            while (generatedKeysResultSet.next()) {
+                this.getGeneratedKeys().add(generatedKeysResultSet.getObject(1));
+            }
+            generatedKeysResultSet.close();
+        }
+        catch (SQLException exception) {
+            throw new MSqlStatementException("Could not retrieve generated keys.", exception);
+        }
+    }
+
+    public void close() throws MDatabaseConnectionFailureException {
+        try {
+            if (null != this.getResultSet()) {
+                this.getResultSet().close();
+            }
+            this.getPreparedStatement().close();
+        }
+        catch (SQLException exception) {
+            throw new MDatabaseConnectionFailureException("Could not close statement and/or result set references.", exception);
+        }
+    }
+
+    protected void finalize() {
+        try {
+            this.close();
+        }
+        catch (Exception exception) {
+        }
+    }
+
+    /* References. */
+
+    protected PreparedStatement getPreparedStatement() {
+        return this.preparedStatement;
+    }
+
+    public ResultSet getResultSet() {
+        return this.resultSet;
+    }
+
+    /* Records. */
+
+    public LinkedList<LinkedHashMap<String, Object>> getRecords() {
+        return this.records;
+    }
+
+    public LinkedList<Object> getRecordsByField(String field) {
+        if (MText.isBlank(field)) {
+            throw new IllegalArgumentException("Invalid 'field': null or empty.");
+        }
+        //
+        LinkedList<Object> recordsByField = new LinkedList<Object>();
+        for (LinkedHashMap<String, Object> record: this.getRecords()) {
+            Object r = record.get(field);
+            if (null == r) {
+                throw new IllegalArgumentException(String.format("Invalid 'field': %s.", field));
+            }
+            recordsByField.add(r);
+        }
+        return recordsByField;
+    }
+
+    /* Generated keys. */
+
+    public LinkedHashSet<Object> getGeneratedKeys() {
+        return this.generatedKeys;
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MSqlTable.java b/5.x/src/java/com/marcozanon/macaco/database/MSqlTable.java
new file mode 100644 (file)
index 0000000..9cbefe3
--- /dev/null
@@ -0,0 +1,154 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+import com.marcozanon.macaco.MObject;
+import com.marcozanon.macaco.text.MText;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+
+public class MSqlTable extends MObject {
+
+    protected MDatabaseConnection databaseConnection = null;
+    protected String table = null;
+    protected String primaryKey = null;
+
+    /* */
+
+    public MSqlTable(MDatabaseConnection databaseConnection, String table, String primaryKey) {
+        super();
+        //
+        if (null == databaseConnection) {
+            throw new IllegalArgumentException("Invalid 'databaseConnection': null.");
+        }
+        if (MText.isBlank(table)) {
+            throw new IllegalArgumentException("Invalid 'table': null or empty.");
+        }
+        if (MText.isBlank(primaryKey)) {
+            throw new IllegalArgumentException("Invalid 'primaryKey': null or empty.");
+        }
+        //
+        this.databaseConnection = databaseConnection;
+        this.table = table;
+        this.primaryKey = primaryKey;
+    }
+
+    /* Database connection. */
+
+    protected MDatabaseConnection getDatabaseConnection() {
+        return this.databaseConnection;
+    }
+
+    /* Table. */
+
+    public String getTable() {
+        return this.table;
+    }
+
+    /* Primary key. */
+
+    public String getPrimaryKey() {
+        return this.primaryKey;
+    }
+
+    /* Statements. */
+
+    protected MSqlStatementResults insertRecord(LinkedHashMap<String, Object> map) throws MSqlStatementException {
+        if (null == map) {
+            throw new IllegalArgumentException("Invalid 'map': null.");
+        }
+        //
+        LinkedList<Object> p = new LinkedList<Object>();
+        StringBuilder fields = new StringBuilder("");
+        StringBuilder s = new StringBuilder("");
+        for (String field: map.keySet()) {
+            fields.append("\"" + this.getTable() + "\".\"" + field + "\", ");
+            s.append("?, ");
+            p.add(map.get(field));
+        }
+        fields.delete(fields.length() - 2, fields.length());
+        s.delete(s.length() - 2, s.length());
+        return this.getDatabaseConnection().executePreparedStatement(" INSERT INTO \"" + this.getTable() + "\""
+                                                                   + "             (" + fields + ")"
+                                                                   + " VALUES      (" + s + ")",
+                                                                     p);
+    }
+
+    protected MSqlStatementResults updateRecord(LinkedHashMap<String, Object> map, Object id) throws MSqlStatementException {
+        if (null == map) {
+            throw new IllegalArgumentException("Invalid 'map': null.");
+        }
+        if (null == id) {
+            throw new IllegalArgumentException("Invalid 'id': null.");
+        }
+        //
+        LinkedList<Object> p = new LinkedList<Object>();
+        StringBuilder s = new StringBuilder("");
+        for (String field: map.keySet()) {
+            s.append("\"" + this.getTable() + "\".\"" + field + "\" = ?, ");
+            p.add(map.get(field));
+        }
+        s.delete(s.length() - 2, s.length());
+        p.add(id);
+        return this.getDatabaseConnection().executePreparedStatement(" UPDATE  \"" + this.getTable() + "\""
+                                                                   + " SET     " + s
+                                                                   + " WHERE   (\"" + this.getTable() + "\".\"" + this.getPrimaryKey() + "\" = ?)",
+                                                                     p);
+    }
+
+    public MSqlStatementResults setRecord(LinkedHashMap<String, Object> map, Object id) throws MSqlStatementException {
+        if (null == id) {
+            return this.insertRecord(map);
+        }
+        else {
+            return this.updateRecord(map, id);
+        }
+    }
+
+    public MSqlStatementResults getRecord(Object id) throws MSqlStatementException {
+        if (null == id) {
+            throw new IllegalArgumentException("Invalid 'id': null.");
+        }
+        //
+        LinkedList<Object> p = new LinkedList<Object>();
+        p.add(id);
+        return this.getDatabaseConnection().executePreparedStatement(" SELECT  *"
+                                                                   + " FROM    \"" + this.getTable() + "\""
+                                                                   + " WHERE   (\"" + this.getTable() + "\".\"" + this.getPrimaryKey() + "\" = ?)",
+                                                                     p);
+    }
+
+    public MSqlStatementResults deleteRecord(Object id) throws MSqlStatementException {
+        if (null == id) {
+            throw new IllegalArgumentException("Invalid 'id': null.");
+        }
+        //
+        LinkedList<Object> p = new LinkedList<Object>();
+        p.add(id);
+        return this.getDatabaseConnection().executePreparedStatement(" DELETE FROM \"" + this.getTable() + "\""
+                                                                   + " WHERE       (\"" + this.getTable() + "\".\"" + this.getPrimaryKey() + "\" = ?)",
+                                                                     p);
+    }
+
+    public MSqlStatementResults deleteRecords(LinkedHashSet idSet) throws MSqlStatementException {
+        if (null == idSet) {
+            throw new IllegalArgumentException("Invalid 'idSet': null.");
+        }
+        //
+        StringBuilder whereClause = new StringBuilder("(0)");
+        LinkedList<Object> p = new LinkedList<Object>();
+        for (Object id: idSet) {
+            whereClause.append(" OR (\"" + this.getTable() + "\".\"" + this.getPrimaryKey() + "\" = ?)");
+            p.add(id);
+        }
+        return this.getDatabaseConnection().executePreparedStatement(" DELETE FROM \"" + this.getTable() + "\""
+                                                                   + " WHERE       (" + whereClause + ")",
+                                                                     p);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/database/MSqlTransactionException.java b/5.x/src/java/com/marcozanon/macaco/database/MSqlTransactionException.java
new file mode 100644 (file)
index 0000000..2a3b883
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.database;
+
+@SuppressWarnings("serial")
+public class MSqlTransactionException extends MDatabaseException {
+
+    /* */
+
+    public MSqlTransactionException() {
+        super();
+    }
+
+    public MSqlTransactionException(String message) {
+        super(message);
+    }
+
+    public MSqlTransactionException(Throwable error) {
+        super(error);
+    }
+
+    public MSqlTransactionException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MInvalidJsonValueException.java b/5.x/src/java/com/marcozanon/macaco/json/MInvalidJsonValueException.java
new file mode 100644 (file)
index 0000000..f6a7dd0
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+@SuppressWarnings("serial")
+public class MInvalidJsonValueException extends MJsonException {
+
+    /* */
+
+    public MInvalidJsonValueException() {
+        super();
+    }
+
+    public MInvalidJsonValueException(String message) {
+        super(message);
+    }
+
+    public MInvalidJsonValueException(Throwable error) {
+        super(error);
+    }
+
+    public MInvalidJsonValueException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonArray.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonArray.java
new file mode 100644 (file)
index 0000000..72e7b44
--- /dev/null
@@ -0,0 +1,208 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.text.MText;
+import java.util.LinkedList;
+
+public class MJsonArray extends MJsonValue {
+
+    protected static enum ParsingMode {
+        START,
+        POST_VALUE,
+        POST_VALUE_SEPARATOR,
+        ERROR
+    };
+
+    protected LinkedList<MJsonValue> values = new LinkedList<MJsonValue>();
+
+    /* */
+
+    public MJsonArray() throws MInvalidJsonValueException {
+        this("[]");
+    }
+
+    public MJsonArray(String x) throws MInvalidJsonValueException {
+        super();
+        //
+        this.parseString(x);
+    }
+
+    public MJsonArray clone() {
+        MJsonArray tmpMJsonArray = null;
+        try {
+            tmpMJsonArray = new MJsonArray(this.getJsonValue());
+        }
+        catch (MInvalidJsonValueException exception) { // cannot happen
+        }
+        return tmpMJsonArray;
+    }
+
+    /* Value handlers. */
+
+    public void addValue(MJsonValue x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        this.getValues().add(x);
+    }
+
+    public void setValue(int index, MJsonValue x) {
+        if ((0 > index) || ((this.getValueCount() - 1) < index)) {
+            throw new IllegalArgumentException(String.format("Invalid 'index': %s: out of range.", index));
+        }
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        this.getValues().set(index, x);
+    }
+
+    protected LinkedList<MJsonValue> getValues() {
+        return this.values;
+    }
+
+    public MJsonValue getValue(int index) {
+        if ((0 > index) || ((this.getValueCount() - 1) < index)) {
+            throw new IllegalArgumentException(String.format("Invalid 'index': %s: out of range.", index));
+        }
+        //
+        return this.getValues().get(index).clone();
+    }
+
+    public int getValueCount() {
+        return this.getValues().size();
+    }
+
+    public void removeValue(int index) {
+        if ((0 > index) || ((this.getValueCount() - 1) < index)) {
+            throw new IllegalArgumentException(String.format("Invalid 'index': %s: out of range.", index));
+        }
+        //
+        this.getValues().remove(index);
+    }
+
+    public void clearValues() {
+        this.getValues().clear();
+    }
+
+    /* Parsers. */
+
+    protected static int getTokenLength(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        if ((2 > x.length()) || ('[' != x.charAt(0))) {
+            return 0;
+        }
+        int position = x.indexOf("]", 1);
+        while (-1 < position) {
+            try {
+                MJsonArray testValue = new MJsonArray();
+                testValue.parseString(x.substring(0, position + 1));
+                return position + 1;
+            }
+            catch (MInvalidJsonValueException exception) {
+                position = x.indexOf("]", position + 1);
+            }
+        }
+        return 0;
+    }
+
+    public void parseString(String x) throws MInvalidJsonValueException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        //
+        if ((2 > x.length()) || ('[' != x.charAt(0)) || (']' != x.charAt(x.length() - 1))) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: must begin and end with square brackets.", x));
+        }
+        String y = x.substring(1, x.length() - 1);
+        int parsingPosition = 0;
+        MJsonArray.ParsingMode parsingMode = MJsonArray.ParsingMode.START;
+        while ((y.length() > parsingPosition) && (MJsonArray.ParsingMode.ERROR != parsingMode)) {
+            int c = y.codePointAt(parsingPosition);
+            switch (parsingMode) {
+                case START:
+                case POST_VALUE_SEPARATOR: // a value is expected
+                    if (MJsonValue.isWhitespace(c)) {
+                        parsingPosition++;
+                    }
+                    else if ((MJsonValue.isValueSeparator(c)) || (MJsonValue.isNameSeparator(c)) || (MJsonValue.isClosingStructuralCharacter(c))) {
+                        parsingMode = MJsonArray.ParsingMode.ERROR;
+                    }
+                    else {
+                        int offset = 0;
+                        if (0 < (offset = MJsonNull.getTokenLength(y.substring(parsingPosition)))) {
+                            this.addValue(new MJsonNull(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonBoolean.getTokenLength(y.substring(parsingPosition)))) {
+                            this.addValue(new MJsonBoolean(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonNumber.getTokenLength(y.substring(parsingPosition)))) {
+                            this.addValue(new MJsonNumber(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonString.getTokenLength(y.substring(parsingPosition)))) {
+                            this.addValue(new MJsonString(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonArray.getTokenLength(y.substring(parsingPosition)))) {
+                            this.addValue(new MJsonArray(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonObject.getTokenLength(y.substring(parsingPosition)))) {
+                            this.addValue(new MJsonObject(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        if (0 < offset) {
+                            parsingPosition += offset;
+                            parsingMode = MJsonArray.ParsingMode.POST_VALUE;
+                        }
+                        else {
+                            parsingMode = MJsonArray.ParsingMode.ERROR;
+                        }
+                    }
+                    break;
+                case POST_VALUE:
+                    if (MJsonValue.isWhitespace(c)) {
+                        parsingPosition++;
+                    }
+                    else if (MJsonValue.isValueSeparator(c)) {
+                        parsingPosition++;
+                        parsingMode = MJsonArray.ParsingMode.POST_VALUE_SEPARATOR;
+                    }
+                    else {
+                        parsingMode = MJsonArray.ParsingMode.ERROR;
+                    }
+                    break;
+                case ERROR: // malformed string
+                    break;
+            }
+        }
+        if (MJsonArray.ParsingMode.POST_VALUE_SEPARATOR == parsingMode) {
+            parsingMode = MJsonArray.ParsingMode.ERROR;
+        }
+        if (MJsonArray.ParsingMode.ERROR == parsingMode) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json array.", x));
+        }
+    }
+
+    /* Formatter. */
+
+    public String getJsonValue() {
+        StringBuilder s = new StringBuilder("");
+        s.append("[");
+        for (int i = 0; this.getValueCount() > i; i++) {
+            if (0 < i) {
+                s.append(", ");
+            }
+            s.append(this.getValues().get(i).getJsonValue());
+        }
+        s.append("]");
+        return s.toString();
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonBoolean.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonBoolean.java
new file mode 100644 (file)
index 0000000..bdb84cc
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.text.MText;
+
+public class MJsonBoolean extends MJsonValue {
+
+    protected Boolean value = null;
+
+    /* */
+
+    public MJsonBoolean() throws MInvalidJsonValueException {
+        this("false");
+    }
+
+    public MJsonBoolean(String x) throws MInvalidJsonValueException {
+        super();
+        //
+        this.parseString(x);
+    }
+
+    public MJsonBoolean clone() {
+        MJsonBoolean tmpMJsonBoolean = null;
+        try {
+            tmpMJsonBoolean = new MJsonBoolean(this.getJsonValue());
+        }
+        catch (MInvalidJsonValueException exception) { // cannot happen
+        }
+        return tmpMJsonBoolean;
+    }
+
+    /* Value handlers. */
+
+    public void setValue(Boolean x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        this.value = x;
+    }
+
+    public Boolean getValue() {
+        return this.value;
+    }
+
+    /* Parsers. */
+
+    protected static int getTokenLength(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        if (x.startsWith("true")) {
+            return "true".length();
+        }
+        else if (x.startsWith("false")) {
+            return "false".length();
+        }
+        return 0;
+    }
+
+    public void parseString(String x) throws MInvalidJsonValueException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        //
+        if ((!"true".equals(x)) && (!"false".equals(x))) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json boolean.", x));
+        }
+        this.setValue(Boolean.valueOf(x));
+    }
+
+    /* Formatter. */
+
+    public String getJsonValue() {
+        return this.getValue().toString();
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonException.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonException.java
new file mode 100644 (file)
index 0000000..3006efd
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.MException;
+
+public abstract class MJsonException extends MException {
+
+    /* */
+
+    public MJsonException() {
+        super();
+    }
+
+    public MJsonException(String message) {
+        super(message);
+    }
+
+    public MJsonException(Throwable error) {
+        super(error);
+    }
+
+    public MJsonException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonNull.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonNull.java
new file mode 100644 (file)
index 0000000..e52fb87
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.text.MText;
+
+public class MJsonNull extends MJsonValue {
+
+    /* */
+
+    public MJsonNull() throws MInvalidJsonValueException {
+        this("null");
+    }
+
+    public MJsonNull(String x) throws MInvalidJsonValueException {
+        super();
+        //
+        this.parseString(x);
+    }
+
+    public MJsonNull clone() {
+        MJsonNull tmpMJsonNull = null;
+        try {
+            tmpMJsonNull = new MJsonNull(this.getJsonValue());
+        }
+        catch (MInvalidJsonValueException exception) { // cannot happen
+        }
+        return tmpMJsonNull;
+    }
+
+    /* Parsers. */
+
+    protected static int getTokenLength(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        if (x.startsWith("null")) {
+            return "null".length();
+        }
+        return 0;
+    }
+
+    public void parseString(String x) throws MInvalidJsonValueException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        //
+        if (!"null".equals(x)) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json null.", x));
+        }
+    }
+
+    /* Formatter. */
+
+    public String getJsonValue() {
+        return "null";
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonNumber.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonNumber.java
new file mode 100644 (file)
index 0000000..06258f6
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.text.MText;
+import java.math.BigDecimal;
+
+public class MJsonNumber extends MJsonValue {
+
+    protected BigDecimal value = null;
+
+    /* */
+
+    public MJsonNumber() throws MInvalidJsonValueException {
+        this("0");
+    }
+
+    public MJsonNumber(String x) throws MInvalidJsonValueException {
+        super();
+        //
+        this.parseString(x);
+    }
+
+    public MJsonNumber clone() {
+        MJsonNumber tmpMJsonNumber = null;
+        try {
+            tmpMJsonNumber = new MJsonNumber(this.getJsonValue());
+        }
+        catch (MInvalidJsonValueException exception) { // cannot happen
+        }
+        return tmpMJsonNumber;
+    }
+
+    /* Value handlers. */
+
+    public void setValue(Number x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        this.value = new BigDecimal(x.toString());
+    }
+
+    public BigDecimal getValue() {
+        return this.value;
+    }
+
+    /* Parsers. */
+
+    protected static int getTokenLength(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        if (' ' == x.charAt(0)) {
+            return 0;
+        }
+        int position = 0;
+        int validPosition = 0;
+        BigDecimal y = null;
+        while (x.length() > position) {
+            if (' ' == x.charAt(position)) {
+                break;
+            }
+            else {
+                try {
+                    y = new BigDecimal(x.substring(0, position + 1));
+                    position++;
+                    validPosition = position;
+                }
+                catch (NumberFormatException exception) {
+                    position++;
+                }
+            }
+        }
+        return validPosition;
+    }
+
+    public void parseString(String x) throws MInvalidJsonValueException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        //
+        BigDecimal y = null;
+        try {
+            y = new BigDecimal(x);
+        }
+        catch (NumberFormatException exception) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json number.", x)); // no need to propagate exception
+        }
+        this.setValue(y);
+    }
+
+    /* Formatter. */
+
+    public String getJsonValue() {
+        return this.getValue().toString();
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonObject.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonObject.java
new file mode 100644 (file)
index 0000000..861c290
--- /dev/null
@@ -0,0 +1,262 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.text.MText;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+
+public class MJsonObject extends MJsonValue {
+
+    protected static enum ParsingMode {
+        START,
+        POST_NAME,
+        POST_NAME_SEPARATOR,
+        POST_VALUE,
+        POST_VALUE_SEPARATOR,
+        ERROR
+    };
+
+    protected LinkedHashMap<String, MJsonValue> values = new LinkedHashMap<String, MJsonValue>();
+
+    /* */
+
+    public MJsonObject() throws MInvalidJsonValueException {
+        this("{}");
+    }
+
+    public MJsonObject(String x) throws MInvalidJsonValueException {
+        super();
+        //
+        this.parseString(x);
+    }
+
+    public MJsonObject clone() {
+        MJsonObject tmpMJsonObject = null;
+        try {
+            tmpMJsonObject = new MJsonObject(this.getJsonValue());
+        }
+        catch (MInvalidJsonValueException exception) { // cannot happen
+        }
+        return tmpMJsonObject;
+    }
+
+    /* Value handlers. */
+
+    public void setValue(String key, MJsonValue x) {
+        if (MText.isBlank(key)) {
+            throw new IllegalArgumentException("Invalid 'key': null or empty.");
+        }
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        this.getValues().put(key, x);
+    }
+
+    protected LinkedHashMap<String, MJsonValue> getValues() {
+        return this.values;
+    }
+
+    public MJsonValue getValue(String key) {
+        if (MText.isBlank(key)) {
+            throw new IllegalArgumentException("Invalid 'key': null or empty.");
+        }
+        //
+        if (!this.containsKey(key)) {
+            throw new IllegalArgumentException(String.format("Invalid 'key': %s: not available.", key));
+        }
+        return this.getValues().get(key).clone();
+    }
+
+    public int getValueCount() {
+        return this.getValues().size();
+    }
+
+    public void removeValue(String key) {
+        if (MText.isBlank(key)) {
+            throw new IllegalArgumentException("Invalid 'key': null or empty.");
+        }
+        //
+        if (!this.containsKey(key)) {
+            throw new IllegalArgumentException(String.format("Invalid 'key': %s: not available.", key));
+        }
+        this.getValues().remove(key);
+    }
+
+    public void clearValues() {
+        this.getValues().clear();
+    }
+
+    public boolean containsKey(String key) {
+        if (MText.isBlank(key)) {
+            throw new IllegalArgumentException("Invalid 'key': null or empty.");
+        }
+        //
+        return this.getValues().containsKey(key);
+    }
+
+    public LinkedHashSet<String> getKeys() {
+        LinkedHashSet<String> keys = new LinkedHashSet<String>();
+        for (String key: this.getValues().keySet()) {
+            keys.add(key);
+        }
+        return keys;
+    }
+
+    /* Parsers. */
+
+    protected static int getTokenLength(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        if ((2 > x.length()) || ('{' != x.charAt(0))) {
+            return 0;
+        }
+        int position = x.indexOf("}", 1);
+        while (-1 < position) {
+            try {
+                MJsonObject testValue = new MJsonObject();
+                testValue.parseString(x.substring(0, position + 1));
+                return position + 1;
+            }
+            catch (MInvalidJsonValueException exception) {
+                position = x.indexOf("}", position + 1);
+            }
+        }
+        return 0;
+    }
+
+    public void parseString(String x) throws MInvalidJsonValueException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        //
+        if ((2 > x.length()) || ('{' != x.charAt(0)) || ('}' != x.charAt(x.length() - 1))) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: must begin and end with curly brackets.", x));
+        }
+        String y = x.substring(1, x.length() - 1);
+        int parsingPosition = 0;
+        MJsonObject.ParsingMode parsingMode = MJsonObject.ParsingMode.START;
+        String lastKey = "";
+        while ((y.length() > parsingPosition) && (MJsonObject.ParsingMode.ERROR != parsingMode)) {
+            int c = y.codePointAt(parsingPosition);
+            switch (parsingMode) {
+                case START:
+                case POST_VALUE_SEPARATOR: // a name is expected
+                    if (MJsonValue.isWhitespace(c)) {
+                        parsingPosition++;
+                    }
+                    else if ((MJsonValue.isValueSeparator(c)) || (MJsonValue.isNameSeparator(c)) || (MJsonValue.isOpeningStructuralCharacter(c)) || (MJsonValue.isClosingStructuralCharacter(c))) {
+                        parsingMode = MJsonObject.ParsingMode.ERROR;
+                    }
+                    else {
+                        int offset = MJsonString.getTokenLength(y.substring(parsingPosition));
+                        if (0 < offset) {
+                            lastKey = (new MJsonString(y.substring(parsingPosition, parsingPosition + offset))).getValue();
+                            parsingPosition += offset;
+                            parsingMode = MJsonObject.ParsingMode.POST_NAME;
+                        }
+                        else {
+                            parsingMode = MJsonObject.ParsingMode.ERROR;
+                        }
+                    }
+                    break;
+                case POST_NAME:
+                    if (MJsonValue.isWhitespace(c)) {
+                        parsingPosition++;
+                    }
+                    else if (MJsonValue.isNameSeparator(c)) {
+                        parsingPosition++;
+                        parsingMode = MJsonObject.ParsingMode.POST_NAME_SEPARATOR;
+                    }
+                    else {
+                        parsingMode = MJsonObject.ParsingMode.ERROR;
+                    }
+                    break;
+                case POST_NAME_SEPARATOR: // a value is expected
+                    if (MJsonValue.isWhitespace(c)) {
+                        parsingPosition++;
+                    }
+                    else if ((MJsonValue.isValueSeparator(c)) || (MJsonValue.isNameSeparator(c)) || (MJsonValue.isClosingStructuralCharacter(c))) {
+                        parsingMode = MJsonObject.ParsingMode.ERROR;
+                    }
+                    else {
+                        int offset = 0;
+                        if (0 < (offset = MJsonNull.getTokenLength(y.substring(parsingPosition)))) {
+                            this.setValue(lastKey, new MJsonNull(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonBoolean.getTokenLength(y.substring(parsingPosition)))) {
+                            this.setValue(lastKey, new MJsonBoolean(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonNumber.getTokenLength(y.substring(parsingPosition)))) {
+                            this.setValue(lastKey, new MJsonNumber(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonString.getTokenLength(y.substring(parsingPosition)))) {
+                            this.setValue(lastKey, new MJsonString(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonArray.getTokenLength(y.substring(parsingPosition)))) {
+                            this.setValue(lastKey, new MJsonArray(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        else if (0 < (offset = MJsonObject.getTokenLength(y.substring(parsingPosition)))) {
+                            this.setValue(lastKey, new MJsonObject(y.substring(parsingPosition, parsingPosition + offset)));
+                        }
+                        if (0 < offset) {
+                            lastKey = "";
+                            parsingPosition += offset;
+                            parsingMode = MJsonObject.ParsingMode.POST_VALUE;
+                        }
+                        else {
+                            parsingMode = MJsonObject.ParsingMode.ERROR;
+                        }
+                    }
+                    break;
+                case POST_VALUE:
+                    if (MJsonValue.isWhitespace(c)) {
+                        parsingPosition++;
+                    }
+                    else if (MJsonValue.isValueSeparator(c)) {
+                        parsingPosition++;
+                        parsingMode = MJsonObject.ParsingMode.POST_VALUE_SEPARATOR;
+                    }
+                    else {
+                        parsingMode = MJsonObject.ParsingMode.ERROR;
+                    }
+                    break;
+                case ERROR: // malformed string
+                    break;
+            }
+        }
+        if ((MJsonObject.ParsingMode.POST_NAME == parsingMode) || (MJsonObject.ParsingMode.POST_NAME_SEPARATOR == parsingMode) || (MJsonObject.ParsingMode.POST_VALUE_SEPARATOR == parsingMode)) {
+            parsingMode = MJsonObject.ParsingMode.ERROR;
+        }
+        if (MJsonObject.ParsingMode.ERROR == parsingMode) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json object.", x));
+        }
+    }
+
+    /* Formatter. */
+
+    public String getJsonValue() {
+        StringBuilder s = new StringBuilder("");
+        s.append("{");
+        int k = 0;
+        for (String key: this.getValues().keySet()) {
+            if (0 < k) {
+                s.append(", ");
+            }
+            s.append("\"" + MJsonString.getEscapedString(key, false) + "\"");
+            s.append(": ");
+            s.append(this.getValues().get(key).getJsonValue());
+            k++;
+        }
+        s.append("}");
+        return s.toString();
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonString.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonString.java
new file mode 100644 (file)
index 0000000..1fa61dd
--- /dev/null
@@ -0,0 +1,292 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.text.MText;
+
+public class MJsonString extends MJsonValue {
+
+    protected boolean extendedEscapeMode = false;
+
+    protected String value = null;
+
+    /* */
+
+    public MJsonString() throws MInvalidJsonValueException {
+        this("\"\"");
+    }
+
+    public MJsonString(String x) throws MInvalidJsonValueException {
+        this(x, false);
+    }
+
+    public MJsonString(String x, boolean extendedEscapeMode) throws MInvalidJsonValueException {
+        super();
+        //
+        this.setExtendedEscapeMode(extendedEscapeMode);
+        this.parseString(x);
+    }
+
+    public MJsonString clone() {
+        MJsonString tmpMJsonString = null;
+        try {
+            tmpMJsonString = new MJsonString(this.getJsonValue());
+        }
+        catch (MInvalidJsonValueException exception) { // cannot happen
+        }
+        return tmpMJsonString;
+    }
+
+    /* Extended escape mode. */
+
+    public void setExtendedEscapeMode(boolean extendedEscapeMode) {
+        this.extendedEscapeMode = extendedEscapeMode;
+    }
+
+    public boolean getExtendedEscapeMode() {
+        return this.extendedEscapeMode;
+    }
+
+    /* Value handlers. */
+
+    public void setValue(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        this.value = x;
+    }
+
+    public String getValue() {
+        return this.value;
+    }
+
+    /* Helpers. */
+
+    protected static String getEscapedString(String x, boolean extendedEscapeMode) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        StringBuilder y = new StringBuilder();
+        for (int i = 0; x.length() > i; i++) {
+            int c = x.codePointAt(i);
+            if (extendedEscapeMode) {
+                if (0x00001F >= c) { // control Ascii character (0x000000-0x00001F)
+                    y.append("\\u" + String.format("%4H", c).replace(" ", "0"));
+                }
+                else if (0x00007F >= c) { // non-control Ascii character (0x000020-0x00007F)
+                    if ('"' == c) {
+                        y.append("\\\"");
+                    }
+                    else if ('\\' == c) {
+                        y.append("\\\\");
+                    }
+                    else if ('/' == c) {
+                        y.append("\\/");
+                    }
+                    else {
+                        y.appendCodePoint(c);
+                    }
+                }
+                else if (0x00FFFF >= c) { // non-Ascii Bmp character (0x000080-0x00FFFF)
+                    y.append("\\u" + String.format("%4H", c).replace(" ", "0"));
+                }
+                else { // non-Bmp character (0x010000-0x10FFFF)
+                    int cc = c - 0x010000;
+                    int ch = 0xD800 + (cc >> 10);
+                    int cl = 0xDC00 + (cc & 0x03FF);
+                    y.append("\\u" + String.format("%4H", ch).replace(" ", "0") + "\\u" + String.format("%4H", cl).replace(" ", "0"));
+                    i++;
+                }
+            }
+            else {
+                if ('"' == c) {
+                    y.append("\\\"");
+                }
+                else if ('\\' == c) {
+                    y.append("\\\\");
+                }
+                else if ('/' == c) {
+                    y.append("\\/");
+                }
+                else if ('\b' == c) {
+                    y.append("\\b");
+                }
+                else if ('\f' == c) {
+                    y.append("\\f");
+                }
+                else if ('\n' == c) {
+                    y.append("\\n");
+                }
+                else if ('\r' == c) {
+                    y.append("\\r");
+                }
+                else if ('\t' == c) {
+                    y.append("\\t");
+                }
+                else if (0x00001F >= c) { // non-special control Ascii character (0x000000-0x00001F)
+                    y.append("\\u" + String.format("%4H", c).replace(" ", "0"));
+                }
+                else {
+                    y.appendCodePoint(c);
+                    if (0x010000 <= c) {
+                        i++;
+                    }
+                }
+            }
+        }
+        return y.toString();
+    }
+
+    protected static String getUnescapedString(String x) throws MInvalidJsonValueException {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        StringBuilder y = new StringBuilder();
+        for (int i = 0; x.length() > i; i++) {
+            if (x.indexOf("\\\"", i) == i) {
+                y.append("\"");
+                i++;
+            }
+            else if (x.indexOf("\\\\", i) == i) {
+                y.append("\\");
+                i++;
+            }
+            else if (x.indexOf("\\/", i) == i) {
+                y.append("/");
+                i++;
+            }
+            else if (x.indexOf("\\b", i) == i) {
+                y.append("\b");
+                i++;
+            }
+            else if (x.indexOf("\\f", i) == i) {
+                y.append("\f");
+                i++;
+            }
+            else if (x.indexOf("\\n", i) == i) {
+                y.append("\n");
+                i++;
+            }
+            else if (x.indexOf("\\r", i) == i) {
+                y.append("\r");
+                i++;
+            }
+            else if (x.indexOf("\\t", i) == i) {
+                y.append("\t");
+                i++;
+            }
+            else if (x.indexOf("\\u", i) == i) {
+                if ((i + 2 + 4) > x.length()) {
+                    throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x));
+                }
+                else {
+                    int ch = -1;
+                    try {
+                        ch = Integer.parseInt(x.substring(i + 2, i + 2 + 4), 16);
+                        i += 5;
+                        if ((0xD800 <= ch) && (0xDBFF >= ch)) {
+                            i++;
+                            if (x.indexOf("\\u", i) != i) {
+                                throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x));
+                            }
+                            else if ((i + 2 + 4) > x.length()) {
+                                throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x));
+                            }
+                            else {
+                                int cl = -1;
+                                try {
+                                    cl = Integer.parseInt(x.substring(i + 2, i + 2 + 4), 16);
+                                    i += 5;
+                                    if ((0xDC00 >= cl) || (0xDFFF <= cl)) {
+                                        throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x));
+                                    }
+                                    int c = ((ch - 0xD800) << 10) + (cl - 0xDC00) + 0x010000;
+                                    y.appendCodePoint(c);
+                                }
+                                catch (NumberFormatException exception) {
+                                    throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x)); // no need to propagate exception
+                                }
+                            }
+                        }
+                        else {
+                            y.appendCodePoint(ch);
+                        }
+                    }
+                    catch (NumberFormatException exception) {
+                        throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x)); // no need to propagate exception
+                    }
+                }
+            }
+            else {
+                int c = x.codePointAt(i);
+                if (0x010000 <= c) {
+                    i++;
+                }
+                y.appendCodePoint(c);
+            }
+        }
+        return y.toString();
+    }
+
+    /* Parsers. */
+
+    protected static int getTokenLength(String x) {
+        if (null == x) {
+            throw new IllegalArgumentException("Invalid 'x': null.");
+        }
+        //
+        if ((2 > x.length()) || ('"' != x.charAt(0))) {
+            return 0;
+        }
+        if ('"' == x.charAt(1)) {
+            return 1 + 1;
+        }
+        String y = x.substring(1);
+        int quotationMarkPosition = 0;
+        while (-1 < (quotationMarkPosition = y.indexOf("\"", quotationMarkPosition + 1))) {
+            int c = 0;
+            StringBuilder s = new StringBuilder("");
+            while ((c <= quotationMarkPosition) && (y.substring(quotationMarkPosition - c, quotationMarkPosition).equals(s.toString()))) {
+                c++;
+                s.append("\\");
+            }
+            c--;
+            if (0 == (c % 2)) {
+                break;
+            }
+        }
+        if (-1 == quotationMarkPosition) {
+            return 0;
+        }
+        return (quotationMarkPosition + 1) + 1;
+    }
+
+    public void parseString(String x) throws MInvalidJsonValueException {
+        if (MText.isBlank(x)) {
+            throw new IllegalArgumentException("Invalid 'x': null or empty.");
+        }
+        //
+        if ((2 > x.length()) || ('"' != x.charAt(0)) || ('"' != x.charAt(x.length() - 1))) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x));
+        }
+        String y = x.substring(1, x.length() - 1);
+        if (-1 < y.replaceAll("\\\\\"", "__").indexOf("\"")) {
+            throw new MInvalidJsonValueException(String.format("Invalid 'x': %s: not a Json string.", x));
+        }
+        this.setValue(MJsonString.getUnescapedString(y));
+    }
+
+    /* Formatter. */
+
+    public String getJsonValue() {
+        return "\"" + MJsonString.getEscapedString(this.getValue(), this.getExtendedEscapeMode()) + "\"";
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/json/MJsonValue.java b/5.x/src/java/com/marcozanon/macaco/json/MJsonValue.java
new file mode 100644 (file)
index 0000000..2bf4fcc
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.json;
+
+import com.marcozanon.macaco.MObject;
+
+public abstract class MJsonValue extends MObject {
+
+    /* */
+
+    public abstract MJsonValue clone();
+
+    /* Helpers. */
+
+    protected static boolean isWhitespace(int c) {
+        return ((' ' == c) || ('\t' == c) || ('\n' == c) || ('\r' == c));
+    }
+
+    protected static boolean isValueSeparator(int c) {
+        return (',' == c);
+    }
+
+    protected static boolean isNameSeparator(int c) {
+        return (':' == c);
+    }
+
+    protected static boolean isOpeningStructuralCharacter(int c) {
+        return (('[' == c) || ('{' == c));
+    }
+
+    protected static boolean isClosingStructuralCharacter(int c) {
+        return ((']' == c) || ('}' == c));
+    }
+
+    /* Formatter. */
+
+    public abstract String getJsonValue();
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLogDatabaseTable.java b/5.x/src/java/com/marcozanon/macaco/logging/MLogDatabaseTable.java
new file mode 100644 (file)
index 0000000..30c68dc
--- /dev/null
@@ -0,0 +1,114 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+import com.marcozanon.macaco.database.MDatabaseConnection;
+import com.marcozanon.macaco.database.MDatabaseConnectionFailureException;
+import com.marcozanon.macaco.database.MDatabaseConnectionPool;
+import com.marcozanon.macaco.database.MSqlStatementException;
+import com.marcozanon.macaco.database.MSqlTable;
+import com.marcozanon.macaco.text.MText;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.LinkedHashMap;
+
+public class MLogDatabaseTable extends MLogTarget {
+
+    protected MDatabaseConnectionPool databaseConnectionPool = null;
+
+    protected String logDatabaseTable = null;
+    protected String logDatabaseTablePrimaryKey = null;
+    protected String logDatabaseField = null;
+
+    protected MDatabaseConnection databaseConnection = null;
+
+    /* */
+
+    public MLogDatabaseTable(MDatabaseConnectionPool databaseConnectionPool, String logDatabaseTable, String logDatabaseTablePrimaryKey, String logDatabaseField) throws MLoggingException {
+        super();
+        //
+        if (null == databaseConnectionPool) {
+            throw new IllegalArgumentException("Invalid 'databaseConnectionPool': null.");
+        }
+        if (MText.isEmpty(logDatabaseTable)) {
+            throw new IllegalArgumentException("Invalid 'logDatabaseTable': null or empty.");
+        }
+        if (MText.isEmpty(logDatabaseTablePrimaryKey)) {
+            throw new IllegalArgumentException("Invalid 'logDatabaseTablePrimaryKey': null or empty.");
+        }
+        if (MText.isEmpty(logDatabaseField)) {
+            throw new IllegalArgumentException("Invalid 'logDatabaseField': null or empty.");
+        }
+        //
+        this.databaseConnectionPool = databaseConnectionPool;
+        this.logDatabaseTable = logDatabaseTable;
+        this.logDatabaseTablePrimaryKey = logDatabaseTablePrimaryKey;
+        this.logDatabaseField = logDatabaseField;
+    }
+
+    public void close() throws MLoggingException {
+    }
+
+    /* Database connection pool. */
+
+    protected MDatabaseConnectionPool getDatabaseConnectionPool() {
+        return this.databaseConnectionPool;
+    }
+
+    /* Database logging parameters. */
+
+    protected String getLogDatabaseTable() {
+        return this.logDatabaseTable;
+    }
+
+    protected String getLogDatabaseTablePrimaryKey() {
+        return this.logDatabaseTablePrimaryKey;
+    }
+
+    protected String getLogDatabaseField() {
+        return this.logDatabaseField;
+    }
+
+    /* Output. */
+
+    public void appendMessage(String message) throws MLoggingException {
+        this.appendMessage(message, 0);
+    }
+
+    public void appendMessage(String message, int indentation) throws MLoggingException {
+        if (null == message) {
+            throw new IllegalArgumentException("Invalid 'message': null.");
+        }
+        if (0 > indentation) {
+            throw new IllegalArgumentException(String.format("Invalid 'indentation': %s: cannot be negative.", indentation));
+        }
+        //
+        try {
+            String timestamp = (new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")).format(new Date());
+            //
+            StringBuilder separator = new StringBuilder(" ");
+            for (int i = 0; i < indentation; i++) {
+                separator.append(" ");
+            }
+            //
+            MDatabaseConnection databaseConnection = this.getDatabaseConnectionPool().popDatabaseConnection();
+            //
+            LinkedHashMap<String, Object> p = new LinkedHashMap<String, Object>();
+            p.put(this.getLogDatabaseField(), timestamp + separator + message);
+            (new MSqlTable(databaseConnection, this.getLogDatabaseTable(), this.getLogDatabaseTablePrimaryKey())).setRecord(p, null);
+            //
+            this.getDatabaseConnectionPool().pushDatabaseConnection(databaseConnection);
+        }
+        catch (MDatabaseConnectionFailureException exception) {
+            throw new MLoggingException("Could not write to database table.", exception);
+        }
+        catch (MSqlStatementException exception) {
+            throw new MLoggingException("Could not write to database table.", exception);
+        }
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLogFilter.java b/5.x/src/java/com/marcozanon/macaco/logging/MLogFilter.java
new file mode 100644 (file)
index 0000000..af34cb5
--- /dev/null
@@ -0,0 +1,124 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+import com.marcozanon.macaco.MObject;
+import java.util.LinkedList;
+
+public class MLogFilter extends MObject {
+
+    public static enum Threshold {
+        STANDARD,
+        DEBUG
+    };
+
+    protected MLogFilter.Threshold threshold = null;
+
+    protected LinkedList<MLogTarget> logTargets = new LinkedList<MLogTarget>();
+
+    protected boolean pausedState = false;
+    protected LinkedList<MLogMessage> logMessageQueue = new LinkedList<MLogMessage>();
+
+    /* */
+
+    public MLogFilter(MLogFilter.Threshold threshold) {
+        super();
+        //
+        this.setThreshold(threshold);
+    }
+
+    public void close() throws MLoggingException {
+        for (MLogTarget t: this.getLogTargets()) {
+            t.close();
+        }
+    }
+
+    protected void finalize() {
+        try {
+            this.close();
+        }
+        catch (Exception exception) {
+        }
+    }
+
+    /* Threshold. */
+
+    public void setThreshold(MLogFilter.Threshold threshold) {
+        if (null == threshold) {
+            throw new IllegalArgumentException("Invalid 'threshold': null.");
+        }
+        //
+        this.threshold = threshold;
+    }
+
+    public MLogFilter.Threshold getThreshold() {
+        return this.threshold;
+    }
+
+    /* Log targets. */
+
+    public void addLogTarget(MLogTarget logTarget) {
+        if (null == logTarget) {
+            throw new IllegalArgumentException("Invalid 'logTarget': null.");
+        }
+        //
+        synchronized (this.logTargets) {
+            this.logTargets.add(logTarget);
+        }
+    }
+
+    protected LinkedList<MLogTarget> getLogTargets() {
+        return this.logTargets;
+    }
+
+    /* Output. */
+
+    public void setPausedState(boolean pausedState) throws MLoggingException {
+        this.pausedState = pausedState;
+        //
+        if (!this.getPausedState()) {
+            this.flushMessages();
+        }
+    }
+
+    public boolean getPausedState() {
+        return this.pausedState;
+    }
+
+    public void appendMessage(MLogFilter.Threshold level, String message) throws MLoggingException {
+        this.appendMessage(level, message, 0);
+    }
+
+    public void appendMessage(MLogFilter.Threshold level, String message, int indentation) throws MLoggingException {
+        if (null == level) {
+            throw new IllegalArgumentException("Invalid 'level': null.");
+        }
+        //
+        if (level.ordinal() > this.getThreshold().ordinal()) {
+            return;
+        }
+        //
+        this.logMessageQueue.add(new MLogMessage(message, indentation));
+        //
+        if (!this.getPausedState()) {
+            this.flushMessages();
+        }
+    }
+
+    protected void flushMessages() throws MLoggingException {
+        while (0 < this.logMessageQueue.size()) {
+            MLogMessage logMessage = this.logMessageQueue.remove();
+            String message = logMessage.getMessage();
+            int indentation = logMessage.getIndentation();
+            //
+            for (MLogTarget logTarget: this.getLogTargets()) {
+                logTarget.appendMessage(message, indentation);
+            }
+        }
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLogListener.java b/5.x/src/java/com/marcozanon/macaco/logging/MLogListener.java
new file mode 100644 (file)
index 0000000..bb03c15
--- /dev/null
@@ -0,0 +1,15 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+public interface MLogListener {
+
+    /* Logging. */
+
+    public void onMessageLogging(String message);
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLogMessage.java b/5.x/src/java/com/marcozanon/macaco/logging/MLogMessage.java
new file mode 100644 (file)
index 0000000..929bd7d
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+import com.marcozanon.macaco.MObject;
+
+public class MLogMessage extends MObject {
+
+    protected String message = null;
+    protected int indentation = 0;
+
+    /* */
+
+    public MLogMessage(String message, int indentation) {
+        if (null == message) {
+            throw new IllegalArgumentException("Invalid 'message': null.");
+        }
+        if (0 > indentation) {
+            throw new IllegalArgumentException(String.format("Invalid 'indentation': %s: cannot be negative.", indentation));
+        }
+        //
+        this.message = message;
+        this.indentation = indentation;
+    }
+
+    /* Values. */
+
+    public String getMessage() {
+        return this.message;
+    }
+
+    public int getIndentation() {
+        return this.indentation;
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLogPlainTextFile.java b/5.x/src/java/com/marcozanon/macaco/logging/MLogPlainTextFile.java
new file mode 100644 (file)
index 0000000..9b2011f
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+import com.marcozanon.macaco.MInformation;
+import com.marcozanon.macaco.text.MText;
+import java.io.BufferedWriter;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.UnsupportedEncodingException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+public class MLogPlainTextFile extends MLogTarget {
+
+    protected String file = null;
+
+    protected BufferedWriter buffer = null;
+
+    /* */
+
+    public MLogPlainTextFile(String file) throws MLoggingException {
+        super();
+        //
+        if (MText.isBlank(file)) {
+            throw new IllegalArgumentException("Invalid 'file': null or empty.");
+        }
+        //
+        this.file = file;
+        try {
+            this.buffer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(this.getFile(), true), MInformation.TEXT_ENCODING));
+        }
+        catch (FileNotFoundException exception) {
+            throw new MLoggingException("Could not open file.", exception);
+        }
+        catch (UnsupportedEncodingException exception) { // cannot happen
+        }
+    }
+
+    public void close() throws MLoggingException {
+        try {
+            this.getBuffer().close();
+        }
+        catch (IOException exception) {
+            throw new MLoggingException("Could not close file.", exception);
+        }
+    }
+
+    /* File. */
+
+    protected String getFile() {
+        return this.file;
+    }
+
+    /* Buffer. */
+
+    protected BufferedWriter getBuffer() {
+        return this.buffer;
+    }
+
+    /* Output. */
+
+    public void appendMessage(String message) throws MLoggingException {
+        this.appendMessage(message, 0);
+    }
+
+    public void appendMessage(String message, int indentation) throws MLoggingException {
+        if (null == message) {
+            throw new IllegalArgumentException("Invalid 'message': null.");
+        }
+        if (0 > indentation) {
+            throw new IllegalArgumentException(String.format("Invalid 'indentation': %s: cannot be negative.", indentation));
+        }
+        //
+        try {
+            String timestamp = (new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")).format(new Date());
+            //
+            StringBuilder separator = new StringBuilder(" ");
+            for (int i = 0; i < indentation; i++) {
+                separator.append(" ");
+            }
+            //
+            synchronized (this.getBuffer()) {
+                this.getBuffer().write(timestamp + separator + message);
+                this.getBuffer().newLine();
+                this.getBuffer().flush();
+            }
+        }
+        catch (IOException exception) {
+            throw new MLoggingException("Could not write to file.", exception);
+        }
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLogTarget.java b/5.x/src/java/com/marcozanon/macaco/logging/MLogTarget.java
new file mode 100644 (file)
index 0000000..4b32f4e
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+import com.marcozanon.macaco.MObject;
+
+public abstract class MLogTarget extends MObject {
+
+    /* */
+
+    public abstract void close() throws MLoggingException;
+
+    protected void finalize() {
+        try {
+            this.close();
+        }
+        catch (Exception exception) {
+        }
+    }
+
+    /* Output. */
+
+    public abstract void appendMessage(String message) throws MLoggingException;
+
+    public abstract void appendMessage(String message, int indentation) throws MLoggingException;
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/logging/MLoggingException.java b/5.x/src/java/com/marcozanon/macaco/logging/MLoggingException.java
new file mode 100644 (file)
index 0000000..129511e
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.logging;
+
+import com.marcozanon.macaco.MException;
+
+@SuppressWarnings("serial")
+public class MLoggingException extends MException {
+
+    /* */
+
+    public MLoggingException() {
+        super();
+    }
+
+    public MLoggingException(String message) {
+        super(message);
+    }
+
+    public MLoggingException(Throwable error) {
+        super(error);
+    }
+
+    public MLoggingException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MText.java b/5.x/src/java/com/marcozanon/macaco/text/MText.java
new file mode 100644 (file)
index 0000000..f89852b
--- /dev/null
@@ -0,0 +1,439 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+import com.marcozanon.macaco.MInformation;
+import com.marcozanon.macaco.MObject;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import org.xml.sax.Attributes;
+import org.xml.sax.EntityResolver;
+import org.xml.sax.ErrorHandler;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+import org.xml.sax.XMLReader;
+import org.xml.sax.helpers.DefaultHandler;
+
+public class MText extends MObject {
+
+    /* Checks. */
+
+    public static boolean isEmpty(String x) {
+        if ((null != x) && ("".equals(x))) {
+            return true;
+        }
+        return false;
+    }
+
+    public static boolean isBlank(String x) {
+        if ((null == x) || ("".equals(x))) {
+            return true;
+        }
+        return false;
+    }
+
+    /* Utility methods. */
+
+    public static String repeat(String x, int times) {
+        if (1 > times) {
+            throw new IllegalArgumentException(String.format("Invalid 'times': %s.", times));
+        }
+        //
+        StringBuilder y = new StringBuilder("");
+        for (int i = 0; i < times; i++) {
+            y = y.append(x);
+        }
+        return y.toString();
+    }
+
+    public static String getRandomToken(int length) {
+        if (0 > length) {
+            throw new IllegalArgumentException(String.format("Invalid 'length': %s.", length));
+        }
+        //
+        StringBuilder y = new StringBuilder("");
+        for (int i = 0; i < length; i++) {
+            char c = (char)(65 + (25 * Math.random()));
+            y = y.append(c);
+        }
+        return y.toString();
+    }
+
+    /* Javascript escape. */
+
+    public static String getJavascriptEscapedString(String x) {
+        if (null == x) {
+            return null;
+        }
+        //
+        String text = x;
+        //
+        text = text.replace("\\", "\\\\");
+        text = text.replace("'", "\\\'");
+        //
+        return text;
+    }
+
+    /* Xhtml escape and safety. */
+
+    public static String getXhtmlEscapedString(String x) { // similar to PHP's htmlspecialchars()
+        if (null == x) {
+            return null;
+        }
+        //
+        String text = x;
+        //
+        text = text.replace("&", "&amp;");
+        text = text.replace("\"", "&quot;");
+        text = text.replace("'", "&apos;");
+        text = text.replace("<", "&lt;");
+        text = text.replace(">", "&gt;");
+        //
+        text = text.replace("\n", "<br />");
+        //
+        return text;
+    }
+
+    public static String getXhtmlNumericEntitiesString(String x) { // for compatibility with innerHTML
+        if (null == x) {
+            return null;
+        }
+        //
+        String text = x;
+        //
+        text = text.replace("&nbsp;", "&#160;");
+        text = text.replace("&iexcl;", "&#161;");
+        text = text.replace("&cent;", "&#162;");
+        text = text.replace("&pound;", "&#163;");
+        text = text.replace("&curren;", "&#164;");
+        text = text.replace("&yen;", "&#165;");
+        text = text.replace("&brvbar;", "&#166;");
+        text = text.replace("&sect;", "&#167;");
+        text = text.replace("&uml;", "&#168;");
+        text = text.replace("&copy;", "&#169;");
+        text = text.replace("&ordf;", "&#170;");
+        text = text.replace("&laquo;", "&#171;");
+        text = text.replace("&not;", "&#172;");
+        text = text.replace("&shy;", "&#173;");
+        text = text.replace("&reg;", "&#174;");
+        text = text.replace("&macr;", "&#175;");
+        text = text.replace("&deg;", "&#176;");
+        text = text.replace("&plusmn;", "&#177;");
+        text = text.replace("&sup2;", "&#178;");
+        text = text.replace("&sup3;", "&#179;");
+        text = text.replace("&acute;", "&#180;");
+        text = text.replace("&micro;", "&#181;");
+        text = text.replace("&para;", "&#182;");
+        text = text.replace("&middot;", "&#183;");
+        text = text.replace("&cedil;", "&#184;");
+        text = text.replace("&sup1;", "&#185;");
+        text = text.replace("&ordm;", "&#186;");
+        text = text.replace("&raquo;", "&#187;");
+        text = text.replace("&frac14;", "&#188;");
+        text = text.replace("&frac12;", "&#189;");
+        text = text.replace("&frac34;", "&#190;");
+        text = text.replace("&iquest;", "&#191;");
+        text = text.replace("&Agrave;", "&#192;");
+        text = text.replace("&Aacute;", "&#193;");
+        text = text.replace("&Acirc;", "&#194;");
+        text = text.replace("&Atilde;", "&#195;");
+        text = text.replace("&Auml;", "&#196;");
+        text = text.replace("&Aring;", "&#197;");
+        text = text.replace("&AElig;", "&#198;");
+        text = text.replace("&Ccedil;", "&#199;");
+        text = text.replace("&Egrave;", "&#200;");
+        text = text.replace("&Eacute;", "&#201;");
+        text = text.replace("&Ecirc;", "&#202;");
+        text = text.replace("&Euml;", "&#203;");
+        text = text.replace("&Igrave;", "&#204;");
+        text = text.replace("&Iacute;", "&#205;");
+        text = text.replace("&Icirc;", "&#206;");
+        text = text.replace("&Iuml;", "&#207;");
+        text = text.replace("&ETH;", "&#208;");
+        text = text.replace("&Ntilde;", "&#209;");
+        text = text.replace("&Ograve;", "&#210;");
+        text = text.replace("&Oacute;", "&#211;");
+        text = text.replace("&Ocirc;", "&#212;");
+        text = text.replace("&Otilde;", "&#213;");
+        text = text.replace("&Ouml;", "&#214;");
+        text = text.replace("&times;", "&#215;");
+        text = text.replace("&Oslash;", "&#216;");
+        text = text.replace("&Ugrave;", "&#217;");
+        text = text.replace("&Uacute;", "&#218;");
+        text = text.replace("&Ucirc;", "&#219;");
+        text = text.replace("&Uuml;", "&#220;");
+        text = text.replace("&Yacute;", "&#221;");
+        text = text.replace("&THORN;", "&#222;");
+        text = text.replace("&szlig;", "&#223;");
+        text = text.replace("&agrave;", "&#224;");
+        text = text.replace("&aacute;", "&#225;");
+        text = text.replace("&acirc;", "&#226;");
+        text = text.replace("&atilde;", "&#227;");
+        text = text.replace("&auml;", "&#228;");
+        text = text.replace("&aring;", "&#229;");
+        text = text.replace("&aelig;", "&#230;");
+        text = text.replace("&ccedil;", "&#231;");
+        text = text.replace("&egrave;", "&#232;");
+        text = text.replace("&eacute;", "&#233;");
+        text = text.replace("&ecirc;", "&#234;");
+        text = text.replace("&euml;", "&#235;");
+        text = text.replace("&igrave;", "&#236;");
+        text = text.replace("&iacute;", "&#237;");
+        text = text.replace("&icirc;", "&#238;");
+        text = text.replace("&iuml;", "&#239;");
+        text = text.replace("&eth;", "&#240;");
+        text = text.replace("&ntilde;", "&#241;");
+        text = text.replace("&ograve;", "&#242;");
+        text = text.replace("&oacute;", "&#243;");
+        text = text.replace("&ocirc;", "&#244;");
+        text = text.replace("&otilde;", "&#245;");
+        text = text.replace("&ouml;", "&#246;");
+        text = text.replace("&divide;", "&#247;");
+        text = text.replace("&oslash;", "&#248;");
+        text = text.replace("&ugrave;", "&#249;");
+        text = text.replace("&uacute;", "&#250;");
+        text = text.replace("&ucirc;", "&#251;");
+        text = text.replace("&uuml;", "&#252;");
+        text = text.replace("&yacute;", "&#253;");
+        text = text.replace("&thorn;", "&#254;");
+        text = text.replace("&yuml;", "&#255;");
+        text = text.replace("&OElig;", "&#338;");
+        text = text.replace("&oelig;", "&#339;");
+        text = text.replace("&Scaron;", "&#352;");
+        text = text.replace("&scaron;", "&#353;");
+        text = text.replace("&Yuml;", "&#376;");
+        text = text.replace("&fnof;", "&#402;");
+        text = text.replace("&circ;", "&#710;");
+        text = text.replace("&tilde;", "&#732;");
+        text = text.replace("&Alpha;", "&#913;");
+        text = text.replace("&Beta;", "&#914;");
+        text = text.replace("&Gamma;", "&#915;");
+        text = text.replace("&Delta;", "&#916;");
+        text = text.replace("&Epsilon;", "&#917;");
+        text = text.replace("&Zeta;", "&#918;");
+        text = text.replace("&Eta;", "&#919;");
+        text = text.replace("&Theta;", "&#920;");
+        text = text.replace("&Iota;", "&#921;");
+        text = text.replace("&Kappa;", "&#922;");
+        text = text.replace("&Lambda;", "&#923;");
+        text = text.replace("&Mu;", "&#924;");
+        text = text.replace("&Nu;", "&#925;");
+        text = text.replace("&Xi;", "&#926;");
+        text = text.replace("&Omicron;", "&#927;");
+        text = text.replace("&Pi;", "&#928;");
+        text = text.replace("&Rho;", "&#929;");
+        text = text.replace("&Sigma;", "&#931;");
+        text = text.replace("&Tau;", "&#932;");
+        text = text.replace("&Upsilon;", "&#933;");
+        text = text.replace("&Phi;", "&#934;");
+        text = text.replace("&Chi;", "&#935;");
+        text = text.replace("&Psi;", "&#936;");
+        text = text.replace("&Omega;", "&#937;");
+        text = text.replace("&alpha;", "&#945;");
+        text = text.replace("&beta;", "&#946;");
+        text = text.replace("&gamma;", "&#947;");
+        text = text.replace("&delta;", "&#948;");
+        text = text.replace("&epsilon;", "&#949;");
+        text = text.replace("&zeta;", "&#950;");
+        text = text.replace("&eta;", "&#951;");
+        text = text.replace("&theta;", "&#952;");
+        text = text.replace("&iota;", "&#953;");
+        text = text.replace("&kappa;", "&#954;");
+        text = text.replace("&lambda;", "&#955;");
+        text = text.replace("&mu;", "&#956;");
+        text = text.replace("&nu;", "&#957;");
+        text = text.replace("&xi;", "&#958;");
+        text = text.replace("&omicron;", "&#959;");
+        text = text.replace("&pi;", "&#960;");
+        text = text.replace("&rho;", "&#961;");
+        text = text.replace("&sigmaf;", "&#962;");
+        text = text.replace("&sigma;", "&#963;");
+        text = text.replace("&tau;", "&#964;");
+        text = text.replace("&upsilon;", "&#965;");
+        text = text.replace("&phi;", "&#966;");
+        text = text.replace("&chi;", "&#967;");
+        text = text.replace("&psi;", "&#968;");
+        text = text.replace("&omega;", "&#969;");
+        text = text.replace("&thetasym;", "&#977;");
+        text = text.replace("&upsih;", "&#978;");
+        text = text.replace("&piv;", "&#982;");
+        text = text.replace("&ensp;", "&#8194;");
+        text = text.replace("&emsp;", "&#8195;");
+        text = text.replace("&thinsp;", "&#8201;");
+        text = text.replace("&zwnj;", "&#8204;");
+        text = text.replace("&zwj;", "&#8205;");
+        text = text.replace("&lrm;", "&#8206;");
+        text = text.replace("&rlm;", "&#8207;");
+        text = text.replace("&ndash;", "&#8211;");
+        text = text.replace("&mdash;", "&#8212;");
+        text = text.replace("&lsquo;", "&#8216;");
+        text = text.replace("&rsquo;", "&#8217;");
+        text = text.replace("&sbquo;", "&#8218;");
+        text = text.replace("&ldquo;", "&#8220;");
+        text = text.replace("&rdquo;", "&#8221;");
+        text = text.replace("&bdquo;", "&#8222;");
+        text = text.replace("&dagger;", "&#8224;");
+        text = text.replace("&Dagger;", "&#8225;");
+        text = text.replace("&bull;", "&#8226;");
+        text = text.replace("&hellip;", "&#8230;");
+        text = text.replace("&permil;", "&#8240;");
+        text = text.replace("&prime;", "&#8242;");
+        text = text.replace("&Prime;", "&#8243;");
+        text = text.replace("&lsaquo;", "&#8249;");
+        text = text.replace("&rsaquo;", "&#8250;");
+        text = text.replace("&oline;", "&#8254;");
+        text = text.replace("&frasl;", "&#8260;");
+        text = text.replace("&euro;", "&#8364;");
+        text = text.replace("&image;", "&#8465;");
+        text = text.replace("&weierp;", "&#8472;");
+        text = text.replace("&real;", "&#8476;");
+        text = text.replace("&trade;", "&#8482;");
+        text = text.replace("&alefsym;", "&#8501;");
+        text = text.replace("&larr;", "&#8592;");
+        text = text.replace("&uarr;", "&#8593;");
+        text = text.replace("&rarr;", "&#8594;");
+        text = text.replace("&darr;", "&#8595;");
+        text = text.replace("&harr;", "&#8596;");
+        text = text.replace("&crarr;", "&#8629;");
+        text = text.replace("&lArr;", "&#8656;");
+        text = text.replace("&uArr;", "&#8657;");
+        text = text.replace("&rArr;", "&#8658;");
+        text = text.replace("&dArr;", "&#8659;");
+        text = text.replace("&hArr;", "&#8660;");
+        text = text.replace("&forall;", "&#8704;");
+        text = text.replace("&part;", "&#8706;");
+        text = text.replace("&exist;", "&#8707;");
+        text = text.replace("&empty;", "&#8709;");
+        text = text.replace("&nabla;", "&#8711;");
+        text = text.replace("&isin;", "&#8712;");
+        text = text.replace("&notin;", "&#8713;");
+        text = text.replace("&ni;", "&#8715;");
+        text = text.replace("&prod;", "&#8719;");
+        text = text.replace("&sum;", "&#8721;");
+        text = text.replace("&minus;", "&#8722;");
+        text = text.replace("&lowast;", "&#8727;");
+        text = text.replace("&radic;", "&#8730;");
+        text = text.replace("&prop;", "&#8733;");
+        text = text.replace("&infin;", "&#8734;");
+        text = text.replace("&ang;", "&#8736;");
+        text = text.replace("&and;", "&#8743;");
+        text = text.replace("&or;", "&#8744;");
+        text = text.replace("&cap;", "&#8745;");
+        text = text.replace("&cup;", "&#8746;");
+        text = text.replace("&int;", "&#8747;");
+        text = text.replace("&there4;", "&#8756;");
+        text = text.replace("&sim;", "&#8764;");
+        text = text.replace("&cong;", "&#8773;");
+        text = text.replace("&asymp;", "&#8776;");
+        text = text.replace("&ne;", "&#8800;");
+        text = text.replace("&equiv;", "&#8801;");
+        text = text.replace("&le;", "&#8804;");
+        text = text.replace("&ge;", "&#8805;");
+        text = text.replace("&sub;", "&#8834;");
+        text = text.replace("&sup;", "&#8835;");
+        text = text.replace("&nsub;", "&#8836;");
+        text = text.replace("&sube;", "&#8838;");
+        text = text.replace("&supe;", "&#8839;");
+        text = text.replace("&oplus;", "&#8853;");
+        text = text.replace("&otimes;", "&#8855;");
+        text = text.replace("&perp;", "&#8869;");
+        text = text.replace("&sdot;", "&#8901;");
+        text = text.replace("&lceil;", "&#8968;");
+        text = text.replace("&rceil;", "&#8969;");
+        text = text.replace("&lfloor;", "&#8970;");
+        text = text.replace("&rfloor;", "&#8971;");
+        text = text.replace("&lang;", "&#9001;");
+        text = text.replace("&rang;", "&#9002;");
+        text = text.replace("&loz;", "&#9674;");
+        text = text.replace("&spades;", "&#9824;");
+        text = text.replace("&clubs;", "&#9827;");
+        text = text.replace("&hearts;", "&#9829;");
+        text = text.replace("&diams;", "&#9830;");
+        //
+        return text;
+    }
+
+    public static String getXhtmlSafeString(String x) throws MXhtmlUnsafeStringException {
+        if (null == x) {
+            return null;
+        }
+        //
+        String text = x;
+        //
+        StringBuilder fakeXhtmlPageContent = new StringBuilder("");
+        fakeXhtmlPageContent.append(String.format("<?xml version=\"1.0\" encoding=\"%s\" ?>", MInformation.TEXT_ENCODING));
+        fakeXhtmlPageContent.append("<!DOCTYPE html SYSTEM \"fake-dtd\" >");
+        fakeXhtmlPageContent.append("<html xmlns=\"http://www.w3.org/1999/xhtml\">");
+        fakeXhtmlPageContent.append("<head><title /></head>");
+        fakeXhtmlPageContent.append(String.format("<body>%s</body>", text));
+        fakeXhtmlPageContent.append("</html>");
+        // Validate Xhtml (with no dangerous tags and event attributes).
+        try {
+            SAXParserFactory factory = SAXParserFactory.newInstance();
+            factory.setValidating(true);
+            factory.setNamespaceAware(true);
+            SAXParser parser = factory.newSAXParser();
+            XMLReader reader = parser.getXMLReader();
+            reader.setEntityResolver(new EntityResolver() {
+
+                public InputSource resolveEntity(String publicId, String systemId) {
+                    return new InputSource(new BufferedInputStream(this.getClass().getClassLoader().getResourceAsStream("dtd/xhtml1-transitional-macaco-edit.dtd")));
+                }
+
+            });
+            reader.setContentHandler(new DefaultHandler() {
+
+                public void startElement(String namespaceUri, String strippedName, String tagName, Attributes attributes) throws SAXException {
+                    if ("script".equalsIgnoreCase(tagName)) {
+                        throw new SAXException(String.format("Tag not allowed: %s.", tagName));
+                    }
+                    for (int a = 0; attributes.getLength() > a; a++) {
+                        if (attributes.getLocalName(a).toLowerCase().startsWith("on")) {
+                            throw new SAXException(String.format("Attribute not allowed: %s.", attributes.getLocalName(a)));
+                        }
+                    }
+                }
+
+            });
+            reader.setErrorHandler(new ErrorHandler() {
+
+                public void error(SAXParseException exception) throws SAXException {
+                    throw new SAXException(exception);
+                }
+
+                public void fatalError(SAXParseException exception) throws SAXException {
+                    throw new SAXException(exception);
+                }
+
+                public void warning(SAXParseException exception) throws SAXException {
+                    throw new SAXException(exception);
+                }
+
+            });
+            //
+            reader.parse(new InputSource(new ByteArrayInputStream(fakeXhtmlPageContent.toString().getBytes(MInformation.TEXT_ENCODING))));
+        }
+        catch (ParserConfigurationException exception) { // cannot happen
+        }
+        catch (SAXException exception) {
+            throw new MXhtmlUnsafeStringException("Invalid 'x': unsafe tags or attributes inside.", exception);
+        }
+        catch (UnsupportedEncodingException exception) { // cannot happen
+        }
+        catch (IOException exception) { // cannot happen; put here not to bypass UnsupportedEncodingException
+        }
+        // Also convert named entities to numeric entities.
+        return MText.getXhtmlNumericEntitiesString(text);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MTextException.java b/5.x/src/java/com/marcozanon/macaco/text/MTextException.java
new file mode 100644 (file)
index 0000000..fd52ce1
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+import com.marcozanon.macaco.MException;
+
+public abstract class MTextException extends MException {
+
+    /* */
+
+    public MTextException() {
+        super();
+    }
+
+    public MTextException(String message) {
+        super(message);
+    }
+
+    public MTextException(Throwable error) {
+        super(error);
+    }
+
+    public MTextException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MTranslationException.java b/5.x/src/java/com/marcozanon/macaco/text/MTranslationException.java
new file mode 100644 (file)
index 0000000..f507402
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+public abstract class MTranslationException extends MTextException {
+
+    /* */
+
+    public MTranslationException() {
+        super();
+    }
+
+    public MTranslationException(String message) {
+        super(message);
+    }
+
+    public MTranslationException(Throwable error) {
+        super(error);
+    }
+
+    public MTranslationException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MTranslationFileParsingException.java b/5.x/src/java/com/marcozanon/macaco/text/MTranslationFileParsingException.java
new file mode 100644 (file)
index 0000000..f071e2c
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+@SuppressWarnings("serial")
+public class MTranslationFileParsingException extends MTranslationException {
+
+    /* */
+
+    public MTranslationFileParsingException() {
+        super();
+    }
+
+    public MTranslationFileParsingException(String message) {
+        super(message);
+    }
+
+    public MTranslationFileParsingException(Throwable error) {
+        super(error);
+    }
+
+    public MTranslationFileParsingException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MTranslationValueNotFoundException.java b/5.x/src/java/com/marcozanon/macaco/text/MTranslationValueNotFoundException.java
new file mode 100644 (file)
index 0000000..75b9b04
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+@SuppressWarnings("serial")
+public class MTranslationValueNotFoundException extends MTranslationException {
+
+    /* */
+
+    public MTranslationValueNotFoundException() {
+        super();
+    }
+
+    public MTranslationValueNotFoundException(String message) {
+        super(message);
+    }
+
+    public MTranslationValueNotFoundException(Throwable error) {
+        super(error);
+    }
+
+    public MTranslationValueNotFoundException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MTranslator.java b/5.x/src/java/com/marcozanon/macaco/text/MTranslator.java
new file mode 100644 (file)
index 0000000..8c3f8e3
--- /dev/null
@@ -0,0 +1,190 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+import com.marcozanon.macaco.MInformation;
+import com.marcozanon.macaco.MObject;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.io.LineNumberReader;
+import java.io.UnsupportedEncodingException;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+
+public class MTranslator extends MObject {
+
+    protected String file = null;
+    protected Locale basicLocale = null;
+
+    protected LinkedHashMap<String, LinkedHashMap<String, String>> messages = new LinkedHashMap<String, LinkedHashMap<String, String>>();
+
+    /* */
+
+    public MTranslator(String file, Locale basicLocale) throws MTranslationFileParsingException {
+        super();
+        //
+        if (null == basicLocale) {
+            throw new IllegalArgumentException("Invalid 'basicLocale': null.");
+        }
+        //
+        this.file = file;
+        this.basicLocale = basicLocale;
+        //
+        this.parseFile(this.getFile());
+    }
+
+    public MTranslator clone() {
+        MTranslator tmpMTranslator = null;
+        try {
+            tmpMTranslator = new MTranslator(this.getFile(), this.getBasicLocale());
+        }
+        catch (MTranslationFileParsingException exception) { // should not happen
+        }
+        return tmpMTranslator;
+    }
+
+    /* File. */
+
+    protected String getFile() {
+        return this.file;
+    }
+
+    /* Locale. */
+
+    protected Locale getBasicLocale() {
+        return this.basicLocale;
+    }
+
+    /* String management. */
+
+    protected LinkedHashMap<String, LinkedHashMap<String, String>> getMessages() {
+        return this.messages;
+    }
+
+    public void parseFile(String file) throws MTranslationFileParsingException {
+        if (MText.isBlank(file)) {
+            throw new IllegalArgumentException("Invalid 'file': null or empty.");
+        }
+        //
+        LineNumberReader buffer = null;
+        try {
+            buffer = new LineNumberReader(new InputStreamReader(new FileInputStream(file), MInformation.TEXT_ENCODING));
+        }
+        catch (FileNotFoundException exception) {
+            throw new MTranslationFileParsingException("Could not open file.", exception);
+        }
+        catch (UnsupportedEncodingException exception) { // cannot happen
+        }
+        String message = null;
+        String line = null;
+        synchronized (this.getMessages()) {
+            while (true) {
+                try {
+                    line = buffer.readLine();
+                }
+                catch (IOException exception) {
+                    throw new MTranslationFileParsingException("Could not read file.", exception);
+                }
+                if (null == line) {
+                    break;
+                }
+                line = line.trim();
+                if (MText.isEmpty(line)) {
+                    message = null;
+                    continue;
+                }
+                else if ((line.startsWith("#")) || (line.startsWith(";"))) {
+                    continue;
+                }
+                else if (null == message) {
+                    message = line;
+                    if (this.getMessages().containsKey(message)) {
+                        throw new MTranslationFileParsingException(String.format("Invalid line: %s: duplicated message: %s.", buffer.getLineNumber(), message));
+                    }
+                    this.getMessages().put(message, new LinkedHashMap<String, String>());
+                }
+                else {
+                    int a = line.indexOf("=");
+                    if (-1 == a) {
+                        throw new MTranslationFileParsingException(String.format("Invalid line: %s: string malformed: %s.", buffer.getLineNumber(), line));
+                    }
+                    String localeRepresentation = line.substring(0, a).trim();
+                    String translation = line.substring(a + 1).trim();
+                    if (this.getMessages().get(message).containsKey(localeRepresentation)) {
+                        throw new MTranslationFileParsingException(String.format("Invalid line: %s: duplicated translation for locale: %s.", buffer.getLineNumber(), message));
+                    }
+                    this.getMessages().get(message).put(localeRepresentation, translation);
+                }
+            }
+        }
+        try {
+            buffer.close();
+        }
+        catch (IOException exception) {
+            throw new MTranslationFileParsingException("Could not close file.", exception);
+        }
+    }
+
+    public void clear() {
+        synchronized (this.getMessages()) {
+            this.getMessages().clear();
+        }
+    }
+
+    public String t(String message, Locale locale) {
+        return this.getLenientTranslation(message, locale);
+    }
+
+    public String getLenientTranslation(String message, Locale locale) {
+        String translation = null;
+        try {
+            translation = this.getTranslation(message, locale, false);
+        }
+        catch (MTranslationValueNotFoundException exception) { // cannot happen
+        }
+        return translation;
+    }
+
+    public String getStrictTranslation(String message, Locale locale) throws MTranslationValueNotFoundException {
+        return this.getTranslation(message, locale, true);
+    }
+
+    protected String getTranslation(String message, Locale locale, boolean strictMode) throws MTranslationValueNotFoundException {
+        if (MText.isBlank(message)) {
+            throw new IllegalArgumentException("Invalid 'message': null or empty.");
+        }
+        if (null == locale) {
+            throw new IllegalArgumentException("Invalid 'locale': null.");
+        }
+        //
+        if (!this.getMessages().containsKey(message)) {
+            if (strictMode) {
+                throw new MTranslationValueNotFoundException(String.format("Invalid 'message': %s: not available.", message));
+            }
+            return message;
+        }
+        if (this.getBasicLocale().equals(locale)) {
+            return message;
+        }
+        LinkedHashMap<String, String> messageTranslations = this.getMessages().get(message);
+        String localeRepresentation = locale.toString();
+        if (!messageTranslations.containsKey(localeRepresentation)) {
+            if (strictMode) {
+                throw new MTranslationValueNotFoundException(String.format("Invalid 'locale': %s: translation not available for message: %s.", localeRepresentation, message));
+            }
+            String localeFallbackRepresentation = locale.getLanguage();
+            if (!messageTranslations.containsKey(localeFallbackRepresentation)) {
+                return message;
+            }
+            return messageTranslations.get(localeFallbackRepresentation);
+        }
+        return messageTranslations.get(localeRepresentation);
+    }
+
+}
diff --git a/5.x/src/java/com/marcozanon/macaco/text/MXhtmlUnsafeStringException.java b/5.x/src/java/com/marcozanon/macaco/text/MXhtmlUnsafeStringException.java
new file mode 100644 (file)
index 0000000..6a0d8f0
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Macaco
+ * Copyright (c) 2009-2017 Marco Zanon <info@marcozanon.com>.
+ * Released under MIT license (see LICENSE for details).
+ */
+
+package com.marcozanon.macaco.text;
+
+@SuppressWarnings("serial")
+public class MXhtmlUnsafeStringException extends MTextException {
+
+    /* */
+
+    public MXhtmlUnsafeStringException() {
+        super();
+    }
+
+    public MXhtmlUnsafeStringException(String message) {
+        super(message);
+    }
+
+    public MXhtmlUnsafeStringException(Throwable error) {
+        super(error);
+    }
+
+    public MXhtmlUnsafeStringException(String message, Throwable error) {
+        super(message, error);
+    }
+
+}