Initial commit, version 0.9.2
authorNiki Roo <niki@nikiroo.be>
Sun, 12 Feb 2017 15:06:27 +0000 (16:06 +0100)
committerNiki Roo <niki@nikiroo.be>
Sun, 12 Feb 2017 15:06:27 +0000 (16:06 +0100)
14 files changed:
Makefile.base [new file with mode: 0644]
VERSION [new file with mode: 0644]
configure.sh [new file with mode: 0755]
libs/unbescape-1.1.4-sources.jar [new file with mode: 0644]
src/be/nikiroo/utils/IOUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/MarkableFileInputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/StringUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/package-info.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Bundle.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Bundles.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Meta.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/TransBundle.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/TransBundle_ResourceList.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/package-info.java [new file with mode: 0644]

diff --git a/Makefile.base b/Makefile.base
new file mode 100644 (file)
index 0000000..9ae14f8
--- /dev/null
@@ -0,0 +1,144 @@
+# Required parameters (the commented out ones are supposed to change per project):
+
+#MAIN = path to main java source to compile
+#MORE = path to supplementary needed resources not linked from MAIN
+#NAME = name of project (used for jar output file)
+#PREFIX = usually /usr/local (where to install the program)
+#TEST = path to main test source to compile
+#JAR_FLAGS += a list of things to pack, each usually prefixed with "-C bin/"
+#SJAR_FLAGS += a list of things to pack, each usually prefixed with "-C src/", for *-sources.jar files
+
+JAVAC = javac
+JAVAC_FLAGS += -encoding UTF-8 -d ./bin/ -cp ./src/ -Xdiags:verbose
+JAVA = java
+JAVA_FLAGS += -cp ./bin/
+JAR = jar
+RJAR = java
+RJAR_FLAGS += -jar
+
+# Usual options:
+#      make            : to build the jar file
+#      make libs       : to update the libraries into src/
+#      make build      : to update the binaries (not the jar)
+#      make test       : to update the test binaries
+#      make build jar  : to update the binaries and jar file
+#      make clean      : to clean the directory of intermediate files
+#      make mrpropre   : to clean the directory of all outputs
+#      make run        : to run the program from the binaries
+#      make run-test   : to run the test program from the binaries
+#      make jrun       : to run the program from the jar file
+#      make install    : to install the application into $PREFIX
+
+# Note: build is actually slower than rebuild in most cases except when
+# small changes only are detected ; so we use rebuild by default
+
+all: build jar
+
+.PHONY: all clean mrproper mrpropre build run jrun jar resources install libs love 
+
+bin:
+       @mkdir -p bin
+
+jar: $(NAME).jar
+
+build: resources
+       @echo Compiling program...
+       @echo " src/$(MAIN)"
+       @$(JAVAC) $(JAVAC_FLAGS) "src/$(MAIN).java"
+       @[ "$(MORE)" = "" ] || for sup in $(MORE); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+test: 
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Compiling test program...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       @[ "$(TEST)"  = "" ] || for sup in $(TEST); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+clean:
+       rm -rf bin/
+       @echo Removing sources taken from libs...
+       @for lib in libs/*-sources.jar; do \
+               basename "$$lib"; \
+               jar tf "$$lib" | while read -r ln; do \
+                       [ -f "src/$$ln" ] && rm "src/$$ln"; \
+               done; \
+               jar tf "$$lib" | tac | while read -r ln; do \
+                       [ -d "src/$$ln" ] && rmdir "src/$$ln" 2>/dev/null || true; \
+               done; \
+       done
+
+mrproper: mrpropre
+
+mrpropre: clean
+       rm -f $(NAME).jar
+
+love:
+       @echo " ...not war."
+
+resources: libs
+       @echo Copying resources into bin/...
+       @cd src && find . | grep -v '\.java$$' | while read -r ln; do \
+               if [ -f "$$ln" ]; then \
+                       dir="`dirname "$$ln"`"; \
+                       mkdir -p "../bin/$$dir" ; \
+                       cp "$$ln" "../bin/$$ln" ; \
+               fi ; \
+       done
+
+libs: bin
+       @[ -e bin/libs -o ! -d libs ] || echo Extracting sources from libs...
+       @[ -e bin/libs -o ! -d libs ] || (cd src && for lib in ../libs/*-sources.jar; do \
+               basename "$$lib"; \
+               jar xf "$$lib"; \
+       done )
+       @[ ! -d libs ] || touch bin/libs
+
+$(NAME).jar: resources
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Making JAR file...
+       @echo > bin/manifest
+       @[ "$(SJAR_FLAGS)" = "" ] || echo Creating $(NAME)-sources.jar...
+       @[ "$(SJAR_FLAGS)" = "" ] || $(JAR) cfm $(NAME)-sources.jar bin/manifest $(SJAR_FLAGS)
+       @[ "$(SJAR_FLAGS)" = "" ] || [ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`-sources.jar"...
+       @[ "$(SJAR_FLAGS)" = "" ] || [ ! -e VERSION ] || cp $(NAME)-sources.jar "$(NAME)-`cat VERSION`-sources.jar"
+       @echo "Main-Class: `echo "$(MAIN)" | sed 's:/:.:g'`" > bin/manifest
+       @echo >> bin/manifest
+       $(JAR) cfm $(NAME).jar bin/manifest $(JAR_FLAGS)
+       @[ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`.jar"...
+       @[ ! -e VERSION ] || cp $(NAME).jar "$(NAME)-`cat VERSION`.jar"
+
+run: 
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Running "$(NAME)"...
+       $(JAVA) $(JAVA_FLAGS) $(MAIN)
+
+jrun:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       @echo Running "$(NAME).jar"...
+       $(RJAR) $(RJAR_FLAGS) $(NAME).jar
+
+run-test: 
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ] || echo You need to build the test sources
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ]
+       @echo Running tests for "$(NAME)"...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       [ "$(TEST)"  = "" ] || $(JAVA) $(JAVA_FLAGS) $(TEST)
+
+install:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       mkdir -p "$(PREFIX)/lib" "$(PREFIX)/bin"
+       cp $(NAME).jar "$(PREFIX)/lib/"
+       echo "#!/bin/sh" > "$(PREFIX)/bin/$(NAME)"
+       echo "$(RJAR) $(RJAR_FLAGS) \"$(PREFIX)/lib/$(NAME).jar\" \"$$@\"" >> "$(PREFIX)/bin/$(NAME)"
+       chmod a+rx "$(PREFIX)/bin/$(NAME)"
+
diff --git a/VERSION b/VERSION
new file mode 100644 (file)
index 0000000..2003b63
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.9.2
diff --git a/configure.sh b/configure.sh
new file mode 100755 (executable)
index 0000000..ed494b7
--- /dev/null
@@ -0,0 +1,44 @@
+#!/bin/sh
+
+# default:
+PREFIX=/usr/local
+PROGS="java javac jar"
+
+valid=true
+while [ "$*" != "" ]; do
+       key=`echo "$1" | cut -c1-9`
+       val=`echo "$1" | cut -c10-`
+       case "$key" in
+       --prefix=)
+               PREFIX="$val"
+       ;;
+       *)
+               echo "Unsupported parameter: '$1'" >&2
+               valid=false
+       ;;
+       esac
+       shift
+done
+
+[ $valid = false ] && exit 1
+
+MESS="A required program cannot be found:"
+for prog in $PROGS; do
+       out="`whereis -b "$prog" 2>/dev/null`"
+       if [ "$out" = "$prog:" ]; then
+               echo "$MESS $prog" >&2
+               valid=false
+       fi
+done
+
+[ $valid = false ] && exit 2
+
+echo "MAIN = be/nikiroo/utils/resources/TransBundle" > Makefile
+echo "MORE = be/nikiroo/utils/StringUtils be/nikiroo/utils/IOUtils be/nikiroo/utils/MarkableFileInputStream" >> Makefile
+echo "NAME = nikiroo-utils" >> Makefile
+echo "PREFIX = $PREFIX" >> Makefile
+echo "JAR_FLAGS += -C bin/ be" >> Makefile
+echo "SJAR_FLAGS += -C src/ be" >> Makefile
+
+cat Makefile.base >> Makefile
+
diff --git a/libs/unbescape-1.1.4-sources.jar b/libs/unbescape-1.1.4-sources.jar
new file mode 100644 (file)
index 0000000..01ddb56
Binary files /dev/null and b/libs/unbescape-1.1.4-sources.jar differ
diff --git a/src/be/nikiroo/utils/IOUtils.java b/src/be/nikiroo/utils/IOUtils.java
new file mode 100644 (file)
index 0000000..3516fd2
--- /dev/null
@@ -0,0 +1,216 @@
+package be.nikiroo.utils;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * This class offer some utilities based around {@link Stream}s.
+ * 
+ * @author niki
+ */
+public class IOUtils {
+       /**
+        * Write the data to the given {@link File}.
+        * 
+        * @param in
+        *            the data source
+        * @param target
+        *            the target {@link File}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void write(InputStream in, File target) throws IOException {
+               OutputStream out = new FileOutputStream(target);
+               try {
+                       write(in, out);
+               } finally {
+                       out.close();
+               }
+       }
+
+       /**
+        * Write the data to the given {@link OutputStream}.
+        * 
+        * @param in
+        *            the data source
+        * @param target
+        *            the target {@link OutputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void write(InputStream in, OutputStream out)
+                       throws IOException {
+               byte buffer[] = new byte[4069];
+               for (int len = 0; (len = in.read(buffer)) > 0;) {
+                       out.write(buffer, 0, len);
+               }
+       }
+
+       /**
+        * Recursively Add a {@link File} (which can thus be a directory, too) to a
+        * {@link ZipOutputStream}.
+        * 
+        * @param zip
+        *            the stream
+        * @param base
+        *            the path to prepend to the ZIP info before the actual
+        *            {@link File} path
+        * @param target
+        *            the source {@link File} (which can be a directory)
+        * @param targetIsRoot
+        *            FALSE if we need to add a {@link ZipEntry} for base/target,
+        *            TRUE to add it at the root of the ZIP
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void zip(ZipOutputStream zip, String base, File target,
+                       boolean targetIsRoot) throws IOException {
+               if (target.isDirectory()) {
+                       if (!targetIsRoot) {
+                               if (base == null || base.isEmpty()) {
+                                       base = target.getName();
+                               } else {
+                                       base += "/" + target.getName();
+                               }
+                               zip.putNextEntry(new ZipEntry(base + "/"));
+                       }
+                       for (File file : target.listFiles()) {
+                               zip(zip, base, file, false);
+                       }
+               } else {
+                       if (base == null || base.isEmpty()) {
+                               base = target.getName();
+                       } else {
+                               base += "/" + target.getName();
+                       }
+                       zip.putNextEntry(new ZipEntry(base));
+                       FileInputStream in = new FileInputStream(target);
+                       try {
+                               IOUtils.write(in, zip);
+                       } finally {
+                               in.close();
+                       }
+               }
+       }
+
+       /**
+        * Zip the given source into dest.
+        * 
+        * @param src
+        *            the source {@link File} (which can be a directory)
+        * @param dest
+        *            the destination <tt>.zip</tt> file
+        * @param srctIsRoot
+        *            FALSE if we need to add a {@link ZipEntry} for src, TRUE to
+        *            add it at the root of the ZIP
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void zip(File src, File dest, boolean srcIsRoot)
+                       throws IOException {
+               OutputStream out = new FileOutputStream(dest);
+               try {
+                       ZipOutputStream zip = new ZipOutputStream(out);
+                       try {
+                               IOUtils.zip(zip, "", src, srcIsRoot);
+                       } finally {
+                               zip.close();
+                       }
+               } finally {
+                       out.close();
+               }
+       }
+
+       /**
+        * Write the {@link String} content to {@link File}.
+        * 
+        * @param dir
+        *            the directory where to write the {@link File}
+        * @param filename
+        *            the {@link File} name
+        * @param content
+        *            the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void writeSmallFile(File dir, String filename, String content)
+                       throws IOException {
+               if (!dir.exists()) {
+                       dir.mkdirs();
+               }
+
+               FileWriter writerVersion = new FileWriter(new File(dir, filename));
+               try {
+                       writerVersion.write(content);
+               } finally {
+                       writerVersion.close();
+               }
+       }
+
+       /**
+        * Read the whole {@link File} content into a {@link String}.
+        * 
+        * @param file
+        *            the {@link File}
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static String readSmallFile(File file) throws IOException {
+               BufferedReader reader = new BufferedReader(new FileReader(file));
+               try {
+                       StringBuilder builder = new StringBuilder();
+                       for (String line = reader.readLine(); line != null; line = reader
+                                       .readLine()) {
+                               builder.append(line);
+                       }
+                       return builder.toString();
+               } finally {
+                       reader.close();
+               }
+       }
+
+       /**
+        * Recursively delete the given {@link File}, which may of course also be a
+        * directory.
+        * <p>
+        * Will silently continue in case of error.
+        * 
+        * @param target
+        *            the target to delete
+        */
+       public static void deltree(File target) {
+               for (File file : target.listFiles()) {
+                       if (file.isDirectory()) {
+                               deltree(file);
+                       } else {
+                               if (!file.delete()) {
+                                       System.err.println("Cannot delete file: "
+                                                       + file.getAbsolutePath());
+                               }
+                       }
+               }
+
+               if (!target.delete()) {
+                       System.err.println("Cannot delete file: "
+                                       + target.getAbsolutePath());
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/MarkableFileInputStream.java b/src/be/nikiroo/utils/MarkableFileInputStream.java
new file mode 100644 (file)
index 0000000..f4d95d5
--- /dev/null
@@ -0,0 +1,51 @@
+package be.nikiroo.utils;
+
+import java.io.FileInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+
+/**
+ * This is a markable (and thus reset-able) stream that you can create from a
+ * FileInputStream.
+ * 
+ * @author niki
+ */
+public class MarkableFileInputStream extends FilterInputStream {
+       private FileChannel channel;
+       private long mark = 0;
+
+       /**
+        * Create a new {@link MarkableFileInputStream} from this stream.
+        * 
+        * @param in
+        *            the original {@link FileInputStream} to wrap
+        */
+       public MarkableFileInputStream(FileInputStream in) {
+               super(in);
+               channel = in.getChannel();
+       }
+
+       @Override
+       public boolean markSupported() {
+               return true;
+       }
+
+       @Override
+       public synchronized void mark(int readlimit) {
+               try {
+                       mark = channel.position();
+               } catch (IOException ex) {
+                       ex.printStackTrace();
+                       mark = -1;
+               }
+       }
+
+       @Override
+       public synchronized void reset() throws IOException {
+               if (mark < 0) {
+                       throw new IOException("mark position not valid");
+               }
+               channel.position(mark);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/StringUtils.java b/src/be/nikiroo/utils/StringUtils.java
new file mode 100644 (file)
index 0000000..993f62b
--- /dev/null
@@ -0,0 +1,607 @@
+package be.nikiroo.utils;
+
+import java.awt.Image;
+import java.awt.geom.AffineTransform;
+import java.awt.image.AffineTransformOp;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.Normalizer;
+import java.text.Normalizer.Form;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Base64;
+import java.util.Date;
+import java.util.regex.Pattern;
+
+import javax.imageio.ImageIO;
+
+import org.unbescape.html.HtmlEscape;
+import org.unbescape.html.HtmlEscapeLevel;
+import org.unbescape.html.HtmlEscapeType;
+
+/**
+ * This class offer some utilities based around {@link String}s.
+ * 
+ * @author niki
+ */
+public class StringUtils {
+       /**
+        * This enum type will decide the alignment of a {@link String} when padding
+        * is applied or if there is enough horizontal space for it to be aligned.
+        */
+       public enum Alignment {
+               /** Aligned at left. */
+               Beginning,
+               /** Centered. */
+               Center,
+               /** Aligned at right. */
+               End
+       }
+
+       static private Pattern marks = Pattern
+                       .compile("[\\p{InCombiningDiacriticalMarks}\\p{IsLm}\\p{IsSk}]+");
+
+       /**
+        * Fix the size of the given {@link String} either with space-padding or by
+        * shortening it.
+        * 
+        * @param text
+        *            the {@link String} to fix
+        * @param width
+        *            the size of the resulting {@link String} or -1 for a noop
+        * 
+        * @return the resulting {@link String} of size <i>size</i>
+        */
+       static public String padString(String text, int width) {
+               return padString(text, width, true, Alignment.Beginning);
+       }
+
+       /**
+        * Fix the size of the given {@link String} either with space-padding or by
+        * optionally shortening it.
+        * 
+        * @param text
+        *            the {@link String} to fix
+        * @param width
+        *            the size of the resulting {@link String} if the text fits or
+        *            if cut is TRUE or -1 for a noop
+        * @param cut
+        *            cut the {@link String} shorter if needed
+        * @param align
+        *            align the {@link String} in this position if we have enough
+        *            space
+        * 
+        * @return the resulting {@link String} of size <i>size</i> minimum
+        */
+       static public String padString(String text, int width, boolean cut,
+                       Alignment align) {
+
+               if (width >= 0) {
+                       if (text == null)
+                               text = "";
+
+                       int diff = width - text.length();
+
+                       if (diff < 0) {
+                               if (cut)
+                                       text = text.substring(0, width);
+                       } else if (diff > 0) {
+                               if (diff < 2 && align != Alignment.End)
+                                       align = Alignment.Beginning;
+
+                               switch (align) {
+                               case Beginning:
+                                       text = text + new String(new char[diff]).replace('\0', ' ');
+                                       break;
+                               case End:
+                                       text = new String(new char[diff]).replace('\0', ' ') + text;
+                                       break;
+                               case Center:
+                               default:
+                                       int pad1 = (diff) / 2;
+                                       int pad2 = (diff + 1) / 2;
+                                       text = new String(new char[pad1]).replace('\0', ' ') + text
+                                                       + new String(new char[pad2]).replace('\0', ' ');
+                                       break;
+                               }
+                       }
+               }
+
+               return text;
+       }
+
+       /**
+        * Sanitise the given input to make it more Terminal-friendly by removing
+        * combining characters.
+        * 
+        * @param input
+        *            the input to sanitise
+        * @param allowUnicode
+        *            allow Unicode or only allow ASCII Latin characters
+        * 
+        * @return the sanitised {@link String}
+        */
+       static public String sanitize(String input, boolean allowUnicode) {
+               return sanitize(input, allowUnicode, !allowUnicode);
+       }
+
+       /**
+        * Sanitise the given input to make it more Terminal-friendly by removing
+        * combining characters.
+        * 
+        * @param input
+        *            the input to sanitise
+        * @param allowUnicode
+        *            allow Unicode or only allow ASCII Latin characters
+        * @param removeAllAccents
+        *            TRUE to replace all accentuated characters by their non
+        *            accentuated counter-parts
+        * 
+        * @return the sanitised {@link String}
+        */
+       static public String sanitize(String input, boolean allowUnicode,
+                       boolean removeAllAccents) {
+
+               if (removeAllAccents) {
+                       input = Normalizer.normalize(input, Form.NFKD);
+                       input = marks.matcher(input).replaceAll("");
+               }
+
+               input = Normalizer.normalize(input, Form.NFKC);
+
+               if (!allowUnicode) {
+                       StringBuilder builder = new StringBuilder();
+                       for (int index = 0; index < input.length(); index++) {
+                               char car = input.charAt(index);
+                               // displayable chars in ASCII are in the range 32<->255,
+                               // except DEL (127)
+                               if (car >= 32 && car <= 255 && car != 127) {
+                                       builder.append(car);
+                               }
+                       }
+                       input = builder.toString();
+               }
+
+               return input;
+       }
+
+       /**
+        * Convert between time in milliseconds to {@link String} in a "static" way
+        * (to exchange data over the wire, for instance).
+        * 
+        * @param time
+        *            the time in milliseconds
+        * 
+        * @return the time as a {@link String}
+        */
+       static public String fromTime(long time) {
+               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+               return sdf.format(new Date(time));
+       }
+
+       /**
+        * Convert between time as a {@link String} to milliseconds in a "static"
+        * way (to exchange data over the wire, for instance).
+        * 
+        * @param time
+        *            the time as a {@link String}
+        * 
+        * @return the time in milliseconds
+        */
+       static public long toTime(String display) {
+               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+               try {
+                       return sdf.parse(display).getTime();
+               } catch (ParseException e) {
+                       return -1;
+               }
+       }
+
+       /**
+        * Convert the given {@link Image} object into a Base64 representation of
+        * the same {@link Image}. object.
+        * 
+        * @param image
+        *            the {@link Image} object to convert
+        * 
+        * @return the Base64 representation
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public String fromImage(BufferedImage image) throws IOException {
+               String imageString = null;
+               ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+               ImageIO.write(image, "jpeg", out);
+               byte[] imageBytes = out.toByteArray();
+
+               imageString = new String(Base64.getEncoder().encode(imageBytes));
+
+               out.close();
+
+               return imageString;
+       }
+
+       /**
+        * Convert the given {@link File} image into a Base64 representation of the
+        * same {@link File}.
+        * 
+        * @param file
+        *            the {@link File} image to convert
+        * 
+        * @return the Base64 representation
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public String fromStream(InputStream in) throws IOException {
+               String fileString = null;
+               ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+               byte[] buf = new byte[8192];
+
+               int c = 0;
+               while ((c = in.read(buf, 0, buf.length)) > 0) {
+                       out.write(buf, 0, c);
+               }
+               out.flush();
+               in.close();
+
+               fileString = new String(Base64.getEncoder().encode(out.toByteArray()));
+               out.close();
+
+               return fileString;
+       }
+
+       /**
+        * Convert the given Base64 representation of an image into an {@link Image}
+        * object.
+        * 
+        * @param b64data
+        *            the {@link Image} in Base64 format
+        * 
+        * @return the {@link Image} object
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public BufferedImage toImage(String b64data) throws IOException {
+               ByteArrayInputStream in = new ByteArrayInputStream(Base64.getDecoder()
+                               .decode(b64data));
+               return toImage(in);
+       }
+
+       /**
+        * Convert the given {@link InputStream} (which must allow calls to
+        * {@link InputStream#reset()}) into an {@link Image} object.
+        * 
+        * @param in
+        *            the 'resetable' {@link InputStream}
+        * 
+        * @return the {@link Image} object
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public BufferedImage toImage(InputStream in) throws IOException {
+               int orientation;
+               try {
+                       orientation = getExifTransorm(in);
+               } catch (Exception e) {
+                       // no EXIF transform, ok
+                       orientation = -1;
+               }
+
+               in.reset();
+               BufferedImage image = ImageIO.read(in);
+
+               if (image == null) {
+                       throw new IOException("Failed to convert input to image");
+               }
+
+               // Note: this code has been found on internet;
+               // thank you anonymous coder.
+               int width = image.getWidth();
+               int height = image.getHeight();
+               AffineTransform affineTransform = new AffineTransform();
+
+               switch (orientation) {
+               case 1:
+                       break;
+               case 2: // Flip X
+                       affineTransform.scale(-1.0, 1.0);
+                       affineTransform.translate(-width, 0);
+                       break;
+               case 3: // PI rotation
+                       affineTransform.translate(width, height);
+                       affineTransform.rotate(Math.PI);
+                       break;
+               case 4: // Flip Y
+                       affineTransform.scale(1.0, -1.0);
+                       affineTransform.translate(0, -height);
+                       break;
+               case 5: // - PI/2 and Flip X
+                       affineTransform.rotate(-Math.PI / 2);
+                       affineTransform.scale(-1.0, 1.0);
+                       break;
+               case 6: // -PI/2 and -width
+                       affineTransform.translate(height, 0);
+                       affineTransform.rotate(Math.PI / 2);
+                       break;
+               case 7: // PI/2 and Flip
+                       affineTransform.scale(-1.0, 1.0);
+                       affineTransform.translate(-height, 0);
+                       affineTransform.translate(0, width);
+                       affineTransform.rotate(3 * Math.PI / 2);
+                       break;
+               case 8: // PI / 2
+                       affineTransform.translate(0, width);
+                       affineTransform.rotate(3 * Math.PI / 2);
+                       break;
+               default:
+                       affineTransform = null;
+                       break;
+               }
+
+               if (affineTransform != null) {
+                       AffineTransformOp affineTransformOp = new AffineTransformOp(
+                                       affineTransform, AffineTransformOp.TYPE_BILINEAR);
+
+                       BufferedImage transformedImage = new BufferedImage(height, width,
+                                       image.getType());
+                       transformedImage = affineTransformOp
+                                       .filter(image, transformedImage);
+
+                       image = transformedImage;
+               }
+               //
+
+               return image;
+       }
+
+       /**
+        * Return a hash of the given {@link String}.
+        * 
+        * @param input
+        *            the input data
+        * 
+        * @return the hash
+        */
+       static public String getHash(String input) {
+               try {
+                       MessageDigest md = MessageDigest.getInstance("MD5");
+                       md.update(input.getBytes());
+                       byte byteData[] = md.digest();
+
+                       StringBuffer hexString = new StringBuffer();
+                       for (int i = 0; i < byteData.length; i++) {
+                               String hex = Integer.toHexString(0xff & byteData[i]);
+                               if (hex.length() == 1)
+                                       hexString.append('0');
+                               hexString.append(hex);
+                       }
+
+                       return hexString.toString();
+               } catch (NoSuchAlgorithmException e) {
+                       return input;
+               }
+       }
+
+       /**
+        * Return the EXIF transformation flag of this image if any.
+        * 
+        * <p>
+        * Note: this code has been found on internet; thank you anonymous coder.
+        * </p>
+        * 
+        * @param in
+        *            the data {@link InputStream}
+        * 
+        * @return the transformation flag if any
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static private int getExifTransorm(InputStream in) throws IOException {
+               int[] exif_data = new int[100];
+               int set_flag = 0;
+               int is_motorola = 0;
+
+               /* Read File head, check for JPEG SOI + Exif APP1 */
+               for (int i = 0; i < 4; i++)
+                       exif_data[i] = in.read();
+
+               if (exif_data[0] != 0xFF || exif_data[1] != 0xD8
+                               || exif_data[2] != 0xFF || exif_data[3] != 0xE1)
+                       return -2;
+
+               /* Get the marker parameter length count */
+               int length = (in.read() << 8 | in.read());
+
+               /* Length includes itself, so must be at least 2 */
+               /* Following Exif data length must be at least 6 */
+               if (length < 8)
+                       return -1;
+               length -= 8;
+               /* Read Exif head, check for "Exif" */
+               for (int i = 0; i < 6; i++)
+                       exif_data[i] = in.read();
+
+               if (exif_data[0] != 0x45 || exif_data[1] != 0x78
+                               || exif_data[2] != 0x69 || exif_data[3] != 0x66
+                               || exif_data[4] != 0 || exif_data[5] != 0)
+                       return -1;
+
+               /* Read Exif body */
+               length = length > exif_data.length ? exif_data.length : length;
+               for (int i = 0; i < length; i++)
+                       exif_data[i] = in.read();
+
+               if (length < 12)
+                       return -1; /* Length of an IFD entry */
+
+               /* Discover byte order */
+               if (exif_data[0] == 0x49 && exif_data[1] == 0x49)
+                       is_motorola = 0;
+               else if (exif_data[0] == 0x4D && exif_data[1] == 0x4D)
+                       is_motorola = 1;
+               else
+                       return -1;
+
+               /* Check Tag Mark */
+               if (is_motorola == 1) {
+                       if (exif_data[2] != 0)
+                               return -1;
+                       if (exif_data[3] != 0x2A)
+                               return -1;
+               } else {
+                       if (exif_data[3] != 0)
+                               return -1;
+                       if (exif_data[2] != 0x2A)
+                               return -1;
+               }
+
+               /* Get first IFD offset (offset to IFD0) */
+               int offset;
+               if (is_motorola == 1) {
+                       if (exif_data[4] != 0)
+                               return -1;
+                       if (exif_data[5] != 0)
+                               return -1;
+                       offset = exif_data[6];
+                       offset <<= 8;
+                       offset += exif_data[7];
+               } else {
+                       if (exif_data[7] != 0)
+                               return -1;
+                       if (exif_data[6] != 0)
+                               return -1;
+                       offset = exif_data[5];
+                       offset <<= 8;
+                       offset += exif_data[4];
+               }
+               if (offset > length - 2)
+                       return -1; /* check end of data segment */
+
+               /* Get the number of directory entries contained in this IFD */
+               int number_of_tags;
+               if (is_motorola == 1) {
+                       number_of_tags = exif_data[offset];
+                       number_of_tags <<= 8;
+                       number_of_tags += exif_data[offset + 1];
+               } else {
+                       number_of_tags = exif_data[offset + 1];
+                       number_of_tags <<= 8;
+                       number_of_tags += exif_data[offset];
+               }
+               if (number_of_tags == 0)
+                       return -1;
+               offset += 2;
+
+               /* Search for Orientation Tag in IFD0 */
+               for (;;) {
+                       if (offset > length - 12)
+                               return -1; /* check end of data segment */
+                       /* Get Tag number */
+                       int tagnum;
+                       if (is_motorola == 1) {
+                               tagnum = exif_data[offset];
+                               tagnum <<= 8;
+                               tagnum += exif_data[offset + 1];
+                       } else {
+                               tagnum = exif_data[offset + 1];
+                               tagnum <<= 8;
+                               tagnum += exif_data[offset];
+                       }
+                       if (tagnum == 0x0112)
+                               break; /* found Orientation Tag */
+                       if (--number_of_tags == 0)
+                               return -1;
+                       offset += 12;
+               }
+
+               /* Get the Orientation value */
+               if (is_motorola == 1) {
+                       if (exif_data[offset + 8] != 0)
+                               return -1;
+                       set_flag = exif_data[offset + 9];
+               } else {
+                       if (exif_data[offset + 9] != 0)
+                               return -1;
+                       set_flag = exif_data[offset + 8];
+               }
+               if (set_flag > 8)
+                       return -1;
+
+               return set_flag;
+       }
+
+       /**
+        * Remove the HTML content from the given input, and un-html-ize the rest.
+        * 
+        * @param html
+        *            the HTML-encoded content
+        * 
+        * @return the HTML-free equivalent content
+        */
+       public static String unhtml(String html) {
+               StringBuilder builder = new StringBuilder();
+
+               int inTag = 0;
+               for (char car : html.toCharArray()) {
+                       if (car == '<') {
+                               inTag++;
+                       } else if (car == '>') {
+                               inTag--;
+                       } else if (inTag <= 0) {
+                               builder.append(car);
+                       }
+               }
+
+               return HtmlEscape.unescapeHtml(builder.toString());
+       }
+
+       /**
+        * Escape the given {@link String} so it can be used in XML, as content.
+        * 
+        * @param input
+        *            the input {@link String}
+        * 
+        * @return the escaped {@link String}
+        */
+       public static String xmlEscape(String input) {
+               if (input == null) {
+                       return "";
+               }
+
+               return HtmlEscape.escapeHtml(input,
+                               HtmlEscapeType.HTML4_NAMED_REFERENCES_DEFAULT_TO_HEXA,
+                               HtmlEscapeLevel.LEVEL_1_ONLY_MARKUP_SIGNIFICANT);
+       }
+
+       /**
+        * Escape the given {@link String} so it can be used in XML, as text content
+        * inside double-quotes.
+        * 
+        * @param input
+        *            the input {@link String}
+        * 
+        * @return the escaped {@link String}
+        */
+       public static String xmlEscapeQuote(String input) {
+               if (input == null) {
+                       return "";
+               }
+
+               return HtmlEscape.escapeHtml(input,
+                               HtmlEscapeType.HTML4_NAMED_REFERENCES_DEFAULT_TO_HEXA,
+                               HtmlEscapeLevel.LEVEL_1_ONLY_MARKUP_SIGNIFICANT);
+       }
+}
diff --git a/src/be/nikiroo/utils/package-info.java b/src/be/nikiroo/utils/package-info.java
new file mode 100644 (file)
index 0000000..4951378
--- /dev/null
@@ -0,0 +1,6 @@
+/**
+ * Some small utilities used through the pogram.
+ * 
+ * @author niki
+ */
+package be.nikiroo.utils;
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/resources/Bundle.java b/src/be/nikiroo/utils/resources/Bundle.java
new file mode 100644 (file)
index 0000000..1c63d69
--- /dev/null
@@ -0,0 +1,480 @@
+package be.nikiroo.utils.resources;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.io.Writer;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.MissingResourceException;
+import java.util.PropertyResourceBundle;
+import java.util.ResourceBundle;
+
+import be.nikiroo.utils.resources.Bundles;
+import be.nikiroo.utils.resources.Meta;
+
+/**
+ * This class encapsulate a {@link ResourceBundle} in UTF-8. It only allows to
+ * retrieve values associated to an enumeration, and allows some additional
+ * methods.
+ * 
+ * @author niki
+ * 
+ * @param <E>
+ *            the enum to use to get values out of this class
+ */
+public class Bundle<E extends Enum<E>> {
+       protected Class<E> type;
+       protected Enum<?> name;
+       private ResourceBundle map;
+
+       /**
+        * Create a new {@link Bundles} of the given name.
+        * 
+        * @param type
+        *            a runtime instance of the class of E
+        * 
+        * @param name
+        *            the name of the {@link Bundles}
+        */
+       protected Bundle(Class<E> type, Enum<?> name) {
+               this.type = type;
+               this.name = name;
+               setBundle(name, Locale.getDefault());
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String}.
+        * 
+        * @param mame
+        *            the id of the value to get
+        * 
+        * @return the associated value, or NULL if not found (not present in the
+        *         resource file)
+        */
+       public String getString(E id) {
+               return getStringX(id, "");
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String} suffixed
+        * with the runtime value "_suffix" (that is, "_" and suffix).
+        * 
+        * @param mame
+        *            the id of the value to get
+        * @param suffix
+        *            the runtime suffix
+        * 
+        * @return the associated value, or NULL if not found (not present in the
+        *         resource file)
+        */
+       public String getStringX(E id, String suffix) {
+               String key = id.name()
+                               + ((suffix == null || suffix.isEmpty()) ? "" : "_"
+                                               + suffix.toUpperCase());
+
+               if (containsKey(key)) {
+                       return getString(key).trim();
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Boolean}.
+        * 
+        * @param mame
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public Boolean getBoolean(E id) {
+               String str = getString(id);
+               if (str != null && str.length() > 0) {
+                       if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("on")
+                                       || str.equalsIgnoreCase("yes"))
+                               return true;
+                       if (str.equalsIgnoreCase("false") || str.equalsIgnoreCase("off")
+                                       || str.equalsIgnoreCase("no"))
+                               return false;
+
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link boolean}.
+        * 
+        * @param mame
+        *            the id of the value to get
+        * @param def
+        *            the default value when it is not present in the config file or
+        *            if it is not a boolean value
+        * 
+        * @return the associated value
+        */
+       public boolean getBoolean(E id, boolean def) {
+               Boolean b = getBoolean(id);
+               if (b != null)
+                       return b;
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as an {@link Integer}.
+        * 
+        * @param mame
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public Integer getInteger(E id) {
+               try {
+                       return Integer.parseInt(getString(id));
+               } catch (Exception e) {
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link int}.
+        * 
+        * @param mame
+        *            the id of the value to get
+        * @param def
+        *            the default value when it is not present in the config file or
+        *            if it is not a int value
+        * 
+        * @return the associated value
+        */
+       public int getInteger(E id, int def) {
+               Integer i = getInteger(id);
+               if (i != null)
+                       return i;
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Character}.
+        * 
+        * @param mame
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public char getChar(E id) {
+               String s = getString(id).trim();
+               if (s.length() > 0) {
+                       return s.charAt(0);
+               }
+
+               return ' ';
+       }
+
+       /**
+        * Create/update the .properties file. Will use the most likely candidate as
+        * base if the file does not already exists and this resource is
+        * translatable (for instance, "en_US" will use "en" as a base if the
+        * resource is a translation file).
+        * 
+        * @param path
+        *            the path where the .properties files are
+        * 
+        * @throws IOException
+        *             in case of IO errors
+        */
+       public void updateFile(String path) throws IOException {
+               File file = getUpdateFile(path);
+
+               BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(file), "UTF-8"));
+
+               writeHeader(writer);
+               writer.write("\n");
+               writer.write("\n");
+
+               for (Field field : type.getDeclaredFields()) {
+                       Meta meta = field.getAnnotation(Meta.class);
+                       if (meta != null) {
+                               E id = E.valueOf(type, field.getName());
+                               String info = getMetaInfo(meta);
+
+                               if (info != null) {
+                                       writer.write(info);
+                                       writer.write("\n");
+                               }
+
+                               writeValue(writer, id);
+                       }
+               }
+
+               writer.close();
+       }
+
+       /**
+        * Check if the internal map contains the given key.
+        * 
+        * @param key
+        *            the key to check for
+        * 
+        * @return true if it does
+        */
+       protected boolean containsKey(String key) {
+               try {
+                       map.getObject(key);
+                       return true;
+               } catch (MissingResourceException e) {
+                       return false;
+               }
+       }
+
+       /**
+        * Get the value for the given key if it exists in the internal map.
+        * 
+        * @param key
+        *            the key to check for
+        * 
+        * @return true if it does
+        */
+       protected String getString(String key) {
+               if (containsKey(key)) {
+                       try {
+                               // Note: it is also possible to fix the borked charset issue
+                               // with a custom ResourceBundle#Control class, but this one,
+                               // while a workaround, depend less upon the JRE classes, which
+                               // may change
+                               return new String(map.getString(key).getBytes("ISO-8859-1"),
+                                               "UTF-8");
+                       } catch (UnsupportedEncodingException e) {
+                               // Those 2 encodings are always supported
+                               e.printStackTrace();
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return formated, display-able information from the {@link Meta} field
+        * given. Each line will always starts with a "#" character.
+        * 
+        * @param meta
+        *            the {@link Meta} field
+        * 
+        * @return the information to display or NULL if none
+        */
+       protected String getMetaInfo(Meta meta) {
+               String what = meta.what();
+               String where = meta.where();
+               String format = meta.format();
+               String info = meta.info();
+
+               int opt = what.length() + where.length() + format.length();
+               if (opt + info.length() == 0)
+                       return null;
+
+               StringBuilder builder = new StringBuilder();
+               builder.append("# ");
+
+               if (opt > 0) {
+                       builder.append("(");
+                       if (what.length() > 0) {
+                               builder.append("WHAT: " + what);
+                               if (where.length() + format.length() > 0)
+                                       builder.append(", ");
+                       }
+
+                       if (where.length() > 0) {
+                               builder.append("WHERE: " + where);
+                               if (format.length() > 0)
+                                       builder.append(", ");
+                       }
+
+                       if (format.length() > 0) {
+                               builder.append("FORMAT: " + format);
+                       }
+
+                       builder.append(")");
+                       if (info.length() > 0) {
+                               builder.append("\n# ");
+                       }
+               }
+
+               builder.append(info);
+
+               return builder.toString();
+       }
+
+       /**
+        * The display name used in the <tt>.properties file</tt>.
+        * 
+        * @return the name
+        */
+       protected String getBundleDisplayName() {
+               return name.toString();
+       }
+
+       /**
+        * Write the header found in the configuration <tt>.properties</tt> file of
+        * this {@link Bundles}.
+        * 
+        * @param writer
+        *            the {@link Writer} to write the header in
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       protected void writeHeader(Writer writer) throws IOException {
+               writer.write("# " + getBundleDisplayName() + "\n");
+               writer.write("#\n");
+       }
+
+       /**
+        * Write the given id to the config file, i.e., "MY_ID = my_curent_value"
+        * followed by a new line
+        * 
+        * @param writer
+        *            the {@link Writer} to write into
+        * @param id
+        *            the id to write
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       protected void writeValue(Writer writer, E id) throws IOException {
+               writeValue(writer, id.name(), getString(id));
+       }
+
+       /**
+        * Write the given data to the config file, i.e., "MY_ID = my_curent_value"
+        * followed by a new line
+        * 
+        * @param writer
+        *            the {@link Writer} to write into
+        * @param id
+        *            the id to write
+        * @param value
+        *            the id's value
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       protected void writeValue(Writer writer, String id, String value)
+                       throws IOException {
+               writer.write(id);
+               writer.write(" = ");
+
+               if (value == null) {
+                       value = "";
+               }
+
+               String[] lines = value.replaceAll("\\\t", "\\\\\\t").split("\n");
+               for (int i = 0; i < lines.length; i++) {
+                       writer.write(lines[i]);
+                       if (i < lines.length - 1) {
+                               writer.write("\\n\\");
+                       }
+                       writer.write("\n");
+               }
+       }
+
+       /**
+        * Return the source file for this {@link Bundles} from the given path.
+        * 
+        * @param path
+        *            the path where the .properties files are
+        * 
+        * @return the source {@link File}
+        * 
+        * @throws IOException
+        *             in case of IO errors
+        */
+       protected File getUpdateFile(String path) {
+               return new File(path, name.name() + ".properties");
+       }
+
+       /**
+        * Change the currently used bundle.
+        * 
+        * @param name
+        *            the name of the bundle to load
+        * @param locale
+        *            the {@link Locale} to use
+        */
+       protected void setBundle(Enum<?> name, Locale locale) {
+               map = null;
+               String dir = Bundles.getDirectory();
+
+               if (dir != null) {
+                       try {
+                               File file = getPropertyFile(dir, name.name(), locale);
+                               if (file != null) {
+                                       Reader reader = new InputStreamReader(new FileInputStream(
+                                                       file), "UTF8");
+                                       map = new PropertyResourceBundle(reader);
+                               }
+                       } catch (IOException e) {
+                               e.printStackTrace();
+                       }
+               }
+
+               if (map == null) {
+                       map = ResourceBundle.getBundle(type.getPackage().getName() + "."
+                                       + name.name(), locale);
+               }
+       }
+
+       /**
+        * Return the resource file that is closer to the {@link Locale}.
+        * 
+        * @param dir
+        *            the dirctory to look into
+        * @param name
+        *            the file basename (without <tt>.properties</tt>)
+        * @param locale
+        *            the {@link Locale}
+        * 
+        * @return the closest match or NULL if none
+        */
+       private File getPropertyFile(String dir, String name, Locale locale) {
+               List<String> locales = new ArrayList<String>();
+               if (locale != null) {
+                       String country = locale.getCountry() == null ? "" : locale
+                                       .getCountry();
+                       String language = locale.getLanguage() == null ? "" : locale
+                                       .getLanguage();
+                       if (!language.isEmpty() && !country.isEmpty()) {
+                               locales.add("_" + language + "-" + country);
+                       }
+                       if (!language.isEmpty()) {
+                               locales.add("_" + language);
+                       }
+               }
+
+               locales.add("");
+
+               File file = null;
+               for (String loc : locales) {
+                       file = new File(dir, name + loc + ".properties");
+                       if (file.exists()) {
+                               break;
+                       } else {
+                               file = null;
+                       }
+               }
+
+               return file;
+       }
+}
diff --git a/src/be/nikiroo/utils/resources/Bundles.java b/src/be/nikiroo/utils/resources/Bundles.java
new file mode 100644 (file)
index 0000000..ad7b99d
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.utils.resources;
+
+import java.util.ResourceBundle;
+
+/**
+ * This class help you get UTF-8 bundles for this application.
+ * 
+ * @author niki
+ */
+public class Bundles {
+       /**
+        * The configuration directory where we try to get the <tt>.properties</tt>
+        * in priority, or NULL to get the information from the compiled resources.
+        */
+       static private String confDir = null;
+
+       /**
+        * Set the primary configuration directory to look for <tt>.properties</tt>
+        * files in.
+        * 
+        * All {@link ResourceBundle}s returned by this class after that point will
+        * respect this new directory.
+        * 
+        * @param confDir
+        *            the new directory
+        */
+       static public void setDirectory(String confDir) {
+               Bundles.confDir = confDir;
+       }
+
+       /**
+        * Get the primary configuration directory to look for <tt>.properties</tt>
+        * files in.
+        * 
+        * @return the directory
+        */
+       static public String getDirectory() {
+               return Bundles.confDir;
+       }
+}
diff --git a/src/be/nikiroo/utils/resources/Meta.java b/src/be/nikiroo/utils/resources/Meta.java
new file mode 100644 (file)
index 0000000..3e14557
--- /dev/null
@@ -0,0 +1,46 @@
+package be.nikiroo.utils.resources;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation used to give some information about the translation keys, so the
+ * translation .properties file can be created programmatically.
+ * 
+ * @author niki
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Meta {
+       /**
+        * What kind of item this key represent (a Key, a Label text, a format to
+        * use for something else...).
+        * 
+        * @return what it is
+        */
+       String what();
+
+       /**
+        * Where in the application will this key appear (in the action keys, in a
+        * menu, in a message...).
+        * 
+        * @return where it is
+        */
+       String where();
+
+       /**
+        * What format should/must this key be in.
+        * 
+        * @return the format it is in
+        */
+       String format();
+
+       /**
+        * Free info text to help translate.
+        * 
+        * @return some info
+        */
+       String info();
+}
diff --git a/src/be/nikiroo/utils/resources/TransBundle.java b/src/be/nikiroo/utils/resources/TransBundle.java
new file mode 100644 (file)
index 0000000..61bc922
--- /dev/null
@@ -0,0 +1,327 @@
+package be.nikiroo.utils.resources;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import be.nikiroo.utils.resources.Bundles;
+
+/**
+ * This class manages a translation-dedicated Bundle.
+ * <p>
+ * Two special cases are handled for the used enum:
+ * <ul>
+ * <li>NULL will always will return an empty {@link String}</li>
+ * <li>DUMMY will return "[DUMMY]" (maybe with a suffix and/or "NOUTF")</li>
+ * </ul>
+ * 
+ * @author niki
+ */
+public class TransBundle<E extends Enum<E>> extends Bundle<E> {
+       private boolean utf = true;
+       private Locale locale;
+       private boolean defaultLocale = false;
+
+       /**
+        * Create a translation service with the default language.
+        * 
+        * @param type
+        *            a runtime instance of the class of E
+        * @param name
+        *            the name of the {@link Bundles}
+        */
+       public TransBundle(Class<E> type, Enum<?> name) {
+               super(type, name);
+               setLanguage(null);
+       }
+
+       /**
+        * Create a translation service for the given language (will fall back to
+        * the default one i not found).
+        * 
+        * @param type
+        *            a runtime instance of the class of E
+        * @param name
+        *            the name of the {@link Bundles}
+        * @param language
+        *            the language to use
+        */
+       public TransBundle(Class<E> type, Enum<?> name, String language) {
+               super(type, name);
+               setLanguage(language);
+       }
+
+       /**
+        * Translate the given id into user text.
+        * 
+        * @param stringId
+        *            the ID to translate
+        * @param values
+        *            the values to insert instead of the place holders in the
+        *            translation
+        * 
+        * @return the translated text with the given value where required or NULL
+        *         if not found (not present in the resource file)
+        */
+       public String getString(E stringId, Object... values) {
+               return getStringX(stringId, "", values);
+       }
+
+       /**
+        * Translate the given id into user text.
+        * 
+        * @param stringId
+        *            the ID to translate
+        * @param values
+        *            the values to insert instead of the place holders in the
+        *            translation
+        * 
+        * @return the translated text with the given value where required or NULL
+        *         if not found (not present in the resource file)
+        */
+       public String getStringNOUTF(E stringId, Object... values) {
+               return getStringX(stringId, "NOUTF", values);
+       }
+
+       /**
+        * Translate the given id suffixed with the runtime value "_suffix" (that
+        * is, "_" and suffix) into user text.
+        * 
+        * @param stringId
+        *            the ID to translate
+        * @param values
+        *            the values to insert instead of the place holders in the
+        *            translation
+        * @param suffix
+        *            the runtime suffix
+        * 
+        * @return the translated text with the given value where required or NULL
+        *         if not found (not present in the resource file)
+        */
+       public String getStringX(E stringId, String suffix, Object... values) {
+               E id = stringId;
+               String result = "";
+
+               String key = id.name()
+                               + ((suffix == null || suffix.isEmpty()) ? "" : "_"
+                                               + suffix.toUpperCase());
+
+               if (!isUnicode()) {
+                       if (containsKey(key + "_NOUTF")) {
+                               key += "_NOUTF";
+                       }
+               }
+
+               if ("NULL".equals(id.name().toUpperCase())) {
+                       result = "";
+               } else if ("DUMMY".equals(id.name().toUpperCase())) {
+                       result = "[" + key.toLowerCase() + "]";
+               } else if (containsKey(key)) {
+                       result = getString(key);
+               } else {
+                       result = null;
+               }
+
+               if (values != null && values.length > 0 && result != null)
+                       return String.format(locale, result, values);
+               else
+                       return result;
+       }
+
+       /**
+        * Check if unicode characters should be used.
+        * 
+        * @return TRUE to allow unicode
+        */
+       public boolean isUnicode() {
+               return utf;
+       }
+
+       /**
+        * Allow or disallow unicode characters in the program.
+        * 
+        * @param utf
+        *            TRUE to allow unuciode, FALSE to only allow ASCII characters
+        */
+       public void setUnicode(boolean utf) {
+               this.utf = utf;
+       }
+
+       /**
+        * Return all the languages known by the program.
+        * 
+        * 
+        * @return the known language codes
+        */
+       public List<String> getKnownLanguages() {
+               return getKnownLanguages(name);
+       }
+
+       /**
+        * Initialise the translation mappings for the given language.
+        * 
+        * @param language
+        *            the language to initialise, in the form "en-GB" or "fr" for
+        *            instance
+        */
+       private void setLanguage(String language) {
+               defaultLocale = (language == null || language.length() == 0);
+               locale = getLocaleFor(language);
+               setBundle(name, locale);
+       }
+
+       @Override
+       public String getString(E id) {
+               return getString(id, (Object[]) null);
+       }
+
+       /**
+        * Create/update the .properties files for each supported language and for
+        * the default language.
+        * <p>
+        * Note: this method is <b>NOT</b> thread-safe.
+        * 
+        * @param path
+        *            the path where the .properties files are
+        * 
+        * @throws IOException
+        *             in case of IO errors
+        */
+       @Override
+       public void updateFile(String path) throws IOException {
+               String prev = locale.getLanguage();
+
+               setLanguage(null); // default locale
+               super.updateFile(path);
+
+               for (String lang : getKnownLanguages()) {
+                       setLanguage(lang);
+                       super.updateFile(path);
+               }
+
+               setLanguage(prev);
+       }
+
+       @Override
+       protected File getUpdateFile(String path) {
+               String code = locale.toString();
+               File file = null;
+               if (!defaultLocale && code.length() > 0) {
+                       file = new File(path, name.name() + "_" + code + ".properties");
+               } else {
+                       // Default properties file:
+                       file = new File(path, name.name() + ".properties");
+               }
+
+               return file;
+       }
+
+       @Override
+       protected void writeHeader(Writer writer) throws IOException {
+               String code = locale.toString();
+               String name = locale.getDisplayCountry(locale);
+
+               if (name.length() == 0) {
+                       name = locale.getDisplayLanguage(locale);
+               }
+
+               if (name.length() == 0) {
+                       name = "default";
+               }
+
+               if (code.length() > 0) {
+                       name = name + " (" + code + ")";
+               }
+
+               name = (name + " " + getBundleDisplayName()).trim();
+
+               writer.write("# " + name + " translation file (UTF-8)\n");
+               writer.write("# \n");
+               writer.write("# Note that any key can be doubled with a _NOUTF suffix\n");
+               writer.write("# to use when the NOUTF env variable is set to 1\n");
+               writer.write("# \n");
+               writer.write("# Also, the comments always refer to the key below them.\n");
+               writer.write("# \n");
+       }
+
+       @Override
+       protected void writeValue(Writer writer, E id) throws IOException {
+               super.writeValue(writer, id);
+
+               String name = id.name() + "_NOUTF";
+               if (containsKey(name)) {
+                       String value = getString(name);
+                       writeValue(writer, name, value);
+               }
+       }
+
+       /**
+        * Return the {@link Locale} representing the given language.
+        * 
+        * @param language
+        *            the language to initialise, in the form "en-GB" or "fr" for
+        *            instance
+        * 
+        * @return the corresponding {@link Locale} or the default {@link Locale} if
+        *         it is not known
+        */
+       static private Locale getLocaleFor(String language) {
+               Locale locale;
+
+               if (language == null) {
+                       locale = Locale.getDefault();
+               } else {
+                       language = language.replaceAll("_", "-");
+                       String lang = language;
+                       String country = null;
+                       if (language.contains("-")) {
+                               lang = language.split("-")[0];
+                               country = language.split("-")[1];
+                       }
+
+                       if (country != null)
+                               locale = new Locale(lang, country);
+                       else
+                               locale = new Locale(lang);
+               }
+
+               return locale;
+       }
+
+       /**
+        * Return all the languages known by the program.
+        * 
+        * @param name
+        *            the enumeration on which we translate
+        * 
+        * @return the known language codes
+        */
+       static protected List<String> getKnownLanguages(Enum<?> name) {
+               List<String> resources = new LinkedList<String>();
+
+               String regex = ".*" + name.name() + "[_a-zA-Za]*\\.properties$";
+
+               for (String res : TransBundle_ResourceList.getResources(Pattern
+                               .compile(regex))) {
+                       String resource = res;
+                       int index = resource.lastIndexOf('/');
+                       if (index >= 0 && index < (resource.length() - 1))
+                               resource = resource.substring(index + 1);
+                       if (resource.startsWith(name.name())) {
+                               resource = resource.substring(0, resource.length()
+                                               - ".properties".length());
+                               resource = resource.substring(name.name().length());
+                               if (resource.startsWith("_")) {
+                                       resource = resource.substring(1);
+                                       resources.add(resource);
+                               }
+                       }
+               }
+
+               return resources;
+       }
+}
diff --git a/src/be/nikiroo/utils/resources/TransBundle_ResourceList.java b/src/be/nikiroo/utils/resources/TransBundle_ResourceList.java
new file mode 100644 (file)
index 0000000..519b33a
--- /dev/null
@@ -0,0 +1,107 @@
+package be.nikiroo.utils.resources;
+
+// code copied from from:
+//             http://forums.devx.com/showthread.php?t=153784,
+// via:
+//             http://stackoverflow.com/questions/3923129/get-a-list-of-resources-from-classpath-directory
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+
+/**
+ * list resources available from the classpath @ *
+ */
+class TransBundle_ResourceList {
+
+       /**
+        * for all elements of java.class.path get a Collection of resources Pattern
+        * pattern = Pattern.compile(".*"); gets all resources
+        * 
+        * @param pattern
+        *            the pattern to match
+        * @return the resources in the order they are found
+        */
+       public static Collection<String> getResources(final Pattern pattern) {
+               final ArrayList<String> retval = new ArrayList<String>();
+               final String classPath = System.getProperty("java.class.path", ".");
+               final String[] classPathElements = classPath.split(System
+                               .getProperty("path.separator"));
+               for (final String element : classPathElements) {
+                       retval.addAll(getResources(element, pattern));
+               }
+
+               return retval;
+       }
+
+       private static Collection<String> getResources(final String element,
+                       final Pattern pattern) {
+               final ArrayList<String> retval = new ArrayList<String>();
+               final File file = new File(element);
+               if (file.isDirectory()) {
+                       retval.addAll(getResourcesFromDirectory(file, pattern));
+               } else {
+                       retval.addAll(getResourcesFromJarFile(file, pattern));
+               }
+
+               return retval;
+       }
+
+       private static Collection<String> getResourcesFromJarFile(final File file,
+                       final Pattern pattern) {
+               final ArrayList<String> retval = new ArrayList<String>();
+               ZipFile zf;
+               try {
+                       zf = new ZipFile(file);
+               } catch (final ZipException e) {
+                       throw new Error(e);
+               } catch (final IOException e) {
+                       throw new Error(e);
+               }
+               final Enumeration<? extends ZipEntry> e = zf.entries();
+               while (e.hasMoreElements()) {
+                       final ZipEntry ze = (ZipEntry) e.nextElement();
+                       final String fileName = ze.getName();
+                       final boolean accept = pattern.matcher(fileName).matches();
+                       if (accept) {
+                               retval.add(fileName);
+                       }
+               }
+               try {
+                       zf.close();
+               } catch (final IOException e1) {
+                       throw new Error(e1);
+               }
+
+               return retval;
+       }
+
+       private static Collection<String> getResourcesFromDirectory(
+                       final File directory, final Pattern pattern) {
+               final ArrayList<String> retval = new ArrayList<String>();
+               final File[] fileList = directory.listFiles();
+               for (final File file : fileList) {
+                       if (file.isDirectory()) {
+                               retval.addAll(getResourcesFromDirectory(file, pattern));
+                       } else {
+                               try {
+                                       final String fileName = file.getCanonicalPath();
+                                       final boolean accept = pattern.matcher(fileName).matches();
+                                       if (accept) {
+                                               retval.add(fileName);
+                                       }
+                               } catch (final IOException e) {
+                                       throw new Error(e);
+                               }
+                       }
+               }
+
+               return retval;
+       }
+}
diff --git a/src/be/nikiroo/utils/resources/package-info.java b/src/be/nikiroo/utils/resources/package-info.java
new file mode 100644 (file)
index 0000000..783cd03
--- /dev/null
@@ -0,0 +1,14 @@
+/**
+ * This package encloses the classes needed to use 
+ * {@link be.nikiroo.utils.resources.bundles.Bundle}s
+ * <p>
+ * Those are basically a <tt>.properties</tt> resource linked to an enumeration
+ * listing all the fields you can use. The classes can also be used to update
+ * the linked <tt>.properties</tt> files (or export them, which is useful when
+ * you work from a JAR file).
+ * <p>
+ * All those classes expect UTF-8 content only.
+ * 
+ * @author niki
+ */
+package be.nikiroo.utils.resources;
\ No newline at end of file