Add 'src/be/nikiroo/utils/' from commit '46add0670fdee4bd936a13fe2448c5e20a7ffd0a'
authorNiki Roo <niki@nikiroo.be>
Fri, 20 Sep 2019 12:04:24 +0000 (14:04 +0200)
committerNiki Roo <niki@nikiroo.be>
Fri, 20 Sep 2019 12:04:24 +0000 (14:04 +0200)
git-subtree-dir: src/be/nikiroo/utils
git-subtree-mainline: ad207feb2815e429ae32484bc6930990099f8ea4
git-subtree-split: 46add0670fdee4bd936a13fe2448c5e20a7ffd0a

90 files changed:
src/be/nikiroo/utils/Cache.java [new file with mode: 0644]
src/be/nikiroo/utils/CacheMemory.java [new file with mode: 0644]
src/be/nikiroo/utils/CryptUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/Downloader.java [new file with mode: 0644]
src/be/nikiroo/utils/IOUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/Image.java [new file with mode: 0644]
src/be/nikiroo/utils/ImageUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/MarkableFileInputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/Progress.java [new file with mode: 0644]
src/be/nikiroo/utils/Proxy.java [new file with mode: 0644]
src/be/nikiroo/utils/StringJustifier.java [new file with mode: 0644]
src/be/nikiroo/utils/StringUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/TempFiles.java [new file with mode: 0644]
src/be/nikiroo/utils/TraceHandler.java [new file with mode: 0644]
src/be/nikiroo/utils/Version.java [new file with mode: 0644]
src/be/nikiroo/utils/android/ImageUtilsAndroid.java [new file with mode: 0644]
src/be/nikiroo/utils/android/test/TestAndroid.java [new file with mode: 0644]
src/be/nikiroo/utils/main/bridge.java [new file with mode: 0644]
src/be/nikiroo/utils/main/img2aa.java [new file with mode: 0644]
src/be/nikiroo/utils/main/justify.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Bundle.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/BundleHelper.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Bundles.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/FixedResourceBundleControl.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Meta.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/MetaInfo.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]
src/be/nikiroo/utils/serial/CustomSerializer.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/Exporter.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/Importer.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/SerialUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectAction.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectActionClient.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectActionClientString.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectActionServer.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ConnectActionServerString.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/Server.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ServerBridge.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ServerObject.java [new file with mode: 0644]
src/be/nikiroo/utils/serial/server/ServerString.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/Base64.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/Base64InputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/Base64OutputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/BufferedInputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/BufferedOutputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/MarkableFileInputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/NextableInputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/NextableInputStreamStep.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/ReplaceInputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/ReplaceOutputStream.java [new file with mode: 0644]
src/be/nikiroo/utils/streams/StreamUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/test/TestCase.java [new file with mode: 0644]
src/be/nikiroo/utils/test/TestLauncher.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/BundleTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/CryptUtilsTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/IOUtilsTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/NextableInputStreamTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/ProgressTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/SerialServerTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/SerialTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/StringUtilsTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/TempFilesTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/Test.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/VersionTest.java [new file with mode: 0644]
src/be/nikiroo/utils/test_code/bundle_test.properties [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigEditor.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItem.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemBoolean.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemBrowse.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemColor.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemCombobox.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemInteger.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemLocale.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemPassword.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ConfigItemString.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ImageTextAwt.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ImageUtilsAwt.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ProgressBar.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/UIUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/WrapLayout.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/test/TestUI.java [new file with mode: 0644]

diff --git a/src/be/nikiroo/utils/Cache.java b/src/be/nikiroo/utils/Cache.java
new file mode 100644 (file)
index 0000000..6233082
--- /dev/null
@@ -0,0 +1,457 @@
+package be.nikiroo.utils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Date;
+
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * A generic cache system, with special support for {@link URL}s.
+ * <p>
+ * This cache also manages timeout information.
+ * 
+ * @author niki
+ */
+public class Cache {
+       private File dir;
+       private long tooOldChanging;
+       private long tooOldStable;
+       private TraceHandler tracer = new TraceHandler();
+
+       /**
+        * Only for inheritance.
+        */
+       protected Cache() {
+       }
+
+       /**
+        * Create a new {@link Cache} object.
+        * 
+        * @param dir
+        *            the directory to use as cache
+        * @param hoursChanging
+        *            the number of hours after which a cached file that is thought
+        *            to change ~often is considered too old (or -1 for
+        *            "never too old")
+        * @param hoursStable
+        *            the number of hours after which a cached file that is thought
+        *            to change rarely is considered too old (or -1 for
+        *            "never too old")
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Cache(File dir, int hoursChanging, int hoursStable)
+                       throws IOException {
+               this.dir = dir;
+               this.tooOldChanging = 1000L * 60 * 60 * hoursChanging;
+               this.tooOldStable = 1000L * 60 * 60 * hoursStable;
+
+               if (dir != null && !dir.exists()) {
+                       dir.mkdirs();
+               }
+
+               if (dir == null || !dir.exists()) {
+                       throw new IOException("Cannot create the cache directory: "
+                                       + (dir == null ? "null" : dir.getAbsolutePath()));
+               }
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @return the traces handler
+        */
+       public TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               this.tracer = tracer;
+       }
+
+       /**
+        * Check the resource to see if it is in the cache.
+        * 
+        * @param uniqueID
+        *            the resource to check
+        * @param allowTooOld
+        *            allow files even if they are considered too old
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return TRUE if it is
+        * 
+        */
+       public boolean check(String uniqueID, boolean allowTooOld, boolean stable) {
+               return check(getCached(uniqueID), allowTooOld, stable);
+       }
+
+       /**
+        * Check the resource to see if it is in the cache.
+        * 
+        * @param url
+        *            the resource to check
+        * @param allowTooOld
+        *            allow files even if they are considered too old
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return TRUE if it is
+        * 
+        */
+       public boolean check(URL url, boolean allowTooOld, boolean stable) {
+               return check(getCached(url), allowTooOld, stable);
+       }
+
+       /**
+        * Check the resource to see if it is in the cache.
+        * 
+        * @param cached
+        *            the resource to check
+        * @param allowTooOld
+        *            allow files even if they are considered too old
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return TRUE if it is
+        * 
+        */
+       private boolean check(File cached, boolean allowTooOld, boolean stable) {
+               if (cached.exists() && cached.isFile()) {
+                       if (!allowTooOld && isOld(cached, stable)) {
+                               if (!cached.delete()) {
+                                       tracer.error("Cannot delete temporary file: "
+                                                       + cached.getAbsolutePath());
+                               }
+                       } else {
+                               return true;
+                       }
+               }
+
+               return false;
+       }
+
+       /**
+        * Clean the cache (delete the cached items).
+        * 
+        * @param onlyOld
+        *            only clean the files that are considered too old for a stable
+        *            resource
+        * 
+        * @return the number of cleaned items
+        */
+       public int clean(boolean onlyOld) {
+               long ms = System.currentTimeMillis();
+
+               tracer.trace("Cleaning cache from old files...");
+
+               int num = clean(onlyOld, dir, -1);
+
+               tracer.trace(num + "cache items cleaned in "
+                               + (System.currentTimeMillis() - ms) + " ms");
+
+               return num;
+       }
+
+       /**
+        * Clean the cache (delete the cached items) in the given cache directory.
+        * 
+        * @param onlyOld
+        *            only clean the files that are considered too old for stable
+        *            resources
+        * @param cacheDir
+        *            the cache directory to clean
+        * @param limit
+        *            stop after limit files deleted, or -1 for unlimited
+        * 
+        * @return the number of cleaned items
+        */
+       private int clean(boolean onlyOld, File cacheDir, int limit) {
+               int num = 0;
+               File[] files = cacheDir.listFiles();
+               if (files != null) {
+                       for (File file : files) {
+                               if (limit >= 0 && num >= limit) {
+                                       return num;
+                               }
+
+                               if (file.isDirectory()) {
+                                       num += clean(onlyOld, file, limit);
+                                       file.delete(); // only if empty
+                               } else {
+                                       if (!onlyOld || isOld(file, true)) {
+                                               if (file.delete()) {
+                                                       num++;
+                                               } else {
+                                                       tracer.error("Cannot delete temporary file: "
+                                                                       + file.getAbsolutePath());
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return num;
+       }
+
+       /**
+        * Open a resource from the cache if it exists.
+        * 
+        * @param uniqueID
+        *            the unique ID
+        * @param allowTooOld
+        *            allow files even if they are considered too old
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return the opened resource if found, NULL if not
+        */
+       public InputStream load(String uniqueID, boolean allowTooOld, boolean stable) {
+               return load(getCached(uniqueID), allowTooOld, stable);
+       }
+
+       /**
+        * Open a resource from the cache if it exists.
+        * 
+        * @param url
+        *            the resource to open
+        * @param allowTooOld
+        *            allow files even if they are considered too old
+        * @param stable
+        *            a stable file (that doesn't change too often) -- parameter
+        *            used to check if the file is too old to keep or not in the
+        *            cache
+        * 
+        * @return the opened resource if found, NULL if not
+        */
+       public InputStream load(URL url, boolean allowTooOld, boolean stable) {
+               return load(getCached(url), allowTooOld, stable);
+       }
+
+       /**
+        * Open a resource from the cache if it exists.
+        * 
+        * @param cached
+        *            the resource to open
+        * @param allowTooOld
+        *            allow files even if they are considered too old
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return the opened resource if found, NULL if not
+        */
+       private InputStream load(File cached, boolean allowTooOld, boolean stable) {
+               if (cached.exists() && cached.isFile()
+                               && (allowTooOld || !isOld(cached, stable))) {
+                       try {
+                               return new MarkableFileInputStream(cached);
+                       } catch (FileNotFoundException e) {
+                               return null;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Save the given resource to the cache.
+        * 
+        * @param in
+        *            the input data
+        * @param uniqueID
+        *            a unique ID used to locate the cached resource
+        * 
+        * @return the number of bytes written
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public long save(InputStream in, String uniqueID) throws IOException {
+               File cached = getCached(uniqueID);
+               cached.getParentFile().mkdirs();
+               return save(in, cached);
+       }
+
+       /**
+        * Save the given resource to the cache.
+        * 
+        * @param in
+        *            the input data
+        * @param url
+        *            the {@link URL} used to locate the cached resource
+        * 
+        * @return the number of bytes written
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public long save(InputStream in, URL url) throws IOException {
+               File cached = getCached(url);
+               return save(in, cached);
+       }
+
+       /**
+        * Save the given resource to the cache.
+        * <p>
+        * Will also clean the {@link Cache} from old files.
+        * 
+        * @param in
+        *            the input data
+        * @param cached
+        *            the cached {@link File} to save to
+        * 
+        * @return the number of bytes written
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private long save(InputStream in, File cached) throws IOException {
+               // We want to force at least an immediate SAVE/LOAD to work for some
+               // workflows, even if we don't accept cached files (times set to "0"
+               // -- and not "-1" or a positive value)
+               clean(true, dir, 10);
+               cached.getParentFile().mkdirs(); // in case we deleted our own parent
+               long bytes = IOUtils.write(in, cached);
+               return bytes;
+       }
+
+       /**
+        * Remove the given resource from the cache.
+        * 
+        * @param uniqueID
+        *            a unique ID used to locate the cached resource
+        * 
+        * @return TRUE if it was removed
+        */
+       public boolean remove(String uniqueID) {
+               File cached = getCached(uniqueID);
+               return cached.delete();
+       }
+
+       /**
+        * Remove the given resource from the cache.
+        * 
+        * @param url
+        *            the {@link URL} used to locate the cached resource
+        * 
+        * @return TRUE if it was removed
+        */
+       public boolean remove(URL url) {
+               File cached = getCached(url);
+               return cached.delete();
+       }
+
+       /**
+        * Check if the {@link File} is too old according to
+        * {@link Cache#tooOldChanging}.
+        * 
+        * @param file
+        *            the file to check
+        * @param stable
+        *            TRUE to denote stable files, that are not supposed to change
+        *            too often
+        * 
+        * @return TRUE if it is
+        */
+       private boolean isOld(File file, boolean stable) {
+               long max = tooOldChanging;
+               if (stable) {
+                       max = tooOldStable;
+               }
+
+               if (max < 0) {
+                       return false;
+               }
+
+               long time = new Date().getTime() - file.lastModified();
+               if (time < 0) {
+                       tracer.error("Timestamp in the future for file: "
+                                       + file.getAbsolutePath());
+               }
+
+               return time < 0 || time > max;
+       }
+
+       /**
+        * Return the associated cache {@link File} from this {@link URL}.
+        * 
+        * @param url
+        *            the {@link URL}
+        * 
+        * @return the cached {@link File} version of this {@link URL}
+        */
+       private File getCached(URL url) {
+               File subdir;
+
+               String name = url.getHost();
+               if (name == null || name.isEmpty()) {
+                       // File
+                       File file = new File(url.getFile());
+                       if (file.getParent() == null) {
+                               subdir = new File("+");
+                       } else {
+                               subdir = new File(file.getParent().replace("..", "__"));
+                       }
+                       subdir = new File(dir, allowedChars(subdir.getPath()));
+                       name = allowedChars(url.getFile());
+               } else {
+                       // URL
+                       File subsubDir = new File(dir, allowedChars(url.getHost()));
+                       subdir = new File(subsubDir, "_" + allowedChars(url.getPath()));
+                       name = allowedChars("_" + url.getQuery());
+               }
+
+               File cacheFile = new File(subdir, name);
+               subdir.mkdirs();
+
+               return cacheFile;
+       }
+
+       /**
+        * Get the basic cache resource file corresponding to this unique ID.
+        * <p>
+        * Note that you may need to add a sub-directory in some cases.
+        * 
+        * @param uniqueID
+        *            the id
+        * 
+        * @return the cached version if present, NULL if not
+        */
+       private File getCached(String uniqueID) {
+               File file = new File(dir, allowedChars(uniqueID));
+               File subdir = new File(file.getParentFile(), "_");
+               return new File(subdir, file.getName());
+       }
+
+       /**
+        * Replace not allowed chars (in a {@link File}) by "_".
+        * 
+        * @param raw
+        *            the raw {@link String}
+        * 
+        * @return the sanitised {@link String}
+        */
+       private String allowedChars(String raw) {
+               return raw.replace('/', '_').replace(':', '_').replace("\\", "_");
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/CacheMemory.java b/src/be/nikiroo/utils/CacheMemory.java
new file mode 100644 (file)
index 0000000..232b632
--- /dev/null
@@ -0,0 +1,109 @@
+package be.nikiroo.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A memory only version of {@link Cache}.
+ * 
+ * @author niki
+ */
+public class CacheMemory extends Cache {
+       private Map<String, byte[]> data = new HashMap<String, byte[]>();
+
+       /**
+        * Create a new {@link CacheMemory}.
+        */
+       public CacheMemory() {
+       }
+
+       @Override
+       public boolean check(String uniqueID, boolean allowTooOld, boolean stable) {
+               return data.containsKey(getKey(uniqueID));
+       }
+
+       @Override
+       public boolean check(URL url, boolean allowTooOld, boolean stable) {
+               return data.containsKey(getKey(url));
+       }
+
+       @Override
+       public int clean(boolean onlyOld) {
+               int cleaned = 0;
+               if (!onlyOld) {
+                       cleaned = data.size();
+                       data.clear();
+               }
+
+               return cleaned;
+       }
+
+       @Override
+       public InputStream load(String uniqueID, boolean allowTooOld, boolean stable) {
+               if (check(uniqueID, allowTooOld, stable)) {
+                       return load(uniqueID, allowTooOld, stable);
+               }
+
+               return null;
+       }
+
+       @Override
+       public InputStream load(URL url, boolean allowTooOld, boolean stable) {
+               if (check(url, allowTooOld, stable)) {
+                       return load(url, allowTooOld, stable);
+               }
+
+               return null;
+       }
+
+       @Override
+       public boolean remove(String uniqueID) {
+               return data.remove(getKey(uniqueID)) != null;
+       }
+
+       @Override
+       public boolean remove(URL url) {
+               return data.remove(getKey(url)) != null;
+       }
+
+       @Override
+       public long save(InputStream in, String uniqueID) throws IOException {
+               byte[] bytes = IOUtils.toByteArray(in);
+               data.put(getKey(uniqueID), bytes);
+               return bytes.length;
+       }
+
+       @Override
+       public long save(InputStream in, URL url) throws IOException {
+               byte[] bytes = IOUtils.toByteArray(in);
+               data.put(getKey(url), bytes);
+               return bytes.length;
+       }
+
+       /**
+        * Return a key mapping to the given unique ID.
+        * 
+        * @param uniqueID
+        *            the unique ID
+        * 
+        * @return the key
+        */
+       private String getKey(String uniqueID) {
+               return "_/" + uniqueID;
+       }
+
+       /**
+        * Return a key mapping to the given urm.
+        * 
+        * @param url
+        *            thr url
+        * 
+        * @return the key
+        */
+       private String getKey(URL url) {
+               return url.toString();
+       }
+}
diff --git a/src/be/nikiroo/utils/CryptUtils.java b/src/be/nikiroo/utils/CryptUtils.java
new file mode 100644 (file)
index 0000000..638f82f
--- /dev/null
@@ -0,0 +1,441 @@
+package be.nikiroo.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.CipherInputStream;
+import javax.crypto.CipherOutputStream;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.utils.streams.Base64InputStream;
+import be.nikiroo.utils.streams.Base64OutputStream;
+
+/**
+ * Small utility class to do AES encryption/decryption.
+ * <p>
+ * It is multi-thread compatible, but beware:
+ * <ul>
+ * <li>The encrypt/decrypt calls are serialized</li>
+ * <li>The streams are independent (and thus parallel)</li>
+ * </ul>
+ * <p>
+ * Do not assume it is secure; it just here to offer a more-or-less protected
+ * exchange of data because anonymous and self-signed certificates backed SSL is
+ * against Google wishes, and I need Android support.
+ * 
+ * @author niki
+ */
+public class CryptUtils {
+       static private final String AES_NAME = "AES/CFB128/NoPadding";
+
+       private Cipher ecipher;
+       private Cipher dcipher;
+       private byte[] bytes32;
+
+       /**
+        * Small and lazy-easy way to initialize a 128 bits key with
+        * {@link CryptUtils}.
+        * <p>
+        * <b>Some</b> part of the key will be used to generate a 128 bits key and
+        * initialize the {@link CryptUtils}; even NULL will generate something.
+        * <p>
+        * <b>This is most probably not secure. Do not use if you actually care
+        * about security.</b>
+        * 
+        * @param key
+        *            the {@link String} to use as a base for the key, can be NULL
+        */
+       public CryptUtils(String key) {
+               try {
+                       init(key2key(key));
+               } catch (InvalidKeyException e) {
+                       // We made sure that the key is correct, so nothing here
+                       e.printStackTrace();
+               }
+       }
+
+       /**
+        * Create a new instance of {@link CryptUtils} with the given 128 bits key.
+        * <p>
+        * The key <b>must</b> be exactly 128 bits long.
+        * 
+        * @param bytes32
+        *            the 128 bits (32 bytes) of the key
+        * 
+        * @throws InvalidKeyException
+        *             if the key is not an array of 128 bits
+        */
+       public CryptUtils(byte[] bytes32) throws InvalidKeyException {
+               init(bytes32);
+       }
+
+       /**
+        * Wrap the given {@link InputStream} so it is transparently encrypted by
+        * the current {@link CryptUtils}.
+        * 
+        * @param in
+        *            the {@link InputStream} to wrap
+        * @return the auto-encode {@link InputStream}
+        */
+       public InputStream encrypt(InputStream in) {
+               Cipher ecipher = newCipher(Cipher.ENCRYPT_MODE);
+               return new CipherInputStream(in, ecipher);
+       }
+
+       /**
+        * Wrap the given {@link InputStream} so it is transparently encrypted by
+        * the current {@link CryptUtils} and encoded in base64.
+        * 
+        * @param in
+        *            the {@link InputStream} to wrap
+        * 
+        * @return the auto-encode {@link InputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream encrypt64(InputStream in) throws IOException {
+               return new Base64InputStream(encrypt(in), true);
+       }
+
+       /**
+        * Wrap the given {@link OutputStream} so it is transparently encrypted by
+        * the current {@link CryptUtils}.
+        * 
+        * @param out
+        *            the {@link OutputStream} to wrap
+        * 
+        * @return the auto-encode {@link OutputStream}
+        */
+       public OutputStream encrypt(OutputStream out) {
+               Cipher ecipher = newCipher(Cipher.ENCRYPT_MODE);
+               return new CipherOutputStream(out, ecipher);
+       }
+
+       /**
+        * Wrap the given {@link OutputStream} so it is transparently encrypted by
+        * the current {@link CryptUtils} and encoded in base64.
+        * 
+        * @param out
+        *            the {@link OutputStream} to wrap
+        * 
+        * @return the auto-encode {@link OutputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public OutputStream encrypt64(OutputStream out) throws IOException {
+               return encrypt(new Base64OutputStream(out, true));
+       }
+
+       /**
+        * Wrap the given {@link OutputStream} so it is transparently decoded by the
+        * current {@link CryptUtils}.
+        * 
+        * @param in
+        *            the {@link InputStream} to wrap
+        * 
+        * @return the auto-decode {@link InputStream}
+        */
+       public InputStream decrypt(InputStream in) {
+               Cipher dcipher = newCipher(Cipher.DECRYPT_MODE);
+               return new CipherInputStream(in, dcipher);
+       }
+
+       /**
+        * Wrap the given {@link OutputStream} so it is transparently decoded by the
+        * current {@link CryptUtils} and decoded from base64.
+        * 
+        * @param in
+        *            the {@link InputStream} to wrap
+        * 
+        * @return the auto-decode {@link InputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream decrypt64(InputStream in) throws IOException {
+               return decrypt(new Base64InputStream(in, false));
+       }
+
+       /**
+        * Wrap the given {@link OutputStream} so it is transparently decoded by the
+        * current {@link CryptUtils}.
+        * 
+        * @param out
+        *            the {@link OutputStream} to wrap
+        * @return the auto-decode {@link OutputStream}
+        */
+       public OutputStream decrypt(OutputStream out) {
+               Cipher dcipher = newCipher(Cipher.DECRYPT_MODE);
+               return new CipherOutputStream(out, dcipher);
+       }
+
+       /**
+        * Wrap the given {@link OutputStream} so it is transparently decoded by the
+        * current {@link CryptUtils} and decoded from base64.
+        * 
+        * @param out
+        *            the {@link OutputStream} to wrap
+        * 
+        * @return the auto-decode {@link OutputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public OutputStream decrypt64(OutputStream out) throws IOException {
+               return new Base64OutputStream(decrypt(out), false);
+       }
+
+       /**
+        * This method required an array of 128 bits.
+        * 
+        * @param bytes32
+        *            the array, which <b>must</b> be of 128 bits (32 bytes)
+        * 
+        * @throws InvalidKeyException
+        *             if the key is not an array of 128 bits (32 bytes)
+        */
+       private void init(byte[] bytes32) throws InvalidKeyException {
+               if (bytes32 == null || bytes32.length != 32) {
+                       throw new InvalidKeyException(
+                                       "The size of the key must be of 128 bits (32 bytes), it is: "
+                                                       + (bytes32 == null ? "null" : "" + bytes32.length)
+                                                       + " bytes");
+               }
+
+               this.bytes32 = bytes32;
+               this.ecipher = newCipher(Cipher.ENCRYPT_MODE);
+               this.dcipher = newCipher(Cipher.DECRYPT_MODE);
+       }
+
+       /**
+        * Create a new {@link Cipher}of the given mode (see
+        * {@link Cipher#ENCRYPT_MODE} and {@link Cipher#ENCRYPT_MODE}).
+        * 
+        * @param mode
+        *            the mode ({@link Cipher#ENCRYPT_MODE} or
+        *            {@link Cipher#ENCRYPT_MODE})
+        * 
+        * @return the new {@link Cipher}
+        */
+       private Cipher newCipher(int mode) {
+               try {
+                       // bytes32 = 32 bytes, 32 > 16
+                       byte[] iv = new byte[16];
+                       for (int i = 0; i < iv.length; i++) {
+                               iv[i] = bytes32[i];
+                       }
+                       IvParameterSpec ivspec = new IvParameterSpec(iv);
+                       Cipher cipher = Cipher.getInstance(AES_NAME);
+                       cipher.init(mode, new SecretKeySpec(bytes32, "AES"), ivspec);
+                       return cipher;
+               } catch (Exception e) {
+                       e.printStackTrace();
+                       throw new RuntimeException(
+                                       "Cannot initialize encryption sub-system", e);
+               }
+       }
+
+       /**
+        * Encrypt the data.
+        * 
+        * @param data
+        *            the data to encrypt
+        * 
+        * @return the encrypted data
+        * 
+        * @throws SSLException
+        *             in case of I/O error (i.e., the data is not what you assumed
+        *             it was)
+        */
+       public byte[] encrypt(byte[] data) throws SSLException {
+               synchronized (ecipher) {
+                       try {
+                               return ecipher.doFinal(data);
+                       } catch (IllegalBlockSizeException e) {
+                               throw new SSLException(e);
+                       } catch (BadPaddingException e) {
+                               throw new SSLException(e);
+                       }
+               }
+       }
+
+       /**
+        * Encrypt the data.
+        * 
+        * @param data
+        *            the data to encrypt
+        * 
+        * @return the encrypted data
+        * 
+        * @throws SSLException
+        *             in case of I/O error (i.e., the data is not what you assumed
+        *             it was)
+        */
+       public byte[] encrypt(String data) throws SSLException {
+               return encrypt(StringUtils.getBytes(data));
+       }
+
+       /**
+        * Encrypt the data, then encode it into Base64.
+        * 
+        * @param data
+        *            the data to encrypt
+        * @param zip
+        *            TRUE to also compress the data in GZIP format; remember that
+        *            compressed and not-compressed content are different; you need
+        *            to know which is which when decoding
+        * 
+        * @return the encrypted data, encoded in Base64
+        * 
+        * @throws SSLException
+        *             in case of I/O error (i.e., the data is not what you assumed
+        *             it was)
+        */
+       public String encrypt64(String data) throws SSLException {
+               return encrypt64(StringUtils.getBytes(data));
+       }
+
+       /**
+        * Encrypt the data, then encode it into Base64.
+        * 
+        * @param data
+        *            the data to encrypt
+        * 
+        * @return the encrypted data, encoded in Base64
+        * 
+        * @throws SSLException
+        *             in case of I/O error (i.e., the data is not what you assumed
+        *             it was)
+        */
+       public String encrypt64(byte[] data) throws SSLException {
+               try {
+                       return StringUtils.base64(encrypt(data));
+               } catch (IOException e) {
+                       // not exactly true, but we consider here that this error is a crypt
+                       // error, not a normal I/O error
+                       throw new SSLException(e);
+               }
+       }
+
+       /**
+        * Decode the data which is assumed to be encrypted with the same utilities.
+        * 
+        * @param data
+        *            the encrypted data to decode
+        * 
+        * @return the original, decoded data
+        * 
+        * @throws SSLException
+        *             in case of I/O error
+        */
+       public byte[] decrypt(byte[] data) throws SSLException {
+               synchronized (dcipher) {
+                       try {
+                               return dcipher.doFinal(data);
+                       } catch (IllegalBlockSizeException e) {
+                               throw new SSLException(e);
+                       } catch (BadPaddingException e) {
+                               throw new SSLException(e);
+                       }
+               }
+       }
+
+       /**
+        * Decode the data which is assumed to be encrypted with the same utilities
+        * and to be a {@link String}.
+        * 
+        * @param data
+        *            the encrypted data to decode
+        * 
+        * @return the original, decoded data,as a {@link String}
+        * 
+        * @throws SSLException
+        *             in case of I/O error
+        */
+       public String decrypts(byte[] data) throws SSLException {
+               try {
+                       return new String(decrypt(data), "UTF-8");
+               } catch (UnsupportedEncodingException e) {
+                       // UTF-8 is required in all conform JVMs
+                       e.printStackTrace();
+                       return null;
+               }
+       }
+
+       /**
+        * Decode the data which is assumed to be encrypted with the same utilities
+        * and is a Base64 encoded value.
+        * 
+        * @param data
+        *            the encrypted data to decode in Base64 format
+        * @param zip
+        *            TRUE to also uncompress the data from a GZIP format
+        *            automatically; if set to FALSE, zipped data can be returned
+        * 
+        * @return the original, decoded data
+        * 
+        * @throws SSLException
+        *             in case of I/O error
+        */
+       public byte[] decrypt64(String data) throws SSLException {
+               try {
+                       return decrypt(StringUtils.unbase64(data));
+               } catch (IOException e) {
+                       // not exactly true, but we consider here that this error is a crypt
+                       // error, not a normal I/O error
+                       throw new SSLException(e);
+               }
+       }
+
+       /**
+        * Decode the data which is assumed to be encrypted with the same utilities
+        * and is a Base64 encoded value, then convert it into a String (this method
+        * assumes the data <b>was</b> indeed a UTF-8 encoded {@link String}).
+        * 
+        * @param data
+        *            the encrypted data to decode in Base64 format
+        * @param zip
+        *            TRUE to also uncompress the data from a GZIP format
+        *            automatically; if set to FALSE, zipped data can be returned
+        * 
+        * @return the original, decoded data
+        * 
+        * @throws SSLException
+        *             in case of I/O error
+        */
+       public String decrypt64s(String data) throws SSLException {
+               try {
+                       return new String(decrypt(StringUtils.unbase64(data)), "UTF-8");
+               } catch (UnsupportedEncodingException e) {
+                       // UTF-8 is required in all conform JVMs
+                       e.printStackTrace();
+                       return null;
+               } catch (IOException e) {
+                       // not exactly true, but we consider here that this error is a crypt
+                       // error, not a normal I/O error
+                       throw new SSLException(e);
+               }
+       }
+
+       /**
+        * This is probably <b>NOT</b> secure!
+        * 
+        * @param input
+        *            some {@link String} input
+        * 
+        * @return a 128 bits key computed from the given input
+        */
+       static private byte[] key2key(String input) {
+               return StringUtils.getMd5Hash("" + input).getBytes();
+       }
+}
diff --git a/src/be/nikiroo/utils/Downloader.java b/src/be/nikiroo/utils/Downloader.java
new file mode 100644 (file)
index 0000000..0487933
--- /dev/null
@@ -0,0 +1,478 @@
+package be.nikiroo.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.net.CookieStore;
+import java.net.HttpCookie;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLEncoder;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * This class will help you download content from Internet Sites ({@link URL}
+ * based).
+ * <p>
+ * It allows you to control some options often required on web sites that do not
+ * want to simply serve HTML, but actively makes your life difficult with stupid
+ * checks.
+ * 
+ * @author niki
+ */
+public class Downloader {
+       private String UA;
+       private CookieManager cookies;
+       private TraceHandler tracer = new TraceHandler();
+       private Cache cache;
+       private boolean offline;
+
+       /**
+        * Create a new {@link Downloader}.
+        * 
+        * @param UA
+        *            the User-Agent to use to download the resources -- note that
+        *            some websites require one, some actively blacklist real UAs
+        *            like the one from wget, some whitelist a couple of browsers
+        *            only (!)
+        */
+       public Downloader(String UA) {
+               this(UA, null);
+       }
+
+       /**
+        * Create a new {@link Downloader}.
+        * 
+        * @param UA
+        *            the User-Agent to use to download the resources -- note that
+        *            some websites require one, some actively blacklist real UAs
+        *            like the one from wget, some whitelist a couple of browsers
+        *            only (!)
+        * @param cache
+        *            the {@link Cache} to use for all access (can be NULL)
+        */
+       public Downloader(String UA, Cache cache) {
+               this.UA = UA;
+
+               cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
+               CookieHandler.setDefault(cookies);
+
+               setCache(cache);
+       }
+       
+       /**
+        * This {@link Downloader} is forbidden to try and connect to the network.
+        * <p>
+        * If TRUE, it will only check the cache if any.
+        * <p>
+        * Default is FALSE.
+        * 
+        * @return TRUE if offline
+        */
+       public boolean isOffline() {
+               return offline;
+       }
+       
+       /**
+        * This {@link Downloader} is forbidden to try and connect to the network.
+        * <p>
+        * If TRUE, it will only check the cache if any.
+        * <p>
+        * Default is FALSE.
+        * 
+        * @param offline TRUE for offline, FALSE for online
+        */
+       public void setOffline(boolean offline) {
+               this.offline = offline;
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @return the traces handler
+        */
+       public TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               this.tracer = tracer;
+       }
+
+       /**
+        * The {@link Cache} to use for all access (can be NULL).
+        * 
+        * @return the cache
+        */
+       public Cache getCache() {
+               return cache;
+       }
+
+       /**
+        * The {@link Cache} to use for all access (can be NULL).
+        * 
+        * @param cache
+        *            the new cache
+        */
+       public void setCache(Cache cache) {
+               this.cache = cache;
+       }
+
+       /**
+        * Clear all the cookies currently in the jar.
+        * <p>
+        * As long as you don't, the cookies are kept.
+        */
+       public void clearCookies() {
+               cookies.getCookieStore().removeAll();
+       }
+
+       /**
+        * Open the given {@link URL} and update the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        **/
+       public InputStream open(URL url) throws IOException {
+               return open(url, false);
+       }
+
+       /**
+        * Open the given {@link URL} and update the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @param stable
+        *            stable a stable file (that doesn't change too often) --
+        *            parameter used to check if the file is too old to keep or not
+        *            in the cache (default is false)
+        * 
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        **/
+       public InputStream open(URL url, boolean stable) throws IOException {
+               return open(url, url, url, null, null, null, null, stable);
+       }
+
+       /**
+        * Open the given {@link URL} and update the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @param currentReferer
+        *            the current referer, for websites that needs this info
+        * @param cookiesValues
+        *            the cookies
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorization (aka, "bearer XXXXXXX")
+        * 
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error (including offline mode + not in cache)
+        */
+       public InputStream open(URL url, URL currentReferer,
+                       Map<String, String> cookiesValues, Map<String, String> postParams,
+                       Map<String, String> getParams, String oauth) throws IOException {
+               return open(url, currentReferer, cookiesValues, postParams, getParams,
+                               oauth, false);
+       }
+
+       /**
+        * Open the given {@link URL} and update the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @param currentReferer
+        *            the current referer, for websites that needs this info
+        * @param cookiesValues
+        *            the cookies
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorization (aka, "bearer XXXXXXX")
+        * @param stable
+        *            stable a stable file (that doesn't change too often) --
+        *            parameter used to check if the file is too old to keep or not
+        *            in the cache (default is false)
+        * 
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error (including offline mode + not in cache)
+        */
+       public InputStream open(URL url, URL currentReferer,
+                       Map<String, String> cookiesValues, Map<String, String> postParams,
+                       Map<String, String> getParams, String oauth, boolean stable)
+                       throws IOException {
+               return open(url, url, currentReferer, cookiesValues, postParams,
+                               getParams, oauth, stable);
+       }
+
+       /**
+        * Open the given {@link URL} and update the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @param originalUrl
+        *            the original {@link URL} before any redirection occurs, which
+        *            is also used for the cache ID if needed (so we can retrieve
+        *            the content with this URL if needed)
+        * @param currentReferer
+        *            the current referer, for websites that needs this info
+        * @param cookiesValues
+        *            the cookies
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorisation (aka, "bearer XXXXXXX")
+        * @param stable
+        *            a stable file (that doesn't change too often) -- parameter
+        *            used to check if the file is too old to keep or not in the
+        *            cache
+        * 
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error (including offline mode + not in cache)
+        */
+       public InputStream open(URL url, final URL originalUrl, URL currentReferer,
+                       Map<String, String> cookiesValues, Map<String, String> postParams,
+                       Map<String, String> getParams, String oauth, boolean stable)
+                       throws IOException {
+
+               tracer.trace("Request: " + url);
+
+               if (cache != null) {
+                       InputStream in = cache.load(originalUrl, false, stable);
+                       if (in != null) {
+                               tracer.trace("Use the cache: " + url);
+                               tracer.trace("Original URL : " + originalUrl);
+                               return in;
+                       }
+               }
+
+               String protocol = originalUrl == null ? null : originalUrl
+                               .getProtocol();
+               if (isOffline() && !"file".equalsIgnoreCase(protocol)) {
+                       tracer.error("Downloader OFFLINE, cannot proceed to URL: " + url);
+                       throw new IOException("Downloader is currently OFFLINE, cannot download: " + url);
+               }
+               
+               tracer.trace("Download: " + url);
+
+               URLConnection conn = openConnectionWithCookies(url, currentReferer,
+                               cookiesValues);
+
+               // Priority: GET over POST
+               Map<String, String> params = getParams;
+               if (getParams == null) {
+                       params = postParams;
+               }
+
+               StringBuilder requestData = null;
+               if ((params != null || oauth != null)
+                               && conn instanceof HttpURLConnection) {
+                       if (params != null) {
+                               requestData = new StringBuilder();
+                               for (Map.Entry<String, String> param : params.entrySet()) {
+                                       if (requestData.length() != 0)
+                                               requestData.append('&');
+                                       requestData.append(URLEncoder.encode(param.getKey(),
+                                                       "UTF-8"));
+                                       requestData.append('=');
+                                       requestData.append(URLEncoder.encode(
+                                                       String.valueOf(param.getValue()), "UTF-8"));
+                               }
+
+                               if (getParams == null && postParams != null) {
+                                       ((HttpURLConnection) conn).setRequestMethod("POST");
+                               }
+
+                               conn.setRequestProperty("Content-Type",
+                                               "application/x-www-form-urlencoded");
+                               conn.setRequestProperty("Content-Length",
+                                               Integer.toString(requestData.length()));
+                       }
+
+                       if (oauth != null) {
+                               conn.setRequestProperty("Authorization", oauth);
+                       }
+
+                       if (requestData != null) {
+                               conn.setDoOutput(true);
+                               OutputStreamWriter writer = new OutputStreamWriter(
+                                               conn.getOutputStream());
+                               try {
+                                       writer.write(requestData.toString());
+                                       writer.flush();
+                               } finally {
+                                       writer.close();
+                               }
+                       }
+               }
+
+               // Manual redirection, much better for POST data
+               if (conn instanceof HttpURLConnection) {
+                       ((HttpURLConnection) conn).setInstanceFollowRedirects(false);
+               }
+
+               conn.connect();
+
+               // Check if redirect
+               // BEWARE! POST data cannot be redirected (some webservers complain) for
+               // HTTP codes 302 and 303
+               if (conn instanceof HttpURLConnection) {
+                       int repCode = 0;
+                       try {
+                               // Can fail in some circumstances
+                               repCode = ((HttpURLConnection) conn).getResponseCode();
+                       } catch (IOException e) {
+                       }
+
+                       if (repCode / 100 == 3) {
+                               String newUrl = conn.getHeaderField("Location");
+                               return open(new URL(newUrl), originalUrl, currentReferer,
+                                               cookiesValues, //
+                                               (repCode == 302 || repCode == 303) ? null : postParams, //
+                                               getParams, oauth, stable);
+                       }
+               }
+
+               try {
+                       InputStream in = conn.getInputStream();
+                       if ("gzip".equals(conn.getContentEncoding())) {
+                               in = new GZIPInputStream(in);
+                       }
+
+                       if (in == null) {
+                               throw new IOException("No InputStream!");
+                       }
+
+                       if (cache != null) {
+                               String size = conn.getContentLength() < 0 ? "unknown size"
+                                               : StringUtils.formatNumber(conn.getContentLength())
+                                                               + "bytes";
+                               tracer.trace("Save to cache (" + size + "): " + originalUrl);
+                               try {
+                                       try {
+                                               long bytes = cache.save(in, originalUrl);
+                                               tracer.trace("Saved to cache: "
+                                                               + StringUtils.formatNumber(bytes) + "bytes");
+                                       } finally {
+                                               in.close();
+                                       }
+                                       in = cache.load(originalUrl, true, true);
+                               } catch (IOException e) {
+                                       tracer.error(new IOException(
+                                                       "Cannot save URL to cache, will ignore cache: "
+                                                                       + url, e));
+                               }
+                       }
+
+                       if (in == null) {
+                               throw new IOException(
+                                               "Cannot retrieve the file after storing it in the cache (??)");
+                       }
+                       
+                       return in;
+               } catch (IOException e) {
+                       throw new IOException(String.format(
+                                       "Cannot find %s (current URL: %s)", originalUrl, url), e);
+               }
+       }
+
+       /**
+        * Open a connection on the given {@link URL}, and manage the cookies that
+        * come with it.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * 
+        * @return the connection
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private URLConnection openConnectionWithCookies(URL url,
+                       URL currentReferer, Map<String, String> cookiesValues)
+                       throws IOException {
+               URLConnection conn = url.openConnection();
+
+               String cookies = generateCookies(cookiesValues);
+               if (cookies != null && !cookies.isEmpty()) {
+                       conn.setRequestProperty("Cookie", cookies);
+               }
+
+               conn.setRequestProperty("User-Agent", UA);
+               conn.setRequestProperty("Accept-Encoding", "gzip");
+               conn.setRequestProperty("Accept", "*/*");
+               conn.setRequestProperty("Charset", "utf-8");
+
+               if (currentReferer != null) {
+                       conn.setRequestProperty("Referer", currentReferer.toString());
+                       conn.setRequestProperty("Host", currentReferer.getHost());
+               }
+
+               return conn;
+       }
+
+       /**
+        * Generate the cookie {@link String} from the local {@link CookieStore} so
+        * it is ready to be passed.
+        * 
+        * @return the cookie
+        */
+       private String generateCookies(Map<String, String> cookiesValues) {
+               StringBuilder builder = new StringBuilder();
+               for (HttpCookie cookie : cookies.getCookieStore().getCookies()) {
+                       if (builder.length() > 0) {
+                               builder.append(';');
+                       }
+
+                       builder.append(cookie.toString());
+               }
+
+               if (cookiesValues != null) {
+                       for (Map.Entry<String, String> set : cookiesValues.entrySet()) {
+                               if (builder.length() > 0) {
+                                       builder.append(';');
+                               }
+                               builder.append(set.getKey());
+                               builder.append('=');
+                               builder.append(set.getValue());
+                       }
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/utils/IOUtils.java b/src/be/nikiroo/utils/IOUtils.java
new file mode 100644 (file)
index 0000000..e3837e1
--- /dev/null
@@ -0,0 +1,476 @@
+package be.nikiroo.utils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * This class offer some utilities based around Streams and Files.
+ * 
+ * @author niki
+ */
+public class IOUtils {
+       /**
+        * Write the data to the given {@link File}.
+        * 
+        * @param in
+        *            the data source
+        * @param target
+        *            the target {@link File}
+        * 
+        * @return the number of bytes written
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static long write(InputStream in, File target) throws IOException {
+               OutputStream out = new FileOutputStream(target);
+               try {
+                       return write(in, out);
+               } finally {
+                       out.close();
+               }
+       }
+
+       /**
+        * Write the data to the given {@link OutputStream}.
+        * 
+        * @param in
+        *            the data source
+        * @param out
+        *            the target {@link OutputStream}
+        * 
+        * @return the number of bytes written
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static long write(InputStream in, OutputStream out)
+                       throws IOException {
+               long written = 0;
+               byte buffer[] = new byte[4096];
+               int len = in.read(buffer);
+               while (len > -1) {
+                       out.write(buffer, 0, len);
+                       written += len;
+                       len = in.read(buffer);
+               }
+
+               return written;
+       }
+
+       /**
+        * 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 + "/"));
+                       }
+
+                       File[] files = target.listFiles();
+                       if (files != null) {
+                               for (File file : files) {
+                                       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 srcIsRoot
+        *            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();
+               }
+       }
+
+       /**
+        * Unzip the given ZIP file into the target directory.
+        * 
+        * @param zipFile
+        *            the ZIP file
+        * @param targetDirectory
+        *            the target directory
+        * 
+        * @return the number of extracted files (not directories)
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public static long unzip(File zipFile, File targetDirectory)
+                       throws IOException {
+               long count = 0;
+
+               if (targetDirectory.exists() && targetDirectory.isFile()) {
+                       throw new IOException("Cannot unzip " + zipFile + " into "
+                                       + targetDirectory + ": it is not a directory");
+               }
+
+               targetDirectory.mkdir();
+               if (!targetDirectory.exists()) {
+                       throw new IOException("Cannot create target directory "
+                                       + targetDirectory);
+               }
+
+               FileInputStream in = new FileInputStream(zipFile);
+               try {
+                       ZipInputStream zipStream = new ZipInputStream(in);
+                       try {
+                               for (ZipEntry entry = zipStream.getNextEntry(); entry != null; entry = zipStream
+                                               .getNextEntry()) {
+                                       File file = new File(targetDirectory, entry.getName());
+                                       if (entry.isDirectory()) {
+                                               file.mkdirs();
+                                       } else {
+                                               IOUtils.write(zipStream, file);
+                                               count++;
+                                       }
+                               }
+                       } finally {
+                               zipStream.close();
+                       }
+               } finally {
+                       in.close();
+               }
+
+               return count;
+       }
+
+       /**
+        * 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();
+               }
+
+               writeSmallFile(new File(dir, filename), content);
+       }
+
+       /**
+        * Write the {@link String} content to {@link File}.
+        * 
+        * @param file
+        *            the {@link File} to write
+        * @param content
+        *            the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void writeSmallFile(File file, String content)
+                       throws IOException {
+               FileOutputStream out = new FileOutputStream(file);
+               try {
+                       out.write(StringUtils.getBytes(content));
+               } finally {
+                       out.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 {
+               InputStream stream = new FileInputStream(file);
+               try {
+                       return readSmallStream(stream);
+               } finally {
+                       stream.close();
+               }
+       }
+
+       /**
+        * Read the whole {@link InputStream} content into a {@link String}.
+        * 
+        * @param stream
+        *            the {@link InputStream}
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static String readSmallStream(InputStream stream) throws IOException {
+               ByteArrayOutputStream out = new ByteArrayOutputStream();
+               try {
+                       write(stream, out);
+                       return out.toString("UTF-8");
+               } finally {
+                       out.close();
+               }
+       }
+
+       /**
+        * Recursively delete the given {@link File}, which may of course also be a
+        * directory.
+        * <p>
+        * Will either silently continue or throw an exception in case of error,
+        * depending upon the parameters.
+        * 
+        * @param target
+        *            the target to delete
+        * @param exception
+        *            TRUE to throw an {@link IOException} in case of error, FALSE
+        *            to silently continue
+        * 
+        * @return TRUE if all files were deleted, FALSE if an error occurred
+        * 
+        * @throws IOException
+        *             if an error occurred and the parameters allow an exception to
+        *             be thrown
+        */
+       public static boolean deltree(File target, boolean exception)
+                       throws IOException {
+               List<File> list = deltree(target, null);
+               if (exception && !list.isEmpty()) {
+                       StringBuilder slist = new StringBuilder();
+                       for (File file : list) {
+                               slist.append("\n").append(file.getPath());
+                       }
+
+                       throw new IOException("Cannot delete all the files from: <" //
+                                       + target + ">:" + slist.toString());
+               }
+
+               return list.isEmpty();
+       }
+
+       /**
+        * 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
+        * 
+        * @return TRUE if all files were deleted, FALSE if an error occurred
+        */
+       public static boolean deltree(File target) {
+               return deltree(target, null).isEmpty();
+       }
+
+       /**
+        * Recursively delete the given {@link File}, which may of course also be a
+        * directory.
+        * <p>
+        * Will collect all {@link File} that cannot be deleted in the given
+        * accumulator.
+        * 
+        * @param target
+        *            the target to delete
+        * @param errorAcc
+        *            the accumulator to use for errors, or NULL to create a new one
+        * 
+        * @return the errors accumulator
+        */
+       public static List<File> deltree(File target, List<File> errorAcc) {
+               if (errorAcc == null) {
+                       errorAcc = new ArrayList<File>();
+               }
+
+               File[] files = target.listFiles();
+               if (files != null) {
+                       for (File file : files) {
+                               errorAcc = deltree(file, errorAcc);
+                       }
+               }
+
+               if (!target.delete()) {
+                       errorAcc.add(target);
+               }
+
+               return errorAcc;
+       }
+
+       /**
+        * Open the given /-separated resource (from the binary root).
+        * 
+        * @param name
+        *            the resource name
+        * 
+        * @return the opened resource if found, NLL if not
+        */
+       public static InputStream openResource(String name) {
+               ClassLoader loader = IOUtils.class.getClassLoader();
+               if (loader == null) {
+                       loader = ClassLoader.getSystemClassLoader();
+               }
+
+               return loader.getResourceAsStream(name);
+       }
+
+       /**
+        * Return a resetable {@link InputStream} from this stream, and reset it.
+        * 
+        * @param in
+        *            the input stream
+        * @return the resetable stream, which <b>may</b> be the same
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static InputStream forceResetableStream(InputStream in)
+                       throws IOException {
+               boolean resetable = in.markSupported();
+               if (resetable) {
+                       try {
+                               in.reset();
+                       } catch (IOException e) {
+                               resetable = false;
+                       }
+               }
+
+               if (resetable) {
+                       return in;
+               }
+
+               final File tmp = File.createTempFile(".tmp-stream.", ".tmp");
+               try {
+                       write(in, tmp);
+                       in.close();
+
+                       return new MarkableFileInputStream(tmp) {
+                               @Override
+                               public void close() throws IOException {
+                                       try {
+                                               super.close();
+                                       } finally {
+                                               tmp.delete();
+                                       }
+                               }
+                       };
+               } catch (IOException e) {
+                       tmp.delete();
+                       throw e;
+               }
+       }
+
+       /**
+        * Convert the {@link InputStream} into a byte array.
+        * 
+        * @param in
+        *            the input stream
+        * 
+        * @return the array
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static byte[] toByteArray(InputStream in) throws IOException {
+               ByteArrayOutputStream out = new ByteArrayOutputStream();
+               try {
+                       write(in, out);
+                       return out.toByteArray();
+               } finally {
+                       out.close();
+               }
+       }
+
+       /**
+        * Convert the {@link File} into a byte array.
+        * 
+        * @param file
+        *            the input {@link File}
+        * 
+        * @return the array
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static byte[] toByteArray(File file) throws IOException {
+               FileInputStream fis = new FileInputStream(file);
+               try {
+                       return toByteArray(fis);
+               } finally {
+                       fis.close();
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/Image.java b/src/be/nikiroo/utils/Image.java
new file mode 100644 (file)
index 0000000..4518577
--- /dev/null
@@ -0,0 +1,281 @@
+package be.nikiroo.utils;
+
+import java.io.ByteArrayInputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.io.Serializable;
+
+import be.nikiroo.utils.streams.Base64InputStream;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * This class represents an image data.
+ * 
+ * @author niki
+ */
+public class Image implements Closeable, Serializable {
+       static private final long serialVersionUID = 1L;
+
+       static private File tempRoot;
+       static private TempFiles tmpRepository;
+       static private long count = 0;
+       static private Object lock = new Object();
+
+       private Object instanceLock = new Object();
+       private File data;
+       private long size;
+
+       /**
+        * Do not use -- for serialisation purposes only.
+        */
+       @SuppressWarnings("unused")
+       private Image() {
+       }
+
+       /**
+        * Create a new {@link Image} with the given data.
+        * 
+        * @param data
+        *            the data
+        */
+       public Image(byte[] data) {
+               ByteArrayInputStream in = new ByteArrayInputStream(data);
+               try {
+                       this.data = getTemporaryFile();
+                       size = IOUtils.write(in, this.data);
+               } catch (IOException e) {
+                       throw new RuntimeException(e);
+               } finally {
+                       try {
+                               in.close();
+                       } catch (IOException e) {
+                               throw new RuntimeException(e);
+                       }
+               }
+       }
+
+       /**
+        * Create an image from Base64 encoded data.
+        * 
+        * <p>
+        * Please use {@link Image#Image(InputStream)} when possible instead, with a
+        * {@link Base64InputStream}; it can be much more efficient.
+        * 
+        * @param base64EncodedData
+        *            the Base64 encoded data as a String
+        * 
+        * @throws IOException
+        *             in case of I/O error or badly formated Base64
+        */
+       public Image(String base64EncodedData) throws IOException {
+               this(new Base64InputStream(new ByteArrayInputStream(
+                               StringUtils.getBytes(base64EncodedData)), false));
+       }
+
+       /**
+        * Create a new {@link Image} from a stream.
+        * 
+        * @param in
+        *            the stream
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Image(InputStream in) throws IOException {
+               data = getTemporaryFile();
+               size = IOUtils.write(in, data);
+       }
+
+       /**
+        * The size of the enclosed image in bytes.
+        * 
+        * @return the size
+        */
+       public long getSize() {
+               return size;
+       }
+
+       /**
+        * Generate an {@link InputStream} that you can {@link InputStream#reset()}
+        * for this {@link Image}.
+        * <p>
+        * This {@link InputStream} will (always) be a new one, and <b>you</b> are
+        * responsible for it.
+        * <p>
+        * Note: take care that the {@link InputStream} <b>must not</b> live past
+        * the {@link Image} life time!
+        * 
+        * @return the stream
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream newInputStream() throws IOException {
+               return new MarkableFileInputStream(data);
+       }
+
+       /**
+        * <b>Read</b> the actual image data, as a byte array.
+        * 
+        * @deprecated if possible, prefer the {@link Image#newInputStream()}
+        *             method, as it can be more efficient
+        * 
+        * @return the image data
+        */
+       @Deprecated
+       public byte[] getData() {
+               try {
+                       InputStream in = newInputStream();
+                       try {
+                               return IOUtils.toByteArray(in);
+                       } finally {
+                               in.close();
+                       }
+               } catch (IOException e) {
+                       throw new RuntimeException(e);
+               }
+       }
+
+       /**
+        * Convert the given {@link Image} object into a Base64 representation of
+        * the same {@link Image} object.
+        * 
+        * @deprecated Please use {@link Image#newInputStream()} instead, it is more
+        *             efficient
+        * 
+        * @return the Base64 representation
+        */
+       @Deprecated
+       public String toBase64() {
+               try {
+                       Base64InputStream stream = new Base64InputStream(newInputStream(),
+                                       true);
+                       try {
+                               return IOUtils.readSmallStream(stream);
+                       } finally {
+                               stream.close();
+                       }
+               } catch (IOException e) {
+                       return null;
+               }
+       }
+
+       /**
+        * Closing the {@link Image} will delete the associated temporary file on
+        * disk.
+        * <p>
+        * Note that even if you don't, the program will still <b>try</b> to delete
+        * all the temporary files at JVM termination.
+        */
+       @Override
+       public void close() throws IOException {
+               synchronized (instanceLock) {
+                       if (size >= 0) {
+                               size = -1;
+                               data.delete();
+                               data = null;
+
+                               synchronized (lock) {
+                                       count--;
+                                       if (count <= 0) {
+                                               count = 0;
+                                               tmpRepository.close();
+                                               tmpRepository = null;
+                                       }
+                               }
+                       }
+               }
+       }
+
+       @Override
+       protected void finalize() throws Throwable {
+               try {
+                       close();
+               } finally {
+                       super.finalize();
+               }
+       }
+
+       /**
+        * Return a newly created temporary file to work on.
+        * 
+        * @return the file
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private File getTemporaryFile() throws IOException {
+               synchronized (lock) {
+                       if (tmpRepository == null) {
+                               tmpRepository = new TempFiles(tempRoot, "images");
+                               count = 0;
+                       }
+
+                       count++;
+
+                       return tmpRepository.createTempFile("image");
+               }
+       }
+
+       /**
+        * Write this {@link Image} for serialization purposes; that is, write the
+        * content of the backing temporary file.
+        * 
+        * @param out
+        *            the {@link OutputStream} to write to
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private void writeObject(ObjectOutputStream out) throws IOException {
+               InputStream in = newInputStream();
+               try {
+                       IOUtils.write(in, out);
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Read an {@link Image} written by
+        * {@link Image#writeObject(java.io.ObjectOutputStream)}; that is, create a
+        * new temporary file with the saved content.
+        * 
+        * @param in
+        *            the {@link InputStream} to read from
+        * @throws IOException
+        *             in case of I/O error
+        * @throws ClassNotFoundException
+        *             will not be thrown by this method
+        */
+       @SuppressWarnings("unused")
+       private void readObject(ObjectInputStream in) throws IOException,
+                       ClassNotFoundException {
+               data = getTemporaryFile();
+               IOUtils.write(in, data);
+       }
+
+       /**
+        * Change the temporary root directory used by the program.
+        * <p>
+        * Caution: the directory will be <b>owned</b> by the system, all its files
+        * now belong to us (and will most probably be deleted).
+        * <p>
+        * Note: it may take some time until the new temporary root is used, we
+        * first need to make sure the previous one is not used anymore (i.e., we
+        * must reach a point where no unclosed {@link Image} remains in memory) to
+        * switch the temporary root.
+        * 
+        * @param root
+        *            the new temporary root, which will be <b>owned</b> by the
+        *            system
+        */
+       public static void setTemporaryFilesRoot(File root) {
+               tempRoot = root;
+       }
+}
diff --git a/src/be/nikiroo/utils/ImageUtils.java b/src/be/nikiroo/utils/ImageUtils.java
new file mode 100644 (file)
index 0000000..fb86929
--- /dev/null
@@ -0,0 +1,220 @@
+package be.nikiroo.utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+import be.nikiroo.utils.serial.SerialUtils;
+
+/**
+ * This class offer some utilities based around images.
+ * 
+ * @author niki
+ */
+public abstract class ImageUtils {
+       private static ImageUtils instance = newObject();
+
+       /**
+        * Get a (unique) instance of an {@link ImageUtils} compatible with your
+        * system.
+        * 
+        * @return an {@link ImageUtils}
+        */
+       public static ImageUtils getInstance() {
+               return instance;
+       }
+
+       /**
+        * Save the given resource as an image on disk using the given image format
+        * for content, or with "png" format if it fails.
+        * 
+        * @param img
+        *            the resource
+        * @param target
+        *            the target file
+        * @param format
+        *            the file format ("png", "jpeg", "bmp"...)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public abstract void saveAsImage(Image img, File target, String format)
+                       throws IOException;
+
+       /**
+        * 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
+        */
+       protected static 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;
+       }
+
+       /**
+        * Check that the class can operate (for instance, that all the required
+        * libraries or frameworks are present).
+        * 
+        * @return TRUE if it works
+        */
+       abstract protected boolean check();
+
+       /**
+        * Create a new {@link ImageUtils}.
+        * 
+        * @return the {@link ImageUtils}
+        */
+       private static ImageUtils newObject() {
+               for (String clazz : new String[] { "be.nikiroo.utils.ui.ImageUtilsAwt",
+                               "be.nikiroo.utils.android.ImageUtilsAndroid" }) {
+                       try {
+                               ImageUtils obj = (ImageUtils) SerialUtils.createObject(clazz);
+                               if (obj.check()) {
+                                       return obj;
+                               }
+                       } catch (Throwable e) {
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/utils/MarkableFileInputStream.java b/src/be/nikiroo/utils/MarkableFileInputStream.java
new file mode 100644 (file)
index 0000000..3f28064
--- /dev/null
@@ -0,0 +1,22 @@
+package be.nikiroo.utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+
+/**
+ * Class was moved to {@link be.nikiroo.utils.streams.MarkableFileInputStream}.
+ * 
+ * @author niki
+ */
+@Deprecated
+public class MarkableFileInputStream extends
+               be.nikiroo.utils.streams.MarkableFileInputStream {
+       public MarkableFileInputStream(File file) throws FileNotFoundException {
+               super(file);
+       }
+
+       public MarkableFileInputStream(FileInputStream fis) {
+               super(fis);
+       }
+}
diff --git a/src/be/nikiroo/utils/Progress.java b/src/be/nikiroo/utils/Progress.java
new file mode 100644 (file)
index 0000000..dea6be3
--- /dev/null
@@ -0,0 +1,433 @@
+package be.nikiroo.utils;
+
+import java.util.ArrayList;
+import java.util.EventListener;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/**
+ * Progress reporting system, possibly nested.
+ * 
+ * @author niki
+ */
+public class Progress {
+       /**
+        * This event listener is designed to report progress events from
+        * {@link Progress}.
+        * 
+        * @author niki
+        */
+       public interface ProgressListener extends EventListener {
+               /**
+                * A progression event.
+                * 
+                * @param progress
+                *            the {@link Progress} object that generated it, not
+                *            necessarily the same as the one where the listener was
+                *            attached (it could be a child {@link Progress} of this
+                *            {@link Progress}).
+                * @param name
+                *            the first non-null name of the {@link Progress} step that
+                *            generated this event
+                */
+               public void progress(Progress progress, String name);
+       }
+
+       private Progress parent = null;
+       private Object lock = new Object();
+       private String name;
+       private Map<Progress, Double> children;
+       private List<ProgressListener> listeners;
+       private int min;
+       private int max;
+       private double relativeLocalProgress;
+       private double relativeProgress; // children included
+
+       /**
+        * Create a new default unnamed {@link Progress}, from 0 to 100.
+        */
+       public Progress() {
+               this(null);
+       }
+
+       /**
+        * Create a new default {@link Progress}, from 0 to 100.
+        * 
+        * @param name
+        *            the name of this {@link Progress} step
+        */
+       public Progress(String name) {
+               this(name, 0, 100);
+       }
+
+       /**
+        * Create a new unnamed {@link Progress}, from min to max.
+        * 
+        * @param min
+        *            the minimum progress value (and starting value) -- must be
+        *            non-negative
+        * @param max
+        *            the maximum progress value
+        */
+       public Progress(int min, int max) {
+               this(null, min, max);
+       }
+
+       /**
+        * Create a new {@link Progress}, from min to max.
+        * 
+        * @param name
+        *            the name of this {@link Progress} step
+        * @param min
+        *            the minimum progress value (and starting value) -- must be
+        *            non-negative
+        * @param max
+        *            the maximum progress value
+        */
+       public Progress(String name, int min, int max) {
+               this.name = name;
+               this.children = new HashMap<Progress, Double>();
+               this.listeners = new ArrayList<Progress.ProgressListener>();
+               setMinMax(min, max);
+               setProgress(min);
+       }
+
+       /**
+        * The name of this {@link Progress} step.
+        * 
+        * @return the name
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * The name of this {@link Progress} step.
+        * 
+        * @param name
+        *            the new name
+        */
+       public void setName(String name) {
+               this.name = name;
+               changed(this, name);
+       }
+
+       /**
+        * The minimum progress value.
+        * 
+        * @return the min
+        */
+       public int getMin() {
+               return min;
+       }
+
+       /**
+        * The minimum progress value.
+        * 
+        * @param min
+        *            the min to set
+        * 
+        * 
+        * @throws RuntimeException
+        *             if min &lt; 0 or if min &gt; max
+        */
+       public void setMin(int min) {
+               if (min < 0) {
+                       throw new RuntimeException("negative values not supported");
+               }
+
+               synchronized (lock) {
+                       if (min > max) {
+                               throw new RuntimeException(
+                                               "The minimum progress value must be <= the maximum progress value");
+                       }
+
+                       this.min = min;
+               }
+       }
+
+       /**
+        * The maximum progress value.
+        * 
+        * @return the max
+        */
+       public int getMax() {
+               return max;
+       }
+
+       /**
+        * The maximum progress value (must be >= the minimum progress value).
+        * 
+        * @param max
+        *            the max to set
+        * 
+        * 
+        * @throws RuntimeException
+        *             if max &lt; min
+        */
+       public void setMax(int max) {
+               synchronized (lock) {
+                       if (max < min) {
+                               throw new Error(
+                                               "The maximum progress value must be >= the minimum progress value");
+                       }
+
+                       this.max = max;
+               }
+       }
+
+       /**
+        * Set both the minimum and maximum progress values.
+        * 
+        * @param min
+        *            the min
+        * @param max
+        *            the max
+        * 
+        * @throws RuntimeException
+        *             if min &lt; 0 or if min &gt; max
+        */
+       public void setMinMax(int min, int max) {
+               if (min < 0) {
+                       throw new RuntimeException("negative values not supported");
+               }
+
+               if (min > max) {
+                       throw new RuntimeException(
+                                       "The minimum progress value must be <= the maximum progress value");
+               }
+
+               synchronized (lock) {
+                       this.min = min;
+                       this.max = max;
+               }
+       }
+
+       /**
+        * Get the total progress value (including the optional children
+        * {@link Progress}) on a {@link Progress#getMin()} to
+        * {@link Progress#getMax()} scale.
+        * 
+        * @return the progress the value
+        */
+       public int getProgress() {
+               return (int) Math.round(relativeProgress * (max - min));
+       }
+
+       /**
+        * Set the local progress value (not including the optional children
+        * {@link Progress}), on a {@link Progress#getMin()} to
+        * {@link Progress#getMax()} scale.
+        * 
+        * @param progress
+        *            the progress to set
+        */
+       public void setProgress(int progress) {
+               synchronized (lock) {
+                       double childrenProgress = relativeProgress - relativeLocalProgress;
+
+                       relativeLocalProgress = ((double) progress) / (max - min);
+
+                       setRelativeProgress(this, name, relativeLocalProgress
+                                       + childrenProgress);
+               }
+       }
+
+       /**
+        * Get the total progress value (including the optional children
+        * {@link Progress}) on a 0.0 to 1.0 scale.
+        * 
+        * @return the progress
+        */
+       public double getRelativeProgress() {
+               return relativeProgress;
+       }
+
+       /**
+        * Set the total progress value (including the optional children
+        * {@link Progress}), on a 0 to 1 scale.
+        * 
+        * @param pg
+        *            the {@link Progress} to report as the progression emitter
+        * @param name
+        *            the current name (if it is NULL, the first non-null name in
+        *            the hierarchy will overwrite it) of the {@link Progress} who
+        *            emitted this change
+        * @param relativeProgress
+        *            the progress to set
+        */
+       private void setRelativeProgress(Progress pg, String name,
+                       double relativeProgress) {
+               synchronized (lock) {
+                       relativeProgress = Math.max(0, relativeProgress);
+                       relativeProgress = Math.min(1, relativeProgress);
+                       this.relativeProgress = relativeProgress;
+
+                       changed(pg, name);
+               }
+       }
+
+       /**
+        * Get the total progress value (including the optional children
+        * {@link Progress}) on a 0 to 1 scale.
+        * 
+        * @return the progress the value
+        */
+       private int getLocalProgress() {
+               return (int) Math.round(relativeLocalProgress * (max - min));
+       }
+
+       /**
+        * Add some value to the current progression of this {@link Progress}.
+        * 
+        * @param step
+        *            the amount to add
+        */
+       public void add(int step) {
+               synchronized (lock) {
+                       setProgress(getLocalProgress() + step);
+               }
+       }
+
+       /**
+        * Check if the action corresponding to this {@link Progress} is done (i.e.,
+        * if its progress value == its max value).
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isDone() {
+               return getProgress() == max;
+       }
+
+       /**
+        * Mark the {@link Progress} as done by setting its value to max.
+        */
+       public void done() {
+               synchronized (lock) {
+                       double childrenProgress = relativeProgress - relativeLocalProgress;
+                       relativeLocalProgress = 1 - childrenProgress;
+                       setRelativeProgress(this, name, 1d);
+               }
+       }
+
+       /**
+        * Return the list of direct children of this {@link Progress}.
+        * 
+        * @return the children (Who will think of the children??)
+        */
+       public List<Progress> getChildren() {
+               synchronized (lock) {
+                       return new ArrayList<Progress>(children.keySet());
+               }
+       }
+
+       /**
+        * Notify the listeners that this {@link Progress} changed value.
+        * 
+        * @param pg
+        *            the emmiter, that is, the (sub-){link Progress} that just
+        *            reported some change, not always the same as <tt>this</tt>
+        * @param name
+        *            the current name (if it is NULL, the first non-null name in
+        *            the hierarchy will overwrite it) of the {@link Progress} who
+        *            emitted this change
+        */
+       private void changed(Progress pg, String name) {
+               if (pg == null) {
+                       pg = this;
+               }
+
+               if (name == null) {
+                       name = this.name;
+               }
+
+               synchronized (lock) {
+                       for (ProgressListener l : listeners) {
+                               l.progress(pg, name);
+                       }
+               }
+       }
+
+       /**
+        * Add a {@link ProgressListener} that will trigger on progress changes.
+        * <p>
+        * Note: the {@link Progress} that will be reported will be the active
+        * progress, not necessarily the same as the current one (it could be a
+        * child {@link Progress} of this {@link Progress}).
+        * 
+        * @param l
+        *            the listener
+        */
+       public void addProgressListener(ProgressListener l) {
+               synchronized (lock) {
+                       this.listeners.add(l);
+               }
+       }
+
+       /**
+        * Remove a {@link ProgressListener} that would trigger on progress changes.
+        * 
+        * @param l
+        *            the listener
+        * 
+        * @return TRUE if it was found (and removed)
+        */
+       public boolean removeProgressListener(ProgressListener l) {
+               synchronized (lock) {
+                       return this.listeners.remove(l);
+               }
+       }
+
+       /**
+        * Add a child {@link Progress} of the given weight.
+        * 
+        * @param progress
+        *            the child {@link Progress} to add
+        * @param weight
+        *            the weight (on a {@link Progress#getMin()} to
+        *            {@link Progress#getMax()} scale) of this child
+        *            {@link Progress} in relation to its parent
+        * 
+        * @throws RuntimeException
+        *             if weight exceed {@link Progress#getMax()} or if progress
+        *             already has a parent
+        */
+       public void addProgress(Progress progress, double weight) {
+               if (weight < min || weight > max) {
+                       throw new RuntimeException(String.format(
+                                       "Progress object %s cannot have a weight of %f, "
+                                                       + "it is outside of its parent (%s) range (%d)",
+                                       progress.name, weight, name, max));
+               }
+
+               if (progress.parent != null) {
+                       throw new RuntimeException(String.format(
+                                       "Progress object %s cannot be added to %s, "
+                                                       + "as it already has a parent (%s)", progress.name,
+                                       name, progress.parent.name));
+               }
+
+               ProgressListener progressListener = new ProgressListener() {
+                       @Override
+                       public void progress(Progress pg, String name) {
+                               synchronized (lock) {
+                                       double total = relativeLocalProgress;
+                                       for (Entry<Progress, Double> entry : children.entrySet()) {
+                                               total += (entry.getValue() / (max - min))
+                                                               * entry.getKey().getRelativeProgress();
+                                       }
+
+                                       setRelativeProgress(pg, name, total);
+                               }
+                       }
+               };
+
+               synchronized (lock) {
+                       progress.parent = this;
+                       this.children.put(progress, weight);
+                       progress.addProgressListener(progressListener);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/Proxy.java b/src/be/nikiroo/utils/Proxy.java
new file mode 100644 (file)
index 0000000..750b3ee
--- /dev/null
@@ -0,0 +1,150 @@
+package be.nikiroo.utils;
+
+import java.net.Authenticator;
+import java.net.PasswordAuthentication;
+
+/**
+ * Simple proxy helper to select a default internet proxy.
+ * 
+ * @author niki
+ */
+public class Proxy {
+       /**
+        * Use the proxy described by this string:
+        * <ul>
+        * <li><tt>((user(:pass)@)proxy:port)</tt></li>
+        * <li>System proxy is noted <tt>:</tt></li>
+        * </ul>
+        * Some examples:
+        * <ul>
+        * <li><tt></tt> → do not use any proxy</li>
+        * <li><tt>:</tt> → use the system proxy</li>
+        * <li><tt>user@prox.com</tt> → use the proxy "prox.com" with default port
+        * and user "user"</li>
+        * <li><tt>prox.com:8080</tt> → use the proxy "prox.com" on port 8080</li>
+        * <li><tt>user:pass@prox.com:8080</tt> → use "prox.com" on port 8080
+        * authenticated as "user" with password "pass"</li>
+        * <li><tt>user:pass@:</tt> → use the system proxy authenticated as user
+        * "user" with password "pass"</li>
+        * </ul>
+        * 
+        * @param proxy
+        *            the proxy
+        */
+       static public void use(String proxy) {
+               if (proxy != null && !proxy.isEmpty()) {
+                       String user = null;
+                       String password = null;
+                       int port = 8080;
+
+                       if (proxy.contains("@")) {
+                               int pos = proxy.indexOf("@");
+                               user = proxy.substring(0, pos);
+                               proxy = proxy.substring(pos + 1);
+                               if (user.contains(":")) {
+                                       pos = user.indexOf(":");
+                                       password = user.substring(pos + 1);
+                                       user = user.substring(0, pos);
+                               }
+                       }
+
+                       if (proxy.equals(":")) {
+                               proxy = null;
+                       } else if (proxy.contains(":")) {
+                               int pos = proxy.indexOf(":");
+                               try {
+                                       port = Integer.parseInt(proxy.substring(0, pos));
+                                       proxy = proxy.substring(pos + 1);
+                               } catch (Exception e) {
+                               }
+                       }
+
+                       if (proxy == null) {
+                               Proxy.useSystemProxy(user, password);
+                       } else {
+                               Proxy.useProxy(proxy, port, user, password);
+                       }
+               }
+       }
+
+       /**
+        * Use the system proxy.
+        */
+       static public void useSystemProxy() {
+               useSystemProxy(null, null);
+       }
+
+       /**
+        * Use the system proxy with the given login/password, for authenticated
+        * proxies.
+        * 
+        * @param user
+        *            the user name or login
+        * @param password
+        *            the password
+        */
+       static public void useSystemProxy(String user, String password) {
+               System.setProperty("java.net.useSystemProxies", "true");
+               auth(user, password);
+       }
+
+       /**
+        * Use the give proxy.
+        * 
+        * @param host
+        *            the proxy host name or IP address
+        * @param port
+        *            the port to use
+        */
+       static public void useProxy(String host, int port) {
+               useProxy(host, port, null, null);
+       }
+
+       /**
+        * Use the given proxy with the given login/password, for authenticated
+        * proxies.
+        * 
+        * @param user
+        *            the user name or login
+        * @param password
+        *            the password
+        * @param host
+        *            the proxy host name or IP address
+        * @param port
+        *            the port to use
+        * @param user
+        *            the user name or login
+        * @param password
+        *            the password
+        */
+       static public void useProxy(String host, int port, String user,
+                       String password) {
+               System.setProperty("http.proxyHost", host);
+               System.setProperty("http.proxyPort", Integer.toString(port));
+               auth(user, password);
+       }
+
+       /**
+        * Select the default authenticator for proxy requests.
+        * 
+        * @param user
+        *            the user name or login
+        * @param password
+        *            the password
+        */
+       static private void auth(final String user, final String password) {
+               if (user != null && password != null) {
+                       Authenticator proxy = new Authenticator() {
+                               @Override
+                               protected PasswordAuthentication getPasswordAuthentication() {
+                                       if (getRequestorType() == RequestorType.PROXY) {
+                                               return new PasswordAuthentication(user,
+                                                               password.toCharArray());
+                                       }
+                                       return null;
+                               }
+                       };
+                       Authenticator.setDefault(proxy);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/StringJustifier.java b/src/be/nikiroo/utils/StringJustifier.java
new file mode 100644 (file)
index 0000000..ed20291
--- /dev/null
@@ -0,0 +1,286 @@
+/*
+ * This file was taken from:
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2017 Kevin Lamonte
+ *
+ * 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.
+ *
+ * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+ * @version 1
+ * 
+ * I added some changes to integrate it here.
+ * @author Niki
+ */
+package be.nikiroo.utils;
+
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * StringJustifier contains methods to convert one or more long lines of strings
+ * into justified text paragraphs.
+ */
+class StringJustifier {
+       /**
+        * Process the given text into a list of left-justified lines of a given
+        * max-width.
+        * 
+        * @param data
+        *            the text to justify
+        * @param width
+        *            the maximum width of a line
+        * 
+        * @return the list of justified lines
+        */
+       static List<String> left(final String data, final int width) {
+               return left(data, width, false);
+       }
+
+       /**
+        * Right-justify a string into a list of lines.
+        * 
+        * @param str
+        *            the string
+        * @param n
+        *            the maximum number of characters in a line
+        * @return the list of lines
+        */
+       static List<String> right(final String str, final int n) {
+               List<String> result = new LinkedList<String>();
+
+               /*
+                * Same as left(), but preceed each line with spaces to make it n chars
+                * long.
+                */
+               List<String> lines = left(str, n);
+               for (String line : lines) {
+                       StringBuilder sb = new StringBuilder();
+                       for (int i = 0; i < n - line.length(); i++) {
+                               sb.append(' ');
+                       }
+                       sb.append(line);
+                       result.add(sb.toString());
+               }
+
+               return result;
+       }
+
+       /**
+        * Center a string into a list of lines.
+        * 
+        * @param str
+        *            the string
+        * @param n
+        *            the maximum number of characters in a line
+        * @return the list of lines
+        */
+       static List<String> center(final String str, final int n) {
+               List<String> result = new LinkedList<String>();
+
+               /*
+                * Same as left(), but preceed/succeed each line with spaces to make it
+                * n chars long.
+                */
+               List<String> lines = left(str, n);
+               for (String line : lines) {
+                       StringBuilder sb = new StringBuilder();
+                       int l = (n - line.length()) / 2;
+                       int r = n - line.length() - l;
+                       for (int i = 0; i < l; i++) {
+                               sb.append(' ');
+                       }
+                       sb.append(line);
+                       for (int i = 0; i < r; i++) {
+                               sb.append(' ');
+                       }
+                       result.add(sb.toString());
+               }
+
+               return result;
+       }
+
+       /**
+        * Fully-justify a string into a list of lines.
+        * 
+        * @param str
+        *            the string
+        * @param n
+        *            the maximum number of characters in a line
+        * @return the list of lines
+        */
+       static List<String> full(final String str, final int n) {
+               List<String> result = new LinkedList<String>();
+
+               /*
+                * Same as left(true), but insert spaces between words to make each line
+                * n chars long. The "algorithm" here is pretty dumb: it performs a
+                * split on space and then re-inserts multiples of n between words.
+                */
+               List<String> lines = left(str, n, true);
+               for (int lineI = 0; lineI < lines.size() - 1; lineI++) {
+                       String line = lines.get(lineI);
+                       String[] words = line.split(" ");
+                       if (words.length > 1) {
+                               int charCount = 0;
+                               for (int i = 0; i < words.length; i++) {
+                                       charCount += words[i].length();
+                               }
+                               int spaceCount = n - charCount;
+                               int q = spaceCount / (words.length - 1);
+                               int r = spaceCount % (words.length - 1);
+                               StringBuilder sb = new StringBuilder();
+                               for (int i = 0; i < words.length - 1; i++) {
+                                       sb.append(words[i]);
+                                       for (int j = 0; j < q; j++) {
+                                               sb.append(' ');
+                                       }
+                                       if (r > 0) {
+                                               sb.append(' ');
+                                               r--;
+                                       }
+                               }
+                               for (int j = 0; j < r; j++) {
+                                       sb.append(' ');
+                               }
+                               sb.append(words[words.length - 1]);
+                               result.add(sb.toString());
+                       } else {
+                               result.add(line);
+                       }
+               }
+               if (lines.size() > 0) {
+                       result.add(lines.get(lines.size() - 1));
+               }
+
+               return result;
+       }
+
+       /**
+        * Process the given text into a list of left-justified lines of a given
+        * max-width.
+        * 
+        * @param data
+        *            the text to justify
+        * @param width
+        *            the maximum width of a line
+        * @param minTwoWords
+        *            use 2 words per line minimum if the text allows it
+        * 
+        * @return the list of justified lines
+        */
+       static private List<String> left(final String data, final int width,
+                       boolean minTwoWords) {
+               List<String> lines = new LinkedList<String>();
+
+               for (String dataLine : data.split("\n")) {
+                       String line = rightTrim(dataLine.replace("\t", "    "));
+
+                       if (width > 0 && line.length() > width) {
+                               while (line.length() > 0) {
+                                       int i = Math.min(line.length(), width - 1); // -1 for "-"
+
+                                       boolean needDash = true;
+                                       // find the best space if any and if needed
+                                       int prevSpace = 0;
+                                       if (i < line.length()) {
+                                               prevSpace = -1;
+                                               int space = line.indexOf(' ');
+                                               int numOfSpaces = 0;
+
+                                               while (space > -1 && space <= i) {
+                                                       prevSpace = space;
+                                                       space = line.indexOf(' ', space + 1);
+                                                       numOfSpaces++;
+                                               }
+
+                                               if (prevSpace > 0 && (!minTwoWords || numOfSpaces >= 2)) {
+                                                       i = prevSpace;
+                                                       needDash = false;
+                                               }
+                                       }
+                                       //
+
+                                       // no dash before space/dash
+                                       if ((i + 1) < line.length()) {
+                                               char car = line.charAt(i);
+                                               char nextCar = line.charAt(i + 1);
+                                               if (car == ' ' || car == '-' || nextCar == ' ') {
+                                                       needDash = false;
+                                               } else if (i > 0) {
+                                                       char prevCar = line.charAt(i - 1);
+                                                       if (prevCar == ' ' || prevCar == '-') {
+                                                               needDash = false;
+                                                               i--;
+                                                       }
+                                               }
+                                       }
+
+                                       // if the space freed by the removed dash allows it, or if
+                                       // it is the last char, add the next char
+                                       if (!needDash || i >= line.length() - 1) {
+                                               int checkI = Math.min(i + 1, line.length());
+                                               if (checkI == i || checkI <= width) {
+                                                       needDash = false;
+                                                       i = checkI;
+                                               }
+                                       }
+
+                                       // no dash before parenthesis (but cannot add one more
+                                       // after)
+                                       if ((i + 1) < line.length()) {
+                                               char nextCar = line.charAt(i + 1);
+                                               if (nextCar == '(' || nextCar == ')') {
+                                                       needDash = false;
+                                               }
+                                       }
+
+                                       if (needDash) {
+                                               lines.add(rightTrim(line.substring(0, i)) + "-");
+                                       } else {
+                                               lines.add(rightTrim(line.substring(0, i)));
+                                       }
+
+                                       // full trim (remove spaces when cutting)
+                                       line = line.substring(i).trim();
+                               }
+                       } else {
+                               lines.add(line);
+                       }
+               }
+
+               return lines;
+       }
+
+       /**
+        * Trim the given {@link String} on the right only.
+        * 
+        * @param data
+        *            the source {@link String}
+        * @return the right-trimmed String or Empty if it was NULL
+        */
+       static private String rightTrim(String data) {
+               if (data == null)
+                       return "";
+
+               return ("|" + data).trim().substring(1);
+       }
+}
diff --git a/src/be/nikiroo/utils/StringUtils.java b/src/be/nikiroo/utils/StringUtils.java
new file mode 100644 (file)
index 0000000..b3c1071
--- /dev/null
@@ -0,0 +1,1162 @@
+package be.nikiroo.utils;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+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.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+import org.unbescape.html.HtmlEscape;
+import org.unbescape.html.HtmlEscapeLevel;
+import org.unbescape.html.HtmlEscapeType;
+
+import be.nikiroo.utils.streams.Base64InputStream;
+import be.nikiroo.utils.streams.Base64OutputStream;
+
+/**
+ * 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
+        * or justification is applied (if there is enough horizontal space for it
+        * to be aligned).
+        */
+       public enum Alignment {
+               /** Aligned at left. */
+               LEFT,
+               /** Centered. */
+               CENTER,
+               /** Aligned at right. */
+               RIGHT,
+               /** Full justified (to both left and right). */
+               JUSTIFY,
+
+               // Old Deprecated values:
+
+               /** DEPRECATED: please use LEFT. */
+               @Deprecated
+               Beginning,
+               /** DEPRECATED: please use CENTER. */
+               @Deprecated
+               Center,
+               /** DEPRECATED: please use RIGHT. */
+               @Deprecated
+               End;
+
+               /**
+                * Return the non-deprecated version of this enum if needed (or return
+                * self if not).
+                * 
+                * @return the non-deprecated value
+                */
+               Alignment undeprecate() {
+                       if (this == Beginning)
+                               return LEFT;
+                       if (this == Center)
+                               return CENTER;
+                       if (this == End)
+                               return RIGHT;
+                       return this;
+               }
+       }
+
+       static private Pattern marks = getMarks();
+
+       /**
+        * 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, null);
+       }
+
+       /**
+        * 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 (default is Alignment.Beginning)
+        * 
+        * @return the resulting {@link String} of size <i>size</i> minimum
+        */
+       static public String padString(String text, int width, boolean cut,
+                       Alignment align) {
+
+               if (align == null) {
+                       align = Alignment.LEFT;
+               }
+
+               align = align.undeprecate();
+
+               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.RIGHT)
+                                       align = Alignment.LEFT;
+
+                               switch (align) {
+                               case RIGHT:
+                                       text = new String(new char[diff]).replace('\0', ' ') + text;
+                                       break;
+                               case CENTER:
+                                       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;
+                               case LEFT:
+                               default:
+                                       text = text + new String(new char[diff]).replace('\0', ' ');
+                                       break;
+                               }
+                       }
+               }
+
+               return text;
+       }
+
+       /**
+        * Justify a text into width-sized (at the maximum) lines and return all the
+        * lines concatenated into a single '\\n'-separated line of text.
+        * 
+        * @param text
+        *            the {@link String} to justify
+        * @param width
+        *            the maximum size of the resulting lines
+        * 
+        * @return a list of justified text lines concatenated into a single
+        *         '\\n'-separated line of text
+        */
+       static public String justifyTexts(String text, int width) {
+               StringBuilder builder = new StringBuilder();
+               for (String line : justifyText(text, width, null)) {
+                       if (builder.length() > 0) {
+                               builder.append('\n');
+                       }
+                       builder.append(line);
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Justify a text into width-sized (at the maximum) lines.
+        * 
+        * @param text
+        *            the {@link String} to justify
+        * @param width
+        *            the maximum size of the resulting lines
+        * 
+        * @return a list of justified text lines
+        */
+       static public List<String> justifyText(String text, int width) {
+               return justifyText(text, width, null);
+       }
+
+       /**
+        * Justify a text into width-sized (at the maximum) lines.
+        * 
+        * @param text
+        *            the {@link String} to justify
+        * @param width
+        *            the maximum size of the resulting lines
+        * @param align
+        *            align the lines in this position (default is
+        *            Alignment.Beginning)
+        * 
+        * @return a list of justified text lines
+        */
+       static public List<String> justifyText(String text, int width,
+                       Alignment align) {
+               if (align == null) {
+                       align = Alignment.LEFT;
+               }
+
+               align = align.undeprecate();
+
+               switch (align) {
+               case CENTER:
+                       return StringJustifier.center(text, width);
+               case RIGHT:
+                       return StringJustifier.right(text, width);
+               case JUSTIFY:
+                       return StringJustifier.full(text, width);
+               case LEFT:
+               default:
+                       return StringJustifier.left(text, width);
+               }
+       }
+
+       /**
+        * Justify a text into width-sized (at the maximum) lines.
+        * 
+        * @param text
+        *            the {@link String} to justify
+        * @param width
+        *            the maximum size of the resulting lines
+        * 
+        * @return a list of justified text lines
+        */
+       static public List<String> justifyText(List<String> text, int width) {
+               return justifyText(text, width, null);
+       }
+
+       /**
+        * Justify a text into width-sized (at the maximum) lines.
+        * 
+        * @param text
+        *            the {@link String} to justify
+        * @param width
+        *            the maximum size of the resulting lines
+        * @param align
+        *            align the lines in this position (default is
+        *            Alignment.Beginning)
+        * 
+        * @return a list of justified text lines
+        */
+       static public List<String> justifyText(List<String> text, int width,
+                       Alignment align) {
+               List<String> result = new ArrayList<String>();
+
+               // Content <-> Bullet spacing (null = no spacing)
+               List<Entry<String, String>> lines = new ArrayList<Entry<String, String>>();
+               StringBuilder previous = null;
+               StringBuilder tmp = new StringBuilder();
+               String previousItemBulletSpacing = null;
+               String itemBulletSpacing = null;
+               for (String inputLine : text) {
+                       boolean previousLineComplete = true;
+
+                       String current = inputLine.replace("\t", "    ");
+                       itemBulletSpacing = getItemSpacing(current);
+                       boolean bullet = isItemLine(current);
+                       if ((previousItemBulletSpacing == null || itemBulletSpacing
+                                       .length() <= previousItemBulletSpacing.length()) && !bullet) {
+                               itemBulletSpacing = null;
+                       }
+
+                       if (itemBulletSpacing != null) {
+                               current = current.trim();
+                               if (!current.isEmpty() && bullet) {
+                                       current = current.substring(1);
+                               }
+                               current = current.trim();
+                               previousLineComplete = bullet;
+                       } else {
+                               tmp.setLength(0);
+                               for (String word : current.split(" ")) {
+                                       if (word.isEmpty()) {
+                                               continue;
+                                       }
+
+                                       if (tmp.length() > 0) {
+                                               tmp.append(' ');
+                                       }
+                                       tmp.append(word.trim());
+                               }
+                               current = tmp.toString();
+
+                               previousLineComplete = current.isEmpty()
+                                               || previousItemBulletSpacing != null
+                                               || (previous != null && isFullLine(previous))
+                                               || isHrLine(current) || isHrLine(previous);
+                       }
+
+                       if (previous == null) {
+                               previous = new StringBuilder();
+                       } else {
+                               if (previousLineComplete) {
+                                       lines.add(new AbstractMap.SimpleEntry<String, String>(
+                                                       previous.toString(), previousItemBulletSpacing));
+                                       previous.setLength(0);
+                                       previousItemBulletSpacing = itemBulletSpacing;
+                               } else {
+                                       previous.append(' ');
+                               }
+                       }
+
+                       previous.append(current);
+
+               }
+
+               if (previous != null) {
+                       lines.add(new AbstractMap.SimpleEntry<String, String>(previous
+                                       .toString(), previousItemBulletSpacing));
+               }
+
+               for (Entry<String, String> line : lines) {
+                       String content = line.getKey();
+                       String spacing = line.getValue();
+
+                       String bullet = "- ";
+                       if (spacing == null) {
+                               bullet = "";
+                               spacing = "";
+                       }
+
+                       if (spacing.length() > width + 3) {
+                               spacing = "";
+                       }
+
+                       for (String subline : StringUtils.justifyText(content, width
+                                       - (spacing.length() + bullet.length()), align)) {
+                               result.add(spacing + bullet + subline);
+                               if (!bullet.isEmpty()) {
+                                       bullet = "  ";
+                               }
+                       }
+               }
+
+               return result;
+       }
+
+       /**
+        * 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);
+                       if (marks != null) {
+                               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 the time in milliseconds to a {@link String} in a "fixed"
+        * way (to exchange data over the wire, for instance).
+        * <p>
+        * Precise to the second.
+        * 
+        * @param time
+        *            the specified number of milliseconds since the standard base
+        *            time known as "the epoch", namely January 1, 1970, 00:00:00
+        *            GMT
+        * 
+        * @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 the time as a {@link String} to milliseconds in a "fixed"
+        * way (to exchange data over the wire, for instance).
+        * <p>
+        * Precise to the second.
+        * 
+        * @param displayTime
+        *            the time as a {@link String}
+        * 
+        * @return the number of milliseconds since the standard base time known as
+        *         "the epoch", namely January 1, 1970, 00:00:00 GMT, or -1 in case
+        *         of error
+        * 
+        * @throws ParseException
+        *             in case of parse error
+        */
+       static public long toTime(String displayTime) throws ParseException {
+               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+               return sdf.parse(displayTime).getTime();
+       }
+
+       /**
+        * Return a hash of the given {@link String}.
+        * 
+        * @param input
+        *            the input data
+        * 
+        * @return the hash
+        */
+       static public String getMd5Hash(String input) {
+               try {
+                       MessageDigest md = MessageDigest.getInstance("MD5");
+                       md.update(getBytes(input));
+                       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;
+               }
+       }
+
+       /**
+        * 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);
+                       }
+               }
+
+               char nbsp = ' '; // non-breakable space (a special char)
+               char space = ' ';
+               return HtmlEscape.unescapeHtml(builder.toString()).replace(nbsp, space);
+       }
+
+       /**
+        * 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);
+       }
+
+       /**
+        * Zip the data and then encode it into Base64.
+        * 
+        * @param data
+        *            the data
+        * 
+        * @return the Base64 zipped version
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static String zip64(String data) throws IOException {
+               try {
+                       return zip64(getBytes(data));
+               } catch (UnsupportedEncodingException e) {
+                       // All conforming JVM are required to support UTF-8
+                       e.printStackTrace();
+                       return null;
+               }
+       }
+
+       /**
+        * Zip the data and then encode it into Base64.
+        * 
+        * @param data
+        *            the data
+        * 
+        * @return the Base64 zipped version
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static String zip64(byte[] data) throws IOException {
+               // 1. compress
+               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+               try {
+                       OutputStream out = new GZIPOutputStream(bout);
+                       try {
+                               out.write(data);
+                       } finally {
+                               out.close();
+                       }
+               } finally {
+                       data = bout.toByteArray();
+                       bout.close();
+               }
+
+               // 2. base64
+               InputStream in = new ByteArrayInputStream(data);
+               try {
+                       in = new Base64InputStream(in, true);
+                       return new String(IOUtils.toByteArray(in), "UTF-8");
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Unconvert from Base64 then unzip the content, which is assumed to be a
+        * String.
+        * 
+        * @param data
+        *            the data in Base64 format
+        * 
+        * @return the raw data
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static String unzip64s(String data) throws IOException {
+               return new String(unzip64(data), "UTF-8");
+       }
+
+       /**
+        * Unconvert from Base64 then unzip the content.
+        * 
+        * @param data
+        *            the data in Base64 format
+        * 
+        * @return the raw data
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static byte[] unzip64(String data) throws IOException {
+               InputStream in = new Base64InputStream(new ByteArrayInputStream(
+                               getBytes(data)), false);
+               try {
+                       in = new GZIPInputStream(in);
+                       return IOUtils.toByteArray(in);
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Convert the given data to Base64 format.
+        * 
+        * @param data
+        *            the data to convert
+        * 
+        * @return the Base64 {@link String} representation of the data
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public static String base64(String data) throws IOException {
+               return base64(getBytes(data));
+       }
+
+       /**
+        * Convert the given data to Base64 format.
+        * 
+        * @param data
+        *            the data to convert
+        * 
+        * @return the Base64 {@link String} representation of the data
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public static String base64(byte[] data) throws IOException {
+               Base64InputStream in = new Base64InputStream(new ByteArrayInputStream(
+                               data), true);
+               try {
+                       return new String(IOUtils.toByteArray(in), "UTF-8");
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Unconvert the given data from Base64 format back to a raw array of bytes.
+        * 
+        * @param data
+        *            the data to unconvert
+        * 
+        * @return the raw data represented by the given Base64 {@link String},
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public static byte[] unbase64(String data) throws IOException {
+               Base64InputStream in = new Base64InputStream(new ByteArrayInputStream(
+                               getBytes(data)), false);
+               try {
+                       return IOUtils.toByteArray(in);
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Unonvert the given data from Base64 format back to a {@link String}.
+        * 
+        * @param data
+        *            the data to unconvert
+        * 
+        * @return the {@link String} represented by the given Base64 {@link String}
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public static String unbase64s(String data) throws IOException {
+               return new String(unbase64(data), "UTF-8");
+       }
+
+       /**
+        * Return a display {@link String} for the given value, which can be
+        * suffixed with "k" or "M" depending upon the number, if it is big enough.
+        * <p>
+        * <p>
+        * Examples:
+        * <ul>
+        * <li><tt>8 765</tt> becomes "8 k"</li>
+        * <li><tt>998 765</tt> becomes "998 k"</li>
+        * <li><tt>12 987 364</tt> becomes "12 M"</li>
+        * <li><tt>5 534 333 221</tt> becomes "5 G"</li>
+        * </ul>
+        * 
+        * @param value
+        *            the value to convert
+        * 
+        * @return the display value
+        */
+       public static String formatNumber(long value) {
+               return formatNumber(value, 0);
+       }
+
+       /**
+        * Return a display {@link String} for the given value, which can be
+        * suffixed with "k" or "M" depending upon the number, if it is big enough.
+        * <p>
+        * Examples (assuming decimalPositions = 1):
+        * <ul>
+        * <li><tt>8 765</tt> becomes "8.7 k"</li>
+        * <li><tt>998 765</tt> becomes "998.7 k"</li>
+        * <li><tt>12 987 364</tt> becomes "12.9 M"</li>
+        * <li><tt>5 534 333 221</tt> becomes "5.5 G"</li>
+        * </ul>
+        * 
+        * @param value
+        *            the value to convert
+        * @param decimalPositions
+        *            the number of decimal positions to keep
+        * 
+        * @return the display value
+        */
+       public static String formatNumber(long value, int decimalPositions) {
+               long userValue = value;
+               String suffix = " ";
+               long mult = 1;
+
+               if (value >= 1000000000l) {
+                       mult = 1000000000l;
+                       userValue = value / 1000000000l;
+                       suffix = " G";
+               } else if (value >= 1000000l) {
+                       mult = 1000000l;
+                       userValue = value / 1000000l;
+                       suffix = " M";
+               } else if (value >= 1000l) {
+                       mult = 1000l;
+                       userValue = value / 1000l;
+                       suffix = " k";
+               }
+
+               String deci = "";
+               if (decimalPositions > 0) {
+                       deci = Long.toString(value % mult);
+                       int size = Long.toString(mult).length() - 1;
+                       while (deci.length() < size) {
+                               deci = "0" + deci;
+                       }
+
+                       deci = deci.substring(0, Math.min(decimalPositions, deci.length()));
+                       while (deci.length() < decimalPositions) {
+                               deci += "0";
+                       }
+
+                       deci = "." + deci;
+               }
+
+               return Long.toString(userValue) + deci + suffix;
+       }
+
+       /**
+        * The reverse operation to {@link StringUtils#formatNumber(long)}: it will
+        * read a "display" number that can contain a "M" or "k" suffix and return
+        * the full value.
+        * <p>
+        * Of course, the conversion to and from display form is lossy (example:
+        * <tt>6870</tt> to "6.5k" to <tt>6500</tt>).
+        * 
+        * @param value
+        *            the value in display form with possible "M" and "k" suffixes,
+        *            can be NULL
+        * 
+        * @return the value as a number, or 0 if not possible to convert
+        */
+       public static long toNumber(String value) {
+               return toNumber(value, 0l);
+       }
+
+       /**
+        * The reverse operation to {@link StringUtils#formatNumber(long)}: it will
+        * read a "display" number that can contain a "M" or "k" suffix and return
+        * the full value.
+        * <p>
+        * Of course, the conversion to and from display form is lossy (example:
+        * <tt>6870</tt> to "6.5k" to <tt>6500</tt>).
+        * 
+        * @param value
+        *            the value in display form with possible "M" and "k" suffixes,
+        *            can be NULL
+        * @param def
+        *            the default value if it is not possible to convert the given
+        *            value to a number
+        * 
+        * @return the value as a number, or 0 if not possible to convert
+        */
+       public static long toNumber(String value, long def) {
+               long count = def;
+               if (value != null) {
+                       value = value.trim().toLowerCase();
+                       try {
+                               long mult = 1;
+                               if (value.endsWith("g")) {
+                                       value = value.substring(0, value.length() - 1).trim();
+                                       mult = 1000000000;
+                               } else if (value.endsWith("m")) {
+                                       value = value.substring(0, value.length() - 1).trim();
+                                       mult = 1000000;
+                               } else if (value.endsWith("k")) {
+                                       value = value.substring(0, value.length() - 1).trim();
+                                       mult = 1000;
+                               }
+
+                               long deci = 0;
+                               if (value.contains(".")) {
+                                       String[] tab = value.split("\\.");
+                                       if (tab.length != 2) {
+                                               throw new NumberFormatException(value);
+                                       }
+                                       double decimal = Double.parseDouble("0."
+                                                       + tab[tab.length - 1]);
+                                       deci = ((long) (mult * decimal));
+                                       value = tab[0];
+                               }
+                               count = mult * Long.parseLong(value) + deci;
+                       } catch (Exception e) {
+                       }
+               }
+
+               return count;
+       }
+
+       /**
+        * Return the bytes array representation of the given {@link String} in
+        * UTF-8.
+        * 
+        * @param str
+        *            the {@link String} to transform into bytes
+        * @return the content in bytes
+        */
+       static public byte[] getBytes(String str) {
+               try {
+                       return str.getBytes("UTF-8");
+               } catch (UnsupportedEncodingException e) {
+                       // All conforming JVM must support UTF-8
+                       e.printStackTrace();
+                       return null;
+               }
+       }
+
+       /**
+        * The "remove accents" pattern.
+        * 
+        * @return the pattern, or NULL if a problem happens
+        */
+       private static Pattern getMarks() {
+               try {
+                       return Pattern
+                                       .compile("[\\p{InCombiningDiacriticalMarks}\\p{IsLm}\\p{IsSk}]+");
+               } catch (Exception e) {
+                       // Can fail on Android...
+                       return null;
+               }
+       }
+
+       //
+       // justify List<String> related:
+       //
+
+       /**
+        * Check if this line ends as a complete line (ends with a "." or similar).
+        * <p>
+        * Note that we consider an empty line as full, and a line ending with
+        * spaces as not complete.
+        * 
+        * @param line
+        *            the line to check
+        * 
+        * @return TRUE if it does
+        */
+       static private boolean isFullLine(StringBuilder line) {
+               if (line.length() == 0) {
+                       return true;
+               }
+
+               char lastCar = line.charAt(line.length() - 1);
+               switch (lastCar) {
+               case '.': // points
+               case '?':
+               case '!':
+
+               case '\'': // quotes
+               case '‘':
+               case '’':
+
+               case '"': // double quotes
+               case '”':
+               case '“':
+               case '»':
+               case '«':
+                       return true;
+               default:
+                       return false;
+               }
+       }
+
+       /**
+        * Check if this line represent an item in a list or description (i.e.,
+        * check that the first non-space char is "-").
+        * 
+        * @param line
+        *            the line to check
+        * 
+        * @return TRUE if it is
+        */
+       static private boolean isItemLine(String line) {
+               String spacing = getItemSpacing(line);
+               return spacing != null && !spacing.isEmpty()
+                               && line.charAt(spacing.length()) == '-';
+       }
+
+       /**
+        * Return all the spaces that start this line (or Empty if none).
+        * 
+        * @param line
+        *            the line to get the starting spaces from
+        * 
+        * @return the left spacing
+        */
+       static private String getItemSpacing(String line) {
+               int i;
+               for (i = 0; i < line.length(); i++) {
+                       if (line.charAt(i) != ' ') {
+                               return line.substring(0, i);
+                       }
+               }
+
+               return "";
+       }
+
+       /**
+        * This line is an horizontal spacer line.
+        * 
+        * @param line
+        *            the line to test
+        * 
+        * @return TRUE if it is
+        */
+       static private boolean isHrLine(CharSequence line) {
+               int count = 0;
+               if (line != null) {
+                       for (int i = 0; i < line.length(); i++) {
+                               char car = line.charAt(i);
+                               if (car == ' ' || car == '\t' || car == '*' || car == '-'
+                                               || car == '_' || car == '~' || car == '=' || car == '/'
+                                               || car == '\\') {
+                                       count++;
+                               } else {
+                                       return false;
+                               }
+                       }
+               }
+
+               return count > 2;
+       }
+
+       // Deprecated functions, please do not use //
+
+       /**
+        * @deprecated please use {@link StringUtils#zip64(byte[])} or
+        *             {@link StringUtils#base64(byte[])} instead.
+        * 
+        * @param data
+        *            the data to encode
+        * @param zip
+        *            TRUE to zip it before Base64 encoding it, FALSE for Base64
+        *            encoding only
+        * 
+        * @return the encoded data
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @Deprecated
+       public static String base64(String data, boolean zip) throws IOException {
+               return base64(getBytes(data), zip);
+       }
+
+       /**
+        * @deprecated please use {@link StringUtils#zip64(String)} or
+        *             {@link StringUtils#base64(String)} instead.
+        * 
+        * @param data
+        *            the data to encode
+        * @param zip
+        *            TRUE to zip it before Base64 encoding it, FALSE for Base64
+        *            encoding only
+        * 
+        * @return the encoded data
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @Deprecated
+       public static String base64(byte[] data, boolean zip) throws IOException {
+               if (zip) {
+                       return zip64(data);
+               }
+
+               Base64InputStream b64 = new Base64InputStream(new ByteArrayInputStream(
+                               data), true);
+               try {
+                       return IOUtils.readSmallStream(b64);
+               } finally {
+                       b64.close();
+               }
+       }
+
+       /**
+        * @deprecated please use {@link Base64OutputStream} and
+        *             {@link GZIPOutputStream} instead.
+        * 
+        * @param breakLines
+        *            NOT USED ANYMORE, it is always considered FALSE now
+        */
+       @Deprecated
+       public static OutputStream base64(OutputStream data, boolean zip,
+                       boolean breakLines) throws IOException {
+               OutputStream out = new Base64OutputStream(data);
+               if (zip) {
+                       out = new java.util.zip.GZIPOutputStream(out);
+               }
+
+               return out;
+       }
+
+       /**
+        * Unconvert the given data from Base64 format back to a raw array of bytes.
+        * <p>
+        * Will automatically detect zipped data and also uncompress it before
+        * returning, unless ZIP is false.
+        * 
+        * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+        * 
+        * @param data
+        *            the data to unconvert
+        * @param zip
+        *            TRUE to also uncompress the data from a GZIP format
+        *            automatically; if set to FALSE, zipped data can be returned
+        * 
+        * @return the raw data represented by the given Base64 {@link String},
+        *         optionally compressed with GZIP
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       @Deprecated
+       public static byte[] unbase64(String data, boolean zip) throws IOException {
+               byte[] buffer = unbase64(data);
+               if (!zip) {
+                       return buffer;
+               }
+
+               try {
+                       GZIPInputStream zipped = new GZIPInputStream(
+                                       new ByteArrayInputStream(buffer));
+                       try {
+                               ByteArrayOutputStream out = new ByteArrayOutputStream();
+                               try {
+                                       IOUtils.write(zipped, out);
+                                       return out.toByteArray();
+                               } finally {
+                                       out.close();
+                               }
+                       } finally {
+                               zipped.close();
+                       }
+               } catch (Exception e) {
+                       return buffer;
+               }
+       }
+
+       /**
+        * Unconvert the given data from Base64 format back to a raw array of bytes.
+        * <p>
+        * Will automatically detect zipped data and also uncompress it before
+        * returning, unless ZIP is false.
+        * 
+        * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+        * 
+        * @param data
+        *            the data to unconvert
+        * @param zip
+        *            TRUE to also uncompress the data from a GZIP format
+        *            automatically; if set to FALSE, zipped data can be returned
+        * 
+        * @return the raw data represented by the given Base64 {@link String},
+        *         optionally compressed with GZIP
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       @Deprecated
+       public static InputStream unbase64(InputStream data, boolean zip)
+                       throws IOException {
+               return new ByteArrayInputStream(unbase64(IOUtils.readSmallStream(data),
+                               zip));
+       }
+
+       /**
+        * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+        */
+       @Deprecated
+       public static byte[] unbase64(byte[] data, int offset, int count,
+                       boolean zip) throws IOException {
+               byte[] dataPart = Arrays.copyOfRange(data, offset, offset + count);
+               return unbase64(new String(dataPart, "UTF-8"), zip);
+       }
+
+       /**
+        * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+        */
+       @Deprecated
+       public static String unbase64s(String data, boolean zip) throws IOException {
+               return new String(unbase64(data, zip), "UTF-8");
+       }
+
+       /**
+        * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+        */
+       @Deprecated
+       public static String unbase64s(byte[] data, int offset, int count,
+                       boolean zip) throws IOException {
+               return new String(unbase64(data, offset, count, zip), "UTF-8");
+       }
+}
diff --git a/src/be/nikiroo/utils/TempFiles.java b/src/be/nikiroo/utils/TempFiles.java
new file mode 100644 (file)
index 0000000..b54f0bc
--- /dev/null
@@ -0,0 +1,187 @@
+package be.nikiroo.utils;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * A small utility class to generate auto-delete temporary files in a
+ * centralised location.
+ * 
+ * @author niki
+ */
+public class TempFiles implements Closeable {
+       /**
+        * Root directory of this instance, owned by it, where all temporary files
+        * must reside.
+        */
+       protected File root;
+
+       /**
+        * Create a new {@link TempFiles} -- each instance is separate and have a
+        * dedicated sub-directory in a shared temporary root.
+        * <p>
+        * The whole repository will be deleted on close (if you fail to call it,
+        * the program will <b>try</b> to call it on JVM termination).
+        * 
+        * @param name
+        *            the instance name (will be <b>part</b> of the final directory
+        *            name)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public TempFiles(String name) throws IOException {
+               this(null, name);
+       }
+
+       /**
+        * Create a new {@link TempFiles} -- each instance is separate and have a
+        * dedicated sub-directory in a given temporary root.
+        * <p>
+        * The whole repository will be deleted on close (if you fail to call it,
+        * the program will <b>try</b> to call it on JVM termination).
+        * <p>
+        * Be careful, this instance will <b>own</b> the given root directory, and
+        * will most probably delete all its files.
+        * 
+        * @param base
+        *            the root base directory to use for all the temporary files of
+        *            this instance (if NULL, will be the default temporary
+        *            directory of the OS)
+        * @param name
+        *            the instance name (will be <b>part</b> of the final directory
+        *            name)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public TempFiles(File base, String name) throws IOException {
+               if (base == null) {
+                       base = File.createTempFile(".temp", "");
+               }
+
+               root = base;
+
+               if (root.exists()) {
+                       IOUtils.deltree(root, true);
+               }
+
+               root = new File(root.getParentFile(), ".temp");
+               root.mkdir();
+               if (!root.exists()) {
+                       throw new IOException("Cannot create root directory: " + root);
+               }
+
+               root.deleteOnExit();
+
+               root = createTempFile(name);
+               IOUtils.deltree(root, true);
+
+               root.mkdir();
+               if (!root.exists()) {
+                       throw new IOException("Cannot create root subdirectory: " + root);
+               }
+       }
+
+       /**
+        * Create an auto-delete temporary file.
+        * 
+        * @param name
+        *            a base for the final filename (only a <b>part</b> of said
+        *            filename)
+        * 
+        * @return the newly created file
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public synchronized File createTempFile(String name) throws IOException {
+               name += "_";
+               while (name.length() < 3) {
+                       name += "_";
+               }
+
+               while (true) {
+                       File tmp = File.createTempFile(name, "");
+                       IOUtils.deltree(tmp, true);
+
+                       File test = new File(root, tmp.getName());
+                       if (!test.exists()) {
+                               test.createNewFile();
+                               if (!test.exists()) {
+                                       throw new IOException(
+                                                       "Cannot create temporary file: " + test);
+                               }
+
+                               test.deleteOnExit();
+                               return test;
+                       }
+               }
+       }
+
+       /**
+        * Create an auto-delete temporary directory.
+        * <p>
+        * Note that creating 2 temporary directories with the same name will result
+        * in two <b>different</b> directories, even if the final name is the same
+        * (the absolute path will be different).
+        * 
+        * @param name
+        *            the actual directory name (not path)
+        * 
+        * @return the newly created file
+        * 
+        * @throws IOException
+        *             in case of I/O errors, or if the name was a path instead of a
+        *             name
+        */
+       public synchronized File createTempDir(String name) throws IOException {
+               File localRoot = createTempFile(name);
+               IOUtils.deltree(localRoot, true);
+
+               localRoot.mkdir();
+               if (!localRoot.exists()) {
+                       throw new IOException("Cannot create subdirectory: " + localRoot);
+               }
+
+               File dir = new File(localRoot, name);
+               if (!dir.getName().equals(name)) {
+                       throw new IOException(
+                                       "Cannot create temporary directory with a path, only names are allowed: "
+                                                       + dir);
+               }
+
+               dir.mkdir();
+               dir.deleteOnExit();
+
+               if (!dir.exists()) {
+                       throw new IOException("Cannot create subdirectory: " + dir);
+               }
+
+               return dir;
+       }
+
+       @Override
+       public synchronized void close() throws IOException {
+               File root = this.root;
+               this.root = null;
+
+               if (root != null) {
+                       IOUtils.deltree(root);
+
+                       // Since we allocate temp directories from a base point,
+                       // try and remove that base point
+                       root.getParentFile().delete(); // (only works if empty)
+               }
+       }
+
+       @Override
+       protected void finalize() throws Throwable {
+               try {
+                       close();
+               } finally {
+                       super.finalize();
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/TraceHandler.java b/src/be/nikiroo/utils/TraceHandler.java
new file mode 100644 (file)
index 0000000..0a09712
--- /dev/null
@@ -0,0 +1,156 @@
+package be.nikiroo.utils;
+
+/**
+ * A handler when a trace message is sent or when a recoverable exception was
+ * caught by the program.
+ * 
+ * @author niki
+ */
+public class TraceHandler {
+       private final boolean showErrors;
+       private final boolean showErrorDetails;
+       private final int traceLevel;
+       private final int maxPrintSize;
+
+       /**
+        * Create a default {@link TraceHandler} that will print errors on stderr
+        * (without details) and no traces.
+        */
+       public TraceHandler() {
+               this(true, false, false);
+       }
+
+       /**
+        * Create a default {@link TraceHandler}.
+        * 
+        * @param showErrors
+        *            show errors on stderr
+        * @param showErrorDetails
+        *            show more details when printing errors
+        * @param showTraces
+        *            show level 1 traces on stderr, or no traces at all
+        */
+       public TraceHandler(boolean showErrors, boolean showErrorDetails,
+                       boolean showTraces) {
+               this(showErrors, showErrorDetails, showTraces ? 1 : 0);
+       }
+
+       /**
+        * Create a default {@link TraceHandler}.
+        * 
+        * @param showErrors
+        *            show errors on stderr
+        * @param showErrorDetails
+        *            show more details when printing errors
+        * @param traceLevel
+        *            show traces of this level or lower (0 means "no traces",
+        *            higher means more traces)
+        */
+       public TraceHandler(boolean showErrors, boolean showErrorDetails,
+                       int traceLevel) {
+               this(showErrors, showErrorDetails, traceLevel, -1);
+       }
+
+       /**
+        * Create a default {@link TraceHandler}.
+        * 
+        * @param showErrors
+        *            show errors on stderr
+        * @param showErrorDetails
+        *            show more details when printing errors
+        * @param traceLevel
+        *            show traces of this level or lower (0 means "no traces",
+        *            higher means more traces)
+        * @param maxPrintSize
+        *            the maximum size at which to truncate traces data (or -1 for
+        *            "no limit")
+        */
+       public TraceHandler(boolean showErrors, boolean showErrorDetails,
+                       int traceLevel, int maxPrintSize) {
+               this.showErrors = showErrors;
+               this.showErrorDetails = showErrorDetails;
+               this.traceLevel = Math.max(traceLevel, 0);
+               this.maxPrintSize = maxPrintSize;
+       }
+
+       /**
+        * The trace level of this {@link TraceHandler}.
+        * 
+        * @return the level
+        */
+       public int getTraceLevel() {
+               return traceLevel;
+       }
+
+       /**
+        * An exception happened, log it.
+        * 
+        * @param e
+        *            the exception
+        */
+       public void error(Exception e) {
+               if (showErrors) {
+                       if (showErrorDetails) {
+                               long now = System.currentTimeMillis();
+                               System.err.print(StringUtils.fromTime(now) + ": ");
+                               e.printStackTrace();
+                       } else {
+                               error(e.toString());
+                       }
+               }
+       }
+
+       /**
+        * An error happened, log it.
+        * 
+        * @param message
+        *            the error message
+        */
+       public void error(String message) {
+               if (showErrors) {
+                       long now = System.currentTimeMillis();
+                       System.err.println(StringUtils.fromTime(now) + ": " + message);
+               }
+       }
+
+       /**
+        * A trace happened, show it.
+        * <p>
+        * By default, will only be effective if {@link TraceHandler#traceLevel} is
+        * not 0.
+        * <p>
+        * A call to this method is equivalent to a call to
+        * {@link TraceHandler#trace(String, int)} with a level of 1.
+        * 
+        * @param message
+        *            the trace message
+        */
+       public void trace(String message) {
+               trace(message, 1);
+       }
+
+       /**
+        * A trace happened, show it.
+        * <p>
+        * By default, will only be effective if {@link TraceHandler#traceLevel} is
+        * not 0 and the level is lower or equal to it.
+        * 
+        * @param message
+        *            the trace message
+        * @param level
+        *            the trace level
+        */
+       public void trace(String message, int level) {
+               if (traceLevel > 0 && level <= traceLevel) {
+                       long now = System.currentTimeMillis();
+                       System.err.print(StringUtils.fromTime(now) + ": ");
+                       if (maxPrintSize > 0 && message.length() > maxPrintSize) {
+
+                               System.err
+                                               .println(message.substring(0, maxPrintSize) + "[...]");
+                       } else {
+                               System.err.println(message);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/Version.java b/src/be/nikiroo/utils/Version.java
new file mode 100644 (file)
index 0000000..269edb6
--- /dev/null
@@ -0,0 +1,366 @@
+package be.nikiroo.utils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * This class describe a program {@link Version}.
+ * 
+ * @author niki
+ */
+public class Version implements Comparable<Version> {
+       private String version;
+       private int major;
+       private int minor;
+       private int patch;
+       private String tag;
+       private int tagVersion;
+
+       /**
+        * Create a new, empty {@link Version}.
+        * 
+        */
+       public Version() {
+       }
+
+       /**
+        * Create a new {@link Version} with the given values.
+        * 
+        * @param major
+        *            the major version
+        * @param minor
+        *            the minor version
+        * @param patch
+        *            the patch version
+        */
+       public Version(int major, int minor, int patch) {
+               this(major, minor, patch, null, -1);
+       }
+
+       /**
+        * Create a new {@link Version} with the given values.
+        * 
+        * @param major
+        *            the major version
+        * @param minor
+        *            the minor version
+        * @param patch
+        *            the patch version
+        * @param tag
+        *            a tag name for this version
+        */
+       public Version(int major, int minor, int patch, String tag) {
+               this(major, minor, patch, tag, -1);
+       }
+
+       /**
+        * Create a new {@link Version} with the given values.
+        * 
+        * @param major
+        *            the major version
+        * @param minor
+        *            the minor version
+        * @param patch
+        *            the patch version the patch version
+        * @param tag
+        *            a tag name for this version
+        * @param tagVersion
+        *            the version of the tagged version
+        */
+       public Version(int major, int minor, int patch, String tag, int tagVersion) {
+               if (tagVersion >= 0 && tag == null) {
+                       throw new java.lang.IllegalArgumentException(
+                                       "A tag version cannot be used without a tag");
+               }
+
+               this.major = major;
+               this.minor = minor;
+               this.patch = patch;
+               this.tag = tag;
+               this.tagVersion = tagVersion;
+
+               this.version = generateVersion();
+       }
+
+       /**
+        * Create a new {@link Version} with the given value, which must be in the
+        * form <tt>MAJOR.MINOR.PATCH(-TAG(TAG_VERSION))</tt>.
+        * 
+        * @param version
+        *            the version (<tt>MAJOR.MINOR.PATCH</tt>,
+        *            <tt>MAJOR.MINOR.PATCH-TAG</tt> or
+        *            <tt>MAJOR.MINOR.PATCH-TAGVERSIONTAG</tt>)
+        */
+       public Version(String version) {
+               try {
+                       String[] tab = version.split("\\.");
+                       this.major = Integer.parseInt(tab[0].trim());
+                       this.minor = Integer.parseInt(tab[1].trim());
+                       if (tab[2].contains("-")) {
+                               int posInVersion = version.indexOf('.');
+                               posInVersion = version.indexOf('.', posInVersion + 1);
+                               String rest = version.substring(posInVersion + 1);
+
+                               int posInRest = rest.indexOf('-');
+                               this.patch = Integer.parseInt(rest.substring(0, posInRest)
+                                               .trim());
+
+                               posInVersion = version.indexOf('-');
+                               this.tag = version.substring(posInVersion + 1).trim();
+                               this.tagVersion = -1;
+
+                               StringBuilder str = new StringBuilder();
+                               while (!tag.isEmpty() && tag.charAt(tag.length() - 1) >= '0'
+                                               && tag.charAt(tag.length() - 1) <= '9') {
+                                       str.insert(0, tag.charAt(tag.length() - 1));
+                                       tag = tag.substring(0, tag.length() - 1);
+                               }
+
+                               if (str.length() > 0) {
+                                       this.tagVersion = Integer.parseInt(str.toString());
+                               }
+                       } else {
+                               this.patch = Integer.parseInt(tab[2].trim());
+                               this.tag = null;
+                               this.tagVersion = -1;
+                       }
+
+                       this.version = generateVersion();
+               } catch (Exception e) {
+                       this.major = 0;
+                       this.minor = 0;
+                       this.patch = 0;
+                       this.tag = null;
+                       this.tagVersion = -1;
+                       this.version = null;
+               }
+       }
+
+       /**
+        * The 'major' version.
+        * <p>
+        * This version should only change when API-incompatible changes are made to
+        * the program.
+        * 
+        * @return the major version
+        */
+       public int getMajor() {
+               return major;
+       }
+
+       /**
+        * The 'minor' version.
+        * <p>
+        * This version should only change when new, backwards-compatible
+        * functionality has been added to the program.
+        * 
+        * @return the minor version
+        */
+       public int getMinor() {
+               return minor;
+       }
+
+       /**
+        * The 'patch' version.
+        * <p>
+        * This version should change when backwards-compatible bugfixes have been
+        * added to the program.
+        * 
+        * @return the patch version
+        */
+       public int getPatch() {
+               return patch;
+       }
+
+       /**
+        * A tag name for this version.
+        * 
+        * @return the tag
+        */
+       public String getTag() {
+               return tag;
+       }
+
+       /**
+        * The version of the tag, or -1 for no version.
+        * 
+        * @return the tag version
+        */
+       public int getTagVersion() {
+               return tagVersion;
+       }
+
+       /**
+        * Check if this {@link Version} is "empty" (i.e., the version was not
+        * parse-able or not given).
+        * 
+        * @return TRUE if it is empty
+        */
+       public boolean isEmpty() {
+               return version == null;
+       }
+
+       /**
+        * Check if we are more recent than the given {@link Version}.
+        * <p>
+        * Note that a tagged version is considered newer than a non-tagged version,
+        * but two tagged versions with different tags are not comparable.
+        * <p>
+        * Also, an empty version is always considered older.
+        * 
+        * @param o
+        *            the other {@link Version}
+        * @return TRUE if this {@link Version} is more recent than the given one
+        */
+       public boolean isNewerThan(Version o) {
+               if (isEmpty()) {
+                       return false;
+               } else if (o.isEmpty()) {
+                       return true;
+               }
+
+               if (major > o.major) {
+                       return true;
+               }
+
+               if (major == o.major && minor > o.minor) {
+                       return true;
+               }
+
+               if (major == o.major && minor == o.minor && patch > o.patch) {
+                       return true;
+               }
+
+               // a tagged version is considered newer than a non-tagged one
+               if (major == o.major && minor == o.minor && patch == o.patch
+                               && tag != null && o.tag == null) {
+                       return true;
+               }
+
+               // 2 <> tagged versions are not comparable
+               boolean sameTag = (tag == null && o.tag == null)
+                               || (tag != null && tag.equals(o.tag));
+               if (major == o.major && minor == o.minor && patch == o.patch && sameTag
+                               && tagVersion > o.tagVersion) {
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Check if we are older than the given {@link Version}.
+        * <p>
+        * Note that a tagged version is considered newer than a non-tagged version,
+        * but two tagged versions with different tags are not comparable.
+        * <p>
+        * Also, an empty version is always considered older.
+        * 
+        * @param o
+        *            the other {@link Version}
+        * @return TRUE if this {@link Version} is older than the given one
+        */
+       public boolean isOlderThan(Version o) {
+               if (o.isEmpty()) {
+                       return false;
+               } else if (isEmpty()) {
+                       return true;
+               }
+
+               // 2 <> tagged versions are not comparable
+               boolean sameTag = (tag == null && o.tag == null)
+                               || (tag != null && tag.equals(o.tag));
+               if (major == o.major && minor == o.minor && patch == o.patch
+                               && !sameTag) {
+                       return false;
+               }
+
+               return !equals(o) && !isNewerThan(o);
+       }
+
+       /**
+        * Return the version of the running program if it follows the VERSION
+        * convention (i.e., if it has a file called VERSION containing the version
+        * as a {@link String} in its binary root, and if this {@link String}
+        * follows the Major/Minor/Patch convention).
+        * <p>
+        * If it does not, return an empty {@link Version} object.
+        * 
+        * @return the {@link Version} of the program, or an empty {@link Version}
+        *         (does not return NULL)
+        */
+       public static Version getCurrentVersion() {
+               String version = null;
+
+               InputStream in = IOUtils.openResource("VERSION");
+               if (in != null) {
+                       try {
+                               ByteArrayOutputStream ba = new ByteArrayOutputStream();
+                               IOUtils.write(in, ba);
+                               in.close();
+
+                               version = ba.toString("UTF-8").trim();
+                       } catch (IOException e) {
+                       }
+               }
+
+               return new Version(version);
+       }
+
+       @Override
+       public int compareTo(Version o) {
+               if (equals(o)) {
+                       return 0;
+               } else if (isNewerThan(o)) {
+                       return 1;
+               } else {
+                       return -1;
+               }
+       }
+
+       @Override
+       public boolean equals(Object obj) {
+               if (obj instanceof Version) {
+                       Version o = (Version) obj;
+                       if (isEmpty()) {
+                               return o.isEmpty();
+                       }
+
+                       boolean sameTag = (tag == null && o.tag == null)
+                                       || (tag != null && tag.equals(o.tag));
+                       return o.major == major && o.minor == minor && o.patch == patch
+                                       && sameTag && o.tagVersion == tagVersion;
+               }
+
+               return false;
+       }
+
+       @Override
+       public int hashCode() {
+               return version == null ? 0 : version.hashCode();
+       }
+
+       /**
+        * Return a user-readable form of this {@link Version}.
+        */
+       @Override
+       public String toString() {
+               return version == null ? "[unknown]" : version;
+       }
+
+       /**
+        * Generate the clean version {@link String} from the current values.
+        * 
+        * @return the clean version string
+        */
+       private String generateVersion() {
+               String tagSuffix = "";
+               if (tag != null) {
+                       tagSuffix = "-" + tag
+                                       + (tagVersion >= 0 ? Integer.toString(tagVersion) : "");
+               }
+
+               return String.format("%d.%d.%d%s", major, minor, patch, tagSuffix);
+       }
+}
diff --git a/src/be/nikiroo/utils/android/ImageUtilsAndroid.java b/src/be/nikiroo/utils/android/ImageUtilsAndroid.java
new file mode 100644 (file)
index 0000000..c2e269c
--- /dev/null
@@ -0,0 +1,99 @@
+package be.nikiroo.utils.android;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.stream.Stream;
+
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class offer some utilities based around images and uses the Android
+ * framework.
+ * 
+ * @author niki
+ */
+public class ImageUtilsAndroid extends ImageUtils {
+       @Override
+       protected boolean check() {
+               // If we can get the class, it means we have access to it
+               Config c = Config.ALPHA_8;
+               return true;
+       }
+
+       @Override
+       public void saveAsImage(Image img, File target, String format)
+                       throws IOException {
+               FileOutputStream fos = new FileOutputStream(target);
+               try {
+                       Bitmap image = fromImage(img);
+
+                       boolean ok = false;
+                       try {
+                               ok = image.compress(
+                                               Bitmap.CompressFormat.valueOf(format.toUpperCase()),
+                                               90, fos);
+                       } catch (Exception e) {
+                               ok = false;
+                       }
+
+                       // Some formats are not reliable
+                       // Second chance: PNG
+                       if (!ok && !format.equals("png")) {
+                               ok = image.compress(Bitmap.CompressFormat.PNG, 90, fos);
+                       }
+
+                       if (!ok) {
+                               throw new IOException(
+                                               "Cannot find a writer for this image and format: "
+                                                               + format);
+                       }
+               } catch (IOException e) {
+                       throw new IOException("Cannot write image to " + target, e);
+               } finally {
+                       fos.close();
+               }
+       }
+
+       /**
+        * Convert the given {@link Image} into a {@link Bitmap} object.
+        * 
+        * @param img
+        *            the {@link Image}
+        * @return the {@link Image} object
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public Bitmap fromImage(Image img) throws IOException {
+               InputStream stream = img.newInputStream();
+               try {
+                       Bitmap image = BitmapFactory.decodeStream(stream);
+                       if (image == null) {
+                               String extra = "";
+                               if (img.getSize() <= 2048) {
+                                       try {
+                                               extra = ", content: "
+                                                               + new String(img.getData(), "UTF-8");
+                                       } catch (Exception e) {
+                                               extra = ", content unavailable";
+                                       }
+                               }
+                               String ssize = StringUtils.formatNumber(img.getSize());
+                               throw new IOException(
+                                               "Failed to convert input to image, size was: " + ssize
+                                                               + extra);
+                       }
+
+                       return image;
+               } finally {
+                       stream.close();
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/android/test/TestAndroid.java b/src/be/nikiroo/utils/android/test/TestAndroid.java
new file mode 100644 (file)
index 0000000..2ded4e1
--- /dev/null
@@ -0,0 +1,7 @@
+package be.nikiroo.utils.android.test;
+
+import be.nikiroo.utils.android.ImageUtilsAndroid;
+
+public class TestAndroid {
+       ImageUtilsAndroid a = new ImageUtilsAndroid();
+}
diff --git a/src/be/nikiroo/utils/main/bridge.java b/src/be/nikiroo/utils/main/bridge.java
new file mode 100644 (file)
index 0000000..1b7ab85
--- /dev/null
@@ -0,0 +1,136 @@
+package be.nikiroo.utils.main;
+
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.serial.server.ServerBridge;
+
+/**
+ * Serialiser bridge (starts a {@link ServerBridge} and can thus intercept
+ * communication between a client and a server).
+ * 
+ * @author niki
+ */
+public class bridge {
+       /**
+        * The optional options that can be passed to the program.
+        * 
+        * @author niki
+        */
+       private enum Option {
+               /**
+                * The encryption key for the input data (optional, but can also be
+                * empty <b>which is different</b> (it will then use an empty encryption
+                * key)).
+                */
+               KEY,
+               /**
+                * The encryption key for the output data (optional, but can also be
+                * empty <b>which is different</b> (it will then use an empty encryption
+                * key)).
+                */
+               FORWARD_KEY,
+               /** The trace level (1, 2, 3.. default is 1). */
+               TRACE_LEVEL,
+               /**
+                * The maximum length after which to truncate data to display (the whole
+                * data will still be sent).
+                */
+               MAX_DISPLAY_SIZE,
+               /** The help message. */
+               HELP,
+       }
+
+       static private String getSyntax() {
+               return "Syntax: (--options) (--) [NAME] [PORT] [FORWARD_HOST] [FORWARD_PORT]\n"//
+                               + "\tNAME         : the bridge name for display/debug purposes\n"//
+                               + "\tPORT         : the port to listen on\n"//
+                               + "\tFORWARD_HOST : the host to connect to\n"//
+                               + "\tFORWARD_PORT : the port to connect to\n"//
+                               + "\n" //
+                               + "\tOptions: \n" //
+                               + "\t--                 : no more options in the rest of the parameters\n" //
+                               + "\t--help             : this help message\n" //
+                               + "\t--key              : the INCOMING encryption key\n" //
+                               + "\t--forward-key      : the OUTGOING encryption key\n" //
+                               + "\t--trace-level      : the trace level (1, 2, 3... default is 1)\n" //
+                               + "\t--max-display-size : the maximum size after which to \n"//
+                               + "\t        truncate the messages to display (the full message will still be sent)\n" //
+               ;
+       }
+
+       /**
+        * Start a bridge between 2 servers.
+        * 
+        * @param args
+        *            the parameters, which can be seen by passing "--help" or just
+        *            calling the program without parameters
+        */
+       public static void main(String[] args) {
+               final TraceHandler tracer = new TraceHandler(true, false, 0);
+               try {
+                       if (args.length == 0) {
+                               tracer.error(getSyntax());
+                               System.exit(0);
+                       }
+
+                       String key = null;
+                       String fkey = null;
+                       int traceLevel = 1;
+                       int maxPrintSize = 0;
+
+                       int i = 0;
+                       while (args[i].startsWith("--")) {
+                               String arg = args[i];
+                               i++;
+
+                               if (arg.equals("--")) {
+                                       break;
+                               }
+
+                               arg = arg.substring(2).toUpperCase().replace("-", "_");
+                               try {
+                                       Option opt = Enum.valueOf(Option.class, arg);
+                                       switch (opt) {
+                                       case HELP:
+                                               tracer.trace(getSyntax());
+                                               System.exit(0);
+                                               break;
+                                       case FORWARD_KEY:
+                                               fkey = args[i++];
+                                               break;
+                                       case KEY:
+                                               key = args[i++];
+                                               break;
+                                       case MAX_DISPLAY_SIZE:
+                                               maxPrintSize = Integer.parseInt(args[i++]);
+                                               break;
+                                       case TRACE_LEVEL:
+                                               traceLevel = Integer.parseInt(args[i++]);
+                                               break;
+                                       }
+                               } catch (Exception e) {
+                                       tracer.error(getSyntax());
+                                       System.exit(1);
+                               }
+                       }
+
+                       if ((args.length - i) != 4) {
+                               tracer.error(getSyntax());
+                               System.exit(2);
+                       }
+
+                       String name = args[i++];
+                       int port = Integer.parseInt(args[i++]);
+                       String fhost = args[i++];
+                       int fport = Integer.parseInt(args[i++]);
+
+                       ServerBridge bridge = new ServerBridge(name, port, key, fhost,
+                                       fport, fkey);
+                       bridge.setTraceHandler(new TraceHandler(true, true, traceLevel,
+                                       maxPrintSize));
+                       bridge.run();
+               } catch (Exception e) {
+                       tracer.error(e);
+                       System.exit(42);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/main/img2aa.java b/src/be/nikiroo/utils/main/img2aa.java
new file mode 100644 (file)
index 0000000..9cc6f0c
--- /dev/null
@@ -0,0 +1,137 @@
+package be.nikiroo.utils.main;
+
+import java.awt.Dimension;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ui.ImageTextAwt;
+import be.nikiroo.utils.ui.ImageTextAwt.Mode;
+import be.nikiroo.utils.ui.ImageUtilsAwt;
+
+/**
+ * Image to ASCII conversion.
+ * 
+ * @author niki
+ */
+public class img2aa {
+       /**
+        * Syntax: (--mode=MODE) (--width=WIDTH) (--height=HEIGHT) (--size=SIZE)
+        * (--output=OUTPUT) (--invert) (--help)
+        * <p>
+        * See "--help".
+        * 
+        * @param args
+        */
+       public static void main(String[] args) {
+               Dimension size = null;
+               Mode mode = null;
+               boolean invert = false;
+               List<String> inputs = new ArrayList<String>();
+               File output = null;
+
+               String lastArg = "";
+               try {
+                       int height = -1;
+                       int width = -1;
+
+                       for (String arg : args) {
+                               lastArg = arg;
+
+                               if (arg.startsWith("--mode=")) {
+                                       mode = Mode.valueOf(arg.substring("--mode=".length()));
+                               } else if (arg.startsWith("--width=")) {
+                                       width = Integer
+                                                       .parseInt(arg.substring("--width=".length()));
+                               } else if (arg.startsWith("--height=")) {
+                                       height = Integer.parseInt(arg.substring("--height="
+                                                       .length()));
+                               } else if (arg.startsWith("--size=")) {
+                                       String content = arg.substring("--size=".length()).replace(
+                                                       "X", "x");
+                                       width = Integer.parseInt(content.split("x")[0]);
+                                       height = Integer.parseInt(content.split("x")[1]);
+                               } else if (arg.startsWith("--ouput=")) {
+                                       if (!arg.equals("--output=-")) {
+                                               output = new File(arg.substring("--output=".length()));
+                                       }
+                               } else if (arg.equals("--invert")) {
+                                       invert = true;
+                               } else if (arg.equals("--help")) {
+                                       System.out
+                                                       .println("Syntax: (--mode=MODE) (--width=WIDTH) (--height=HEIGHT) (--size=SIZE) (--output=OUTPUT) (--invert) (--help)");
+                                       System.out.println("\t --help: will show this screen");
+                                       System.out
+                                                       .println("\t --invert: will invert the 'colours'");
+                                       System.out
+                                                       .println("\t --mode: will select the rendering mode (default: ASCII):");
+                                       System.out
+                                                       .println("\t\t ASCII: ASCI output mode, that is, characters \" .-+=o8#\"");
+                                       System.out
+                                                       .println("\t\t DITHERING: Use 5 different \"colours\" which are actually"
+                                                                       + "\n\t\t Unicode characters \" ░▒▓█\"");
+                                       System.out
+                                                       .println("\t\t DOUBLE_RESOLUTION: Use \"block\" Unicode characters up to quarter"
+                                                                       + "\n\t\t blocks, thus in effect doubling the resolution both in vertical"
+                                                                       + "\n\t\t and horizontal space."
+                                                                       + "\n\t\t Note that since 2 characters next to each other are square,"
+                                                                       + "\n\t\t 4 blocks per 2 blocks for w/h resolution.");
+                                       System.out
+                                                       .println("\t\t DOUBLE_DITHERING: Use characters from both DOUBLE_RESOLUTION"
+                                                                       + "\n\t\t and DITHERING");
+                                       return;
+                               } else {
+                                       inputs.add(arg);
+                               }
+                       }
+
+                       size = new Dimension(width, height);
+                       if (inputs.size() == 0) {
+                               inputs.add("-"); // by default, stdin
+                       }
+               } catch (Exception e) {
+                       System.err.println("Syntax error: \"" + lastArg + "\" is invalid");
+                       System.exit(1);
+               }
+
+               try {
+                       if (mode == null) {
+                               mode = Mode.ASCII;
+                       }
+
+                       for (String input : inputs) {
+                               InputStream in = null;
+
+                               try {
+                                       if (input.equals("-")) {
+                                               in = System.in;
+                                       } else {
+                                               in = new FileInputStream(input);
+                                       }
+                                       BufferedImage image = ImageUtilsAwt
+                                                       .fromImage(new Image(in));
+                                       ImageTextAwt img = new ImageTextAwt(image, size, mode,
+                                                       invert);
+                                       if (output == null) {
+                                               System.out.println(img.getText());
+                                       } else {
+                                               IOUtils.writeSmallFile(output, img.getText());
+                                       }
+                               } finally {
+                                       if (!input.equals("-")) {
+                                               in.close();
+                                       }
+                               }
+                       }
+               } catch (IOException e) {
+                       e.printStackTrace();
+                       System.exit(2);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/main/justify.java b/src/be/nikiroo/utils/main/justify.java
new file mode 100644 (file)
index 0000000..2a83389
--- /dev/null
@@ -0,0 +1,53 @@
+package be.nikiroo.utils.main;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.StringUtils.Alignment;
+
+/**
+ * Text justification (left, right, center, justify).
+ * 
+ * @author niki
+ */
+public class justify {
+       /**
+        * Syntax: $0 ([left|right|center|justify]) (max width)
+        * <p>
+        * <ul>
+        * <li>mode: left, right, center or full justification (defaults to left)</li>
+        * <li>max width: the maximum width of a line, or "" for "no maximum"
+        * (defaults to "no maximum")</li>
+        * </ul>
+        * 
+        * @param args
+        */
+       public static void main(String[] args) {
+               int width = -1;
+               StringUtils.Alignment align = Alignment.LEFT;
+
+               if (args.length >= 1) {
+                       align = Alignment.valueOf(args[0].toUpperCase());
+               }
+               if (args.length >= 2) {
+                       width = Integer.parseInt(args[1]);
+               }
+
+               Scanner scan = new Scanner(System.in);
+               scan.useDelimiter("\r\n|[\r\n]");
+               try {
+                       List<String> lines = new ArrayList<String>();
+                       while (scan.hasNext()) {
+                               lines.add(scan.next());
+                       }
+
+                       for (String line : StringUtils.justifyText(lines, width, align)) {
+                               System.out.println(line);
+                       }
+               } finally {
+                       scan.close();
+               }
+       }
+}
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..0c57bf9
--- /dev/null
@@ -0,0 +1,1286 @@
+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.Writer;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.MissingResourceException;
+import java.util.PropertyResourceBundle;
+import java.util.ResourceBundle;
+
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * This class encapsulate a {@link ResourceBundle} in UTF-8. It allows to
+ * retrieve values associated to an enumeration, and allows some additional
+ * methods.
+ * <p>
+ * It also sports a writable change map, and you can save back the
+ * {@link Bundle} to file with {@link Bundle#updateFile(String)}.
+ * 
+ * @param <E>
+ *            the enum to use to get values out of this class
+ * 
+ * @author niki
+ */
+
+public class Bundle<E extends Enum<E>> {
+       /** The type of E. */
+       protected Class<E> type;
+       /**
+        * The {@link Enum} associated to this {@link Bundle} (all the keys used in
+        * this {@link Bundle} will be of this type).
+        */
+       protected Enum<?> keyType;
+
+       private TransBundle<E> descriptionBundle;
+
+       /** R/O map */
+       private Map<String, String> map;
+       /** R/W map */
+       private Map<String, String> changeMap;
+
+       /**
+        * 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}
+        * @param descriptionBundle
+        *            the description {@link TransBundle}, that is, a
+        *            {@link TransBundle} dedicated to the description of the values
+        *            of the given {@link Bundle} (can be NULL)
+        */
+       protected Bundle(Class<E> type, Enum<?> name,
+                       TransBundle<E> descriptionBundle) {
+               this.type = type;
+               this.keyType = name;
+               this.descriptionBundle = descriptionBundle;
+
+               this.map = new HashMap<String, String>();
+               this.changeMap = new HashMap<String, String>();
+               setBundle(name, Locale.getDefault(), false);
+       }
+
+       /**
+        * Check if the setting is set into this {@link Bundle}.
+        * 
+        * @param id
+        *            the id of the setting to check
+        * @param includeDefaultValue
+        *            TRUE to only return false when the setting is not set AND
+        *            there is no default value
+        * 
+        * @return TRUE if the setting is set
+        */
+       public boolean isSet(E id, boolean includeDefaultValue) {
+               return isSet(id.name(), includeDefaultValue);
+       }
+
+       /**
+        * Check if the setting is set into this {@link Bundle}.
+        * 
+        * @param name
+        *            the id of the setting to check
+        * @param includeDefaultValue
+        *            TRUE to only return false when the setting is not set AND
+        *            there is no default value
+        * 
+        * @return TRUE if the setting is set
+        */
+       protected boolean isSet(String name, boolean includeDefaultValue) {
+               if (getString(name, null) == null) {
+                       if (!includeDefaultValue || getString(name, "") == null) {
+                               return false;
+                       }
+               }
+
+               return true;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String}.
+        * 
+        * @param id
+        *            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 getString(id, null);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * @param def
+        *            the default value when it is not present in the config file
+        * 
+        * @return the associated value, or NULL if not found (not present in the
+        *         resource file)
+        */
+       public String getString(E id, String def) {
+               return getString(id, def, -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String}.
+        * <p>
+        * If no value is associated (or if it is empty!), take the default one if
+        * any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * @param def
+        *            the default value when it is not present in the config file
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated value, or NULL if not found (not present in the
+        *         resource file)
+        */
+       public String getString(E id, String def, int item) {
+               String rep = getString(id.name(), null);
+               if (rep == null) {
+                       try {
+                               Meta meta = type.getDeclaredField(id.name()).getAnnotation(
+                                               Meta.class);
+                               rep = meta.def();
+                       } catch (NoSuchFieldException e) {
+                       } catch (SecurityException e) {
+                       }
+               }
+
+               if (rep == null || rep.isEmpty()) {
+                       return def;
+               }
+
+               if (item >= 0) {
+                       List<String> values = BundleHelper.parseList(rep, item);
+                       if (values != null && item < values.size()) {
+                               return values.get(item);
+                       }
+
+                       return null;
+               }
+
+               return rep;
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link String}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * 
+        */
+       public void setString(E id, String value) {
+               setString(id.name(), value);
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link String}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        */
+       public void setString(E id, String value, int item) {
+               if (item < 0) {
+                       setString(id.name(), value);
+               } else {
+                       List<String> values = getList(id);
+                       setString(id.name(), BundleHelper.fromList(values, value, item));
+               }
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String} suffixed
+        * with the runtime value "_suffix" (that is, "_" and suffix).
+        * <p>
+        * Will only accept suffixes that form an existing id.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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) {
+               return getStringX(id, suffix, null, -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String} suffixed
+        * with the runtime value "_suffix" (that is, "_" and suffix).
+        * <p>
+        * Will only accept suffixes that form an existing id.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * @param suffix
+        *            the runtime suffix
+        * @param def
+        *            the default value when it is not present in the config file
+        * 
+        * @return the associated value, or NULL if not found (not present in the
+        *         resource file)
+        */
+       public String getStringX(E id, String suffix, String def) {
+               return getStringX(id, suffix, def, -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link String} suffixed
+        * with the runtime value "_suffix" (that is, "_" and suffix).
+        * <p>
+        * Will only accept suffixes that form an existing id.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * @param suffix
+        *            the runtime suffix
+        * @param def
+        *            the default value when it is not present in the config file
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated value, or NULL if not found (not present in the
+        *         resource file)
+        */
+       public String getStringX(E id, String suffix, String def, int item) {
+               String key = id.name()
+                               + (suffix == null ? "" : "_" + suffix.toUpperCase());
+
+               try {
+                       id = Enum.valueOf(type, key);
+                       return getString(id, def, item);
+               } catch (IllegalArgumentException e) {
+               }
+
+               return null;
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link String} suffixed
+        * with the runtime value "_suffix" (that is, "_" and suffix).
+        * <p>
+        * Will only accept suffixes that form an existing id.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param suffix
+        *            the runtime suffix
+        * @param value
+        *            the value
+        */
+       public void setStringX(E id, String suffix, String value) {
+               setStringX(id, suffix, value, -1);
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link String} suffixed
+        * with the runtime value "_suffix" (that is, "_" and suffix).
+        * <p>
+        * Will only accept suffixes that form an existing id.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param suffix
+        *            the runtime suffix
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        */
+       public void setStringX(E id, String suffix, String value, int item) {
+               String key = id.name()
+                               + (suffix == null ? "" : "_" + suffix.toUpperCase());
+
+               try {
+                       id = Enum.valueOf(type, key);
+                       setString(id, value, item);
+               } catch (IllegalArgumentException e) {
+               }
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Boolean}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public Boolean getBoolean(E id) {
+               return BundleHelper.parseBoolean(getString(id), -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Boolean}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 value = getBoolean(id);
+               if (value != null) {
+                       return value;
+               }
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Boolean}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated value
+        */
+       public Boolean getBoolean(E id, boolean def, int item) {
+               String value = getString(id);
+               if (value != null) {
+                       return BundleHelper.parseBoolean(value, item);
+               }
+
+               return def;
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link Boolean}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * 
+        */
+       public void setBoolean(E id, boolean value) {
+               setBoolean(id, value, -1);
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link Boolean}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        */
+       public void setBoolean(E id, boolean value, int item) {
+               setString(id, BundleHelper.fromBoolean(value), item);
+       }
+
+       /**
+        * Return the value associated to the given id as an {@link Integer}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public Integer getInteger(E id) {
+               String value = getString(id);
+               if (value != null) {
+                       return BundleHelper.parseInteger(value, -1);
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the value associated to the given id as an int.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 value = getInteger(id);
+               if (value != null) {
+                       return value;
+               }
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as an int.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated value
+        */
+       public Integer getInteger(E id, int def, int item) {
+               String value = getString(id);
+               if (value != null) {
+                       return BundleHelper.parseInteger(value, item);
+               }
+
+               return def;
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link Integer}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * 
+        */
+       public void setInteger(E id, int value) {
+               setInteger(id, value, -1);
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link Integer}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        */
+       public void setInteger(E id, int value, int item) {
+               setString(id, BundleHelper.fromInteger(value), item);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Character}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public Character getCharacter(E id) {
+               return BundleHelper.parseCharacter(getString(id), -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Character}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 char value
+        * 
+        * @return the associated value
+        */
+       public char getCharacter(E id, char def) {
+               Character value = getCharacter(id);
+               if (value != null) {
+                       return value;
+               }
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as a {@link Character}.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 char value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated value
+        */
+       public Character getCharacter(E id, char def, int item) {
+               String value = getString(id);
+               if (value != null) {
+                       return BundleHelper.parseCharacter(value, item);
+               }
+
+               return def;
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link Character}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * 
+        */
+       public void setCharacter(E id, char value) {
+               setCharacter(id, value, -1);
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link Character}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        */
+       public void setCharacter(E id, char value, int item) {
+               setString(id, BundleHelper.fromCharacter(value), item);
+       }
+
+       /**
+        * Return the value associated to the given id as a colour if it is found
+        * and can be parsed.
+        * <p>
+        * The returned value is an ARGB value.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * 
+        * @return the associated value
+        */
+       public Integer getColor(E id) {
+               return BundleHelper.parseColor(getString(id), -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a colour if it is found
+        * and can be parsed.
+        * <p>
+        * The returned value is an ARGB value.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 char value
+        * 
+        * @return the associated value
+        */
+       public int getColor(E id, int def) {
+               Integer value = getColor(id);
+               if (value != null) {
+                       return value;
+               }
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as a colour if it is found
+        * and can be parsed.
+        * <p>
+        * The returned value is an ARGB value.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 char value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated value
+        */
+       public Integer getColor(E id, int def, int item) {
+               String value = getString(id);
+               if (value != null) {
+                       return BundleHelper.parseColor(value, item);
+               }
+
+               return def;
+       }
+
+       /**
+        * Set the value associated to the given id as a colour.
+        * <p>
+        * The value is a BGRA value.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param color
+        *            the new colour
+        */
+       public void setColor(E id, Integer color) {
+               setColor(id, color, -1);
+       }
+
+       /**
+        * Set the value associated to the given id as a Color.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        */
+       public void setColor(E id, int value, int item) {
+               setString(id, BundleHelper.fromColor(value), item);
+       }
+
+       /**
+        * Return the value associated to the given id as a list of values if it is
+        * found and can be parsed.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            the id of the value to get
+        * 
+        * @return the associated list, empty if the value is empty, NULL if it is
+        *         not found or cannot be parsed as a list
+        */
+       public List<String> getList(E id) {
+               return BundleHelper.parseList(getString(id), -1);
+       }
+
+       /**
+        * Return the value associated to the given id as a list of values if it is
+        * found and can be parsed.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 char value
+        * 
+        * @return the associated list, empty if the value is empty, NULL if it is
+        *         not found or cannot be parsed as a list
+        */
+       public List<String> getList(E id, List<String> def) {
+               List<String> value = getList(id);
+               if (value != null) {
+                       return value;
+               }
+
+               return def;
+       }
+
+       /**
+        * Return the value associated to the given id as a list of values if it is
+        * found and can be parsed.
+        * <p>
+        * If no value is associated, take the default one if any.
+        * 
+        * @param id
+        *            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 char value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the associated list, empty if the value is empty, NULL if it is
+        *         not found or cannot be parsed as a list
+        */
+       public List<String> getList(E id, List<String> def, int item) {
+               String value = getString(id);
+               if (value != null) {
+                       return BundleHelper.parseList(value, item);
+               }
+
+               return def;
+       }
+
+       /**
+        * Set the value associated to the given id as a list of values.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param list
+        *            the new list of values
+        */
+       public void setList(E id, List<String> list) {
+               setList(id, list, -1);
+       }
+
+       /**
+        * Set the value associated to the given id as a {@link List}.
+        * 
+        * @param id
+        *            the id of the value to set
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays
+        * 
+        */
+       public void setList(E id, List<String> value, int item) {
+               setString(id, BundleHelper.fromList(value), item);
+       }
+
+       /**
+        * Create/update the .properties file.
+        * <p>
+        * 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).
+        * <p>
+        * Will update the files in {@link Bundles#getDirectory()}; it <b>MUST</b>
+        * be set.
+        * 
+        * @throws IOException
+        *             in case of IO errors
+        */
+       public void updateFile() throws IOException {
+               updateFile(Bundles.getDirectory());
+       }
+
+       /**
+        * Create/update the .properties file.
+        * <p>
+        * 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, <b>MUST NOT</b> be
+        *            NULL
+        * 
+        * @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 = Enum.valueOf(type, field.getName());
+                               String info = getMetaInfo(meta);
+
+                               if (info != null) {
+                                       writer.write(info);
+                                       writer.write("\n");
+                               }
+
+                               writeValue(writer, id);
+                       }
+               }
+
+               writer.close();
+       }
+
+       /**
+        * Delete the .properties file.
+        * <p>
+        * 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).
+        * <p>
+        * Will delete the files in {@link Bundles#getDirectory()}; it <b>MUST</b>
+        * be set.
+        * 
+        * @return TRUE if the file was deleted
+        */
+       public boolean deleteFile() {
+               return deleteFile(Bundles.getDirectory());
+       }
+
+       /**
+        * Delete the .properties file.
+        * <p>
+        * 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, <b>MUST NOT</b> be
+        *            NULL
+        * 
+        * @return TRUE if the file was deleted
+        */
+       public boolean deleteFile(String path) {
+               File file = getUpdateFile(path);
+               return file.delete();
+       }
+
+       /**
+        * The description {@link TransBundle}, that is, a {@link TransBundle}
+        * dedicated to the description of the values of the given {@link Bundle}
+        * (can be NULL).
+        * 
+        * @return the description {@link TransBundle}
+        */
+       public TransBundle<E> getDescriptionBundle() {
+               return descriptionBundle;
+       }
+
+       /**
+        * Reload the {@link Bundle} data files.
+        * 
+        * @param resetToDefault
+        *            reset to the default configuration (do not look into the
+        *            possible user configuration files, only take the original
+        *            configuration)
+        */
+       public void reload(boolean resetToDefault) {
+               setBundle(keyType, Locale.getDefault(), resetToDefault);
+       }
+
+       /**
+        * 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) {
+               return changeMap.containsKey(key) || map.containsKey(key);
+       }
+
+       /**
+        * Get the value for the given key if it exists in the internal map, or
+        * <tt>def</tt> if not.
+        * 
+        * @param key
+        *            the key to check for
+        * @param def
+        *            the default value when it is not present in the internal map
+        * 
+        * @return the value, or <tt>def</tt> if not found
+        */
+       protected String getString(String key, String def) {
+               if (changeMap.containsKey(key)) {
+                       return changeMap.get(key);
+               }
+
+               if (map.containsKey(key)) {
+                       return map.get(key);
+               }
+
+               return def;
+       }
+
+       /**
+        * Set the value for this key, in the change map (it is kept in memory, not
+        * yet on disk).
+        * 
+        * @param key
+        *            the key
+        * @param value
+        *            the associated value
+        */
+       protected void setString(String key, String value) {
+               changeMap.put(key, value == null ? null : value.trim());
+       }
+
+       /**
+        * 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 desc = meta.description();
+               boolean group = meta.group();
+               Meta.Format format = meta.format();
+               String[] list = meta.list();
+               boolean nullable = meta.nullable();
+               String def = meta.def();
+               boolean array = meta.array();
+
+               // Default, empty values -> NULL
+               if (desc.length() + list.length + def.length() == 0 && !group
+                               && nullable && format == Format.STRING) {
+                       return null;
+               }
+
+               StringBuilder builder = new StringBuilder();
+               for (String line : desc.split("\n")) {
+                       builder.append("# ").append(line).append("\n");
+               }
+
+               if (group) {
+                       builder.append("# This item is used as a group, its content is not expected to be used.");
+               } else {
+                       builder.append("# (FORMAT: ").append(format)
+                                       .append(nullable ? "" : ", required");
+                       builder.append(") ");
+
+                       if (list.length > 0) {
+                               builder.append("\n# ALLOWED VALUES: ");
+                               boolean first = true;
+                               for (String value : list) {
+                                       if (!first) {
+                                               builder.append(", ");
+                                       }
+                                       builder.append(BundleHelper.escape(value));
+                                       first = false;
+                               }
+                       }
+
+                       if (array) {
+                               builder.append("\n# (This item accepts a list of ^escaped comma-separated values)");
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * The display name used in the <tt>.properties file</tt>.
+        * 
+        * @return the name
+        */
+       protected String getBundleDisplayName() {
+               return keyType.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 data to the config file, i.e., "MY_ID = my_curent_value"
+        * followed by a new line.
+        * <p>
+        * Will prepend a # sign if the is is not set (see
+        * {@link Bundle#isSet(Enum, boolean)}).
+        * 
+        * @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 {
+               boolean set = isSet(id, false);
+               writeValue(writer, id.name(), getString(id), set);
+       }
+
+       /**
+        * Write the given data to the config file, i.e., "MY_ID = my_curent_value"
+        * followed by a new line.
+        * <p>
+        * Will prepend a # sign if the is is not set.
+        * 
+        * @param writer
+        *            the {@link Writer} to write into
+        * @param id
+        *            the id to write
+        * @param value
+        *            the id's value
+        * @param set
+        *            the value is set in this {@link Bundle}
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       protected void writeValue(Writer writer, String id, String value,
+                       boolean set) throws IOException {
+
+               if (!set) {
+                       writer.write('#');
+               }
+
+               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}
+        */
+       protected File getUpdateFile(String path) {
+               return new File(path, keyType.name() + ".properties");
+       }
+
+       /**
+        * Change the currently used bundle, and reset all changes.
+        * 
+        * @param name
+        *            the name of the bundle to load
+        * @param locale
+        *            the {@link Locale} to use
+        * @param resetToDefault
+        *            reset to the default configuration (do not look into the
+        *            possible user configuration files, only take the original
+        *            configuration)
+        */
+       protected void setBundle(Enum<?> name, Locale locale, boolean resetToDefault) {
+               changeMap.clear();
+               String dir = Bundles.getDirectory();
+               String bname = type.getPackage().getName() + "." + name.name();
+
+               boolean found = false;
+               if (!resetToDefault && dir != null) {
+                       // Look into Bundles.getDirectory() for .properties files
+                       try {
+                               File file = getPropertyFile(dir, name.name(), locale);
+                               if (file != null) {
+                                       Reader reader = new InputStreamReader(new FileInputStream(
+                                                       file), "UTF-8");
+                                       resetMap(new PropertyResourceBundle(reader));
+                                       found = true;
+                               }
+                       } catch (IOException e) {
+                               e.printStackTrace();
+                       }
+               }
+
+               if (!found) {
+                       // Look into the package itself for resources
+                       try {
+                               resetMap(ResourceBundle
+                                               .getBundle(bname, locale, type.getClassLoader(),
+                                                               new FixedResourceBundleControl()));
+                               found = true;
+                       } catch (MissingResourceException e) {
+                       } catch (Exception e) {
+                               e.printStackTrace();
+                       }
+               }
+
+               if (!found) {
+                       // We have no bundle for this Bundle
+                       System.err.println("No bundle found for: " + bname);
+                       resetMap(null);
+               }
+       }
+
+       /**
+        * Reset the backing map to the content of the given bundle, or with NULL
+        * values if bundle is NULL.
+        * 
+        * @param bundle
+        *            the bundle to copy
+        */
+       protected void resetMap(ResourceBundle bundle) {
+               this.map.clear();
+               for (Field field : type.getDeclaredFields()) {
+                       try {
+                               Meta meta = field.getAnnotation(Meta.class);
+                               if (meta != null) {
+                                       E id = Enum.valueOf(type, field.getName());
+
+                                       String value;
+                                       if (bundle != null) {
+                                               value = bundle.getString(id.name());
+                                       } else {
+                                               value = null;
+                                       }
+
+                                       this.map.put(id.name(), value == null ? null : value.trim());
+                               }
+                       } catch (MissingResourceException e) {
+                       }
+               }
+       }
+
+       /**
+        * Take a snapshot of the changes in memory in this {@link Bundle} made by
+        * the "set" methods ( {@link Bundle#setString(Enum, String)}...) at the
+        * current time.
+        * 
+        * @return a snapshot to use with {@link Bundle#restoreSnapshot(Object)}
+        */
+       public Object takeSnapshot() {
+               return new HashMap<String, String>(changeMap);
+       }
+
+       /**
+        * Restore a snapshot taken with {@link Bundle}, or reset the current
+        * changes if the snapshot is NULL.
+        * 
+        * @param snap
+        *            the snapshot or NULL
+        */
+       @SuppressWarnings("unchecked")
+       public void restoreSnapshot(Object snap) {
+               if (snap == null) {
+                       changeMap.clear();
+               } else {
+                       if (snap instanceof Map) {
+                               changeMap = (Map<String, String>) snap;
+                       } else {
+                               throw new RuntimeException(
+                                               "Restoring changes in a Bundle must be done on a changes snapshot, "
+                                                               + "or NULL to discard current changes");
+                       }
+               }
+       }
+
+       /**
+        * Return the resource file that is closer to the {@link Locale}.
+        * 
+        * @param dir
+        *            the directory to look into
+        * @param name
+        *            the file base name (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;
+                       }
+
+                       file = null;
+               }
+
+               return file;
+       }
+}
diff --git a/src/be/nikiroo/utils/resources/BundleHelper.java b/src/be/nikiroo/utils/resources/BundleHelper.java
new file mode 100644 (file)
index 0000000..c6b26c7
--- /dev/null
@@ -0,0 +1,589 @@
+package be.nikiroo.utils.resources;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Internal class used to convert data to/from {@link String}s in the context of
+ * {@link Bundle}s.
+ * 
+ * @author niki
+ */
+class BundleHelper {
+       /**
+        * Convert the given {@link String} into a {@link Boolean} if it represents
+        * a {@link Boolean}, or NULL if it doesn't.
+        * <p>
+        * Note: null, "strange text", ""... will all be converted to NULL.
+        * 
+        * @param str
+        *            the input {@link String}
+        * @param item
+        *            the item number to use for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the converted {@link Boolean} or NULL
+        */
+       static public Boolean parseBoolean(String str, int item) {
+               str = getItem(str, item);
+               if (str == null) {
+                       return null;
+               }
+
+               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 a {@link String} representation of the given {@link Boolean}.
+        * 
+        * @param value
+        *            the input value
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromBoolean(boolean value) {
+               return Boolean.toString(value);
+       }
+
+       /**
+        * Convert the given {@link String} into a {@link Integer} if it represents
+        * a {@link Integer}, or NULL if it doesn't.
+        * <p>
+        * Note: null, "strange text", ""... will all be converted to NULL.
+        * 
+        * @param str
+        *            the input {@link String}
+        * @param item
+        *            the item number to use for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the converted {@link Integer} or NULL
+        */
+       static public Integer parseInteger(String str, int item) {
+               str = getItem(str, item);
+               if (str == null) {
+                       return null;
+               }
+
+               try {
+                       return Integer.parseInt(str);
+               } catch (Exception e) {
+               }
+
+               return null;
+       }
+
+       /**
+        * Return a {@link String} representation of the given {@link Integer}.
+        * 
+        * @param value
+        *            the input value
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromInteger(int value) {
+               return Integer.toString(value);
+       }
+
+       /**
+        * Convert the given {@link String} into a {@link Character} if it
+        * represents a {@link Character}, or NULL if it doesn't.
+        * <p>
+        * Note: null, "strange text", ""... will all be converted to NULL
+        * (remember: any {@link String} whose length is not 1 is <b>not</b> a
+        * {@link Character}).
+        * 
+        * @param str
+        *            the input {@link String}
+        * @param item
+        *            the item number to use for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the converted {@link Character} or NULL
+        */
+       static public Character parseCharacter(String str, int item) {
+               str = getItem(str, item);
+               if (str == null) {
+                       return null;
+               }
+
+               String s = str.trim();
+               if (s.length() == 1) {
+                       return s.charAt(0);
+               }
+
+               return null;
+       }
+
+       /**
+        * Return a {@link String} representation of the given {@link Boolean}.
+        * 
+        * @param value
+        *            the input value
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromCharacter(char value) {
+               return Character.toString(value);
+       }
+
+       /**
+        * Convert the given {@link String} into a colour (represented here as an
+        * {@link Integer}) if it represents a colour, or NULL if it doesn't.
+        * <p>
+        * The returned colour value is an ARGB value.
+        * 
+        * @param str
+        *            the input {@link String}
+        * @param item
+        *            the item number to use for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the converted colour as an {@link Integer} value or NULL
+        */
+       static Integer parseColor(String str, int item) {
+               str = getItem(str, item);
+               if (str == null) {
+                       return null;
+               }
+
+               Integer rep = null;
+
+               str = str.trim();
+               int r = 0, g = 0, b = 0, a = -1;
+               if (str.startsWith("#") && (str.length() == 7 || str.length() == 9)) {
+                       try {
+                               r = Integer.parseInt(str.substring(1, 3), 16);
+                               g = Integer.parseInt(str.substring(3, 5), 16);
+                               b = Integer.parseInt(str.substring(5, 7), 16);
+                               if (str.length() == 9) {
+                                       a = Integer.parseInt(str.substring(7, 9), 16);
+                               } else {
+                                       a = 255;
+                               }
+
+                       } catch (NumberFormatException e) {
+                               // no changes
+                       }
+               }
+
+               // Try by name if still not found
+               if (a == -1) {
+                       if ("black".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 0;
+                               g = 0;
+                               b = 0;
+                       } else if ("white".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 255;
+                               g = 255;
+                               b = 255;
+                       } else if ("red".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 255;
+                               g = 0;
+                               b = 0;
+                       } else if ("green".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 0;
+                               g = 255;
+                               b = 0;
+                       } else if ("blue".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 0;
+                               g = 0;
+                               b = 255;
+                       } else if ("grey".equalsIgnoreCase(str)
+                                       || "gray".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 128;
+                               g = 128;
+                               b = 128;
+                       } else if ("cyan".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 0;
+                               g = 255;
+                               b = 255;
+                       } else if ("magenta".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 255;
+                               g = 0;
+                               b = 255;
+                       } else if ("yellow".equalsIgnoreCase(str)) {
+                               a = 255;
+                               r = 255;
+                               g = 255;
+                               b = 0;
+                       }
+               }
+
+               if (a != -1) {
+                       rep = ((a & 0xFF) << 24) //
+                                       | ((r & 0xFF) << 16) //
+                                       | ((g & 0xFF) << 8) //
+                                       | ((b & 0xFF) << 0);
+               }
+
+               return rep;
+       }
+
+       /**
+        * Return a {@link String} representation of the given colour.
+        * <p>
+        * The colour value is interpreted as an ARGB value.
+        * 
+        * @param color
+        *            the ARGB colour value
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromColor(int color) {
+               int a = (color >> 24) & 0xFF;
+               int r = (color >> 16) & 0xFF;
+               int g = (color >> 8) & 0xFF;
+               int b = (color >> 0) & 0xFF;
+
+               String rs = Integer.toString(r, 16);
+               String gs = Integer.toString(g, 16);
+               String bs = Integer.toString(b, 16);
+               String as = "";
+               if (a < 255) {
+                       as = Integer.toString(a, 16);
+               }
+
+               return "#" + rs + gs + bs + as;
+       }
+
+       /**
+        * The size of this raw list (note than a NULL list is of size 0).
+        * 
+        * @param raw
+        *            the raw list
+        * 
+        * @return its size if it is a list (NULL is an empty list), -1 if it is not
+        *         a list
+        */
+       static public int getListSize(String raw) {
+               if (raw == null) {
+                       return 0;
+               }
+
+               List<String> list = parseList(raw, -1);
+               if (list == null) {
+                       return -1;
+               }
+
+               return list.size();
+       }
+
+       /**
+        * Return a {@link String} representation of the given list of values.
+        * <p>
+        * The list of values is comma-separated and each value is surrounded by
+        * double-quotes; caret (^) and double-quotes (") are escaped by a caret.
+        * 
+        * @param str
+        *            the input value
+        * @param item
+        *            the item number to use for an array of values, or -1 for
+        *            non-arrays
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public List<String> parseList(String str, int item) {
+               if (str == null) {
+                       return null;
+               }
+
+               if (item >= 0) {
+                       str = getItem(str, item);
+               }
+
+               List<String> list = new ArrayList<String>();
+               try {
+                       boolean inQuote = false;
+                       boolean prevIsBackSlash = false;
+                       StringBuilder builder = new StringBuilder();
+                       for (int i = 0; i < str.length(); i++) {
+                               char car = str.charAt(i);
+
+                               if (prevIsBackSlash) {
+                                       // We don't process it here
+                                       builder.append(car);
+                                       prevIsBackSlash = false;
+                               } else {
+                                       switch (car) {
+                                       case '"':
+                                               // We don't process it here
+                                               builder.append(car);
+
+                                               if (inQuote) {
+                                                       list.add(unescape(builder.toString()));
+                                                       builder.setLength(0);
+                                               }
+
+                                               inQuote = !inQuote;
+                                               break;
+                                       case '^':
+                                               // We don't process it here
+                                               builder.append(car);
+                                               prevIsBackSlash = true;
+                                               break;
+                                       case ' ':
+                                       case '\n':
+                                       case '\r':
+                                               if (inQuote) {
+                                                       builder.append(car);
+                                               }
+                                               break;
+
+                                       case ',':
+                                               if (!inQuote) {
+                                                       break;
+                                               }
+                                               // continue to default
+                                       default:
+                                               if (!inQuote) {
+                                                       // Bad format!
+                                                       return null;
+                                               }
+
+                                               builder.append(car);
+                                               break;
+                                       }
+                               }
+                       }
+
+                       if (inQuote || prevIsBackSlash) {
+                               // Bad format!
+                               return null;
+                       }
+
+               } catch (Exception e) {
+                       return null;
+               }
+
+               return list;
+       }
+
+       /**
+        * Return a {@link String} representation of the given list of values.
+        * <p>
+        * NULL will be assimilated to an empty {@link String} if later non-null
+        * values exist, or just ignored if not.
+        * <p>
+        * Example:
+        * <ul>
+        * <li><tt>1</tt>,<tt>NULL</tt>, <tt>3</tt> will become <tt>1</tt>,
+        * <tt>""</tt>, <tt>3</tt></li>
+        * <li><tt>1</tt>,<tt>NULL</tt>, <tt>NULL</tt> will become <tt>1</tt></li>
+        * <li><tt>NULL</tt>, <tt>NULL</tt>, <tt>NULL</tt> will become an empty list
+        * </li>
+        * </ul>
+        * 
+        * @param list
+        *            the input value
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromList(List<String> list) {
+               if (list == null) {
+                       list = new ArrayList<String>();
+               }
+
+               int last = list.size() - 1;
+               for (int i = 0; i < list.size(); i++) {
+                       if (list.get(i) != null) {
+                               last = i;
+                       }
+               }
+
+               StringBuilder builder = new StringBuilder();
+               for (int i = 0; i <= last; i++) {
+                       String item = list.get(i);
+                       if (item == null) {
+                               item = "";
+                       }
+
+                       if (builder.length() > 0) {
+                               builder.append(", ");
+                       }
+                       builder.append(escape(item));
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Return a {@link String} representation of the given list of values.
+        * <p>
+        * NULL will be assimilated to an empty {@link String} if later non-null
+        * values exist, or just ignored if not.
+        * <p>
+        * Example:
+        * <ul>
+        * <li><tt>1</tt>,<tt>NULL</tt>, <tt>3</tt> will become <tt>1</tt>,
+        * <tt>""</tt>, <tt>3</tt></li>
+        * <li><tt>1</tt>,<tt>NULL</tt>, <tt>NULL</tt> will become <tt>1</tt></li>
+        * <li><tt>NULL</tt>, <tt>NULL</tt>, <tt>NULL</tt> will become an empty list
+        * </li>
+        * </ul>
+        * 
+        * @param list
+        *            the input value
+        * @param value
+        *            the value to insert
+        * @param item
+        *            the position to insert it at
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromList(List<String> list, String value, int item) {
+               if (list == null) {
+                       list = new ArrayList<String>();
+               }
+
+               while (item >= list.size()) {
+                       list.add(null);
+               }
+               list.set(item, value);
+
+               return fromList(list);
+       }
+
+       /**
+        * Return a {@link String} representation of the given list of values.
+        * <p>
+        * NULL will be assimilated to an empty {@link String} if later non-null
+        * values exist, or just ignored if not.
+        * <p>
+        * Example:
+        * <ul>
+        * <li><tt>1</tt>,<tt>NULL</tt>, <tt>3</tt> will become <tt>1</tt>,
+        * <tt>""</tt>, <tt>3</tt></li>
+        * <li><tt>1</tt>,<tt>NULL</tt>, <tt>NULL</tt> will become <tt>1</tt></li>
+        * <li><tt>NULL</tt>, <tt>NULL</tt>, <tt>NULL</tt> will become an empty list
+        * </li>
+        * </ul>
+        * 
+        * @param list
+        *            the input value
+        * @param value
+        *            the value to insert
+        * @param item
+        *            the position to insert it at
+        * 
+        * @return the raw {@link String} value that correspond to it
+        */
+       static public String fromList(String list, String value, int item) {
+               return fromList(parseList(list, -1), value, item);
+       }
+
+       /**
+        * Escape the given value for list formating (no carets, no NEWLINES...).
+        * <p>
+        * You can unescape it with {@link BundleHelper#unescape(String)}
+        * 
+        * @param value
+        *            the value to escape
+        * 
+        * @return an escaped value that can unquoted by the reverse operation
+        *         {@link BundleHelper#unescape(String)}
+        */
+       static public String escape(String value) {
+               return '"' + value//
+                               .replace("^", "^^") //
+                               .replace("\"", "^\"") //
+                               .replace("\n", "^\n") //
+                               .replace("\r", "^\r") //
+               + '"';
+       }
+
+       /**
+        * Unescape the given value for list formating (change ^n into NEWLINE and
+        * so on).
+        * <p>
+        * You can escape it with {@link BundleHelper#escape(String)}
+        * 
+        * @param value
+        *            the value to escape
+        * 
+        * @return an unescaped value that can reverted by the reverse operation
+        *         {@link BundleHelper#escape(String)}, or NULL if it was badly
+        *         formated
+        */
+       static public String unescape(String value) {
+               if (value.length() < 2 || !value.startsWith("\"")
+                               || !value.endsWith("\"")) {
+                       // Bad format
+                       return null;
+               }
+
+               value = value.substring(1, value.length() - 1);
+
+               boolean prevIsBackslash = false;
+               StringBuilder builder = new StringBuilder();
+               for (char car : value.toCharArray()) {
+                       if (prevIsBackslash) {
+                               switch (car) {
+                               case 'n':
+                               case 'N':
+                                       builder.append('\n');
+                                       break;
+                               case 'r':
+                               case 'R':
+                                       builder.append('\r');
+                                       break;
+                               default: // includes ^ and "
+                                       builder.append(car);
+                                       break;
+                               }
+                               prevIsBackslash = false;
+                       } else {
+                               if (car == '^') {
+                                       prevIsBackslash = true;
+                               } else {
+                                       builder.append(car);
+                               }
+                       }
+               }
+
+               if (prevIsBackslash) {
+                       // Bad format
+                       return null;
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Retrieve the specific item in the given value, assuming it is an array.
+        * 
+        * @param value
+        *            the value to look into
+        * @param item
+        *            the item number to get for an array of values, or -1 for
+        *            non-arrays (in that case, simply return the value as-is)
+        * 
+        * @return the value as-is for non arrays, the item <tt>item</tt> if found,
+        *         NULL if not
+        */
+       static private String getItem(String value, int item) {
+               if (item >= 0) {
+                       value = null;
+                       List<String> values = parseList(value, -1);
+                       if (values != null && item < values.size()) {
+                               value = values.get(item);
+                       }
+               }
+
+               return value;
+       }
+}
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/FixedResourceBundleControl.java b/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java
new file mode 100644 (file)
index 0000000..b53da9d
--- /dev/null
@@ -0,0 +1,60 @@
+package be.nikiroo.utils.resources;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Locale;
+import java.util.PropertyResourceBundle;
+import java.util.ResourceBundle;
+import java.util.ResourceBundle.Control;
+
+/**
+ * Fixed ResourceBundle.Control class. It will use UTF-8 for the files to load.
+ * 
+ * Also support an option to first check into the given path before looking into
+ * the resources.
+ * 
+ * @author niki
+ * 
+ */
+class FixedResourceBundleControl extends Control {
+       @Override
+       public ResourceBundle newBundle(String baseName, Locale locale,
+                       String format, ClassLoader loader, boolean reload)
+                       throws IllegalAccessException, InstantiationException, IOException {
+               // The below is a copy of the default implementation.
+               String bundleName = toBundleName(baseName, locale);
+               String resourceName = toResourceName(bundleName, "properties");
+
+               ResourceBundle bundle = null;
+               InputStream stream = null;
+               if (reload) {
+                       URL url = loader.getResource(resourceName);
+                       if (url != null) {
+                               URLConnection connection = url.openConnection();
+                               if (connection != null) {
+                                       connection.setUseCaches(false);
+                                       stream = connection.getInputStream();
+                               }
+                       }
+               } else {
+                       stream = loader.getResourceAsStream(resourceName);
+               }
+
+               if (stream != null) {
+                       try {
+                               // This line is changed to make it to read properties files
+                               // as UTF-8.
+                               // How can someone use an archaic encoding such as ISO 8859-1 by
+                               // *DEFAULT* is beyond me...
+                               bundle = new PropertyResourceBundle(new InputStreamReader(
+                                               stream, "UTF-8"));
+                       } finally {
+                               stream.close();
+                       }
+               }
+               return bundle;
+       }
+}
\ No newline at end of file
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..8ed74dc
--- /dev/null
@@ -0,0 +1,122 @@
+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 {
+       /**
+        * The format of an item (the values it is expected to be of).
+        * <p>
+        * Note that the INI file can contain arbitrary data, but it is expected to
+        * be valid.
+        * 
+        * @author niki
+        */
+       public enum Format {
+               /** An integer value, can be negative. */
+               INT,
+               /** true or false. */
+               BOOLEAN,
+               /** Any text String. */
+               STRING,
+               /** A password field. */
+               PASSWORD,
+               /** A colour (either by name or #rrggbb or #aarrggbb). */
+               COLOR,
+               /** A locale code (e.g., fr-BE, en-GB, es...). */
+               LOCALE,
+               /** A path to a file. */
+               FILE,
+               /** A path to a directory. */
+               DIRECTORY,
+               /** A fixed list of values (see {@link Meta#list()} for the values). */
+               FIXED_LIST,
+               /**
+                * A fixed list of values (see {@link Meta#list()} for the values) OR a
+                * custom String value (basically, a {@link Format#FIXED_LIST} with an
+                * option to enter a not accounted for value).
+                */
+               COMBO_LIST,
+       }
+
+       /**
+        * A description for this item: what it is or does, how to explain that item
+        * to the user including what can be used here (i.e., %s = file name, %d =
+        * file size...).
+        * <p>
+        * For group, the first line ('\\n'-separated) will be used as a title while
+        * the rest will be the description.
+        * 
+        * @return what it is
+        */
+       String description() default "";
+
+       /**
+        * This item is only used as a group, not as an option.
+        * <p>
+        * For instance, you could have LANGUAGE_CODE as a group for which you won't
+        * use the value in the program, and LANGUAGE_CODE_FR, LANGUAGE_CODE_EN
+        * inside for which the value must be set.
+        * 
+        * @return TRUE if it is a group
+        */
+       boolean group() default false;
+
+       /**
+        * What format should/must this key be in.
+        * 
+        * @return the format it is in
+        */
+       Format format() default Format.STRING;
+
+       /**
+        * The list of fixed values this item can be (either for
+        * {@link Format#FIXED_LIST} or {@link Format#COMBO_LIST}).
+        * 
+        * @return the list of values
+        */
+       String[] list() default {};
+
+       /**
+        * This item can be left unspecified.
+        * 
+        * @return TRUE if it can
+        */
+       boolean nullable() default true;
+
+       /**
+        * The default value of this item.
+        * 
+        * @return the value
+        */
+       String def() default "";
+
+       /**
+        * This item is a comma-separated list of values instead of a single value.
+        * <p>
+        * The list items are separated by a comma, each surrounded by
+        * double-quotes, with backslashes and double-quotes escaped by a backslash.
+        * <p>
+        * Example: <tt>"un", "deux"</tt>
+        * 
+        * @return TRUE if it is
+        */
+       boolean array() default false;
+
+       /**
+        * @deprecated add the info into the description, as only the description
+        *             will be translated.
+        */
+       @Deprecated
+       String info() default "";
+}
diff --git a/src/be/nikiroo/utils/resources/MetaInfo.java b/src/be/nikiroo/utils/resources/MetaInfo.java
new file mode 100644 (file)
index 0000000..f7598f1
--- /dev/null
@@ -0,0 +1,747 @@
+package be.nikiroo.utils.resources;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * A graphical item that reflect a configuration option from the given
+ * {@link Bundle}.
+ * 
+ * @author niki
+ * 
+ * @param <E>
+ *            the type of {@link Bundle} to edit
+ */
+public class MetaInfo<E extends Enum<E>> implements Iterable<MetaInfo<E>> {
+       private final Bundle<E> bundle;
+       private final E id;
+
+       private Meta meta;
+       private List<MetaInfo<E>> children = new ArrayList<MetaInfo<E>>();
+
+       private String value;
+       private List<Runnable> reloadedListeners = new ArrayList<Runnable>();
+       private List<Runnable> saveListeners = new ArrayList<Runnable>();
+
+       private String name;
+       private String description;
+
+       private boolean dirty;
+
+       /**
+        * Create a new {@link MetaInfo} from a value (without children).
+        * <p>
+        * For instance, you can call
+        * <tt>new MetaInfo(Config.class, configBundle, Config.MY_VALUE)</tt>.
+        * 
+        * @param type
+        *            the type of enum the value is
+        * @param bundle
+        *            the bundle this value belongs to
+        * @param id
+        *            the value itself
+        */
+       public MetaInfo(Class<E> type, Bundle<E> bundle, E id) {
+               this.bundle = bundle;
+               this.id = id;
+
+               try {
+                       this.meta = type.getDeclaredField(id.name()).getAnnotation(
+                                       Meta.class);
+               } catch (NoSuchFieldException e) {
+               } catch (SecurityException e) {
+               }
+
+               // We consider that if a description bundle is used, everything is in it
+
+               String description = null;
+               if (bundle.getDescriptionBundle() != null) {
+                       description = bundle.getDescriptionBundle().getString(id);
+                       if (description != null && description.trim().isEmpty()) {
+                               description = null;
+                       }
+               }
+               if (description == null) {
+                       description = meta.description();
+                       if (description == null) {
+                               description = "";
+                       }
+               }
+
+               String name = idToName(id, null);
+
+               // Special rules for groups:
+               if (meta.group()) {
+                       String groupName = description.split("\n")[0];
+                       description = description.substring(groupName.length()).trim();
+                       if (!groupName.isEmpty()) {
+                               name = groupName;
+                       }
+               }
+
+               if (meta.def() != null && !meta.def().isEmpty()) {
+                       if (!description.isEmpty()) {
+                               description += "\n\n";
+                       }
+                       description += "(Default value: " + meta.def() + ")";
+               }
+
+               this.name = name;
+               this.description = description;
+
+               reload();
+       }
+
+       /**
+        * For normal items, this is the name of this item, deduced from its ID (or
+        * in other words, it is the ID but presented in a displayable form).
+        * <p>
+        * For group items, this is the first line of the description if it is not
+        * empty (else, it is the ID in the same way as normal items).
+        * <p>
+        * Never NULL.
+        * 
+        * 
+        * @return the name, never NULL
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * A description for this item: what it is or does, how to explain that item
+        * to the user including what can be used here (i.e., %s = file name, %d =
+        * file size...).
+        * <p>
+        * For group, the first line ('\\n'-separated) will be used as a title while
+        * the rest will be the description.
+        * <p>
+        * If a default value is known, it will be specified here, too.
+        * <p>
+        * Never NULL.
+        * 
+        * @return the description, not NULL
+        */
+       public String getDescription() {
+               return description;
+       }
+
+       /**
+        * The format this item is supposed to follow
+        * 
+        * @return the format
+        */
+       public Format getFormat() {
+               return meta.format();
+       }
+
+       /**
+        * The allowed list of values that a {@link Format#FIXED_LIST} item is
+        * allowed to be, or a list of suggestions for {@link Format#COMBO_LIST}
+        * items. Also works for {@link Format#LOCALE}.
+        * <p>
+        * Will always allow an empty string in addition to the rest.
+        * 
+        * @return the list of values
+        */
+       public String[] getAllowedValues() {
+               String[] list = meta.list();
+
+               String[] withEmpty = new String[list.length + 1];
+               withEmpty[0] = "";
+               for (int i = 0; i < list.length; i++) {
+                       withEmpty[i + 1] = list[i];
+               }
+
+               return withEmpty;
+       }
+
+       /**
+        * Return all the languages known by the program for this bundle.
+        * <p>
+        * This only works for {@link TransBundle}, and will return an empty list if
+        * this is not a {@link TransBundle}.
+        * 
+        * @return the known language codes
+        */
+       public List<String> getKnownLanguages() {
+               if (bundle instanceof TransBundle) {
+                       return ((TransBundle<E>) bundle).getKnownLanguages();
+               }
+
+               return new ArrayList<String>();
+       }
+
+       /**
+        * This item is a comma-separated list of values instead of a single value.
+        * <p>
+        * The list items are separated by a comma, each surrounded by
+        * double-quotes, with backslashes and double-quotes escaped by a backslash.
+        * <p>
+        * Example: <tt>"un", "deux"</tt>
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isArray() {
+               return meta.array();
+       }
+
+       /**
+        * A manual flag to specify if the data has been changed or not, which can
+        * be used by {@link MetaInfo#save(boolean)}.
+        * 
+        * @return TRUE if it is dirty (if it has changed)
+        */
+       public boolean isDirty() {
+               return dirty;
+       }
+
+       /**
+        * A manual flag to specify that the data has been changed, which can be
+        * used by {@link MetaInfo#save(boolean)}.
+        */
+       public void setDirty() {
+               this.dirty = true;
+       }
+
+       /**
+        * The number of items in this item if it {@link MetaInfo#isArray()}, or -1
+        * if not.
+        * 
+        * @param useDefaultIfEmpty
+        *            check the size of the default list instead if the list is
+        *            empty
+        * 
+        * @return -1 or the number of items
+        */
+       public int getListSize(boolean useDefaultIfEmpty) {
+               if (!isArray()) {
+                       return -1;
+               }
+
+               return BundleHelper.getListSize(getString(-1, useDefaultIfEmpty));
+       }
+
+       /**
+        * This item is only used as a group, not as an option.
+        * <p>
+        * For instance, you could have LANGUAGE_CODE as a group for which you won't
+        * use the value in the program, and LANGUAGE_CODE_FR, LANGUAGE_CODE_EN
+        * inside for which the value must be set.
+        * 
+        * @return TRUE if it is a group
+        */
+       public boolean isGroup() {
+               return meta.group();
+       }
+
+       /**
+        * The value stored by this item, as a {@link String}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param useDefaultIfEmpty
+        *            use the default value instead of NULL if the setting is not
+        *            set
+        * 
+        * @return the value
+        */
+       public String getString(int item, boolean useDefaultIfEmpty) {
+               if (isArray() && item >= 0) {
+                       List<String> values = BundleHelper.parseList(value, -1);
+                       if (values != null && item < values.size()) {
+                               return values.get(item);
+                       }
+
+                       if (useDefaultIfEmpty) {
+                               return getDefaultString(item);
+                       }
+
+                       return null;
+               }
+
+               if (value == null && useDefaultIfEmpty) {
+                       return getDefaultString(item);
+               }
+
+               return value;
+       }
+
+       /**
+        * The default value of this item, as a {@link String}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the default value
+        */
+       public String getDefaultString(int item) {
+               if (isArray() && item >= 0) {
+                       List<String> values = BundleHelper.parseList(meta.def(), item);
+                       if (values != null && item < values.size()) {
+                               return values.get(item);
+                       }
+
+                       return null;
+               }
+
+               return meta.def();
+       }
+
+       /**
+        * The value stored by this item, as a {@link Boolean}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param useDefaultIfEmpty
+        *            use the default value instead of NULL if the setting is not
+        *            set
+        * 
+        * @return the value
+        */
+       public Boolean getBoolean(int item, boolean useDefaultIfEmpty) {
+               return BundleHelper
+                               .parseBoolean(getString(item, useDefaultIfEmpty), -1);
+       }
+
+       /**
+        * The default value of this item, as a {@link Boolean}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the default value
+        */
+       public Boolean getDefaultBoolean(int item) {
+               return BundleHelper.parseBoolean(getDefaultString(item), -1);
+       }
+
+       /**
+        * The value stored by this item, as a {@link Character}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param useDefaultIfEmpty
+        *            use the default value instead of NULL if the setting is not
+        *            set
+        * 
+        * @return the value
+        */
+       public Character getCharacter(int item, boolean useDefaultIfEmpty) {
+               return BundleHelper.parseCharacter(getString(item, useDefaultIfEmpty),
+                               -1);
+       }
+
+       /**
+        * The default value of this item, as a {@link Character}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the default value
+        */
+       public Character getDefaultCharacter(int item) {
+               return BundleHelper.parseCharacter(getDefaultString(item), -1);
+       }
+
+       /**
+        * The value stored by this item, as an {@link Integer}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param useDefaultIfEmpty
+        *            use the default value instead of NULL if the setting is not
+        *            set
+        * 
+        * @return the value
+        */
+       public Integer getInteger(int item, boolean useDefaultIfEmpty) {
+               return BundleHelper
+                               .parseInteger(getString(item, useDefaultIfEmpty), -1);
+       }
+
+       /**
+        * The default value of this item, as an {@link Integer}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the default value
+        */
+       public Integer getDefaultInteger(int item) {
+               return BundleHelper.parseInteger(getDefaultString(item), -1);
+       }
+
+       /**
+        * The value stored by this item, as a colour (represented here as an
+        * {@link Integer}) if it represents a colour, or NULL if it doesn't.
+        * <p>
+        * The returned colour value is an ARGB value.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param useDefaultIfEmpty
+        *            use the default value instead of NULL if the setting is not
+        *            set
+        * 
+        * @return the value
+        */
+       public Integer getColor(int item, boolean useDefaultIfEmpty) {
+               return BundleHelper.parseColor(getString(item, useDefaultIfEmpty), -1);
+       }
+
+       /**
+        * The default value stored by this item, as a colour (represented here as
+        * an {@link Integer}) if it represents a colour, or NULL if it doesn't.
+        * <p>
+        * The returned colour value is an ARGB value.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the value
+        */
+       public Integer getDefaultColor(int item) {
+               return BundleHelper.parseColor(getDefaultString(item), -1);
+       }
+
+       /**
+        * A {@link String} representation of the list of values.
+        * <p>
+        * The list of values is comma-separated and each value is surrounded by
+        * double-quotes; backslashes and double-quotes are escaped by a backslash.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param useDefaultIfEmpty
+        *            use the default value instead of NULL if the setting is not
+        *            set
+        * 
+        * @return the value
+        */
+       public List<String> getList(int item, boolean useDefaultIfEmpty) {
+               return BundleHelper.parseList(getString(item, useDefaultIfEmpty), -1);
+       }
+
+       /**
+        * A {@link String} representation of the default list of values.
+        * <p>
+        * The list of values is comma-separated and each value is surrounded by
+        * double-quotes; backslashes and double-quotes are escaped by a backslash.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the value
+        */
+       public List<String> getDefaultList(int item) {
+               return BundleHelper.parseList(getDefaultString(item), -1);
+       }
+
+       /**
+        * The value stored by this item, as a {@link String}.
+        * 
+        * @param value
+        *            the new value
+        * @param item
+        *            the item number to set for an array of values, or -1 to set
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       public void setString(String value, int item) {
+               if (isArray() && item >= 0) {
+                       this.value = BundleHelper.fromList(this.value, value, item);
+               } else {
+                       this.value = value;
+               }
+       }
+
+       /**
+        * The value stored by this item, as a {@link Boolean}.
+        * 
+        * @param value
+        *            the new value
+        * @param item
+        *            the item number to set for an array of values, or -1 to set
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       public void setBoolean(boolean value, int item) {
+               setString(BundleHelper.fromBoolean(value), item);
+       }
+
+       /**
+        * The value stored by this item, as a {@link Character}.
+        * 
+        * @param value
+        *            the new value
+        * @param item
+        *            the item number to set for an array of values, or -1 to set
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       public void setCharacter(char value, int item) {
+               setString(BundleHelper.fromCharacter(value), item);
+       }
+
+       /**
+        * The value stored by this item, as an {@link Integer}.
+        * 
+        * @param value
+        *            the new value
+        * @param item
+        *            the item number to set for an array of values, or -1 to set
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       public void setInteger(int value, int item) {
+               setString(BundleHelper.fromInteger(value), item);
+       }
+
+       /**
+        * The value stored by this item, as a colour (represented here as an
+        * {@link Integer}) if it represents a colour, or NULL if it doesn't.
+        * <p>
+        * The colour value is an ARGB value.
+        * 
+        * @param value
+        *            the value
+        * @param item
+        *            the item number to set for an array of values, or -1 to set
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       public void setColor(int value, int item) {
+               setString(BundleHelper.fromColor(value), item);
+       }
+
+       /**
+        * A {@link String} representation of the default list of values.
+        * <p>
+        * The list of values is comma-separated and each value is surrounded by
+        * double-quotes; backslashes and double-quotes are escaped by a backslash.
+        * 
+        * @param value
+        *            the {@link String} representation
+        * @param item
+        *            the item number to set for an array of values, or -1 to set
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       public void setList(List<String> value, int item) {
+               setString(BundleHelper.fromList(value), item);
+       }
+
+       /**
+        * Reload the value from the {@link Bundle}, so the last value that was
+        * saved will be used.
+        */
+       public void reload() {
+               if (bundle.isSet(id, false)) {
+                       value = bundle.getString(id);
+               } else {
+                       value = null;
+               }
+
+               // Copy the list so we can create new listener in a listener
+               for (Runnable listener : new ArrayList<Runnable>(reloadedListeners)) {
+                       try {
+                               listener.run();
+                       } catch (Exception e) {
+                               e.printStackTrace();
+                       }
+               }
+       }
+
+       /**
+        * Add a listener that will be called <b>after</b> a reload operation.
+        * <p>
+        * You could use it to refresh the UI for instance.
+        * 
+        * @param listener
+        *            the listener
+        */
+       public void addReloadedListener(Runnable listener) {
+               reloadedListeners.add(listener);
+       }
+
+       /**
+        * Save the current value to the {@link Bundle}.
+        * <p>
+        * Note that listeners will be called <b>before</b> the dirty check and
+        * <b>before</b> saving the value.
+        * 
+        * @param onlyIfDirty
+        *            only save the data if the dirty flag is set (will reset the
+        *            dirty flag)
+        */
+       public void save(boolean onlyIfDirty) {
+               // Copy the list so we can create new listener in a listener
+               for (Runnable listener : new ArrayList<Runnable>(saveListeners)) {
+                       try {
+                               listener.run();
+                       } catch (Exception e) {
+                               e.printStackTrace();
+                       }
+               }
+
+               if (!onlyIfDirty || isDirty()) {
+                       bundle.setString(id, value);
+               }
+       }
+
+       /**
+        * Add a listener that will be called <b>before</b> a save operation.
+        * <p>
+        * You could use it to make some modification to the stored value before it
+        * is saved.
+        * 
+        * @param listener
+        *            the listener
+        */
+       public void addSaveListener(Runnable listener) {
+               saveListeners.add(listener);
+       }
+
+       /**
+        * The sub-items if any (if no sub-items, will return an empty list).
+        * <p>
+        * Sub-items are declared when a {@link Meta} has an ID that starts with the
+        * ID of a {@link Meta#group()} {@link MetaInfo}.
+        * <p>
+        * For instance:
+        * <ul>
+        * <li>{@link Meta} <tt>MY_PREFIX</tt> is a {@link Meta#group()}</li>
+        * <li>{@link Meta} <tt>MY_PREFIX_DESCRIPTION</tt> is another {@link Meta}</li>
+        * <li><tt>MY_PREFIX_DESCRIPTION</tt> will be a child of <tt>MY_PREFIX</tt></li>
+        * </ul>
+        * 
+        * @return the sub-items if any
+        */
+       public List<MetaInfo<E>> getChildren() {
+               return children;
+       }
+
+       @Override
+       public Iterator<MetaInfo<E>> iterator() {
+               return children.iterator();
+       }
+
+       /**
+        * Create a list of {@link MetaInfo}, one for each of the item in the given
+        * {@link Bundle}.
+        * 
+        * @param <E>
+        *            the type of {@link Bundle} to edit
+        * @param type
+        *            a class instance of the item type to work on
+        * @param bundle
+        *            the {@link Bundle} to sort through
+        * 
+        * @return the list
+        */
+       static public <E extends Enum<E>> List<MetaInfo<E>> getItems(Class<E> type,
+                       Bundle<E> bundle) {
+               List<MetaInfo<E>> list = new ArrayList<MetaInfo<E>>();
+               List<MetaInfo<E>> shadow = new ArrayList<MetaInfo<E>>();
+               for (E id : type.getEnumConstants()) {
+                       MetaInfo<E> info = new MetaInfo<E>(type, bundle, id);
+                       list.add(info);
+                       shadow.add(info);
+               }
+
+               for (int i = 0; i < list.size(); i++) {
+                       MetaInfo<E> info = list.get(i);
+
+                       MetaInfo<E> parent = findParent(info, shadow);
+                       if (parent != null) {
+                               list.remove(i--);
+                               parent.children.add(info);
+                               info.name = idToName(info.id, parent.id);
+                       }
+               }
+
+               return list;
+       }
+
+       /**
+        * Find the longest parent of the given {@link MetaInfo}, which means:
+        * <ul>
+        * <li>the parent is a {@link Meta#group()}</li>
+        * <li>the parent Id is a substring of the Id of the given {@link MetaInfo}</li>
+        * <li>there is no other parent sharing a substring for this
+        * {@link MetaInfo} with a longer Id</li>
+        * </ul>
+        * 
+        * @param <E>
+        *            the kind of enum
+        * @param info
+        *            the info to look for a parent for
+        * @param candidates
+        *            the list of potential parents
+        * 
+        * @return the longest parent or NULL if no parent is found
+        */
+       static private <E extends Enum<E>> MetaInfo<E> findParent(MetaInfo<E> info,
+                       List<MetaInfo<E>> candidates) {
+               String id = info.id.toString();
+               MetaInfo<E> group = null;
+               for (MetaInfo<E> pcandidate : candidates) {
+                       if (pcandidate.isGroup()) {
+                               String candidateId = pcandidate.id.toString();
+                               if (!id.equals(candidateId) && id.startsWith(candidateId)) {
+                                       if (group == null
+                                                       || group.id.toString().length() < candidateId
+                                                                       .length()) {
+                                               group = pcandidate;
+                                       }
+                               }
+                       }
+               }
+
+               return group;
+       }
+
+       static private <E extends Enum<E>> String idToName(E id, E prefix) {
+               String name = id.toString();
+               if (prefix != null && name.startsWith(prefix.toString())) {
+                       name = name.substring(prefix.toString().length());
+               }
+
+               if (name.length() > 0) {
+                       name = name.substring(0, 1).toUpperCase()
+                                       + name.substring(1).toLowerCase();
+               }
+
+               name = name.replace("_", " ");
+
+               return name.trim();
+       }
+}
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..28fa280
--- /dev/null
@@ -0,0 +1,398 @@
+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;
+
+/**
+ * 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>
+ * 
+ * @param <E>
+ *            the enum to use to get values out of this class
+ * 
+ * @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) {
+               this(type, name, (Locale) 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, can be NULL for default
+        */
+       public TransBundle(Class<E> type, Enum<?> name, String language) {
+               super(type, name, null);
+               setLocale(language);
+       }
+
+       /**
+        * 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, can be NULL for default
+        */
+       public TransBundle(Class<E> type, Enum<?> name, Locale language) {
+               super(type, name, null);
+               setLocale(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, null);
+               } else {
+                       result = null;
+               }
+
+               if (values != null && values.length > 0 && result != null) {
+                       return String.format(locale, result, values);
+               }
+
+               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 for this bundle.
+        * 
+        * @return the known language codes
+        */
+       public List<String> getKnownLanguages() {
+               return getKnownLanguages(keyType);
+       }
+
+       /**
+        * The current language (which can be the default one, but NOT NULL).
+        * 
+        * @return the language, not NULL
+        */
+       public Locale getLocale() {
+               return locale;
+       }
+
+       /**
+        * The current language (which can be the default one, but NOT NULL).
+        * 
+        * @return the language, not NULL, in a display format (fr-BE, en-GB, es,
+        *         de...)
+        */
+       public String getLocaleString() {
+               String lang = locale.getLanguage();
+               String country = locale.getCountry();
+               if (country != null && !country.isEmpty()) {
+                       return lang + "-" + country;
+               }
+               return lang;
+       }
+
+       /**
+        * 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 setLocale(String language) {
+               setLocale(getLocaleFor(language));
+       }
+
+       /**
+        * Initialise the translation mappings for the given language.
+        * 
+        * @param language
+        *            the language to initialise, or NULL for default
+        */
+       private void setLocale(Locale language) {
+               if (language != null) {
+                       defaultLocale = false;
+                       locale = language;
+               } else {
+                       defaultLocale = true;
+                       locale = Locale.getDefault();
+               }
+
+               setBundle(keyType, locale, false);
+       }
+
+       @Override
+       public void reload(boolean resetToDefault) {
+               setBundle(keyType, locale, resetToDefault);
+       }
+
+       @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();
+               Object status = takeSnapshot();
+
+               // default locale
+               setLocale((Locale) null);
+               if (prev.equals(Locale.getDefault().getLanguage())) {
+                       // restore snapshot if default locale = current locale
+                       restoreSnapshot(status);
+               }
+               super.updateFile(path);
+
+               for (String lang : getKnownLanguages()) {
+                       setLocale(lang);
+                       if (lang.equals(prev)) {
+                               restoreSnapshot(status);
+                       }
+                       super.updateFile(path);
+               }
+
+               setLocale(prev);
+               restoreSnapshot(status);
+       }
+
+       @Override
+       protected File getUpdateFile(String path) {
+               String code = locale.toString();
+               File file = null;
+               if (!defaultLocale && code.length() > 0) {
+                       file = new File(path, keyType.name() + "_" + code + ".properties");
+               } else {
+                       // Default properties file:
+                       file = new File(path, keyType.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, null);
+                       boolean set = isSet(id, false);
+                       writeValue(writer, name, value, set);
+               }
+       }
+
+       /**
+        * 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 NULL if it is not known
+        */
+       static private Locale getLocaleFor(String language) {
+               Locale locale;
+
+               if (language == null || language.trim().isEmpty()) {
+                       return null;
+               }
+
+               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..9983b8b
--- /dev/null
@@ -0,0 +1,125 @@
+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.List;
+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 = 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) {
+               List<String> acc = new ArrayList<String>();
+               List<File> dirs = new ArrayList<File>();
+               getResourcesFromDirectory(acc, dirs, directory, pattern);
+
+               List<String> rep = new ArrayList<String>();
+               for (String value : acc) {
+                       if (pattern.matcher(value).matches()) {
+                               rep.add(value);
+                       }
+               }
+
+               return rep;
+       }
+
+       private static void getResourcesFromDirectory(List<String> acc,
+                       List<File> dirs, final File directory, final Pattern pattern) {
+               final File[] fileList = directory.listFiles();
+               if (fileList != null) {
+                       for (final File file : fileList) {
+                               if (!dirs.contains(file)) {
+                                       try {
+                                               String key = file.getCanonicalPath();
+                                               if (!acc.contains(key)) {
+                                                       if (file.isDirectory()) {
+                                                               dirs.add(file);
+                                                               getResourcesFromDirectory(acc, dirs, file,
+                                                                               pattern);
+                                                       } else {
+                                                               acc.add(key);
+                                                       }
+                                               }
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+       }
+}
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..bda940b
--- /dev/null
@@ -0,0 +1,14 @@
+/**
+ * This package encloses the classes needed to use 
+ * {@link be.nikiroo.utils.resources.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
diff --git a/src/be/nikiroo/utils/serial/CustomSerializer.java b/src/be/nikiroo/utils/serial/CustomSerializer.java
new file mode 100644 (file)
index 0000000..e58ccf2
--- /dev/null
@@ -0,0 +1,150 @@
+package be.nikiroo.utils.serial;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import be.nikiroo.utils.streams.BufferedInputStream;
+import be.nikiroo.utils.streams.ReplaceInputStream;
+import be.nikiroo.utils.streams.ReplaceOutputStream;
+
+/**
+ * A {@link CustomSerializer} supports and generates values in the form:
+ * <ul>
+ * <li><tt>custom^<i>TYPE</i>^<i>ENCODED_VALUE</i></tt></li>
+ * </ul>
+ * <p>
+ * In this scheme, the values are:
+ * <ul>
+ * <li><tt>custom</tt>: a fixed keyword</li>
+ * <li><tt>^</tt>: a fixed separator character (the
+ * <tt><i>ENCODED_VALUE</i></tt> can still use it inside its content, though</li>
+ * <li><tt><i>TYPE</i></tt>: the object type of this value</li>
+ * <li><tt><i>ENCODED_VALUE</i></tt>: the custom encoded value</li>
+ * </ul>
+ * <p>
+ * To create a new {@link CustomSerializer}, you are expected to implement the
+ * abstract methods of this class. The rest should be taken care of bythe
+ * system.
+ * 
+ * @author niki
+ */
+public abstract class CustomSerializer {
+       /**
+        * Generate the custom <tt><i>ENCODED_VALUE</i></tt> from this
+        * <tt>value</tt>.
+        * <p>
+        * The <tt>value</tt> will always be of the supported type.
+        * 
+        * @param out
+        *            the {@link OutputStream} to write the value to
+        * @param value
+        *            the value to serialize
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract void toStream(OutputStream out, Object value)
+                       throws IOException;
+
+       /**
+        * Regenerate the value from the custom <tt><i>ENCODED_VALUE</i></tt>.
+        * <p>
+        * The value in the {@link InputStream} <tt>in</tt> will always be of the
+        * supported type.
+        * 
+        * @param in
+        *            the {@link InputStream} containing the
+        *            <tt><i>ENCODED_VALUE</i></tt>
+        * 
+        * @return the regenerated object
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract Object fromStream(InputStream in) throws IOException;
+
+       /**
+        * Return the supported type name.
+        * <p>
+        * It <b>must</b> be the name returned by {@link Object#getClass()
+        * #getCanonicalName()}.
+        * 
+        * @return the supported class name
+        */
+       protected abstract String getType();
+
+       /**
+        * Encode the object into the given {@link OutputStream}, i.e., generate the
+        * <tt><i>ENCODED_VALUE</i></tt> part.
+        * <p>
+        * Use whatever scheme you wish, the system shall ensure that the content is
+        * correctly encoded and that you will receive the same content at decode
+        * time.
+        * 
+        * @param out
+        *            the builder to append to
+        * @param value
+        *            the object to encode
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void encode(OutputStream out, Object value) throws IOException {
+               ReplaceOutputStream replace = new ReplaceOutputStream(out, //
+                               new String[] { "\\", "\n" }, //
+                               new String[] { "\\\\", "\\n" });
+
+               try {
+                       SerialUtils.write(replace, "custom^");
+                       SerialUtils.write(replace, getType());
+                       SerialUtils.write(replace, "^");
+                       toStream(replace, value);
+               } finally {
+                       replace.close(false);
+               }
+       }
+
+       /**
+        * Decode the value back into the supported object type.
+        * <p>
+        * We do <b>not</b> expect the full content here but only:
+        * <ul>
+        * <li>ENCODED_VALUE
+        * <li>
+        * </ul>
+        * That is, we do not expect the "<tt>custom</tt>^<tt><i>TYPE</i></tt>^"
+        * part.
+        * 
+        * @param in
+        *            the encoded value
+        * 
+        * @return the object
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Object decode(InputStream in) throws IOException {
+               ReplaceInputStream replace = new ReplaceInputStream(in, //
+                               new String[] { "\\\\", "\\n" }, //
+                               new String[] { "\\", "\n" });
+
+               try {
+                       return fromStream(replace);
+               } finally {
+                       replace.close(false);
+               }
+       }
+
+       public static boolean isCustom(BufferedInputStream in) throws IOException {
+               return in.startsWith("custom^");
+       }
+
+       public static String typeOf(String encodedValue) {
+               int pos1 = encodedValue.indexOf('^');
+               int pos2 = encodedValue.indexOf('^', pos1 + 1);
+               String type = encodedValue.substring(pos1 + 1, pos2);
+
+               return type;
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/Exporter.java b/src/be/nikiroo/utils/serial/Exporter.java
new file mode 100644 (file)
index 0000000..2470bde
--- /dev/null
@@ -0,0 +1,60 @@
+package be.nikiroo.utils.serial;
+
+import java.io.IOException;
+import java.io.NotSerializableException;
+import java.io.OutputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A simple class to serialise objects to {@link String}.
+ * <p>
+ * This class does not support inner classes (it does support nested classes,
+ * though).
+ * 
+ * @author niki
+ */
+public class Exporter {
+       private Map<Integer, Object> map;
+       private OutputStream out;
+
+       /**
+        * Create a new {@link Exporter}.
+        * 
+        * @param out
+        *            export the data to this stream
+        */
+       public Exporter(OutputStream out) {
+               if (out == null) {
+                       throw new NullPointerException(
+                                       "Cannot create an be.nikiroo.utils.serials.Exporter that will export to NULL");
+               }
+
+               this.out = out;
+               map = new HashMap<Integer, Object>();
+       }
+
+       /**
+        * Serialise the given object and add it to the list.
+        * <p>
+        * <b>Important: </b>If the operation fails (with a
+        * {@link NotSerializableException}), the {@link Exporter} will be corrupted
+        * (will contain bad, most probably not importable data).
+        * 
+        * @param o
+        *            the object to serialise
+        * @return this (for easier appending of multiple values)
+        * 
+        * @throws NotSerializableException
+        *             if the object cannot be serialised (in this case, the
+        *             {@link Exporter} can contain bad, most probably not
+        *             importable data)
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Exporter append(Object o) throws NotSerializableException,
+                       IOException {
+               SerialUtils.append(out, o, map);
+               return this;
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/Importer.java b/src/be/nikiroo/utils/serial/Importer.java
new file mode 100644 (file)
index 0000000..81814df
--- /dev/null
@@ -0,0 +1,288 @@
+package be.nikiroo.utils.serial;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.streams.Base64InputStream;
+import be.nikiroo.utils.streams.BufferedInputStream;
+import be.nikiroo.utils.streams.NextableInputStream;
+import be.nikiroo.utils.streams.NextableInputStreamStep;
+
+/**
+ * A simple class that can accept the output of {@link Exporter} to recreate
+ * objects as they were sent to said exporter.
+ * <p>
+ * This class requires the objects (and their potential enclosing objects) to
+ * have an empty constructor, and does not support inner classes (it does
+ * support nested classes, though).
+ * 
+ * @author niki
+ */
+public class Importer {
+       private Boolean link;
+       private Object me;
+       private Importer child;
+       private Map<String, Object> map;
+
+       private String currentFieldName;
+
+       /**
+        * Create a new {@link Importer}.
+        */
+       public Importer() {
+               map = new HashMap<String, Object>();
+               map.put("NULL", null);
+       }
+
+       private Importer(Map<String, Object> map) {
+               this.map = map;
+       }
+
+       /**
+        * Read some data into this {@link Importer}: it can be the full serialised
+        * content, or a number of lines of it (any given line <b>MUST</b> be
+        * complete though) and accumulate it with the already present data.
+        * 
+        * @param in
+        *            the data to parse
+        * 
+        * @return itself so it can be chained
+        * 
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        * @throws IOException
+        *             if the content cannot be read (for instance, corrupt data)
+        * @throws NullPointerException
+        *             if the stream is empty
+        */
+       public Importer read(InputStream in) throws NoSuchFieldException,
+                       NoSuchMethodException, ClassNotFoundException, IOException,
+                       NullPointerException {
+
+               NextableInputStream stream = new NextableInputStream(in,
+                               new NextableInputStreamStep('\n'));
+
+               try {
+                       if (in == null) {
+                               throw new NullPointerException("InputStream is null");
+                       }
+
+                       boolean first = true;
+                       while (stream.next()) {
+                               if (stream.eof()) {
+                                       if (first) {
+                                               throw new NullPointerException(
+                                                               "InputStream empty, normal termination");
+                                       }
+                                       return this;
+                               }
+                               first = false;
+
+                               boolean zip = stream.startsWith("ZIP:");
+                               boolean b64 = stream.startsWith("B64:");
+
+                               if (zip || b64) {
+                                       stream.skip("XXX:".length());
+
+                                       InputStream decoded = stream.open();
+                                       if (zip) {
+                                               decoded = new GZIPInputStream(decoded);
+                                       }
+                                       decoded = new Base64InputStream(decoded, false);
+
+                                       try {
+                                               read(decoded);
+                                       } finally {
+                                               decoded.close();
+                                       }
+                               } else {
+                                       processLine(stream);
+                               }
+                       }
+               } finally {
+                       stream.close(false);
+               }
+
+               return this;
+       }
+
+       /**
+        * Read a single (whole) line of serialised data into this {@link Importer}
+        * and accumulate it with the already present data.
+        * 
+        * @param in
+        *            the line to parse
+        * 
+        * @return TRUE if we are just done with one object or sub-object
+        * 
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        * @throws IOException
+        *             if the content cannot be read (for instance, corrupt data)
+        */
+       private boolean processLine(BufferedInputStream in)
+                       throws NoSuchFieldException, NoSuchMethodException,
+                       ClassNotFoundException, IOException {
+
+               // Defer to latest child if any
+               if (child != null) {
+                       if (child.processLine(in)) {
+                               if (currentFieldName != null) {
+                                       setField(currentFieldName, child.getValue());
+                                       currentFieldName = null;
+                               }
+                               child = null;
+                       }
+
+                       return false;
+               }
+
+               // Start/Stop object
+               if (in.is("{")) { // START: new child if needed
+                       if (link != null) {
+                               child = new Importer(map);
+                       }
+                       in.end();
+                       return false;
+               } else if (in.is("}")) { // STOP: report self to parent
+                       in.end();
+                       return true;
+               }
+
+               // Custom objects
+               if (CustomSerializer.isCustom(in)) {
+                       // not a field value but a direct value
+                       me = SerialUtils.decode(in);
+                       return false;
+               }
+
+               // REF: (object)
+               if (in.startsWith("REF ")) { // REF: create/link self
+                       // here, line is REF type@999:xxx
+                       // xxx is optional
+
+                       NextableInputStream stream = new NextableInputStream(in,
+                                       new NextableInputStreamStep(':'));
+                       try {
+                               stream.next();
+
+                               stream.skip("REF ".length());
+                               String header = IOUtils.readSmallStream(stream);
+
+                               String[] tab = header.split("@");
+                               if (tab.length != 2) {
+                                       throw new IOException("Bad import header line: " + header);
+                               }
+                               String type = tab[0];
+                               String ref = tab[1];
+
+                               stream.nextAll();
+
+                               link = map.containsKey(ref);
+                               if (link) {
+                                       me = map.get(ref);
+                                       stream.end();
+                               } else {
+                                       if (stream.eof()) {
+                                               // construct
+                                               me = SerialUtils.createObject(type);
+                                       } else {
+                                               // direct value
+                                               me = SerialUtils.decode(stream);
+                                       }
+                                       map.put(ref, me);
+                               }
+                       } finally {
+                               stream.close(false);
+                       }
+
+                       return false;
+               }
+
+               if (SerialUtils.isDirectValue(in)) {
+                       // not a field value but a direct value
+                       me = SerialUtils.decode(in);
+                       return false;
+               }
+
+               if (in.startsWith("^")) {
+                       in.skip(1);
+
+                       NextableInputStream nameThenContent = new NextableInputStream(in,
+                                       new NextableInputStreamStep(':'));
+
+                       try {
+                               nameThenContent.next();
+                               String fieldName = IOUtils.readSmallStream(nameThenContent);
+
+                               if (nameThenContent.nextAll() && !nameThenContent.eof()) {
+                                       // field value is direct or custom
+                                       Object value = null;
+                                       value = SerialUtils.decode(nameThenContent);
+
+                                       // To support simple types directly:
+                                       if (me == null) {
+                                               me = value;
+                                       } else {
+                                               setField(fieldName, value);
+                                       }
+                               } else {
+                                       // field value is compound
+                                       currentFieldName = fieldName;
+                               }
+                       } finally {
+                               nameThenContent.close(false);
+                       }
+
+                       return false;
+               }
+
+               String line = IOUtils.readSmallStream(in);
+               throw new IOException("Line cannot be processed: <" + line + ">");
+       }
+
+       private void setField(String name, Object value)
+                       throws NoSuchFieldException {
+
+               try {
+                       Field field = me.getClass().getDeclaredField(name);
+
+                       field.setAccessible(true);
+                       field.set(me, value);
+               } catch (NoSuchFieldException e) {
+                       throw new NoSuchFieldException(String.format(
+                                       "Field \"%s\" was not found in object of type \"%s\".",
+                                       name, me.getClass().getCanonicalName()));
+               } catch (Exception e) {
+                       throw new NoSuchFieldException(String.format(
+                                       "Internal error when setting \"%s.%s\": %s", me.getClass()
+                                                       .getCanonicalName(), name, e.getMessage()));
+               }
+       }
+
+       /**
+        * Return the current deserialised value.
+        * 
+        * @return the current value
+        */
+       public Object getValue() {
+               return me;
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/SerialUtils.java b/src/be/nikiroo/utils/serial/SerialUtils.java
new file mode 100644 (file)
index 0000000..ad3b5d4
--- /dev/null
@@ -0,0 +1,733 @@
+package be.nikiroo.utils.serial;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.NotSerializableException;
+import java.io.OutputStream;
+import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UnknownFormatConversionException;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.streams.Base64InputStream;
+import be.nikiroo.utils.streams.Base64OutputStream;
+import be.nikiroo.utils.streams.BufferedInputStream;
+import be.nikiroo.utils.streams.NextableInputStream;
+import be.nikiroo.utils.streams.NextableInputStreamStep;
+
+/**
+ * Small class to help with serialisation.
+ * <p>
+ * Note that we do not support inner classes (but we do support nested classes)
+ * and all objects require an empty constructor to be deserialised.
+ * <p>
+ * It is possible to add support to custom types (both the encoder and the
+ * decoder will require the custom classes) -- see {@link CustomSerializer}.
+ * <p>
+ * Default supported types are:
+ * <ul>
+ * <li>NULL (as a null value)</li>
+ * <li>String</li>
+ * <li>Boolean</li>
+ * <li>Byte</li>
+ * <li>Character</li>
+ * <li>Short</li>
+ * <li>Long</li>
+ * <li>Float</li>
+ * <li>Double</li>
+ * <li>Integer</li>
+ * <li>Enum (any enum whose name and value is known by the caller)</li>
+ * <li>java.awt.image.BufferedImage (as a {@link CustomSerializer})</li>
+ * <li>An array of the above (as a {@link CustomSerializer})</li>
+ * <li>URL</li>
+ * </ul>
+ * 
+ * @author niki
+ */
+public class SerialUtils {
+       private static Map<String, CustomSerializer> customTypes;
+
+       static {
+               customTypes = new HashMap<String, CustomSerializer>();
+
+               // Array types:
+               customTypes.put("[]", new CustomSerializer() {
+                       @Override
+                       protected void toStream(OutputStream out, Object value)
+                                       throws IOException {
+
+                               String type = value.getClass().getCanonicalName();
+                               type = type.substring(0, type.length() - 2); // remove the []
+
+                               write(out, type);
+                               try {
+                                       for (int i = 0; true; i++) {
+                                               Object item = Array.get(value, i);
+
+                                               // encode it normally if direct value
+                                               write(out, "\r");
+                                               if (!SerialUtils.encode(out, item)) {
+                                                       try {
+                                                               write(out, "B64:");
+                                                               OutputStream out64 = new Base64OutputStream(
+                                                                               out, true);
+                                                               new Exporter(out64).append(item);
+                                                               out64.flush();
+                                                       } catch (NotSerializableException e) {
+                                                               throw new UnknownFormatConversionException(e
+                                                                               .getMessage());
+                                                       }
+                                               }
+                                       }
+                               } catch (ArrayIndexOutOfBoundsException e) {
+                                       // Done.
+                               }
+                       }
+
+                       @Override
+                       protected Object fromStream(InputStream in) throws IOException {
+                               NextableInputStream stream = new NextableInputStream(in,
+                                               new NextableInputStreamStep('\r'));
+
+                               try {
+                                       List<Object> list = new ArrayList<Object>();
+                                       stream.next();
+                                       String type = IOUtils.readSmallStream(stream);
+
+                                       while (stream.next()) {
+                                               Object value = new Importer().read(stream).getValue();
+                                               list.add(value);
+                                       }
+
+                                       Object array = Array.newInstance(
+                                                       SerialUtils.getClass(type), list.size());
+                                       for (int i = 0; i < list.size(); i++) {
+                                               Array.set(array, i, list.get(i));
+                                       }
+
+                                       return array;
+                               } catch (Exception e) {
+                                       if (e instanceof IOException) {
+                                               throw (IOException) e;
+                                       }
+                                       throw new IOException(e.getMessage());
+                               }
+                       }
+
+                       @Override
+                       protected String getType() {
+                               return "[]";
+                       }
+               });
+
+               // URL:
+               customTypes.put("java.net.URL", new CustomSerializer() {
+                       @Override
+                       protected void toStream(OutputStream out, Object value)
+                                       throws IOException {
+                               String val = "";
+                               if (value != null) {
+                                       val = ((URL) value).toString();
+                               }
+
+                               out.write(StringUtils.getBytes(val));
+                       }
+
+                       @Override
+                       protected Object fromStream(InputStream in) throws IOException {
+                               String val = IOUtils.readSmallStream(in);
+                               if (!val.isEmpty()) {
+                                       return new URL(val);
+                               }
+
+                               return null;
+                       }
+
+                       @Override
+                       protected String getType() {
+                               return "java.net.URL";
+                       }
+               });
+
+               // Images (this is currently the only supported image type by default)
+               customTypes.put("be.nikiroo.utils.Image", new CustomSerializer() {
+                       @Override
+                       protected void toStream(OutputStream out, Object value)
+                                       throws IOException {
+                               Image img = (Image) value;
+                               OutputStream encoded = new Base64OutputStream(out, true);
+                               try {
+                                       InputStream in = img.newInputStream();
+                                       try {
+                                               IOUtils.write(in, encoded);
+                                       } finally {
+                                               in.close();
+                                       }
+                               } finally {
+                                       encoded.flush();
+                                       // Cannot close!
+                               }
+                       }
+
+                       @Override
+                       protected String getType() {
+                               return "be.nikiroo.utils.Image";
+                       }
+
+                       @Override
+                       protected Object fromStream(InputStream in) throws IOException {
+                               try {
+                                       // Cannot close it!
+                                       InputStream decoded = new Base64InputStream(in, false);
+                                       return new Image(decoded);
+                               } catch (IOException e) {
+                                       throw new UnknownFormatConversionException(e.getMessage());
+                               }
+                       }
+               });
+       }
+
+       /**
+        * Create an empty object of the given type.
+        * 
+        * @param type
+        *            the object type (its class name)
+        * 
+        * @return the new object
+        * 
+        * @throws ClassNotFoundException
+        *             if the class cannot be found
+        * @throws NoSuchMethodException
+        *             if the given class is not compatible with this code
+        */
+       public static Object createObject(String type)
+                       throws ClassNotFoundException, NoSuchMethodException {
+
+               String desc = null;
+               try {
+                       Class<?> clazz = getClass(type);
+                       String className = clazz.getName();
+                       List<Object> args = new ArrayList<Object>();
+                       List<Class<?>> classes = new ArrayList<Class<?>>();
+                       Constructor<?> ctor = null;
+                       if (className.contains("$")) {
+                               for (String parentName = className.substring(0,
+                                               className.lastIndexOf('$'));; parentName = parentName
+                                               .substring(0, parentName.lastIndexOf('$'))) {
+                                       Object parent = createObject(parentName);
+                                       args.add(parent);
+                                       classes.add(parent.getClass());
+
+                                       if (!parentName.contains("$")) {
+                                               break;
+                                       }
+                               }
+
+                               // Better error description in case there is no empty
+                               // constructor:
+                               desc = "";
+                               String end = "";
+                               for (Class<?> parent = clazz; parent != null
+                                               && !parent.equals(Object.class); parent = parent
+                                               .getSuperclass()) {
+                                       if (!desc.isEmpty()) {
+                                               desc += " [:";
+                                               end += "]";
+                                       }
+                                       desc += parent;
+                               }
+                               desc += end;
+                               //
+
+                               try {
+                                       ctor = clazz.getDeclaredConstructor(classes
+                                                       .toArray(new Class[] {}));
+                               } catch (NoSuchMethodException nsme) {
+                                       // TODO: it seems we do not always need a parameter for each
+                                       // level, so we currently try "ALL" levels or "FIRST" level
+                                       // only -> we should check the actual rule and use it
+                                       ctor = clazz.getDeclaredConstructor(classes.get(0));
+                                       Object firstParent = args.get(0);
+                                       args.clear();
+                                       args.add(firstParent);
+                               }
+                               desc = null;
+                       } else {
+                               ctor = clazz.getDeclaredConstructor();
+                       }
+
+                       ctor.setAccessible(true);
+                       return ctor.newInstance(args.toArray());
+               } catch (ClassNotFoundException e) {
+                       throw e;
+               } catch (NoSuchMethodException e) {
+                       if (desc != null) {
+                               throw new NoSuchMethodException("Empty constructor not found: "
+                                               + desc);
+                       }
+                       throw e;
+               } catch (Exception e) {
+                       throw new NoSuchMethodException("Cannot instantiate: " + type);
+               }
+       }
+
+       /**
+        * Insert a custom serialiser that will take precedence over the default one
+        * or the target class.
+        * 
+        * @param serializer
+        *            the custom serialiser
+        */
+       static public void addCustomSerializer(CustomSerializer serializer) {
+               customTypes.put(serializer.getType(), serializer);
+       }
+
+       /**
+        * Serialise the given object into this {@link OutputStream}.
+        * <p>
+        * <b>Important: </b>If the operation fails (with a
+        * {@link NotSerializableException}), the {@link StringBuilder} will be
+        * corrupted (will contain bad, most probably not importable data).
+        * 
+        * @param out
+        *            the output {@link OutputStream} to serialise to
+        * @param o
+        *            the object to serialise
+        * @param map
+        *            the map of already serialised objects (if the given object or
+        *            one of its descendant is already present in it, only an ID
+        *            will be serialised)
+        * 
+        * @throws NotSerializableException
+        *             if the object cannot be serialised (in this case, the
+        *             {@link StringBuilder} can contain bad, most probably not
+        *             importable data)
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       static void append(OutputStream out, Object o, Map<Integer, Object> map)
+                       throws NotSerializableException, IOException {
+
+               Field[] fields = new Field[] {};
+               String type = "";
+               String id = "NULL";
+
+               if (o != null) {
+                       int hash = System.identityHashCode(o);
+                       fields = o.getClass().getDeclaredFields();
+                       type = o.getClass().getCanonicalName();
+                       if (type == null) {
+                               // Anonymous inner classes support
+                               type = o.getClass().getName();
+                       }
+                       id = Integer.toString(hash);
+                       if (map.containsKey(hash)) {
+                               fields = new Field[] {};
+                       } else {
+                               map.put(hash, o);
+                       }
+               }
+
+               write(out, "{\nREF ");
+               write(out, type);
+               write(out, "@");
+               write(out, id);
+               write(out, ":");
+
+               if (!encode(out, o)) { // check if direct value
+                       try {
+                               for (Field field : fields) {
+                                       field.setAccessible(true);
+
+                                       if (field.getName().startsWith("this$")
+                                                       || field.isSynthetic()
+                                                       || (field.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
+                                               // Do not keep this links of nested classes
+                                               // Do not keep synthetic fields
+                                               // Do not keep final fields
+                                               continue;
+                                       }
+
+                                       write(out, "\n^");
+                                       write(out, field.getName());
+                                       write(out, ":");
+
+                                       Object value = field.get(o);
+
+                                       if (!encode(out, value)) {
+                                               write(out, "\n");
+                                               append(out, value, map);
+                                       }
+                               }
+                       } catch (IllegalArgumentException e) {
+                               e.printStackTrace(); // should not happen (see
+                                                                               // setAccessible)
+                       } catch (IllegalAccessException e) {
+                               e.printStackTrace(); // should not happen (see
+                                                                               // setAccessible)
+                       }
+
+                       write(out, "\n}");
+               }
+       }
+
+       /**
+        * Encode the object into the given {@link OutputStream} if possible and if
+        * supported.
+        * <p>
+        * A supported object in this context means an object we can directly
+        * encode, like an Integer or a String. Custom objects and arrays are also
+        * considered supported, but <b>compound objects are not supported here</b>.
+        * <p>
+        * For compound objects, you should use {@link Exporter}.
+        * 
+        * @param out
+        *            the {@link OutputStream} to append to
+        * @param value
+        *            the object to encode (can be NULL, which will be encoded)
+        * 
+        * @return TRUE if success, FALSE if not (the content of the
+        *         {@link OutputStream} won't be changed in case of failure)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       static boolean encode(OutputStream out, Object value) throws IOException {
+               if (value == null) {
+                       write(out, "NULL");
+               } else if (value.getClass().getSimpleName().endsWith("[]")) {
+                       // Simple name does support [] suffix and do not return NULL for
+                       // inner anonymous classes
+                       customTypes.get("[]").encode(out, value);
+               } else if (customTypes.containsKey(value.getClass().getCanonicalName())) {
+                       customTypes.get(value.getClass().getCanonicalName())//
+                                       .encode(out, value);
+               } else if (value instanceof String) {
+                       encodeString(out, (String) value);
+               } else if (value instanceof Boolean) {
+                       write(out, value);
+               } else if (value instanceof Byte) {
+                       write(out, "b");
+                       write(out, value);
+               } else if (value instanceof Character) {
+                       write(out, "c");
+                       encodeString(out, "" + value);
+               } else if (value instanceof Short) {
+                       write(out, "s");
+                       write(out, value);
+               } else if (value instanceof Integer) {
+                       write(out, "i");
+                       write(out, value);
+               } else if (value instanceof Long) {
+                       write(out, "l");
+                       write(out, value);
+               } else if (value instanceof Float) {
+                       write(out, "f");
+                       write(out, value);
+               } else if (value instanceof Double) {
+                       write(out, "d");
+                       write(out, value);
+               } else if (value instanceof Enum) {
+                       write(out, "E:");
+                       String type = value.getClass().getCanonicalName();
+                       write(out, type);
+                       write(out, ".");
+                       write(out, ((Enum<?>) value).name());
+                       write(out, ";");
+               } else {
+                       return false;
+               }
+
+               return true;
+       }
+
+       static boolean isDirectValue(BufferedInputStream encodedValue)
+                       throws IOException {
+               if (CustomSerializer.isCustom(encodedValue)) {
+                       return false;
+               }
+
+               for (String fullValue : new String[] { "NULL", "null", "true", "false" }) {
+                       if (encodedValue.is(fullValue)) {
+                               return true;
+                       }
+               }
+
+               for (String prefix : new String[] { "c\"", "\"", "b", "s", "i", "l",
+                               "f", "d", "E:" }) {
+                       if (encodedValue.startsWith(prefix)) {
+                               return true;
+                       }
+               }
+
+               return false;
+       }
+
+       /**
+        * Decode the data into an equivalent supported source object.
+        * <p>
+        * A supported object in this context means an object we can directly
+        * encode, like an Integer or a String (see
+        * {@link SerialUtils#decode(String)}.
+        * <p>
+        * Custom objects and arrays are also considered supported here, but
+        * <b>compound objects are not</b>.
+        * <p>
+        * For compound objects, you should use {@link Importer}.
+        * 
+        * @param encodedValue
+        *            the encoded data, cannot be NULL
+        * 
+        * @return the object (can be NULL for NULL encoded values)
+        * 
+        * @throws IOException
+        *             if the content cannot be converted
+        */
+       static Object decode(BufferedInputStream encodedValue) throws IOException {
+               if (CustomSerializer.isCustom(encodedValue)) {
+                       // custom^TYPE^ENCODED_VALUE
+                       NextableInputStream content = new NextableInputStream(encodedValue,
+                                       new NextableInputStreamStep('^'));
+                       try {
+                               content.next();
+                               @SuppressWarnings("unused")
+                               String custom = IOUtils.readSmallStream(content);
+                               content.next();
+                               String type = IOUtils.readSmallStream(content);
+                               content.nextAll();
+                               if (customTypes.containsKey(type)) {
+                                       return customTypes.get(type).decode(content);
+                               }
+                               content.end();
+                               throw new IOException("Unknown custom type: " + type);
+                       } finally {
+                               content.close(false);
+                               encodedValue.end();
+                       }
+               }
+
+               String encodedString = IOUtils.readSmallStream(encodedValue);
+               return decode(encodedString);
+       }
+
+       /**
+        * Decode the data into an equivalent supported source object.
+        * <p>
+        * A supported object in this context means an object we can directly
+        * encode, like an Integer or a String.
+        * <p>
+        * For custom objects and arrays, you should use
+        * {@link SerialUtils#decode(InputStream)} or directly {@link Importer}.
+        * <p>
+        * For compound objects, you should use {@link Importer}.
+        * 
+        * @param encodedValue
+        *            the encoded data, cannot be NULL
+        * 
+        * @return the object (can be NULL for NULL encoded values)
+        * 
+        * @throws IOException
+        *             if the content cannot be converted
+        */
+       static Object decode(String encodedValue) throws IOException {
+               try {
+                       String cut = "";
+                       if (encodedValue.length() > 1) {
+                               cut = encodedValue.substring(1);
+                       }
+
+                       if (encodedValue.equals("NULL") || encodedValue.equals("null")) {
+                               return null;
+                       } else if (encodedValue.startsWith("\"")) {
+                               return decodeString(encodedValue);
+                       } else if (encodedValue.equals("true")) {
+                               return true;
+                       } else if (encodedValue.equals("false")) {
+                               return false;
+                       } else if (encodedValue.startsWith("b")) {
+                               return Byte.parseByte(cut);
+                       } else if (encodedValue.startsWith("c")) {
+                               return decodeString(cut).charAt(0);
+                       } else if (encodedValue.startsWith("s")) {
+                               return Short.parseShort(cut);
+                       } else if (encodedValue.startsWith("l")) {
+                               return Long.parseLong(cut);
+                       } else if (encodedValue.startsWith("f")) {
+                               return Float.parseFloat(cut);
+                       } else if (encodedValue.startsWith("d")) {
+                               return Double.parseDouble(cut);
+                       } else if (encodedValue.startsWith("i")) {
+                               return Integer.parseInt(cut);
+                       } else if (encodedValue.startsWith("E:")) {
+                               cut = cut.substring(1);
+                               return decodeEnum(cut);
+                       } else {
+                               throw new IOException("Unrecognized value: " + encodedValue);
+                       }
+               } catch (Exception e) {
+                       if (e instanceof IOException) {
+                               throw (IOException) e;
+                       }
+                       throw new IOException(e.getMessage(), e);
+               }
+       }
+
+       /**
+        * Write the given {@link String} into the given {@link OutputStream} in
+        * UTF-8.
+        * 
+        * @param out
+        *            the {@link OutputStream}
+        * @param data
+        *            the data to write, cannot be NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       static void write(OutputStream out, Object data) throws IOException {
+               out.write(StringUtils.getBytes(data.toString()));
+       }
+
+       /**
+        * Return the corresponding class or throw an {@link Exception} if it
+        * cannot.
+        * 
+        * @param type
+        *            the class name to look for
+        * 
+        * @return the class (will never be NULL)
+        * 
+        * @throws ClassNotFoundException
+        *             if the class cannot be found
+        * @throws NoSuchMethodException
+        *             if the class cannot be created (usually because it or its
+        *             enclosing class doesn't have an empty constructor)
+        */
+       static private Class<?> getClass(String type)
+                       throws ClassNotFoundException, NoSuchMethodException {
+               Class<?> clazz = null;
+               try {
+                       clazz = Class.forName(type);
+               } catch (ClassNotFoundException e) {
+                       int pos = type.length();
+                       pos = type.lastIndexOf(".", pos);
+                       if (pos >= 0) {
+                               String parentType = type.substring(0, pos);
+                               String nestedType = type.substring(pos + 1);
+                               Class<?> javaParent = null;
+                               try {
+                                       javaParent = getClass(parentType);
+                                       parentType = javaParent.getName();
+                                       clazz = Class.forName(parentType + "$" + nestedType);
+                               } catch (Exception ee) {
+                               }
+
+                               if (javaParent == null) {
+                                       throw new NoSuchMethodException(
+                                                       "Class not found: "
+                                                                       + type
+                                                                       + " (the enclosing class cannot be created: maybe it doesn't have an empty constructor?)");
+                               }
+                       }
+               }
+
+               if (clazz == null) {
+                       throw new ClassNotFoundException("Class not found: " + type);
+               }
+
+               return clazz;
+       }
+
+       @SuppressWarnings({ "unchecked", "rawtypes" })
+       static private Enum<?> decodeEnum(String escaped) {
+               // escaped: be.xxx.EnumType.VALUE;
+               int pos = escaped.lastIndexOf(".");
+               String type = escaped.substring(0, pos);
+               String name = escaped.substring(pos + 1, escaped.length() - 1);
+
+               try {
+                       return Enum.valueOf((Class<Enum>) getClass(type), name);
+               } catch (Exception e) {
+                       throw new UnknownFormatConversionException("Unknown enum: <" + type
+                                       + "> " + name);
+               }
+       }
+
+       // aa bb -> "aa\tbb"
+       static void encodeString(OutputStream out, String raw) throws IOException {
+               // TODO: not. efficient.
+               out.write('\"');
+               for (char car : raw.toCharArray()) {
+                       encodeString(out, car);
+               }
+               out.write('\"');
+       }
+
+       // for encoding string, NOT to encode a char by itself!
+       static void encodeString(OutputStream out, char raw) throws IOException {
+               switch (raw) {
+               case '\\':
+                       out.write('\\');
+                       out.write('\\');
+                       break;
+               case '\r':
+                       out.write('\\');
+                       out.write('r');
+                       break;
+               case '\n':
+                       out.write('\\');
+                       out.write('n');
+                       break;
+               case '"':
+                       out.write('\\');
+                       out.write('\"');
+                       break;
+               default:
+                       out.write(raw);
+                       break;
+               }
+       }
+
+       // "aa\tbb" -> aa bb
+       static String decodeString(String escaped) {
+               StringBuilder builder = new StringBuilder();
+
+               boolean escaping = false;
+               for (char car : escaped.toCharArray()) {
+                       if (!escaping) {
+                               if (car == '\\') {
+                                       escaping = true;
+                               } else {
+                                       builder.append(car);
+                               }
+                       } else {
+                               switch (car) {
+                               case '\\':
+                                       builder.append('\\');
+                                       break;
+                               case 'r':
+                                       builder.append('\r');
+                                       break;
+                               case 'n':
+                                       builder.append('\n');
+                                       break;
+                               case '"':
+                                       builder.append('"');
+                                       break;
+                               }
+                               escaping = false;
+                       }
+               }
+
+               return builder.substring(1, builder.length() - 1);
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/server/ConnectAction.java b/src/be/nikiroo/utils/serial/server/ConnectAction.java
new file mode 100644 (file)
index 0000000..6a19368
--- /dev/null
@@ -0,0 +1,474 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.utils.CryptUtils;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.Exporter;
+import be.nikiroo.utils.serial.Importer;
+import be.nikiroo.utils.streams.BufferedOutputStream;
+import be.nikiroo.utils.streams.NextableInputStream;
+import be.nikiroo.utils.streams.NextableInputStreamStep;
+import be.nikiroo.utils.streams.ReplaceInputStream;
+import be.nikiroo.utils.streams.ReplaceOutputStream;
+
+/**
+ * Base class used for the client/server basic handling.
+ * <p>
+ * It represents a single action: a client is expected to only execute one
+ * action, while a server is expected to execute one action for each client
+ * action.
+ * 
+ * @author niki
+ */
+abstract class ConnectAction {
+       // We separate each "packet" we send with this character and make sure it
+       // does not occurs in the message itself.
+       static private char STREAM_SEP = '\b';
+       static private String[] STREAM_RAW = new String[] { "\\", "\b" };
+       static private String[] STREAM_CODED = new String[] { "\\\\", "\\b" };
+
+       private Socket s;
+       private boolean server;
+
+       private Version clientVersion;
+       private Version serverVersion;
+
+       private CryptUtils crypt;
+
+       private Object lock = new Object();
+       private NextableInputStream in;
+       private BufferedOutputStream out;
+       private boolean contentToSend;
+
+       /**
+        * Method that will be called when an action is performed on either the
+        * client or server this {@link ConnectAction} represent.
+        * 
+        * @param version
+        *            the version on the other side of the communication (client or
+        *            server)
+        * 
+        * @throws Exception
+        *             in case of I/O error
+        */
+       abstract protected void action(Version version) throws Exception;
+
+       /**
+        * Method called when we negotiate the version with the client.
+        * <p>
+        * Thus, it is only called on the server.
+        * <p>
+        * Will return the actual server version by default.
+        * 
+        * @param clientVersion
+        *            the client version
+        * 
+        * @return the version to send to the client
+        */
+       abstract protected Version negotiateVersion(Version clientVersion);
+
+       /**
+        * Handler called when an unexpected error occurs in the code.
+        * 
+        * @param e
+        *            the exception that occurred, SSLException usually denotes a
+        *            crypt error
+        */
+       abstract protected void onError(Exception e);
+
+       /**
+        * Create a new {@link ConnectAction}.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param server
+        *            TRUE for a server action, FALSE for a client action (will
+        *            impact the process)
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param version
+        *            the client-or-server version (depending upon the boolean
+        *            parameter <tt>server</tt>)
+        */
+       protected ConnectAction(Socket s, boolean server, String key,
+                       Version version) {
+               this.s = s;
+               this.server = server;
+               if (key != null) {
+                       crypt = new CryptUtils(key);
+               }
+
+               if (version == null) {
+                       version = new Version();
+               }
+
+               if (server) {
+                       serverVersion = version;
+               } else {
+                       clientVersion = version;
+               }
+       }
+
+       /**
+        * The version of this client-or-server.
+        * 
+        * @return the version
+        */
+       public Version getVersion() {
+               if (server) {
+                       return serverVersion;
+               }
+
+               return clientVersion;
+       }
+
+       /**
+        * The total amount of bytes received.
+        * 
+        * @return the amount of bytes received
+        */
+       public long getBytesReceived() {
+               return in.getBytesRead();
+       }
+
+       /**
+        * The total amount of bytes sent.
+        * 
+        * @return the amount of bytes sent
+        */
+       public long getBytesWritten() {
+               return out.getBytesWritten();
+       }
+
+       /**
+        * Actually start the process (this is synchronous).
+        */
+       public void connect() {
+               try {
+                       in = new NextableInputStream(s.getInputStream(),
+                                       new NextableInputStreamStep(STREAM_SEP));
+                       try {
+                               out = new BufferedOutputStream(s.getOutputStream());
+                               try {
+                                       // Negotiate version
+                                       Version version;
+                                       if (server) {
+                                               String HELLO = recString();
+                                               if (HELLO == null || !HELLO.startsWith("VERSION ")) {
+                                                       throw new SSLException(
+                                                                       "Client used bad encryption key");
+                                               }
+                                               version = negotiateVersion(new Version(
+                                                               HELLO.substring("VERSION ".length())));
+                                               sendString("VERSION " + version);
+                                       } else {
+                                               String HELLO = sendString("VERSION " + clientVersion);
+                                               if (HELLO == null || !HELLO.startsWith("VERSION ")) {
+                                                       throw new SSLException(
+                                                                       "Server did not accept the encryption key");
+                                               }
+                                               version = new Version(HELLO.substring("VERSION "
+                                                               .length()));
+                                       }
+
+                                       // Actual code
+                                       action(version);
+                               } finally {
+                                       out.close();
+                               }
+                       } finally {
+                               in.close();
+                       }
+               } catch (Exception e) {
+                       onError(e);
+               } finally {
+                       try {
+                               s.close();
+                       } catch (Exception e) {
+                               onError(e);
+                       }
+               }
+       }
+
+       /**
+        * Serialise and send the given object to the counter part (and, only for
+        * client, return the deserialised answer -- the server will always receive
+        * NULL).
+        * 
+        * @param data
+        *            the data to send
+        * 
+        * @return the answer (which can be NULL if no answer, or NULL for an answer
+        *         which is NULL) if this action is a client, always NULL if it is a
+        *         server
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        */
+       protected Object sendObject(Object data) throws IOException,
+                       NoSuchFieldException, NoSuchMethodException, ClassNotFoundException {
+               return send(out, data, false);
+       }
+
+       /**
+        * Reserved for the server: flush the data to the client and retrieve its
+        * answer.
+        * <p>
+        * Also used internally for the client (only do something if there is
+        * contentToSend).
+        * <p>
+        * Will only flush the data if there is contentToSend.
+        * 
+        * @return the deserialised answer (which can actually be NULL)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        * @throws java.lang.NullPointerException
+        *             if the counter part has no data to send
+        */
+       protected Object recObject() throws IOException, NoSuchFieldException,
+                       NoSuchMethodException, ClassNotFoundException,
+                       java.lang.NullPointerException {
+               return rec(false);
+       }
+
+       /**
+        * Send the given string to the counter part (and, only for client, return
+        * the answer -- the server will always receive NULL).
+        * 
+        * @param line
+        *            the data to send (we will add a line feed)
+        * 
+        * @return the answer if this action is a client (without the added line
+        *         feed), NULL if it is a server
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws SSLException
+        *             in case of crypt error
+        */
+       protected String sendString(String line) throws IOException {
+               try {
+                       return (String) send(out, line, true);
+               } catch (NoSuchFieldException e) {
+                       // Cannot happen
+                       e.printStackTrace();
+               } catch (NoSuchMethodException e) {
+                       // Cannot happen
+                       e.printStackTrace();
+               } catch (ClassNotFoundException e) {
+                       // Cannot happen
+                       e.printStackTrace();
+               }
+
+               return null;
+       }
+
+       /**
+        * Reserved for the server (externally): flush the data to the client and
+        * retrieve its answer.
+        * <p>
+        * Also used internally for the client (only do something if there is
+        * contentToSend).
+        * <p>
+        * Will only flush the data if there is contentToSend.
+        * 
+        * @return the answer (which can be NULL if no more content)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws SSLException
+        *             in case of crypt error
+        */
+       protected String recString() throws IOException {
+               try {
+                       return (String) rec(true);
+               } catch (NoSuchFieldException e) {
+                       // Cannot happen
+                       e.printStackTrace();
+               } catch (NoSuchMethodException e) {
+                       // Cannot happen
+                       e.printStackTrace();
+               } catch (ClassNotFoundException e) {
+                       // Cannot happen
+                       e.printStackTrace();
+               } catch (NullPointerException e) {
+                       // Should happen
+                       e.printStackTrace();
+               }
+
+               return null;
+       }
+
+       /**
+        * Serialise and send the given object to the counter part (and, only for
+        * client, return the deserialised answer -- the server will always receive
+        * NULL).
+        * 
+        * @param out
+        *            the stream to write to
+        * @param data
+        *            the data to write
+        * @param asString
+        *            TRUE to write it as a String, FALSE to write it as an Object
+        * 
+        * @return the answer (which can be NULL if no answer, or NULL for an answer
+        *         which is NULL) if this action is a client, always NULL if it is a
+        *         server
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws SSLException
+        *             in case of crypt error
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        */
+       private Object send(BufferedOutputStream out, Object data, boolean asString)
+                       throws IOException, NoSuchFieldException, NoSuchMethodException,
+                       ClassNotFoundException, java.lang.NullPointerException {
+
+               synchronized (lock) {
+                       OutputStream sub;
+                       if (crypt != null) {
+                               sub = crypt.encrypt64(out.open());
+                       } else {
+                               sub = out.open();
+                       }
+
+                       sub = new ReplaceOutputStream(sub, STREAM_RAW, STREAM_CODED);
+                       try {
+                               if (asString) {
+                                       sub.write(StringUtils.getBytes(data.toString()));
+                               } else {
+                                       new Exporter(sub).append(data);
+                               }
+                       } finally {
+                               sub.close();
+                       }
+
+                       out.write(STREAM_SEP);
+
+                       if (server) {
+                               out.flush();
+                               return null;
+                       }
+
+                       contentToSend = true;
+                       try {
+                               return rec(asString);
+                       } catch (NullPointerException e) {
+                               // We accept no data here for Objects
+                       }
+
+                       return null;
+               }
+       }
+
+       /**
+        * Reserved for the server: flush the data to the client and retrieve its
+        * answer.
+        * <p>
+        * Also used internally for the client (only do something if there is
+        * contentToSend).
+        * <p>
+        * Will only flush the data if there is contentToSend.
+        * <p>
+        * Note that the behaviour is slightly different for String and Object
+        * reading regarding exceptions:
+        * <ul>
+        * <li>NULL means that the counter part has no more data to send</li>
+        * <li>All the exceptions except {@link IOException} are there for Object
+        * conversion</li>
+        * </ul>
+        * 
+        * @param asString
+        *            TRUE for String reading, FALSE for Object reading (which can
+        *            still be a String)
+        * 
+        * @return the deserialised answer (which can actually be NULL)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        * @throws java.lang.NullPointerException
+        *             for Objects only: if the counter part has no data to send
+        */
+       @SuppressWarnings("resource")
+       private Object rec(boolean asString) throws IOException,
+                       NoSuchFieldException, NoSuchMethodException,
+                       ClassNotFoundException, java.lang.NullPointerException {
+
+               synchronized (lock) {
+                       if (server || contentToSend) {
+                               if (contentToSend) {
+                                       out.flush();
+                                       contentToSend = false;
+                               }
+
+                               if (in.next() && !in.eof()) {
+                                       InputStream read = new ReplaceInputStream(in.open(),
+                                                       STREAM_CODED, STREAM_RAW);
+                                       try {
+                                               if (crypt != null) {
+                                                       read = crypt.decrypt64(read);
+                                               }
+
+                                               if (asString) {
+                                                       return IOUtils.readSmallStream(read);
+                                               }
+
+                                               return new Importer().read(read).getValue();
+                                       } finally {
+                                               read.close();
+                                       }
+                               }
+
+                               if (!asString) {
+                                       throw new NullPointerException();
+                               }
+                       }
+
+                       return null;
+               }
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionClient.java b/src/be/nikiroo/utils/serial/server/ConnectActionClient.java
new file mode 100644 (file)
index 0000000..cb6bef3
--- /dev/null
@@ -0,0 +1,166 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * Base class used for the client basic handling.
+ * <p>
+ * It represents a single action: a client is expected to only execute one
+ * action.
+ * 
+ * @author niki
+ */
+abstract class ConnectActionClient {
+       /**
+        * The underlying {@link ConnectAction}.
+        * <p>
+        * Cannot be NULL.
+        */
+       protected ConnectAction action;
+
+       /**
+        * Create a new {@link ConnectActionClient}, using the current version of
+        * the program.
+        * 
+        * @param host
+        *            the host to bind to
+        * @param port
+        *            the port to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the host is not known
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ConnectActionClient(String host, int port, String key)
+                       throws IOException {
+               this(host, port, key, Version.getCurrentVersion());
+       }
+
+       /**
+        * Create a new {@link ConnectActionClient}.
+        * 
+        * @param host
+        *            the host to bind to
+        * @param port
+        *            the port to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param clientVersion
+        *            the client version
+        * 
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the host is not known
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ConnectActionClient(String host, int port, String key,
+                       Version clientVersion) throws IOException {
+               this(new Socket(host, port), key, clientVersion);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClient}, using the current version of
+        * the program.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        */
+       public ConnectActionClient(Socket s, String key) {
+               this(s, key, Version.getCurrentVersion());
+       }
+
+       /**
+        * Create a new {@link ConnectActionClient}.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param clientVersion
+        *            the client version
+        */
+       public ConnectActionClient(Socket s, String key, Version clientVersion) {
+               action = new ConnectAction(s, false, key, clientVersion) {
+                       @Override
+                       protected void action(Version serverVersion) throws Exception {
+                               ConnectActionClient.this.action(serverVersion);
+                       }
+
+                       @Override
+                       protected void onError(Exception e) {
+                               ConnectActionClient.this.onError(e);
+                       }
+
+                       @Override
+                       protected Version negotiateVersion(Version clientVersion) {
+                               new Exception("Should never be called on a client")
+                                               .printStackTrace();
+                               return null;
+                       }
+               };
+       }
+
+       /**
+        * Actually start the process and call the action (synchronous).
+        */
+       public void connect() {
+               action.connect();
+       }
+
+       /**
+        * Actually start the process and call the action (asynchronous).
+        */
+       public void connectAsync() {
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               connect();
+                       }
+               }).start();
+       }
+
+       /**
+        * Method that will be called when an action is performed on the client.
+        * 
+        * @param serverVersion
+        *            the version of the server connected to this client
+        * 
+        * @throws Exception
+        *             in case of I/O error
+        */
+       @SuppressWarnings("unused")
+       public void action(Version serverVersion) throws Exception {
+       }
+
+       /**
+        * Handler called when an unexpected error occurs in the code.
+        * <p>
+        * Will just ignore the error by default.
+        * 
+        * @param e
+        *            the exception that occurred
+        */
+       protected void onError(@SuppressWarnings("unused") Exception e) {
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java b/src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java
new file mode 100644 (file)
index 0000000..9385645
--- /dev/null
@@ -0,0 +1,175 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * Class used for the client basic handling.
+ * <p>
+ * It represents a single action: a client is expected to only execute one
+ * action.
+ * 
+ * @author niki
+ */
+public class ConnectActionClientObject extends ConnectActionClient {
+       /**
+        * Create a new {@link ConnectActionClientObject} .
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        */
+       public ConnectActionClientObject(Socket s, String key) {
+               super(s, key);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClientObject} .
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param clientVersion
+        *            the version of the client
+        */
+       public ConnectActionClientObject(Socket s, String key, Version clientVersion) {
+               super(s, key, clientVersion);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClientObject}.
+        * 
+        * @param host
+        *            the host to bind to
+        * @param port
+        *            the port to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ConnectActionClientObject(String host, int port, String key)
+                       throws IOException {
+               super(host, port, key);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClientObject}.
+        * 
+        * @param host
+        *            the host to bind to
+        * @param port
+        *            the port to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param clientVersion
+        *            the version of the client
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ConnectActionClientObject(String host, int port, String key,
+                       Version clientVersion) throws IOException {
+               super(host, port, key, clientVersion);
+       }
+
+       /**
+        * Serialise and send the given object to the server (and return the
+        * deserialised answer).
+        * 
+        * @param data
+        *            the data to send
+        * 
+        * @return the answer, which can be NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        */
+       public Object send(Object data) throws IOException, NoSuchFieldException,
+                       NoSuchMethodException, ClassNotFoundException {
+               return action.sendObject(data);
+       }
+
+       // Deprecated //
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ConnectActionClientObject(String host, int port, boolean ssl)
+                       throws IOException {
+               this(host, port, ssl ? "" : null);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ConnectActionClientObject(String host, int port, boolean ssl,
+                       Version version) throws IOException {
+               this(host, port, ssl ? "" : null, version);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @SuppressWarnings("unused")
+       @Deprecated
+       public ConnectActionClientObject(Socket s, boolean ssl) throws IOException {
+               this(s, ssl ? "" : null);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @SuppressWarnings("unused")
+       @Deprecated
+       public ConnectActionClientObject(Socket s, boolean ssl, Version version)
+                       throws IOException {
+               this(s, ssl ? "" : null, version);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionClientString.java b/src/be/nikiroo/utils/serial/server/ConnectActionClientString.java
new file mode 100644 (file)
index 0000000..3005cee
--- /dev/null
@@ -0,0 +1,165 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * Class used for the client basic handling.
+ * <p>
+ * It represents a single action: a client is expected to only execute one
+ * action.
+ * 
+ * @author niki
+ */
+public class ConnectActionClientString extends ConnectActionClient {
+       /**
+        * Create a new {@link ConnectActionClientString}.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        */
+       public ConnectActionClientString(Socket s, String key) {
+               super(s, key);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClientString}.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param clientVersion
+        *            the version of this client
+        */
+       public ConnectActionClientString(Socket s, String key, Version clientVersion) {
+               super(s, key, clientVersion);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClientString}.
+        * 
+        * @param host
+        *            the host to bind to
+        * @param port
+        *            the port to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ConnectActionClientString(String host, int port, String key)
+                       throws IOException {
+               super(host, port, key);
+       }
+
+       /**
+        * Create a new {@link ConnectActionClientString}.
+        * 
+        * @param host
+        *            the host to bind to
+        * @param port
+        *            the port to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param clientVersion
+        *            the version of this client
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ConnectActionClientString(String host, int port, String key,
+                       Version clientVersion) throws IOException {
+               super(host, port, key, clientVersion);
+       }
+
+       /**
+        * Send the given object to the server (and return the answer).
+        * 
+        * @param data
+        *            the data to send
+        * 
+        * @return the answer, which can be NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public String send(String data) throws IOException {
+               return action.sendString(data);
+       }
+
+       // Deprecated //
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ConnectActionClientString(String host, int port, boolean ssl)
+                       throws IOException {
+               this(host, port, ssl ? "" : null);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ConnectActionClientString(String host, int port, boolean ssl,
+                       Version version) throws IOException {
+               this(host, port, ssl ? "" : null, version);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @SuppressWarnings("unused")
+       @Deprecated
+       public ConnectActionClientString(Socket s, boolean ssl) throws IOException {
+               this(s, ssl ? "" : null);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @SuppressWarnings("unused")
+       @Deprecated
+       public ConnectActionClientString(Socket s, boolean ssl, Version version)
+                       throws IOException {
+               this(s, ssl ? "" : null, version);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionServer.java b/src/be/nikiroo/utils/serial/server/ConnectActionServer.java
new file mode 100644 (file)
index 0000000..350d3fe
--- /dev/null
@@ -0,0 +1,171 @@
+package be.nikiroo.utils.serial.server;
+
+import java.net.Socket;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * Base class used for the server basic handling.
+ * <p>
+ * It represents a single action: a server is expected to execute one action for
+ * each client action.
+ * 
+ * @author niki
+ */
+abstract class ConnectActionServer {
+       private boolean closing;
+
+       /**
+        * The underlying {@link ConnectAction}.
+        * <p>
+        * Cannot be NULL.
+        */
+       protected ConnectAction action;
+
+       /**
+        * Create a new {@link ConnectActionServer}, using the current version.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        */
+       public ConnectActionServer(Socket s, String key) {
+               this(s, key, Version.getCurrentVersion());
+       }
+
+       /**
+        * Create a new {@link ConnectActionServer}.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param serverVersion
+        *            the version of this server,that will be sent to the client
+        */
+       public ConnectActionServer(Socket s, String key, Version serverVersion) {
+               action = new ConnectAction(s, true, key, serverVersion) {
+                       @Override
+                       protected void action(Version clientVersion) throws Exception {
+                               ConnectActionServer.this.action(clientVersion);
+                       }
+
+                       @Override
+                       protected void onError(Exception e) {
+                               ConnectActionServer.this.onError(e);
+                       }
+
+                       @Override
+                       protected Version negotiateVersion(Version clientVersion) {
+                               return ConnectActionServer.this.negotiateVersion(clientVersion);
+                       }
+               };
+       }
+
+       /**
+        * Actually start the process and call the action (synchronous).
+        */
+       public void connect() {
+               action.connect();
+       }
+
+       /**
+        * Actually start the process and call the action (asynchronous).
+        */
+       public void connectAsync() {
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               connect();
+                       }
+               }).start();
+       }
+
+       /**
+        * Stop the client/server connection on behalf of the server (usually, the
+        * client connects then is allowed to send as many requests as it wants; in
+        * some cases, though, the server may wish to forcefully close the
+        * connection and can do via this value, when it is set to TRUE).
+        * <p>
+        * Example of usage: the client failed an authentication check, cut the
+        * connection here and now.
+        * 
+        * @return TRUE when it is
+        */
+       public boolean isClosing() {
+               return closing;
+       }
+
+       /**
+        * Can be called to stop the client/server connection on behalf of the
+        * server (usually, the client connects then is allowed to send as many
+        * requests as it wants; in some cases, though, the server may wish to
+        * forcefully close the connection and can do so by calling this method).
+        * <p>
+        * Example of usage: the client failed an authentication check, cut the
+        * connection here and now.
+        */
+       public void close() {
+               closing = true;
+       }
+
+       /**
+        * The total amount of bytes received.
+        * 
+        * @return the amount of bytes received
+        */
+       public long getBytesReceived() {
+               return action.getBytesReceived();
+       }
+
+       /**
+        * The total amount of bytes sent.
+        * 
+        * @return the amount of bytes sent
+        */
+       public long getBytesSent() {
+               return action.getBytesWritten();
+       }
+
+       /**
+        * Method that will be called when an action is performed on the server.
+        * 
+        * @param clientVersion
+        *            the version of the client connected to this server
+        * 
+        * @throws Exception
+        *             in case of I/O error
+        */
+       @SuppressWarnings("unused")
+       public void action(Version clientVersion) throws Exception {
+       }
+
+       /**
+        * Handler called when an unexpected error occurs in the code.
+        * <p>
+        * Will just ignore the error by default.
+        * 
+        * @param e
+        *            the exception that occurred
+        */
+       protected void onError(@SuppressWarnings("unused") Exception e) {
+       }
+
+       /**
+        * Method called when we negotiate the version with the client.
+        * <p>
+        * Will return the actual server version by default.
+        * 
+        * @param clientVersion
+        *            the client version
+        * 
+        * @return the version to send to the client
+        */
+       protected Version negotiateVersion(
+                       @SuppressWarnings("unused") Version clientVersion) {
+               return action.getVersion();
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java b/src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java
new file mode 100644 (file)
index 0000000..07d9867
--- /dev/null
@@ -0,0 +1,72 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+
+/**
+ * Class used for the server basic handling.
+ * <p>
+ * It represents a single action: a server is expected to execute one action for
+ * each client action.
+ * 
+ * @author niki
+ */
+public class ConnectActionServerObject extends ConnectActionServer {
+       /**
+        * Create a new {@link ConnectActionServerObject} as the server version.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        */
+       public ConnectActionServerObject(Socket s, String key) {
+               super(s, key);
+       }
+
+       /**
+        * Serialise and send the given object to the client.
+        * 
+        * @param data
+        *            the data to send
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        */
+       public void send(Object data) throws IOException, NoSuchFieldException,
+                       NoSuchMethodException, ClassNotFoundException {
+               action.sendObject(data);
+       }
+
+       /**
+        * (Flush the data to the client if needed and) retrieve its answer.
+        * 
+        * @return the deserialised answer (which can actually be NULL)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws NoSuchFieldException
+        *             if the serialised data contains information about a field
+        *             which does actually not exist in the class we know of
+        * @throws NoSuchMethodException
+        *             if a class described in the serialised data cannot be created
+        *             because it is not compatible with this code
+        * @throws ClassNotFoundException
+        *             if a class described in the serialised data cannot be found
+        * @throws java.lang.NullPointerException
+        *             if the counter part has no data to send
+        */
+       public Object rec() throws NoSuchFieldException, NoSuchMethodException,
+                       ClassNotFoundException, IOException, java.lang.NullPointerException {
+               return action.recObject();
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionServerString.java b/src/be/nikiroo/utils/serial/server/ConnectActionServerString.java
new file mode 100644 (file)
index 0000000..8d113c1
--- /dev/null
@@ -0,0 +1,52 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+
+/**
+ * Class used for the server basic handling.
+ * <p>
+ * It represents a single action: a server is expected to execute one action for
+ * each client action.
+ * 
+ * @author niki
+ */
+public class ConnectActionServerString extends ConnectActionServer {
+       /**
+        * Create a new {@link ConnectActionServerString} as the server version.
+        * 
+        * @param s
+        *            the socket to bind to
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        */
+       public ConnectActionServerString(Socket s, String key) {
+               super(s, key);
+       }
+
+       /**
+        * Serialise and send the given object to the client.
+        * 
+        * @param data
+        *            the data to send
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void send(String data) throws IOException {
+               action.sendString(data);
+       }
+
+       /**
+        * (Flush the data to the client if needed and) retrieve its answer.
+        * 
+        * @return the answer if it is available, or NULL if not
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public String rec() throws IOException {
+               return action.recString();
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/server/Server.java b/src/be/nikiroo/utils/serial/server/Server.java
new file mode 100644 (file)
index 0000000..0470159
--- /dev/null
@@ -0,0 +1,419 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.TraceHandler;
+
+/**
+ * This class implements a simple server that can listen for connections and
+ * send/receive objects.
+ * <p>
+ * Note: this {@link Server} has to be discarded after use (cannot be started
+ * twice).
+ * 
+ * @author niki
+ */
+abstract class Server implements Runnable {
+       protected final String key;
+       protected long id = 0;
+
+       private final String name;
+       private final Object lock = new Object();
+       private final Object counterLock = new Object();
+
+       private ServerSocket ss;
+       private int port;
+
+       private boolean started;
+       private boolean exiting = false;
+       private int counter;
+
+       private long bytesReceived;
+       private long bytesSent;
+
+       private TraceHandler tracer = new TraceHandler();
+
+       /**
+        * Create a new {@link ConnectActionServer} to handle a request.
+        * 
+        * @param s
+        *            the socket to service
+        * 
+        * @return the action
+        */
+       abstract ConnectActionServer createConnectActionServer(Socket s);
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link Server#start()} is called.
+        * 
+        * @param port
+        *            the port to listen on, or 0 to assign any unallocated port
+        *            found (which can later on be queried via
+        *            {@link Server#getPort()}
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public Server(int port, String key) throws IOException {
+               this((String) null, port, key);
+       }
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link Server#start()} is called.
+        * <p>
+        * All the communications will happen in plain text.
+        * 
+        * @param name
+        *            the server name (only used for debug info and traces)
+        * @param port
+        *            the port to listen on
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public Server(String name, int port) throws IOException {
+               this(name, port, null);
+       }
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link Server#start()} is called.
+        * 
+        * @param name
+        *            the server name (only used for debug info and traces)
+        * @param port
+        *            the port to listen on
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public Server(String name, int port, String key) throws IOException {
+               this.name = name;
+               this.port = port;
+               this.key = key;
+               this.ss = new ServerSocket(port);
+
+               if (this.port == 0) {
+                       this.port = this.ss.getLocalPort();
+               }
+       }
+
+       /**
+        * The traces handler for this {@link Server}.
+        * 
+        * @return the traces handler
+        */
+       public TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link Server}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               this.tracer = tracer;
+       }
+
+       /**
+        * The name of this {@link Server} if any.
+        * <p>
+        * Used for traces and debug purposes only.
+        * 
+        * @return the name or NULL
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * Return the assigned port.
+        * 
+        * @return the assigned port
+        */
+       public int getPort() {
+               return port;
+       }
+
+       /**
+        * The total amount of bytes received.
+        * 
+        * @return the amount of bytes received
+        */
+       public long getBytesReceived() {
+               return bytesReceived;
+       }
+
+       /**
+        * The total amount of bytes sent.
+        * 
+        * @return the amount of bytes sent
+        */
+       public long getBytesSent() {
+               return bytesSent;
+       }
+
+       /**
+        * Start the server (listen on the network for new connections).
+        * <p>
+        * Can only be called once.
+        * <p>
+        * This call is asynchronous, and will just start a new {@link Thread} on
+        * itself (see {@link Server#run()}).
+        */
+       public void start() {
+               new Thread(this).start();
+       }
+
+       /**
+        * Start the server (listen on the network for new connections).
+        * <p>
+        * Can only be called once.
+        * <p>
+        * You may call it via {@link Server#start()} for an asynchronous call, too.
+        */
+       @Override
+       public void run() {
+               ServerSocket ss = null;
+               boolean alreadyStarted = false;
+               synchronized (lock) {
+                       ss = this.ss;
+                       if (!started && ss != null) {
+                               started = true;
+                       } else {
+                               alreadyStarted = started;
+                       }
+               }
+
+               if (alreadyStarted) {
+                       tracer.error(name + ": cannot start server on port " + port
+                                       + ", it is already started");
+                       return;
+               }
+
+               if (ss == null) {
+                       tracer.error(name + ": cannot start server on port " + port
+                                       + ", it has already been used");
+                       return;
+               }
+
+               try {
+                       tracer.trace(name + ": server starting on port " + port + " ("
+                                       + (key != null ? "encrypted" : "plain text") + ")");
+
+                       while (started && !exiting) {
+                               count(1);
+                               final Socket s = ss.accept();
+                               new Thread(new Runnable() {
+                                       @Override
+                                       public void run() {
+                                               ConnectActionServer action = null;
+                                               try {
+                                                       action = createConnectActionServer(s);
+                                                       action.connect();
+                                               } finally {
+                                                       count(-1);
+                                                       if (action != null) {
+                                                               bytesReceived += action.getBytesReceived();
+                                                               bytesSent += action.getBytesSent();
+                                                       }
+                                               }
+                                       }
+                               }).start();
+                       }
+
+                       // Will be covered by @link{Server#stop(long)} for timeouts
+                       while (counter > 0) {
+                               Thread.sleep(10);
+                       }
+               } catch (Exception e) {
+                       if (counter > 0) {
+                               onError(e);
+                       }
+               } finally {
+                       try {
+                               ss.close();
+                       } catch (Exception e) {
+                               onError(e);
+                       }
+
+                       this.ss = null;
+
+                       started = false;
+                       exiting = false;
+                       counter = 0;
+
+                       tracer.trace(name + ": client terminated on port " + port);
+               }
+       }
+
+       /**
+        * Will stop the server, synchronously and without a timeout.
+        */
+       public void stop() {
+               tracer.trace(name + ": stopping server");
+               stop(0, true);
+       }
+
+       /**
+        * Stop the server.
+        * 
+        * @param timeout
+        *            the maximum timeout to wait for existing actions to complete,
+        *            or 0 for "no timeout"
+        * @param wait
+        *            wait for the server to be stopped before returning
+        *            (synchronous) or not (asynchronous)
+        */
+       public void stop(final long timeout, final boolean wait) {
+               if (wait) {
+                       stop(timeout);
+               } else {
+                       new Thread(new Runnable() {
+                               @Override
+                               public void run() {
+                                       stop(timeout);
+                               }
+                       }).start();
+               }
+       }
+
+       /**
+        * Stop the server (synchronous).
+        * 
+        * @param timeout
+        *            the maximum timeout to wait for existing actions to complete,
+        *            or 0 for "no timeout"
+        */
+       private void stop(long timeout) {
+               tracer.trace(name + ": server stopping on port " + port);
+               synchronized (lock) {
+                       if (started && !exiting) {
+                               exiting = true;
+
+                               try {
+                                       getConnectionToMe().connect();
+                                       long time = 0;
+                                       while (ss != null && timeout > 0 && timeout > time) {
+                                               Thread.sleep(10);
+                                               time += 10;
+                                       }
+                               } catch (Exception e) {
+                                       if (ss != null) {
+                                               counter = 0; // will stop the main thread
+                                               onError(e);
+                                       }
+                               }
+                       }
+               }
+
+               // return only when stopped
+               while (started || exiting) {
+                       try {
+                               Thread.sleep(10);
+                       } catch (InterruptedException e) {
+                       }
+               }
+       }
+
+       /**
+        * Return a connection to this server (used by the Exit code to send an exit
+        * message).
+        * 
+        * @return the connection
+        * 
+        * @throws UnknownHostException
+        *             the host should always be NULL (localhost)
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract protected ConnectActionClient getConnectionToMe()
+                       throws UnknownHostException, IOException;
+
+       /**
+        * Change the number of currently serviced actions.
+        * 
+        * @param change
+        *            the number to increase or decrease
+        * 
+        * @return the current number after this operation
+        */
+       private int count(int change) {
+               synchronized (counterLock) {
+                       counter += change;
+                       return counter;
+               }
+       }
+
+       /**
+        * This method will be called on errors.
+        * <p>
+        * By default, it will only call the trace handler (so you may want to call
+        * super {@link Server#onError} if you override it).
+        * 
+        * @param e
+        *            the error
+        */
+       protected void onError(Exception e) {
+               tracer.error(e);
+       }
+
+       /**
+        * Return the next ID to use.
+        * 
+        * @return the next ID
+        */
+       protected synchronized long getNextId() {
+               return id++;
+       }
+
+       /**
+        * Method called when
+        * {@link ServerObject#onRequest(ConnectActionServerObject, Object, long)}
+        * has successfully finished.
+        * <p>
+        * Can be used to know how much data was transmitted.
+        * 
+        * @param id
+        *            the ID used to identify the request
+        * @param bytesReceived
+        *            the bytes received during the request
+        * @param bytesSent
+        *            the bytes sent during the request
+        */
+       @SuppressWarnings("unused")
+       protected void onRequestDone(long id, long bytesReceived, long bytesSent) {
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/server/ServerBridge.java b/src/be/nikiroo/utils/serial/server/ServerBridge.java
new file mode 100644 (file)
index 0000000..0b734c6
--- /dev/null
@@ -0,0 +1,292 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Array;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.Importer;
+
+/**
+ * This class implements a simple server that can bridge two other
+ * {@link Server}s.
+ * <p>
+ * It can, of course, inspect the data that goes through it (by default, it
+ * prints traces of the data).
+ * <p>
+ * Note: this {@link ServerBridge} has to be discarded after use (cannot be
+ * started twice).
+ * 
+ * @author niki
+ */
+public class ServerBridge extends Server {
+       private final String forwardToHost;
+       private final int forwardToPort;
+       private final String forwardToKey;
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link ServerBridge#start()} is called.
+        * 
+        * @param port
+        *            the port to listen on, or 0 to assign any unallocated port
+        *            found (which can later on be queried via
+        *            {@link ServerBridge#getPort()}
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param forwardToHost
+        *            the host server to forward the calls to
+        * @param forwardToPort
+        *            the host port to forward the calls to
+        * @param forwardToKey
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ServerBridge(int port, String key, String forwardToHost,
+                       int forwardToPort, String forwardToKey) throws IOException {
+               super(port, key);
+               this.forwardToHost = forwardToHost;
+               this.forwardToPort = forwardToPort;
+               this.forwardToKey = forwardToKey;
+       }
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link ServerBridge#start()} is called.
+        * 
+        * @param name
+        *            the server name (only used for debug info and traces)
+        * @param port
+        *            the port to listen on
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * @param forwardToHost
+        *            the host server to forward the calls to
+        * @param forwardToPort
+        *            the host port to forward the calls to
+        * @param forwardToKey
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text) use an SSL connection
+        *            for the forward server or not
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ServerBridge(String name, int port, String key,
+                       String forwardToHost, int forwardToPort, String forwardToKey)
+                       throws IOException {
+               super(name, port, key);
+               this.forwardToHost = forwardToHost;
+               this.forwardToPort = forwardToPort;
+               this.forwardToKey = forwardToKey;
+       }
+
+       /**
+        * The traces handler for this {@link Server}.
+        * <p>
+        * The trace levels are handled as follow:
+        * <ul>
+        * <li>1: it will only print basic IN/OUT messages with length</li>
+        * <li>2: it will try to interpret it as an object (SLOW) and print the
+        * object class if possible</li>
+        * <li>3: it will try to print the {@link Object#toString()} value, or the
+        * data if it is not an object</li>
+        * <li>4: it will also print the unzipped serialised value if it is an
+        * object</li>
+        * </ul>
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       @Override
+       public void setTraceHandler(TraceHandler tracer) {
+               super.setTraceHandler(tracer);
+       }
+
+       @Override
+       protected ConnectActionServer createConnectActionServer(Socket s) {
+               // Bad impl, not up to date (should work, but not efficient)
+               return new ConnectActionServerString(s, key) {
+                       @Override
+                       public void action(Version clientVersion) throws Exception {
+                               onClientContact(clientVersion);
+                               final ConnectActionServerString bridge = this;
+
+                               try {
+                                       new ConnectActionClientString(forwardToHost, forwardToPort,
+                                                       forwardToKey) {
+                                               @Override
+                                               public void action(Version serverVersion)
+                                                               throws Exception {
+                                                       onServerContact(serverVersion);
+
+                                                       for (String fromClient = bridge.rec(); fromClient != null; fromClient = bridge
+                                                                       .rec()) {
+                                                               onRec(fromClient);
+                                                               String fromServer = send(fromClient);
+                                                               onSend(fromServer);
+                                                               bridge.send(fromServer);
+                                                       }
+
+                                                       getTraceHandler().trace("=== DONE", 1);
+                                                       getTraceHandler().trace("", 1);
+                                               }
+
+                                               @Override
+                                               protected void onError(Exception e) {
+                                                       ServerBridge.this.onError(e);
+                                               }
+                                       }.connect();
+                               } catch (Exception e) {
+                                       ServerBridge.this.onError(e);
+                               }
+                       }
+               };
+       }
+
+       /**
+        * This is the method that is called each time a client contact us.
+        */
+       protected void onClientContact(Version clientVersion) {
+               getTraceHandler().trace(">>> CLIENT " + clientVersion);
+       }
+
+       /**
+        * This is the method that is called each time a client contact us.
+        */
+       protected void onServerContact(Version serverVersion) {
+               getTraceHandler().trace("<<< SERVER " + serverVersion);
+               getTraceHandler().trace("");
+       }
+
+       /**
+        * This is the method that is called each time a client contact us.
+        * 
+        * @param data
+        *            the data sent by the client
+        */
+       protected void onRec(String data) {
+               trace(">>> CLIENT", data);
+       }
+
+       /**
+        * This is the method that is called each time the forwarded server contact
+        * us.
+        * 
+        * @param data
+        *            the data sent by the client
+        */
+       protected void onSend(String data) {
+               trace("<<< SERVER", data);
+       }
+
+       @Override
+       protected ConnectActionClient getConnectionToMe()
+                       throws UnknownHostException, IOException {
+               return new ConnectActionClientString(new Socket((String) null,
+                               getPort()), key);
+       }
+
+       @Override
+       public void run() {
+               getTraceHandler().trace(
+                               getName() + ": will forward to " + forwardToHost + ":"
+                                               + forwardToPort + " ("
+                                               + (forwardToKey != null ? "encrypted" : "plain text")
+                                               + ")");
+               super.run();
+       }
+
+       /**
+        * Trace the data with the given prefix.
+        * 
+        * @param prefix
+        *            the prefix (client, server, version...)
+        * @param data
+        *            the data to trace
+        */
+       private void trace(String prefix, String data) {
+               int size = data == null ? 0 : data.length();
+               String ssize = StringUtils.formatNumber(size) + "bytes";
+
+               getTraceHandler().trace(prefix + ": " + ssize, 1);
+
+               if (getTraceHandler().getTraceLevel() >= 2) {
+                       try {
+                               while (data.startsWith("ZIP:") || data.startsWith("B64:")) {
+                                       if (data.startsWith("ZIP:")) {
+                                               data = StringUtils.unzip64s(data.substring(4));
+                                       } else if (data.startsWith("B64:")) {
+                                               data = StringUtils.unzip64s(data.substring(4));
+                                       }
+                               }
+
+                               InputStream stream = new ByteArrayInputStream(
+                                               StringUtils.getBytes(data));
+                               try {
+                                       Object obj = new Importer().read(stream).getValue();
+                                       if (obj == null) {
+                                               getTraceHandler().trace("NULL", 2);
+                                               getTraceHandler().trace("NULL", 3);
+                                               getTraceHandler().trace("NULL", 4);
+                                       } else {
+                                               if (obj.getClass().isArray()) {
+                                                       getTraceHandler().trace(
+                                                                       "(" + obj.getClass() + ") with "
+                                                                                       + Array.getLength(obj)
+                                                                                       + "element(s)", 3);
+                                               } else {
+                                                       getTraceHandler().trace("(" + obj.getClass() + ")",
+                                                                       2);
+                                               }
+                                               getTraceHandler().trace("" + obj.toString(), 3);
+                                               getTraceHandler().trace(data, 4);
+                                       }
+                               } finally {
+                                       stream.close();
+                               }
+                       } catch (NoSuchMethodException e) {
+                               getTraceHandler().trace("(not an object)", 2);
+                               getTraceHandler().trace(data, 3);
+                               getTraceHandler().trace("", 4);
+                       } catch (NoSuchFieldException e) {
+                               getTraceHandler().trace(
+                                               "(incompatible: " + e.getMessage() + ")", 2);
+                               getTraceHandler().trace(data, 3);
+                               getTraceHandler().trace("", 4);
+                       } catch (ClassNotFoundException e) {
+                               getTraceHandler().trace(
+                                               "(unknown object: " + e.getMessage() + ")", 2);
+                               getTraceHandler().trace(data, 3);
+                               getTraceHandler().trace("", 4);
+                       } catch (Exception e) {
+                               getTraceHandler().trace(
+                                               "(decode error: " + e.getMessage() + ")", 2);
+                               getTraceHandler().trace(data, 3);
+                               getTraceHandler().trace("", 4);
+                       }
+
+                       getTraceHandler().trace("", 2);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/server/ServerObject.java b/src/be/nikiroo/utils/serial/server/ServerObject.java
new file mode 100644 (file)
index 0000000..a6a5dd1
--- /dev/null
@@ -0,0 +1,180 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * This class implements a simple server that can listen for connections and
+ * send/receive objects.
+ * <p>
+ * Note: this {@link ServerObject} has to be discarded after use (cannot be
+ * started twice).
+ * 
+ * @author niki
+ */
+abstract public class ServerObject extends Server {
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link ServerObject#start()} is called.
+        * 
+        * @param port
+        *            the port to listen on, or 0 to assign any unallocated port
+        *            found (which can later on be queried via
+        *            {@link ServerObject#getPort()}
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ServerObject(int port, String key) throws IOException {
+               super(port, key);
+       }
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link ServerObject#start()} is called.
+        * 
+        * @param name
+        *            the server name (only used for debug info and traces)
+        * @param port
+        *            the port to listen on
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ServerObject(String name, int port, String key) throws IOException {
+               super(name, port, key);
+       }
+
+       @Override
+       protected ConnectActionServer createConnectActionServer(Socket s) {
+               return new ConnectActionServerObject(s, key) {
+                       @Override
+                       public void action(Version clientVersion) throws Exception {
+                               long id = getNextId();
+                               try {
+                                       for (Object data = rec(); true; data = rec()) {
+                                               Object rep = null;
+                                               try {
+                                                       rep = onRequest(this, clientVersion, data, id);
+                                                       if (isClosing()) {
+                                                               return;
+                                                       }
+                                               } catch (Exception e) {
+                                                       onError(e);
+                                               }
+
+                                               send(rep);
+                                       }
+                               } catch (NullPointerException e) {
+                                       // Client has no data any more, we quit
+                                       onRequestDone(id, getBytesReceived(), getBytesSent());
+                               }
+                       }
+
+                       @Override
+                       protected void onError(Exception e) {
+                               ServerObject.this.onError(e);
+                       }
+               };
+       }
+
+       @Override
+       protected ConnectActionClient getConnectionToMe()
+                       throws UnknownHostException, IOException {
+               return new ConnectActionClientObject(new Socket((String) null,
+                               getPort()), key);
+       }
+
+       /**
+        * This is the method that is called on each client request.
+        * <p>
+        * You are expected to react to it and return an answer (which can be NULL).
+        * 
+        * @param action
+        *            the client action
+        * @param data
+        *            the data sent by the client (which can be NULL)
+        * @param id
+        *            an ID to identify this request (will also be re-used for
+        *            {@link ServerObject#onRequestDone(long, long, long)}.
+        * 
+        * @return the answer to return to the client (which can be NULL)
+        * 
+        * @throws Exception
+        *             in case of an exception, the error will only be logged
+        */
+       protected Object onRequest(ConnectActionServerObject action,
+                       Version clientVersion, Object data,
+                       @SuppressWarnings("unused") long id) throws Exception {
+               // TODO: change to abstract when deprecated method is removed
+               // Default implementation for compat
+               return onRequest(action, clientVersion, data);
+       }
+
+       // Deprecated //
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ServerObject(int port, boolean ssl) throws IOException {
+               this(port, ssl ? "" : null);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ServerObject(String name, int port, boolean ssl) throws IOException {
+               this(name, port, ssl ? "" : null);
+       }
+
+       /**
+        * Will be called if the correct version is not overrided.
+        * 
+        * @deprecated use the version with the id.
+        * 
+        * @param action
+        *            the client action
+        * @param data
+        *            the data sent by the client
+        * 
+        * @return the answer to return to the client
+        * 
+        * @throws Exception
+        *             in case of an exception, the error will only be logged
+        */
+       @Deprecated
+       @SuppressWarnings("unused")
+       protected Object onRequest(ConnectActionServerObject action,
+                       Version version, Object data) throws Exception {
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/utils/serial/server/ServerString.java b/src/be/nikiroo/utils/serial/server/ServerString.java
new file mode 100644 (file)
index 0000000..3c982fd
--- /dev/null
@@ -0,0 +1,183 @@
+package be.nikiroo.utils.serial.server;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * This class implements a simple server that can listen for connections and
+ * send/receive Strings.
+ * <p>
+ * Note: this {@link ServerString} has to be discarded after use (cannot be
+ * started twice).
+ * 
+ * @author niki
+ */
+abstract public class ServerString extends Server {
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link ServerString#start()} is called.
+        * 
+        * @param port
+        *            the port to listen on, or 0 to assign any unallocated port
+        *            found (which can later on be queried via
+        *            {@link ServerString#getPort()}
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ServerString(int port, String key) throws IOException {
+               super(port, key);
+       }
+
+       /**
+        * Create a new server that will start listening on the network when
+        * {@link ServerString#start()} is called.
+        * 
+        * @param name
+        *            the server name (only used for debug info and traces)
+        * @param port
+        *            the port to listen on
+        * @param key
+        *            an optional key to encrypt all the communications (if NULL,
+        *            everything will be sent in clear text)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws UnknownHostException
+        *             if the IP address of the host could not be determined
+        * @throws IllegalArgumentException
+        *             if the port parameter is outside the specified range of valid
+        *             port values, which is between 0 and 65535, inclusive
+        */
+       public ServerString(String name, int port, String key) throws IOException {
+               super(name, port, key);
+       }
+
+       @Override
+       protected ConnectActionServer createConnectActionServer(Socket s) {
+               return new ConnectActionServerString(s, key) {
+                       @Override
+                       public void action(Version clientVersion) throws Exception {
+                               long id = getNextId();
+                               for (String data = rec(); data != null; data = rec()) {
+                                       String rep = null;
+                                       try {
+                                               rep = onRequest(this, clientVersion, data, id);
+                                               if (isClosing()) {
+                                                       return;
+                                               }
+                                       } catch (Exception e) {
+                                               onError(e);
+                                       }
+
+                                       if (rep == null) {
+                                               rep = "";
+                                       }
+                                       send(rep);
+                               }
+
+                               onRequestDone(id, getBytesReceived(), getBytesSent());
+                       }
+
+                       @Override
+                       protected void onError(Exception e) {
+                               ServerString.this.onError(e);
+                       }
+               };
+       }
+
+       @Override
+       protected ConnectActionClient getConnectionToMe()
+                       throws UnknownHostException, IOException {
+               return new ConnectActionClientString(new Socket((String) null,
+                               getPort()), key);
+       }
+
+       /**
+        * This is the method that is called on each client request.
+        * <p>
+        * You are expected to react to it and return an answer (NULL will be
+        * converted to an empty {@link String}).
+        * 
+        * @param action
+        *            the client action
+        * @param clientVersion
+        *            the client version
+        * @param data
+        *            the data sent by the client
+        * @param id
+        *            an ID to identify this request (will also be re-used for
+        *            {@link ServerObject#onRequestDone(long, long, long)}.
+        * 
+        * @return the answer to return to the client
+        * 
+        * @throws Exception
+        *             in case of an exception, the error will only be logged
+        */
+       protected String onRequest(ConnectActionServerString action,
+                       Version clientVersion, String data,
+                       @SuppressWarnings("unused") long id) throws Exception {
+               // TODO: change to abstract when deprecated method is removed
+               // Default implementation for compat
+               return onRequest(action, clientVersion, data);
+       }
+
+       // Deprecated //
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ServerString(int port, boolean ssl) throws IOException {
+               this(port, ssl ? "" : null);
+       }
+
+       /**
+        * @deprecated SSL support has been replaced by key-based encryption.
+        *             <p>
+        *             Please use the version with key encryption (this deprecated
+        *             version uses an empty key when <tt>ssl</tt> is TRUE and no
+        *             key (NULL) when <tt>ssl</tt> is FALSE).
+        */
+       @Deprecated
+       public ServerString(String name, int port, boolean ssl) throws IOException {
+               this(name, port, ssl ? "" : null);
+       }
+
+       /**
+        * Will be called if the correct version is not overrided.
+        * 
+        * @deprecated use the version with the id.
+        * 
+        * @param action
+        *            the client action
+        * @param data
+        *            the data sent by the client
+        * 
+        * @return the answer to return to the client
+        * 
+        * @throws Exception
+        *             in case of an exception, the error will only be logged
+        */
+       @Deprecated
+       @SuppressWarnings("unused")
+       protected String onRequest(ConnectActionServerString action,
+                       Version version, String data) throws Exception {
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/Base64.java b/src/be/nikiroo/utils/streams/Base64.java
new file mode 100644 (file)
index 0000000..d54794b
--- /dev/null
@@ -0,0 +1,752 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Changes (@author niki):
+ * - default charset -> UTF-8
+ */
+
+package be.nikiroo.utils.streams;
+
+import java.io.UnsupportedEncodingException;
+
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Utilities for encoding and decoding the Base64 representation of
+ * binary data.  See RFCs <a
+ * href="http://www.ietf.org/rfc/rfc2045.txt">2045</a> and <a
+ * href="http://www.ietf.org/rfc/rfc3548.txt">3548</a>.
+ */
+class Base64 {
+    /**
+     * Default values for encoder/decoder flags.
+     */
+    public static final int DEFAULT = 0;
+
+    /**
+     * Encoder flag bit to omit the padding '=' characters at the end
+     * of the output (if any).
+     */
+    public static final int NO_PADDING = 1;
+
+    /**
+     * Encoder flag bit to omit all line terminators (i.e., the output
+     * will be on one long line).
+     */
+    public static final int NO_WRAP = 2;
+
+    /**
+     * Encoder flag bit to indicate lines should be terminated with a
+     * CRLF pair instead of just an LF.  Has no effect if {@code
+     * NO_WRAP} is specified as well.
+     */
+    public static final int CRLF = 4;
+
+    /**
+     * Encoder/decoder flag bit to indicate using the "URL and
+     * filename safe" variant of Base64 (see RFC 3548 section 4) where
+     * {@code -} and {@code _} are used in place of {@code +} and
+     * {@code /}.
+     */
+    public static final int URL_SAFE = 8;
+
+    /**
+     * Flag to pass to {@link Base64OutputStream} to indicate that it
+     * should not close the output stream it is wrapping when it
+     * itself is closed.
+     */
+    public static final int NO_CLOSE = 16;
+
+    //  --------------------------------------------------------
+    //  shared code
+    //  --------------------------------------------------------
+
+    /* package */ static abstract class Coder {
+        public byte[] output;
+        public int op;
+
+        /**
+         * Encode/decode another block of input data.  this.output is
+         * provided by the caller, and must be big enough to hold all
+         * the coded data.  On exit, this.opwill be set to the length
+         * of the coded data.
+         *
+         * @param finish true if this is the final call to process for
+         *        this object.  Will finalize the coder state and
+         *        include any final bytes in the output.
+         *
+         * @return true if the input so far is good; false if some
+         *         error has been detected in the input stream..
+         */
+        public abstract boolean process(byte[] input, int offset, int len, boolean finish);
+
+        /**
+         * @return the maximum number of bytes a call to process()
+         * could produce for the given number of input bytes.  This may
+         * be an overestimate.
+         */
+        public abstract int maxOutputSize(int len);
+    }
+
+    //  --------------------------------------------------------
+    //  decoding
+    //  --------------------------------------------------------
+
+    /**
+     * Decode the Base64-encoded data in input and return the data in
+     * a new byte array.
+     *
+     * <p>The padding '=' characters at the end are considered optional, but
+     * if any are present, there must be the correct number of them.
+     *
+     * @param str    the input String to decode, which is converted to
+     *               bytes using the default charset
+     * @param flags  controls certain features of the decoded output.
+     *               Pass {@code DEFAULT} to decode standard Base64.
+     *
+     * @throws IllegalArgumentException if the input contains
+     * incorrect padding
+     */
+    public static byte[] decode(String str, int flags) {
+               return decode(StringUtils.getBytes(str), flags);
+    }
+
+    /**
+     * Decode the Base64-encoded data in input and return the data in
+     * a new byte array.
+     *
+     * <p>The padding '=' characters at the end are considered optional, but
+     * if any are present, there must be the correct number of them.
+     *
+     * @param input the input array to decode
+     * @param flags  controls certain features of the decoded output.
+     *               Pass {@code DEFAULT} to decode standard Base64.
+     *
+     * @throws IllegalArgumentException if the input contains
+     * incorrect padding
+     */
+    public static byte[] decode(byte[] input, int flags) {
+        return decode(input, 0, input.length, flags);
+    }
+
+    /**
+     * Decode the Base64-encoded data in input and return the data in
+     * a new byte array.
+     *
+     * <p>The padding '=' characters at the end are considered optional, but
+     * if any are present, there must be the correct number of them.
+     *
+     * @param input  the data to decode
+     * @param offset the position within the input array at which to start
+     * @param len    the number of bytes of input to decode
+     * @param flags  controls certain features of the decoded output.
+     *               Pass {@code DEFAULT} to decode standard Base64.
+     *
+     * @throws IllegalArgumentException if the input contains
+     * incorrect padding
+     */
+    public static byte[] decode(byte[] input, int offset, int len, int flags) {
+        // Allocate space for the most data the input could represent.
+        // (It could contain less if it contains whitespace, etc.)
+        Decoder decoder = new Decoder(flags, new byte[len*3/4]);
+
+        if (!decoder.process(input, offset, len, true)) {
+            throw new IllegalArgumentException("bad base-64");
+        }
+
+        // Maybe we got lucky and allocated exactly enough output space.
+        if (decoder.op == decoder.output.length) {
+            return decoder.output;
+        }
+
+        // Need to shorten the array, so allocate a new one of the
+        // right size and copy.
+        byte[] temp = new byte[decoder.op];
+        System.arraycopy(decoder.output, 0, temp, 0, decoder.op);
+        return temp;
+    }
+
+    /* package */ static class Decoder extends Coder {
+        /**
+         * Lookup table for turning bytes into their position in the
+         * Base64 alphabet.
+         */
+        private static final int DECODE[] = {
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
+            52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1,
+            -1,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14,
+            15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1,
+            -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
+            41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        };
+
+        /**
+         * Decode lookup table for the "web safe" variant (RFC 3548
+         * sec. 4) where - and _ replace + and /.
+         */
+        private static final int DECODE_WEBSAFE[] = {
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1,
+            52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1,
+            -1,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14,
+            15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63,
+            -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
+            41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        };
+
+        /** Non-data values in the DECODE arrays. */
+        private static final int SKIP = -1;
+        private static final int EQUALS = -2;
+
+        /**
+         * States 0-3 are reading through the next input tuple.
+         * State 4 is having read one '=' and expecting exactly
+         * one more.
+         * State 5 is expecting no more data or padding characters
+         * in the input.
+         * State 6 is the error state; an error has been detected
+         * in the input and no future input can "fix" it.
+         */
+        private int state;   // state number (0 to 6)
+        private int value;
+
+        final private int[] alphabet;
+
+        public Decoder(int flags, byte[] output) {
+            this.output = output;
+
+            alphabet = ((flags & URL_SAFE) == 0) ? DECODE : DECODE_WEBSAFE;
+            state = 0;
+            value = 0;
+        }
+
+        /**
+         * @return an overestimate for the number of bytes {@code
+         * len} bytes could decode to.
+         */
+        @Override
+               public int maxOutputSize(int len) {
+            return len * 3/4 + 10;
+        }
+
+        /**
+         * Decode another block of input data.
+         *
+         * @return true if the state machine is still healthy.  false if
+         *         bad base-64 data has been detected in the input stream.
+         */
+        @Override
+               public boolean process(byte[] input, int offset, int len, boolean finish) {
+            if (this.state == 6) return false;
+
+            int p = offset;
+            len += offset;
+
+            // Using local variables makes the decoder about 12%
+            // faster than if we manipulate the member variables in
+            // the loop.  (Even alphabet makes a measurable
+            // difference, which is somewhat surprising to me since
+            // the member variable is final.)
+            int state = this.state;
+            int value = this.value;
+            int op = 0;
+            final byte[] output = this.output;
+            final int[] alphabet = this.alphabet;
+
+            while (p < len) {
+                // Try the fast path:  we're starting a new tuple and the
+                // next four bytes of the input stream are all data
+                // bytes.  This corresponds to going through states
+                // 0-1-2-3-0.  We expect to use this method for most of
+                // the data.
+                //
+                // If any of the next four bytes of input are non-data
+                // (whitespace, etc.), value will end up negative.  (All
+                // the non-data values in decode are small negative
+                // numbers, so shifting any of them up and or'ing them
+                // together will result in a value with its top bit set.)
+                //
+                // You can remove this whole block and the output should
+                // be the same, just slower.
+                if (state == 0) {
+                    while (p+4 <= len &&
+                           (value = ((alphabet[input[p] & 0xff] << 18) |
+                                     (alphabet[input[p+1] & 0xff] << 12) |
+                                     (alphabet[input[p+2] & 0xff] << 6) |
+                                     (alphabet[input[p+3] & 0xff]))) >= 0) {
+                        output[op+2] = (byte) value;
+                        output[op+1] = (byte) (value >> 8);
+                        output[op] = (byte) (value >> 16);
+                        op += 3;
+                        p += 4;
+                    }
+                    if (p >= len) break;
+                }
+
+                // The fast path isn't available -- either we've read a
+                // partial tuple, or the next four input bytes aren't all
+                // data, or whatever.  Fall back to the slower state
+                // machine implementation.
+
+                int d = alphabet[input[p++] & 0xff];
+
+                switch (state) {
+                case 0:
+                    if (d >= 0) {
+                        value = d;
+                        ++state;
+                    } else if (d != SKIP) {
+                        this.state = 6;
+                        return false;
+                    }
+                    break;
+
+                case 1:
+                    if (d >= 0) {
+                        value = (value << 6) | d;
+                        ++state;
+                    } else if (d != SKIP) {
+                        this.state = 6;
+                        return false;
+                    }
+                    break;
+
+                case 2:
+                    if (d >= 0) {
+                        value = (value << 6) | d;
+                        ++state;
+                    } else if (d == EQUALS) {
+                        // Emit the last (partial) output tuple;
+                        // expect exactly one more padding character.
+                        output[op++] = (byte) (value >> 4);
+                        state = 4;
+                    } else if (d != SKIP) {
+                        this.state = 6;
+                        return false;
+                    }
+                    break;
+
+                case 3:
+                    if (d >= 0) {
+                        // Emit the output triple and return to state 0.
+                        value = (value << 6) | d;
+                        output[op+2] = (byte) value;
+                        output[op+1] = (byte) (value >> 8);
+                        output[op] = (byte) (value >> 16);
+                        op += 3;
+                        state = 0;
+                    } else if (d == EQUALS) {
+                        // Emit the last (partial) output tuple;
+                        // expect no further data or padding characters.
+                        output[op+1] = (byte) (value >> 2);
+                        output[op] = (byte) (value >> 10);
+                        op += 2;
+                        state = 5;
+                    } else if (d != SKIP) {
+                        this.state = 6;
+                        return false;
+                    }
+                    break;
+
+                case 4:
+                    if (d == EQUALS) {
+                        ++state;
+                    } else if (d != SKIP) {
+                        this.state = 6;
+                        return false;
+                    }
+                    break;
+
+                case 5:
+                    if (d != SKIP) {
+                        this.state = 6;
+                        return false;
+                    }
+                    break;
+                }
+            }
+
+            if (!finish) {
+                // We're out of input, but a future call could provide
+                // more.
+                this.state = state;
+                this.value = value;
+                this.op = op;
+                return true;
+            }
+
+            // Done reading input.  Now figure out where we are left in
+            // the state machine and finish up.
+
+            switch (state) {
+            case 0:
+                // Output length is a multiple of three.  Fine.
+                break;
+            case 1:
+                // Read one extra input byte, which isn't enough to
+                // make another output byte.  Illegal.
+                this.state = 6;
+                return false;
+            case 2:
+                // Read two extra input bytes, enough to emit 1 more
+                // output byte.  Fine.
+                output[op++] = (byte) (value >> 4);
+                break;
+            case 3:
+                // Read three extra input bytes, enough to emit 2 more
+                // output bytes.  Fine.
+                output[op++] = (byte) (value >> 10);
+                output[op++] = (byte) (value >> 2);
+                break;
+            case 4:
+                // Read one padding '=' when we expected 2.  Illegal.
+                this.state = 6;
+                return false;
+            case 5:
+                // Read all the padding '='s we expected and no more.
+                // Fine.
+                break;
+            }
+
+            this.state = state;
+            this.op = op;
+            return true;
+        }
+    }
+
+    //  --------------------------------------------------------
+    //  encoding
+    //  --------------------------------------------------------
+
+    /**
+     * Base64-encode the given data and return a newly allocated
+     * String with the result.
+     *
+     * @param input  the data to encode
+     * @param flags  controls certain features of the encoded output.
+     *               Passing {@code DEFAULT} results in output that
+     *               adheres to RFC 2045.
+     */
+    public static String encodeToString(byte[] input, int flags) {
+        try {
+            return new String(encode(input, flags), "US-ASCII");
+        } catch (UnsupportedEncodingException e) {
+            // US-ASCII is guaranteed to be available.
+            throw new AssertionError(e);
+        }
+    }
+
+    /**
+     * Base64-encode the given data and return a newly allocated
+     * String with the result.
+     *
+     * @param input  the data to encode
+     * @param offset the position within the input array at which to
+     *               start
+     * @param len    the number of bytes of input to encode
+     * @param flags  controls certain features of the encoded output.
+     *               Passing {@code DEFAULT} results in output that
+     *               adheres to RFC 2045.
+     */
+    public static String encodeToString(byte[] input, int offset, int len, int flags) {
+        try {
+            return new String(encode(input, offset, len, flags), "US-ASCII");
+        } catch (UnsupportedEncodingException e) {
+            // US-ASCII is guaranteed to be available.
+            throw new AssertionError(e);
+        }
+    }
+
+    /**
+     * Base64-encode the given data and return a newly allocated
+     * byte[] with the result.
+     *
+     * @param input  the data to encode
+     * @param flags  controls certain features of the encoded output.
+     *               Passing {@code DEFAULT} results in output that
+     *               adheres to RFC 2045.
+     */
+    public static byte[] encode(byte[] input, int flags) {
+        return encode(input, 0, input.length, flags);
+    }
+
+    /**
+     * Base64-encode the given data and return a newly allocated
+     * byte[] with the result.
+     *
+     * @param input  the data to encode
+     * @param offset the position within the input array at which to
+     *               start
+     * @param len    the number of bytes of input to encode
+     * @param flags  controls certain features of the encoded output.
+     *               Passing {@code DEFAULT} results in output that
+     *               adheres to RFC 2045.
+     */
+    public static byte[] encode(byte[] input, int offset, int len, int flags) {
+        Encoder encoder = new Encoder(flags, null);
+
+        // Compute the exact length of the array we will produce.
+        int output_len = len / 3 * 4;
+
+        // Account for the tail of the data and the padding bytes, if any.
+        if (encoder.do_padding) {
+            if (len % 3 > 0) {
+                output_len += 4;
+            }
+        } else {
+            switch (len % 3) {
+                case 0: break;
+                case 1: output_len += 2; break;
+                case 2: output_len += 3; break;
+            }
+        }
+
+        // Account for the newlines, if any.
+        if (encoder.do_newline && len > 0) {
+            output_len += (((len-1) / (3 * Encoder.LINE_GROUPS)) + 1) *
+                (encoder.do_cr ? 2 : 1);
+        }
+
+        encoder.output = new byte[output_len];
+        encoder.process(input, offset, len, true);
+
+        assert encoder.op == output_len;
+
+        return encoder.output;
+    }
+
+    /* package */ static class Encoder extends Coder {
+        /**
+         * Emit a new line every this many output tuples.  Corresponds to
+         * a 76-character line length (the maximum allowable according to
+         * <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045</a>).
+         */
+        public static final int LINE_GROUPS = 19;
+
+        /**
+         * Lookup table for turning Base64 alphabet positions (6 bits)
+         * into output bytes.
+         */
+        private static final byte ENCODE[] = {
+            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
+            'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
+            'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
+            'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/',
+        };
+
+        /**
+         * Lookup table for turning Base64 alphabet positions (6 bits)
+         * into output bytes.
+         */
+        private static final byte ENCODE_WEBSAFE[] = {
+            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
+            'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
+            'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
+            'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_',
+        };
+
+        final private byte[] tail;
+        /* package */ int tailLen;
+        private int count;
+
+        final public boolean do_padding;
+        final public boolean do_newline;
+        final public boolean do_cr;
+        final private byte[] alphabet;
+
+        public Encoder(int flags, byte[] output) {
+            this.output = output;
+
+            do_padding = (flags & NO_PADDING) == 0;
+            do_newline = (flags & NO_WRAP) == 0;
+            do_cr = (flags & CRLF) != 0;
+            alphabet = ((flags & URL_SAFE) == 0) ? ENCODE : ENCODE_WEBSAFE;
+
+            tail = new byte[2];
+            tailLen = 0;
+
+            count = do_newline ? LINE_GROUPS : -1;
+        }
+
+        /**
+         * @return an overestimate for the number of bytes {@code
+         * len} bytes could encode to.
+         */
+        @Override
+               public int maxOutputSize(int len) {
+            return len * 8/5 + 10;
+        }
+
+        @Override
+               public boolean process(byte[] input, int offset, int len, boolean finish) {
+            // Using local variables makes the encoder about 9% faster.
+            final byte[] alphabet = this.alphabet;
+            final byte[] output = this.output;
+            int op = 0;
+            int count = this.count;
+
+            int p = offset;
+            len += offset;
+            int v = -1;
+
+            // First we need to concatenate the tail of the previous call
+            // with any input bytes available now and see if we can empty
+            // the tail.
+
+            switch (tailLen) {
+                case 0:
+                    // There was no tail.
+                    break;
+
+                case 1:
+                    if (p+2 <= len) {
+                        // A 1-byte tail with at least 2 bytes of
+                        // input available now.
+                        v = ((tail[0] & 0xff) << 16) |
+                            ((input[p++] & 0xff) << 8) |
+                            (input[p++] & 0xff);
+                        tailLen = 0;
+                    }
+                    break;
+
+                case 2:
+                    if (p+1 <= len) {
+                        // A 2-byte tail with at least 1 byte of input.
+                        v = ((tail[0] & 0xff) << 16) |
+                            ((tail[1] & 0xff) << 8) |
+                            (input[p++] & 0xff);
+                        tailLen = 0;
+                    }
+                    break;
+            }
+
+            if (v != -1) {
+                output[op++] = alphabet[(v >> 18) & 0x3f];
+                output[op++] = alphabet[(v >> 12) & 0x3f];
+                output[op++] = alphabet[(v >> 6) & 0x3f];
+                output[op++] = alphabet[v & 0x3f];
+                if (--count == 0) {
+                    if (do_cr) output[op++] = '\r';
+                    output[op++] = '\n';
+                    count = LINE_GROUPS;
+                }
+            }
+
+            // At this point either there is no tail, or there are fewer
+            // than 3 bytes of input available.
+
+            // The main loop, turning 3 input bytes into 4 output bytes on
+            // each iteration.
+            while (p+3 <= len) {
+                v = ((input[p] & 0xff) << 16) |
+                    ((input[p+1] & 0xff) << 8) |
+                    (input[p+2] & 0xff);
+                output[op] = alphabet[(v >> 18) & 0x3f];
+                output[op+1] = alphabet[(v >> 12) & 0x3f];
+                output[op+2] = alphabet[(v >> 6) & 0x3f];
+                output[op+3] = alphabet[v & 0x3f];
+                p += 3;
+                op += 4;
+                if (--count == 0) {
+                    if (do_cr) output[op++] = '\r';
+                    output[op++] = '\n';
+                    count = LINE_GROUPS;
+                }
+            }
+
+            if (finish) {
+                // Finish up the tail of the input.  Note that we need to
+                // consume any bytes in tail before any bytes
+                // remaining in input; there should be at most two bytes
+                // total.
+
+                if (p-tailLen == len-1) {
+                    int t = 0;
+                    v = ((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 4;
+                    tailLen -= t;
+                    output[op++] = alphabet[(v >> 6) & 0x3f];
+                    output[op++] = alphabet[v & 0x3f];
+                    if (do_padding) {
+                        output[op++] = '=';
+                        output[op++] = '=';
+                    }
+                    if (do_newline) {
+                        if (do_cr) output[op++] = '\r';
+                        output[op++] = '\n';
+                    }
+                } else if (p-tailLen == len-2) {
+                    int t = 0;
+                    v = (((tailLen > 1 ? tail[t++] : input[p++]) & 0xff) << 10) |
+                        (((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 2);
+                    tailLen -= t;
+                    output[op++] = alphabet[(v >> 12) & 0x3f];
+                    output[op++] = alphabet[(v >> 6) & 0x3f];
+                    output[op++] = alphabet[v & 0x3f];
+                    if (do_padding) {
+                        output[op++] = '=';
+                    }
+                    if (do_newline) {
+                        if (do_cr) output[op++] = '\r';
+                        output[op++] = '\n';
+                    }
+                } else if (do_newline && op > 0 && count != LINE_GROUPS) {
+                    if (do_cr) output[op++] = '\r';
+                    output[op++] = '\n';
+                }
+
+                assert tailLen == 0;
+                assert p == len;
+            } else {
+                // Save the leftovers in tail to be consumed on the next
+                // call to encodeInternal.
+
+                if (p == len-1) {
+                    tail[tailLen++] = input[p];
+                } else if (p == len-2) {
+                    tail[tailLen++] = input[p];
+                    tail[tailLen++] = input[p+1];
+                }
+            }
+
+            this.op = op;
+            this.count = count;
+
+            return true;
+        }
+    }
+
+    private Base64() { }   // don't instantiate
+}
diff --git a/src/be/nikiroo/utils/streams/Base64InputStream.java b/src/be/nikiroo/utils/streams/Base64InputStream.java
new file mode 100644 (file)
index 0000000..a3afaef
--- /dev/null
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package be.nikiroo.utils.streams;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * An InputStream that does Base64 decoding on the data read through
+ * it.
+ */
+public class Base64InputStream extends FilterInputStream {
+    private final Base64.Coder coder;
+
+    private static byte[] EMPTY = new byte[0];
+
+    private static final int BUFFER_SIZE = 2048;
+    private boolean eof;
+    private byte[] inputBuffer;
+    private int outputStart;
+    private int outputEnd;
+
+    /**
+     * An InputStream that performs Base64 decoding on the data read
+     * from the wrapped stream.
+     *
+     * @param in the InputStream to read the source data from
+     */
+    public Base64InputStream(InputStream in) {
+        this(in, false);
+    }
+
+    /**
+     * Performs Base64 encoding or decoding on the data read from the
+     * wrapped InputStream.
+     *
+     * @param in the InputStream to read the source data from
+     * @param flags bit flags for controlling the decoder; see the
+     *        constants in {@link Base64}
+     * @param encode true to encode, false to decode
+     *
+     * @hide
+     */
+    public Base64InputStream(InputStream in, boolean encode) {
+        super(in);
+        eof = false;
+        inputBuffer = new byte[BUFFER_SIZE];
+        if (encode) {
+            coder = new Base64.Encoder(Base64.NO_WRAP, null);
+        } else {
+            coder = new Base64.Decoder(Base64.NO_WRAP, null);
+        }
+        coder.output = new byte[coder.maxOutputSize(BUFFER_SIZE)];
+        outputStart = 0;
+        outputEnd = 0;
+    }
+
+    @Override
+       public boolean markSupported() {
+        return false;
+    }
+
+    @SuppressWarnings("sync-override")
+       @Override
+       public void mark(int readlimit) {
+        throw new UnsupportedOperationException();
+    }
+
+    @SuppressWarnings("sync-override")
+       @Override
+       public void reset() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+       public void close() throws IOException {
+        in.close();
+        inputBuffer = null;
+    }
+
+    @Override
+       public int available() {
+        return outputEnd - outputStart;
+    }
+
+    @Override
+       public long skip(long n) throws IOException {
+        if (outputStart >= outputEnd) {
+            refill();
+        }
+        if (outputStart >= outputEnd) {
+            return 0;
+        }
+        long bytes = Math.min(n, outputEnd-outputStart);
+        outputStart += bytes;
+        return bytes;
+    }
+
+    @Override
+       public int read() throws IOException {
+        if (outputStart >= outputEnd) {
+            refill();
+        }
+        if (outputStart >= outputEnd) {
+            return -1;
+        }
+        
+        return coder.output[outputStart++] & 0xff;
+    }
+
+    @Override
+       public int read(byte[] b, int off, int len) throws IOException {
+        if (outputStart >= outputEnd) {
+            refill();
+        }
+        if (outputStart >= outputEnd) {
+            return -1;
+        }
+        int bytes = Math.min(len, outputEnd-outputStart);
+        System.arraycopy(coder.output, outputStart, b, off, bytes);
+        outputStart += bytes;
+        return bytes;
+    }
+
+    /**
+     * Read data from the input stream into inputBuffer, then
+     * decode/encode it into the empty coder.output, and reset the
+     * outputStart and outputEnd pointers.
+     */
+    private void refill() throws IOException {
+        if (eof) return;
+        int bytesRead = in.read(inputBuffer);
+        boolean success;
+        if (bytesRead == -1) {
+            eof = true;
+            success = coder.process(EMPTY, 0, 0, true);
+        } else {
+            success = coder.process(inputBuffer, 0, bytesRead, false);
+        }
+        if (!success) {
+            throw new IOException("bad base-64");
+        }
+        outputEnd = coder.op;
+        outputStart = 0;
+    }
+}
diff --git a/src/be/nikiroo/utils/streams/Base64OutputStream.java b/src/be/nikiroo/utils/streams/Base64OutputStream.java
new file mode 100644 (file)
index 0000000..ab4e457
--- /dev/null
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package be.nikiroo.utils.streams;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * An OutputStream that does Base64 encoding on the data written to
+ * it, writing the resulting data to another OutputStream.
+ */
+public class Base64OutputStream extends FilterOutputStream {
+    private final Base64.Coder coder;
+    private final int flags;
+
+    private byte[] buffer = null;
+    private int bpos = 0;
+
+    private static byte[] EMPTY = new byte[0];
+
+    /**
+     * Performs Base64 encoding on the data written to the stream,
+     * writing the encoded data to another OutputStream.
+     *
+     * @param out the OutputStream to write the encoded data to
+     */
+    public Base64OutputStream(OutputStream out) {
+        this(out, true);
+    }
+
+    /**
+     * Performs Base64 encoding or decoding on the data written to the
+     * stream, writing the encoded/decoded data to another
+     * OutputStream.
+     *
+     * @param out the OutputStream to write the encoded data to
+     * @param encode true to encode, false to decode
+     *
+     * @hide
+     */
+    public Base64OutputStream(OutputStream out, boolean encode) {
+        super(out);
+        this.flags = Base64.NO_WRAP;
+        if (encode) {
+            coder = new Base64.Encoder(flags, null);
+        } else {
+            coder = new Base64.Decoder(flags, null);
+        }
+    }
+
+    @Override
+       public void write(int b) throws IOException {
+        // To avoid invoking the encoder/decoder routines for single
+        // bytes, we buffer up calls to write(int) in an internal
+        // byte array to transform them into writes of decently-sized
+        // arrays.
+
+        if (buffer == null) {
+            buffer = new byte[1024];
+        }
+        if (bpos >= buffer.length) {
+            // internal buffer full; write it out.
+            internalWrite(buffer, 0, bpos, false);
+            bpos = 0;
+        }
+        buffer[bpos++] = (byte) b;
+    }
+
+    /**
+     * Flush any buffered data from calls to write(int).  Needed
+     * before doing a write(byte[], int, int) or a close().
+     */
+    private void flushBuffer() throws IOException {
+        if (bpos > 0) {
+            internalWrite(buffer, 0, bpos, false);
+            bpos = 0;
+        }
+    }
+
+    @Override
+       public void write(byte[] b, int off, int len) throws IOException {
+        if (len <= 0) return;
+        flushBuffer();
+        internalWrite(b, off, len, false);
+    }
+
+    @Override
+       public void close() throws IOException {
+        IOException thrown = null;
+        try {
+            flushBuffer();
+            internalWrite(EMPTY, 0, 0, true);
+        } catch (IOException e) {
+            thrown = e;
+        }
+
+        try {
+            if ((flags & Base64.NO_CLOSE) == 0) {
+                out.close();
+            } else {
+                out.flush();
+            }
+        } catch (IOException e) {
+            if (thrown != null) {
+                thrown = e;
+            }
+        }
+
+        if (thrown != null) {
+            throw thrown;
+        }
+    }
+
+    /**
+     * Write the given bytes to the encoder/decoder.
+     *
+     * @param finish true if this is the last batch of input, to cause
+     *        encoder/decoder state to be finalized.
+     */
+    private void internalWrite(byte[] b, int off, int len, boolean finish) throws IOException {
+        coder.output = embiggen(coder.output, coder.maxOutputSize(len));
+        if (!coder.process(b, off, len, finish)) {
+            throw new IOException("bad base-64");
+        }
+        out.write(coder.output, 0, coder.op);
+    }
+
+    /**
+     * If b.length is at least len, return b.  Otherwise return a new
+     * byte array of length len.
+     */
+    private byte[] embiggen(byte[] b, int len) {
+        if (b == null || b.length < len) {
+            return new byte[len];
+        }
+        return b;
+    }
+}
diff --git a/src/be/nikiroo/utils/streams/BufferedInputStream.java b/src/be/nikiroo/utils/streams/BufferedInputStream.java
new file mode 100644 (file)
index 0000000..683fa55
--- /dev/null
@@ -0,0 +1,522 @@
+package be.nikiroo.utils.streams;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * A simple {@link InputStream} that is buffered with a bytes array.
+ * <p>
+ * It is mostly intended to be used as a base class to create new
+ * {@link InputStream}s with special operation modes, and to give some default
+ * methods.
+ * 
+ * @author niki
+ */
+public class BufferedInputStream extends InputStream {
+       /**
+        * The size of the internal buffer (can be different if you pass your own
+        * buffer, of course).
+        * <p>
+        * A second buffer of twice the size can sometimes be created as needed for
+        * the {@link BufferedInputStream#startsWith(byte[])} search operation.
+        */
+       static private final int BUFFER_SIZE = 4096;
+
+       /** The current position in the buffer. */
+       protected int start;
+       /** The index of the last usable position of the buffer. */
+       protected int stop;
+       /** The buffer itself. */
+       protected byte[] buffer;
+       /** An End-Of-File (or {@link InputStream}, here) marker. */
+       protected boolean eof;
+
+       private boolean closed;
+       private InputStream in;
+       private int openCounter;
+
+       // special use, prefetched next buffer
+       private byte[] buffer2;
+       private int pos2;
+       private int len2;
+       private byte[] originalBuffer;
+
+       private long bytesRead;
+
+       /**
+        * Create a new {@link BufferedInputStream} that wraps the given
+        * {@link InputStream}.
+        * 
+        * @param in
+        *            the {@link InputStream} to wrap
+        */
+       public BufferedInputStream(InputStream in) {
+               this.in = in;
+
+               this.buffer = new byte[BUFFER_SIZE];
+               this.originalBuffer = this.buffer;
+               this.start = 0;
+               this.stop = 0;
+       }
+
+       /**
+        * Create a new {@link BufferedInputStream} that wraps the given bytes array
+        * as a data source.
+        * 
+        * @param in
+        *            the array to wrap, cannot be NULL
+        */
+       public BufferedInputStream(byte[] in) {
+               this(in, 0, in.length);
+       }
+
+       /**
+        * Create a new {@link BufferedInputStream} that wraps the given bytes array
+        * as a data source.
+        * 
+        * @param in
+        *            the array to wrap, cannot be NULL
+        * @param offset
+        *            the offset to start the reading at
+        * @param length
+        *            the number of bytes to take into account in the array,
+        *            starting from the offset
+        * 
+        * @throws NullPointerException
+        *             if the array is NULL
+        * @throws IndexOutOfBoundsException
+        *             if the offset and length do not correspond to the given array
+        */
+       public BufferedInputStream(byte[] in, int offset, int length) {
+               if (in == null) {
+                       throw new NullPointerException();
+               } else if (offset < 0 || length < 0 || length > in.length - offset) {
+                       throw new IndexOutOfBoundsException();
+               }
+
+               this.in = null;
+
+               this.buffer = in;
+               this.originalBuffer = this.buffer;
+               this.start = offset;
+               this.stop = length;
+       }
+
+       /**
+        * The internal buffer size (can be useful to know for search methods).
+        * 
+        * @return the size of the internal buffer, in bytes.
+        */
+       public int getInternalBufferSize() {
+               return originalBuffer.length;
+       }
+
+       /**
+        * Return this very same {@link BufferedInputStream}, but keep a counter of
+        * how many streams were open this way. When calling
+        * {@link BufferedInputStream#close()}, decrease this counter if it is not
+        * already zero instead of actually closing the stream.
+        * <p>
+        * You are now responsible for it &mdash; you <b>must</b> close it.
+        * <p>
+        * This method allows you to use a wrapping stream around this one and still
+        * close the wrapping stream.
+        * 
+        * @return the same stream, but you are now responsible for closing it
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the stream is closed
+        */
+       public synchronized InputStream open() throws IOException {
+               checkClose();
+               openCounter++;
+               return this;
+       }
+
+       /**
+        * Check if the current content (until eof) is equal to the given search
+        * term.
+        * <p>
+        * Note: the search term size <b>must</b> be smaller or equal the internal
+        * buffer size.
+        * 
+        * @param search
+        *            the term to search for
+        * 
+        * @return TRUE if the content that will be read starts with it
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the size of the search term is
+        *             greater than the internal buffer
+        */
+       public boolean is(String search) throws IOException {
+               return is(StringUtils.getBytes(search));
+       }
+
+       /**
+        * Check if the current content (until eof) is equal to the given search
+        * term.
+        * <p>
+        * Note: the search term size <b>must</b> be smaller or equal the internal
+        * buffer size.
+        * 
+        * @param search
+        *            the term to search for
+        * 
+        * @return TRUE if the content that will be read starts with it
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the size of the search term is
+        *             greater than the internal buffer
+        */
+       public boolean is(byte[] search) throws IOException {
+               if (startsWith(search)) {
+                       return (stop - start) == search.length;
+               }
+
+               return false;
+       }
+
+       /**
+        * Check if the current content (what will be read next) starts with the
+        * given search term.
+        * <p>
+        * Note: the search term size <b>must</b> be smaller or equal the internal
+        * buffer size.
+        * 
+        * @param search
+        *            the term to search for
+        * 
+        * @return TRUE if the content that will be read starts with it
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the size of the search term is
+        *             greater than the internal buffer
+        */
+       public boolean startsWith(String search) throws IOException {
+               return startsWith(StringUtils.getBytes(search));
+       }
+
+       /**
+        * Check if the current content (what will be read next) starts with the
+        * given search term.
+        * <p>
+        * An empty string will always return true (unless the stream is closed,
+        * which would throw an {@link IOException}).
+        * <p>
+        * Note: the search term size <b>must</b> be smaller or equal the internal
+        * buffer size.
+        * 
+        * @param search
+        *            the term to search for
+        * 
+        * @return TRUE if the content that will be read starts with it
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the size of the search term is
+        *             greater than the internal buffer
+        */
+       public boolean startsWith(byte[] search) throws IOException {
+               if (search.length > originalBuffer.length) {
+                       throw new IOException(
+                                       "This stream does not support searching for more than "
+                                                       + buffer.length + " bytes");
+               }
+
+               checkClose();
+
+               if (available() < search.length) {
+                       preRead();
+               }
+
+               if (available() >= search.length) {
+                       // Easy path
+                       return StreamUtils.startsWith(search, buffer, start, stop);
+               } else if (in != null && !eof) {
+                       // Harder path
+                       if (buffer2 == null && buffer.length == originalBuffer.length) {
+                               buffer2 = Arrays.copyOf(buffer, buffer.length * 2);
+
+                               pos2 = buffer.length;
+                               len2 = read(in, buffer2, pos2, buffer.length);
+                               if (len2 > 0) {
+                                       bytesRead += len2;
+                               }
+
+                               // Note: here, len/len2 = INDEX of last good byte
+                               len2 += pos2;
+                       }
+
+                       return StreamUtils.startsWith(search, buffer2, pos2, len2);
+               }
+
+               return false;
+       }
+
+       /**
+        * The number of bytes read from the under-laying {@link InputStream}.
+        * 
+        * @return the number of bytes
+        */
+       public long getBytesRead() {
+               return bytesRead;
+       }
+
+       /**
+        * Check if this stream is spent (no more data to read or to
+        * process).
+        * 
+        * @return TRUE if it is
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public boolean eof() throws IOException {
+               if (closed) {
+                       return true;
+               }
+
+               preRead();
+               return !hasMoreData();
+       }
+
+       /**
+        * Read the whole {@link InputStream} until the end and return the number of
+        * bytes read.
+        * 
+        * @return the number of bytes read
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public long end() throws IOException {
+               long skipped = 0;
+               while (hasMoreData()) {
+                       skipped += skip(buffer.length);
+               }
+
+               return skipped;
+       }
+
+       @Override
+       public int read() throws IOException {
+               checkClose();
+
+               preRead();
+               if (eof) {
+                       return -1;
+               }
+
+               return buffer[start++];
+       }
+
+       @Override
+       public int read(byte[] b) throws IOException {
+               return read(b, 0, b.length);
+       }
+
+       @Override
+       public int read(byte[] b, int boff, int blen) throws IOException {
+               checkClose();
+
+               if (b == null) {
+                       throw new NullPointerException();
+               } else if (boff < 0 || blen < 0 || blen > b.length - boff) {
+                       throw new IndexOutOfBoundsException();
+               } else if (blen == 0) {
+                       return 0;
+               }
+
+               int done = 0;
+               while (hasMoreData() && done < blen) {
+                       preRead();
+                       if (hasMoreData()) {
+                               int now = Math.min(blen - done, stop - start);
+                               if (now > 0) {
+                                       System.arraycopy(buffer, start, b, boff + done, now);
+                                       start += now;
+                                       done += now;
+                               }
+                       }
+               }
+
+               return done > 0 ? done : -1;
+       }
+
+       @Override
+       public long skip(long n) throws IOException {
+               if (n <= 0) {
+                       return 0;
+               }
+
+               long skipped = 0;
+               while (hasMoreData() && n > 0) {
+                       preRead();
+
+                       long inBuffer = Math.min(n, available());
+                       start += inBuffer;
+                       n -= inBuffer;
+                       skipped += inBuffer;
+               }
+
+               return skipped;
+       }
+
+       @Override
+       public int available() {
+               if (closed) {
+                       return 0;
+               }
+
+               return Math.max(0, stop - start);
+       }
+
+       /**
+        * Closes this stream and releases any system resources associated with the
+        * stream.
+        * <p>
+        * Including the under-laying {@link InputStream}.
+        * <p>
+        * <b>Note:</b> if you called the {@link BufferedInputStream#open()} method
+        * prior to this one, it will just decrease the internal count of how many
+        * open streams it held and do nothing else. The stream will actually be
+        * closed when you have called {@link BufferedInputStream#close()} once more
+        * than {@link BufferedInputStream#open()}.
+        * 
+        * @exception IOException
+        *                in case of I/O error
+        */
+       @Override
+       public synchronized void close() throws IOException {
+               close(true);
+       }
+
+       /**
+        * Closes this stream and releases any system resources associated with the
+        * stream.
+        * <p>
+        * Including the under-laying {@link InputStream} if
+        * <tt>incudingSubStream</tt> is true.
+        * <p>
+        * You can call this method multiple times, it will not cause an
+        * {@link IOException} for subsequent calls.
+        * <p>
+        * <b>Note:</b> if you called the {@link BufferedInputStream#open()} method
+        * prior to this one, it will just decrease the internal count of how many
+        * open streams it held and do nothing else. The stream will actually be
+        * closed when you have called {@link BufferedInputStream#close()} once more
+        * than {@link BufferedInputStream#open()}.
+        * 
+        * @param includingSubStream
+        *            also close the under-laying stream
+        * 
+        * @exception IOException
+        *                in case of I/O error
+        */
+       public synchronized void close(boolean includingSubStream)
+                       throws IOException {
+               if (!closed) {
+                       if (openCounter > 0) {
+                               openCounter--;
+                       } else {
+                               closed = true;
+                               if (includingSubStream && in != null) {
+                                       in.close();
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Check if we still have some data in the buffer and, if not, fetch some.
+        * 
+        * @return TRUE if we fetched some data, FALSE if there are still some in
+        *         the buffer
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected boolean preRead() throws IOException {
+               boolean hasRead = false;
+               if (in != null && !eof && start >= stop) {
+                       start = 0;
+                       if (buffer2 != null) {
+                               buffer = buffer2;
+                               start = pos2;
+                               stop = len2;
+
+                               buffer2 = null;
+                               pos2 = 0;
+                               len2 = 0;
+                       } else {
+                               buffer = originalBuffer;
+
+                               stop = read(in, buffer, 0, buffer.length);
+                               if (stop > 0) {
+                                       bytesRead += stop;
+                               }
+                       }
+
+                       hasRead = true;
+               }
+
+               if (start >= stop) {
+                       eof = true;
+               }
+
+               return hasRead;
+       }
+
+       /**
+        * Read the under-laying stream into the local buffer.
+        * 
+        * @param in
+        *            the under-laying {@link InputStream}
+        * @param buffer
+        *            the buffer we use in this {@link BufferedInputStream}
+        * @param off
+        *            the offset
+        * @param len
+        *            the length in bytes
+        * 
+        * @return the number of bytes read
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected int read(InputStream in, byte[] buffer, int off, int len)
+                       throws IOException {
+               return in.read(buffer, off, len);
+       }
+
+       /**
+        * We have more data available in the buffer <b>or</b> we can, maybe, fetch
+        * more.
+        * 
+        * @return TRUE if it is the case, FALSE if not
+        */
+       protected boolean hasMoreData() {
+               if (closed) {
+                       return false;
+               }
+
+               return (start < stop) || !eof;
+       }
+
+       /**
+        * Check that the stream was not closed, and throw an {@link IOException} if
+        * it was.
+        * 
+        * @throws IOException
+        *             if it was closed
+        */
+       protected void checkClose() throws IOException {
+               if (closed) {
+                       throw new IOException(
+                                       "This BufferedInputStream was closed, you cannot use it anymore.");
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/BufferedOutputStream.java b/src/be/nikiroo/utils/streams/BufferedOutputStream.java
new file mode 100644 (file)
index 0000000..1442534
--- /dev/null
@@ -0,0 +1,260 @@
+package be.nikiroo.utils.streams;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * A simple {@link OutputStream} that is buffered with a bytes array.
+ * <p>
+ * It is mostly intended to be used as a base class to create new
+ * {@link OutputStream}s with special operation modes, and to give some default
+ * methods.
+ * 
+ * @author niki
+ */
+public class BufferedOutputStream extends OutputStream {
+       /** The current position in the buffer. */
+       protected int start;
+       /** The index of the last usable position of the buffer. */
+       protected int stop;
+       /** The buffer itself. */
+       protected byte[] buffer;
+       /** An End-Of-File (or buffer, here) marker. */
+       protected boolean eof;
+       /** The actual under-laying stream. */
+       protected OutputStream out;
+       /** The number of bytes written to the under-laying stream. */
+       protected long bytesWritten;
+       /**
+        * Can bypass the flush process for big writes (will directly write to the
+        * under-laying buffer if the array to write is &gt; the internal buffer
+        * size).
+        * <p>
+        * By default, this is true.
+        */
+       protected boolean bypassFlush = true;
+
+       private boolean closed;
+       private int openCounter;
+       private byte[] b1;
+
+       /**
+        * Create a new {@link BufferedInputStream} that wraps the given
+        * {@link InputStream}.
+        * 
+        * @param out
+        *            the {@link OutputStream} to wrap
+        */
+       public BufferedOutputStream(OutputStream out) {
+               this.out = out;
+
+               this.buffer = new byte[4096];
+               this.b1 = new byte[1];
+               this.start = 0;
+               this.stop = 0;
+       }
+
+       @Override
+       public void write(int b) throws IOException {
+               b1[0] = (byte) b;
+               write(b1, 0, 1);
+       }
+
+       @Override
+       public void write(byte[] b) throws IOException {
+               write(b, 0, b.length);
+       }
+
+       @Override
+       public void write(byte[] source, int sourceOffset, int sourceLength)
+                       throws IOException {
+
+               checkClose();
+
+               if (source == null) {
+                       throw new NullPointerException();
+               } else if ((sourceOffset < 0) || (sourceOffset > source.length)
+                               || (sourceLength < 0)
+                               || ((sourceOffset + sourceLength) > source.length)
+                               || ((sourceOffset + sourceLength) < 0)) {
+                       throw new IndexOutOfBoundsException();
+               } else if (sourceLength == 0) {
+                       return;
+               }
+
+               if (bypassFlush && sourceLength >= buffer.length) {
+                       /*
+                        * If the request length exceeds the size of the output buffer,
+                        * flush the output buffer and then write the data directly. In this
+                        * way buffered streams will cascade harmlessly.
+                        */
+                       flush(false);
+                       out.write(source, sourceOffset, sourceLength);
+                       bytesWritten += (sourceLength - sourceOffset);
+                       return;
+               }
+
+               int done = 0;
+               while (done < sourceLength) {
+                       if (available() <= 0) {
+                               flush(false);
+                       }
+
+                       int now = Math.min(sourceLength - done, available());
+                       System.arraycopy(source, sourceOffset + done, buffer, stop, now);
+                       stop += now;
+                       done += now;
+               }
+       }
+
+       /**
+        * The available space in the buffer.
+        * 
+        * @return the space in bytes
+        */
+       private int available() {
+               if (closed) {
+                       return 0;
+               }
+
+               return Math.max(0, buffer.length - stop - 1);
+       }
+
+       /**
+        * The number of bytes written to the under-laying {@link OutputStream}.
+        * 
+        * @return the number of bytes
+        */
+       public long getBytesWritten() {
+               return bytesWritten;
+       }
+
+       /**
+        * Return this very same {@link BufferedInputStream}, but keep a counter of
+        * how many streams were open this way. When calling
+        * {@link BufferedInputStream#close()}, decrease this counter if it is not
+        * already zero instead of actually closing the stream.
+        * <p>
+        * You are now responsible for it &mdash; you <b>must</b> close it.
+        * <p>
+        * This method allows you to use a wrapping stream around this one and still
+        * close the wrapping stream.
+        * 
+        * @return the same stream, but you are now responsible for closing it
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the stream is closed
+        */
+       public synchronized OutputStream open() throws IOException {
+               checkClose();
+               openCounter++;
+               return this;
+       }
+
+       /**
+        * Check that the stream was not closed, and throw an {@link IOException} if
+        * it was.
+        * 
+        * @throws IOException
+        *             if it was closed
+        */
+       protected void checkClose() throws IOException {
+               if (closed) {
+                       throw new IOException(
+                                       "This BufferedInputStream was closed, you cannot use it anymore.");
+               }
+       }
+
+       @Override
+       public void flush() throws IOException {
+               flush(true);
+       }
+
+       /**
+        * Flush the {@link BufferedOutputStream}, write the current buffered data
+        * to (and optionally also flush) the under-laying stream.
+        * <p>
+        * If {@link BufferedOutputStream#bypassFlush} is false, all writes to the
+        * under-laying stream are done in this method.
+        * <p>
+        * This can be used if you want to write some data in the under-laying
+        * stream yourself (in that case, flush this {@link BufferedOutputStream}
+        * with or without flushing the under-laying stream, then you can write to
+        * the under-laying stream).
+        * 
+        * @param includingSubStream
+        *            also flush the under-laying stream
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void flush(boolean includingSubStream) throws IOException {
+               if (stop > start) {
+                       out.write(buffer, start, stop - start);
+                       bytesWritten += (stop - start);
+               }
+               start = 0;
+               stop = 0;
+
+               if (includingSubStream) {
+                       out.flush();
+               }
+       }
+
+       /**
+        * Closes this stream and releases any system resources associated with the
+        * stream.
+        * <p>
+        * Including the under-laying {@link InputStream}.
+        * <p>
+        * <b>Note:</b> if you called the {@link BufferedInputStream#open()} method
+        * prior to this one, it will just decrease the internal count of how many
+        * open streams it held and do nothing else. The stream will actually be
+        * closed when you have called {@link BufferedInputStream#close()} once more
+        * than {@link BufferedInputStream#open()}.
+        * 
+        * @exception IOException
+        *                in case of I/O error
+        */
+       @Override
+       public synchronized void close() throws IOException {
+               close(true);
+       }
+
+       /**
+        * Closes this stream and releases any system resources associated with the
+        * stream.
+        * <p>
+        * Including the under-laying {@link InputStream} if
+        * <tt>incudingSubStream</tt> is true.
+        * <p>
+        * You can call this method multiple times, it will not cause an
+        * {@link IOException} for subsequent calls.
+        * <p>
+        * <b>Note:</b> if you called the {@link BufferedInputStream#open()} method
+        * prior to this one, it will just decrease the internal count of how many
+        * open streams it held and do nothing else. The stream will actually be
+        * closed when you have called {@link BufferedInputStream#close()} once more
+        * than {@link BufferedInputStream#open()}.
+        * 
+        * @param includingSubStream
+        *            also close the under-laying stream
+        * 
+        * @exception IOException
+        *                in case of I/O error
+        */
+       public synchronized void close(boolean includingSubStream)
+                       throws IOException {
+               if (!closed) {
+                       if (openCounter > 0) {
+                               openCounter--;
+                       } else {
+                               closed = true;
+                               flush(includingSubStream);
+                               if (includingSubStream && out != null) {
+                                       out.close();
+                               }
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/MarkableFileInputStream.java b/src/be/nikiroo/utils/streams/MarkableFileInputStream.java
new file mode 100644 (file)
index 0000000..7622b24
--- /dev/null
@@ -0,0 +1,66 @@
+package be.nikiroo.utils.streams;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+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 file.
+        * 
+        * @param file
+        *            the {@link File} to wrap
+        * 
+        * @throws FileNotFoundException
+        *             if the {@link File} cannot be found
+        */
+       public MarkableFileInputStream(File file) throws FileNotFoundException {
+               this(new FileInputStream(file));
+       }
+
+       /**
+        * 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/streams/NextableInputStream.java b/src/be/nikiroo/utils/streams/NextableInputStream.java
new file mode 100644 (file)
index 0000000..dcab472
--- /dev/null
@@ -0,0 +1,279 @@
+package be.nikiroo.utils.streams;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+/**
+ * This {@link InputStream} can be separated into sub-streams (you can process
+ * it as a normal {@link InputStream} but, when it is spent, you can call
+ * {@link NextableInputStream#next()} on it to unlock new data).
+ * <p>
+ * The separation in sub-streams is done via {@link NextableInputStreamStep}.
+ * 
+ * @author niki
+ */
+public class NextableInputStream extends BufferedInputStream {
+       private NextableInputStreamStep step;
+       private boolean started;
+       private boolean stopped;
+
+       /**
+        * Create a new {@link NextableInputStream} that wraps the given
+        * {@link InputStream}.
+        * 
+        * @param in
+        *            the {@link InputStream} to wrap
+        * @param step
+        *            how to separate it into sub-streams (can be NULL, but in that
+        *            case it will behave as a normal {@link InputStream})
+        */
+       public NextableInputStream(InputStream in, NextableInputStreamStep step) {
+               super(in);
+               this.step = step;
+       }
+
+       /**
+        * Create a new {@link NextableInputStream} that wraps the given bytes array
+        * as a data source.
+        * 
+        * @param in
+        *            the array to wrap, cannot be NULL
+        * @param step
+        *            how to separate it into sub-streams (can be NULL, but in that
+        *            case it will behave as a normal {@link InputStream})
+        */
+       public NextableInputStream(byte[] in, NextableInputStreamStep step) {
+               this(in, step, 0, in.length);
+       }
+
+       /**
+        * Create a new {@link NextableInputStream} that wraps the given bytes array
+        * as a data source.
+        * 
+        * @param in
+        *            the array to wrap, cannot be NULL
+        * @param step
+        *            how to separate it into sub-streams (can be NULL, but in that
+        *            case it will behave as a normal {@link InputStream})
+        * @param offset
+        *            the offset to start the reading at
+        * @param length
+        *            the number of bytes to take into account in the array,
+        *            starting from the offset
+        * 
+        * @throws NullPointerException
+        *             if the array is NULL
+        * @throws IndexOutOfBoundsException
+        *             if the offset and length do not correspond to the given array
+        */
+       public NextableInputStream(byte[] in, NextableInputStreamStep step,
+                       int offset, int length) {
+               super(in, offset, length);
+               this.step = step;
+               checkBuffer(true);
+       }
+
+       /**
+        * Unblock the processing of the next sub-stream.
+        * <p>
+        * It can only be called when the "current" stream is spent (i.e., you must
+        * first process the stream until it is spent).
+        * <p>
+        * {@link IOException}s can happen when we have no data available in the
+        * buffer; in that case, we fetch more data to know if we can have a next
+        * sub-stream or not.
+        * <p>
+        * This is can be a blocking call when data need to be fetched.
+        * 
+        * @return TRUE if we unblocked the next sub-stream, FALSE if not (i.e.,
+        *         FALSE when there are no more sub-streams to fetch)
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the stream is closed
+        */
+       public boolean next() throws IOException {
+               return next(false);
+       }
+
+       /**
+        * Unblock the next sub-stream as would have done
+        * {@link NextableInputStream#next()}, but disable the sub-stream systems.
+        * <p>
+        * That is, the next stream, if any, will be the last one and will not be
+        * subject to the {@link NextableInputStreamStep}.
+        * <p>
+        * This is can be a blocking call when data need to be fetched.
+        * 
+        * @return TRUE if we unblocked the next sub-stream, FALSE if not
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the stream is closed
+        */
+       public boolean nextAll() throws IOException {
+               return next(true);
+       }
+
+       /**
+        * Check if this stream is totally spent (no more data to read or to
+        * process).
+        * <p>
+        * Note: when the stream is divided into sub-streams, each sub-stream will
+        * report its own eof when spent.
+        * 
+        * @return TRUE if it is
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @Override
+       public boolean eof() throws IOException {
+               return super.eof();
+       }
+
+       /**
+        * Check if we still have some data in the buffer and, if not, fetch some.
+        * 
+        * @return TRUE if we fetched some data, FALSE if there are still some in
+        *         the buffer
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @Override
+       protected boolean preRead() throws IOException {
+               if (!stopped) {
+                       boolean bufferChanged = super.preRead();
+                       checkBuffer(bufferChanged);
+                       return bufferChanged;
+               }
+
+               if (start >= stop) {
+                       eof = true;
+               }
+
+               return false;
+       }
+
+       @Override
+       protected boolean hasMoreData() {
+               return started && super.hasMoreData();
+       }
+
+       /**
+        * Check that the buffer didn't overshot to the next item, and fix
+        * {@link NextableInputStream#stop} if needed.
+        * <p>
+        * If {@link NextableInputStream#stop} is fixed,
+        * {@link NextableInputStream#eof} and {@link NextableInputStream#stopped}
+        * are set to TRUE.
+        * 
+        * @param newBuffer
+        *            we changed the buffer, we need to clear some information in
+        *            the {@link NextableInputStreamStep}
+        */
+       private void checkBuffer(boolean newBuffer) {
+               if (step != null && stop >= 0) {
+                       if (newBuffer) {
+                               step.clearBuffer();
+                       }
+
+                       int stopAt = step.stop(buffer, start, stop, eof);
+                       if (stopAt >= 0) {
+                               stop = stopAt;
+                               eof = true;
+                               stopped = true;
+                       }
+               }
+       }
+
+       /**
+        * The implementation of {@link NextableInputStream#next()} and
+        * {@link NextableInputStream#nextAll()}.
+        * <p>
+        * This is can be a blocking call when data need to be fetched.
+        * 
+        * @param all
+        *            TRUE for {@link NextableInputStream#nextAll()}, FALSE for
+        *            {@link NextableInputStream#next()}
+        * 
+        * @return TRUE if we unblocked the next sub-stream, FALSE if not (i.e.,
+        *         FALSE when there are no more sub-streams to fetch)
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the stream is closed
+        */
+       private boolean next(boolean all) throws IOException {
+               checkClose();
+
+               if (!started) {
+                       // First call before being allowed to read
+                       started = true;
+
+                       if (all) {
+                               step = null;
+                       }
+
+                       return true;
+               }
+
+               // If started, must be stopped and no more data to continue
+               // i.e., sub-stream must be spent
+               if (!stopped || hasMoreData()) {
+                       return false;
+               }
+
+               if (step != null) {
+                       stop = step.getResumeLen();
+                       start += step.getResumeSkip();
+                       eof = step.getResumeEof();
+                       stopped = false;
+
+                       if (all) {
+                               step = null;
+                       }
+
+                       checkBuffer(false);
+
+                       return true;
+               }
+
+               return false;
+
+               // // consider that if EOF, there is no next
+               // if (start >= stop) {
+               // // Make sure, block if necessary
+               // preRead();
+               //
+               // return hasMoreData();
+               // }
+               //
+               // return true;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        * <p>
+        * Do <b>not</b> use for release code.
+        */
+       @Override
+       public String toString() {
+               String data = "";
+               if (stop > 0) {
+                       try {
+                               data = new String(Arrays.copyOfRange(buffer, 0, stop), "UTF-8");
+                       } catch (UnsupportedEncodingException e) {
+                       }
+                       if (data.length() > 200) {
+                               data = data.substring(0, 197) + "...";
+                       }
+               }
+               String rep = String.format(
+                               "Nextable %s: %d -> %d [eof: %s] [more data: %s]: %s",
+                               (stopped ? "stopped" : "running"), start, stop, "" + eof, ""
+                                               + hasMoreData(), data);
+
+               return rep;
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/NextableInputStreamStep.java b/src/be/nikiroo/utils/streams/NextableInputStreamStep.java
new file mode 100644 (file)
index 0000000..fda998d
--- /dev/null
@@ -0,0 +1,112 @@
+package be.nikiroo.utils.streams;
+
+import java.io.InputStream;
+
+/**
+ * Divide an {@link InputStream} into sub-streams.
+ * 
+ * @author niki
+ */
+public class NextableInputStreamStep {
+       private int stopAt;
+       private int last = -1;
+       private int resumeLen;
+       private int resumeSkip;
+       private boolean resumeEof;
+
+       /**
+        * Create a new divider that will separate the sub-streams each time it sees
+        * this byte.
+        * <p>
+        * Note that the byte will be bypassed by the {@link InputStream} as far as
+        * the consumers will be aware.
+        * 
+        * @param byt
+        *            the byte at which to separate two sub-streams
+        */
+       public NextableInputStreamStep(int byt) {
+               stopAt = byt;
+       }
+
+       /**
+        * Check if we need to stop the {@link InputStream} reading at some point in
+        * the current buffer.
+        * <p>
+        * If we do, return the index at which to stop; if not, return -1.
+        * <p>
+        * This method will <b>not</b> return the same index a second time (unless
+        * we cleared the buffer).
+        * 
+        * @param buffer
+        *            the buffer to check
+        * @param pos
+        *            the current position of what was read in the buffer
+        * @param len
+        *            the maximum index to use in the buffer (anything above that is
+        *            not to be used)
+        * @param eof
+        *            the current state of the under-laying stream
+        * 
+        * @return the index at which to stop, or -1
+        */
+       public int stop(byte[] buffer, int pos, int len, boolean eof) {
+               for (int i = pos; i < len; i++) {
+                       if (buffer[i] == stopAt) {
+                               if (i > this.last) {
+                                       // we skip the sep
+                                       this.resumeSkip = 1;
+
+                                       this.resumeLen = len;
+                                       this.resumeEof = eof;
+                                       this.last = i;
+                                       return i;
+                               }
+                       }
+               }
+
+               return -1;
+       }
+
+       /**
+        * Get the maximum index to use in the buffer used in
+        * {@link NextableInputStreamStep#stop(byte[], int, int, boolean)} at resume
+        * time.
+        * 
+        * @return the index
+        */
+       public int getResumeLen() {
+               return resumeLen;
+       }
+
+       /**
+        * Get the number of bytes to skip at resume time.
+        * 
+        * @return the number of bytes to skip
+        */
+       public int getResumeSkip() {
+               return resumeSkip;
+       }
+
+       /**
+        * Get the under-laying stream state at resume time.
+        * 
+        * @return the EOF state
+        */
+       public boolean getResumeEof() {
+               return resumeEof;
+       }
+
+       /**
+        * Clear the information we may have kept about the current buffer
+        * <p>
+        * You should call this method each time you change the content of the
+        * buffer used in
+        * {@link NextableInputStreamStep#stop(byte[], int, int, boolean)}.
+        */
+       public void clearBuffer() {
+               this.last = -1;
+               this.resumeSkip = 0;
+               this.resumeLen = 0;
+               this.resumeEof = false;
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/ReplaceInputStream.java b/src/be/nikiroo/utils/streams/ReplaceInputStream.java
new file mode 100644 (file)
index 0000000..1cc5139
--- /dev/null
@@ -0,0 +1,162 @@
+package be.nikiroo.utils.streams;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This {@link InputStream} will change some of its content by replacing it with
+ * something else.
+ * 
+ * @author niki
+ */
+public class ReplaceInputStream extends BufferedInputStream {
+       /**
+        * The minimum size of the internal buffer (could be more if at least one of
+        * the 'FROM' bytes arrays is &gt; 2048 bytes &mdash; in that case the
+        * buffer will be twice the largest size of the 'FROM' bytes arrays).
+        * <p>
+        * This is a different buffer than the one from the inherited class.
+        */
+       static private final int MIN_BUFFER_SIZE = 4096;
+
+       private byte[][] froms;
+       private byte[][] tos;
+       private int maxFromSize;
+       private int maxToSize;
+
+       private byte[] source;
+       private int spos;
+       private int slen;
+
+       /**
+        * Create a {@link ReplaceInputStream} that will replace <tt>from</tt> with
+        * <tt>to</tt>.
+        * 
+        * @param in
+        *            the under-laying {@link InputStream}
+        * @param from
+        *            the {@link String} to replace
+        * @param to
+        *            the {@link String} to replace with
+        */
+       public ReplaceInputStream(InputStream in, String from, String to) {
+               this(in, StringUtils.getBytes(from), StringUtils.getBytes(to));
+       }
+
+       /**
+        * Create a {@link ReplaceInputStream} that will replace <tt>from</tt> with
+        * <tt>to</tt>.
+        * 
+        * @param in
+        *            the under-laying {@link InputStream}
+        * @param from
+        *            the value to replace
+        * @param to
+        *            the value to replace with
+        */
+       public ReplaceInputStream(InputStream in, byte[] from, byte[] to) {
+               this(in, new byte[][] { from }, new byte[][] { to });
+       }
+
+       /**
+        * Create a {@link ReplaceInputStream} that will replace all <tt>froms</tt>
+        * with <tt>tos</tt>.
+        * <p>
+        * Note that they will be replaced in order, and that for each <tt>from</tt>
+        * a <tt>to</tt> must correspond.
+        * 
+        * @param in
+        *            the under-laying {@link InputStream}
+        * @param froms
+        *            the values to replace
+        * @param tos
+        *            the values to replace with
+        */
+       public ReplaceInputStream(InputStream in, String[] froms, String[] tos) {
+               this(in, StreamUtils.getBytes(froms), StreamUtils.getBytes(tos));
+       }
+
+       /**
+        * Create a {@link ReplaceInputStream} that will replace all <tt>froms</tt>
+        * with <tt>tos</tt>.
+        * <p>
+        * Note that they will be replaced in order, and that for each <tt>from</tt>
+        * a <tt>to</tt> must correspond.
+        * 
+        * @param in
+        *            the under-laying {@link InputStream}
+        * @param froms
+        *            the values to replace
+        * @param tos
+        *            the values to replace with
+        */
+       public ReplaceInputStream(InputStream in, byte[][] froms, byte[][] tos) {
+               super(in);
+
+               if (froms.length != tos.length) {
+                       throw new IllegalArgumentException(
+                                       "For replacing, each FROM must have a corresponding TO");
+               }
+
+               this.froms = froms;
+               this.tos = tos;
+
+               maxFromSize = 0;
+               for (int i = 0; i < froms.length; i++) {
+                       maxFromSize = Math.max(maxFromSize, froms[i].length);
+               }
+
+               maxToSize = 0;
+               for (int i = 0; i < tos.length; i++) {
+                       maxToSize = Math.max(maxToSize, tos[i].length);
+               }
+
+               // We need at least maxFromSize so we can iterate and replace
+               source = new byte[Math.max(2 * maxFromSize, MIN_BUFFER_SIZE)];
+               spos = 0;
+               slen = 0;
+       }
+
+       @Override
+       protected int read(InputStream in, byte[] buffer, int off, int len)
+                       throws IOException {
+               if (len < maxToSize || source.length < maxToSize * 2) {
+                       throw new IOException(
+                                       "An underlaying buffer is too small for these replace values");
+               }
+
+               // We need at least one byte of data to process
+               if (available() < Math.max(maxFromSize, 1) && !eof) {
+                       spos = 0;
+                       slen = in.read(source);
+               }
+
+               // Note: very simple, not efficient implementation; sorry.
+               int count = 0;
+               while (spos < slen && count < len - maxToSize) {
+                       boolean replaced = false;
+                       for (int i = 0; i < froms.length; i++) {
+                               if (froms[i] != null && froms[i].length > 0
+                                               && StreamUtils.startsWith(froms[i], source, spos, slen)) {
+                                       if (tos[i] != null && tos[i].length > 0) {
+                                               System.arraycopy(tos[i], 0, buffer, off + spos,
+                                                               tos[i].length);
+                                               count += tos[i].length;
+                                       }
+
+                                       spos += froms[i].length;
+                                       replaced = true;
+                                       break;
+                               }
+                       }
+
+                       if (!replaced) {
+                               buffer[off + count++] = source[spos++];
+                       }
+               }
+
+               return count;
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/ReplaceOutputStream.java b/src/be/nikiroo/utils/streams/ReplaceOutputStream.java
new file mode 100644 (file)
index 0000000..c6679cc
--- /dev/null
@@ -0,0 +1,148 @@
+package be.nikiroo.utils.streams;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This {@link OutputStream} will change some of its content by replacing it
+ * with something else.
+ * 
+ * @author niki
+ */
+public class ReplaceOutputStream extends BufferedOutputStream {
+       private byte[][] froms;
+       private byte[][] tos;
+
+       /**
+        * Create a {@link ReplaceOutputStream} that will replace <tt>from</tt> with
+        * <tt>to</tt>.
+        * 
+        * @param out
+        *            the under-laying {@link OutputStream}
+        * @param from
+        *            the {@link String} to replace
+        * @param to
+        *            the {@link String} to replace with
+        */
+       public ReplaceOutputStream(OutputStream out, String from, String to) {
+               this(out, StringUtils.getBytes(from), StringUtils.getBytes(to));
+       }
+
+       /**
+        * Create a {@link ReplaceOutputStream} that will replace <tt>from</tt> with
+        * <tt>to</tt>.
+        * 
+        * @param out
+        *            the under-laying {@link OutputStream}
+        * @param from
+        *            the value to replace
+        * @param to
+        *            the value to replace with
+        */
+       public ReplaceOutputStream(OutputStream out, byte[] from, byte[] to) {
+               this(out, new byte[][] { from }, new byte[][] { to });
+       }
+
+       /**
+        * Create a {@link ReplaceOutputStream} that will replace all <tt>froms</tt>
+        * with <tt>tos</tt>.
+        * <p>
+        * Note that they will be replaced in order, and that for each <tt>from</tt>
+        * a <tt>to</tt> must correspond.
+        * 
+        * @param out
+        *            the under-laying {@link OutputStream}
+        * @param froms
+        *            the values to replace
+        * @param tos
+        *            the values to replace with
+        */
+       public ReplaceOutputStream(OutputStream out, String[] froms, String[] tos) {
+               this(out, StreamUtils.getBytes(froms), StreamUtils.getBytes(tos));
+       }
+
+       /**
+        * Create a {@link ReplaceOutputStream} that will replace all <tt>froms</tt>
+        * with <tt>tos</tt>.
+        * <p>
+        * Note that they will be replaced in order, and that for each <tt>from</tt>
+        * a <tt>to</tt> must correspond.
+        * 
+        * @param out
+        *            the under-laying {@link OutputStream}
+        * @param froms
+        *            the values to replace
+        * @param tos
+        *            the values to replace with
+        */
+       public ReplaceOutputStream(OutputStream out, byte[][] froms, byte[][] tos) {
+               super(out);
+               bypassFlush = false;
+
+               if (froms.length != tos.length) {
+                       throw new IllegalArgumentException(
+                                       "For replacing, each FROM must have a corresponding TO");
+               }
+
+               this.froms = froms;
+               this.tos = tos;
+       }
+
+       /**
+        * Flush the {@link BufferedOutputStream}, write the current buffered data
+        * to (and optionally also flush) the under-laying stream.
+        * <p>
+        * If {@link BufferedOutputStream#bypassFlush} is false, all writes to the
+        * under-laying stream are done in this method.
+        * <p>
+        * This can be used if you want to write some data in the under-laying
+        * stream yourself (in that case, flush this {@link BufferedOutputStream}
+        * with or without flushing the under-laying stream, then you can write to
+        * the under-laying stream).
+        * <p>
+        * <b>But be careful!</b> If a replacement could be done with the end o the
+        * currently buffered data and the start of the data to come, we obviously
+        * will not be able to do it.
+        * 
+        * @param includingSubStream
+        *            also flush the under-laying stream
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @Override
+       public void flush(boolean includingSubStream) throws IOException {
+               // Note: very simple, not efficient implementation; sorry.
+               while (start < stop) {
+                       boolean replaced = false;
+                       for (int i = 0; i < froms.length; i++) {
+                               if (froms[i] != null
+                                               && froms[i].length > 0
+                                               && StreamUtils
+                                                               .startsWith(froms[i], buffer, start, stop)) {
+                                       if (tos[i] != null && tos[i].length > 0) {
+                                               out.write(tos[i]);
+                                               bytesWritten += tos[i].length;
+                                       }
+
+                                       start += froms[i].length;
+                                       replaced = true;
+                                       break;
+                               }
+                       }
+
+                       if (!replaced) {
+                               out.write(buffer[start++]);
+                               bytesWritten++;
+                       }
+               }
+
+               start = 0;
+               stop = 0;
+
+               if (includingSubStream) {
+                       out.flush();
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/streams/StreamUtils.java b/src/be/nikiroo/utils/streams/StreamUtils.java
new file mode 100644 (file)
index 0000000..dc75090
--- /dev/null
@@ -0,0 +1,69 @@
+package be.nikiroo.utils.streams;
+
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Some non-public utilities used in the stream classes.
+ * 
+ * @author niki
+ */
+class StreamUtils {
+       /**
+        * Check if the buffer starts with the given search term (given as an array,
+        * a start position and an end position).
+        * <p>
+        * Note: the parameter <tt>stop</tt> is the <b>index</b> of the last
+        * position, <b>not</b> the length.
+        * <p>
+        * Note: the search term size <b>must</b> be smaller or equal the internal
+        * buffer size.
+        * 
+        * @param search
+        *            the term to search for
+        * @param buffer
+        *            the buffer to look into
+        * @param start
+        *            the offset at which to start the search
+        * @param stop
+        *            the maximum index of the data to check (this is <b>not</b> a
+        *            length, but an index)
+        * 
+        * @return TRUE if the search content is present at the given location and
+        *         does not exceed the <tt>len</tt> index
+        */
+       static public boolean startsWith(byte[] search, byte[] buffer, int start,
+                       int stop) {
+
+               // Check if there even is enough space for it
+               if (search.length > (stop - start)) {
+                       return false;
+               }
+
+               boolean same = true;
+               for (int i = 0; i < search.length; i++) {
+                       if (search[i] != buffer[start + i]) {
+                               same = false;
+                               break;
+                       }
+               }
+
+               return same;
+       }
+
+       /**
+        * Return the bytes array representation of the given {@link String} in
+        * UTF-8.
+        * 
+        * @param strs
+        *            the {@link String}s to transform into bytes
+        * @return the content in bytes
+        */
+       static public byte[][] getBytes(String[] strs) {
+               byte[][] bytes = new byte[strs.length][];
+               for (int i = 0; i < strs.length; i++) {
+                       bytes[i] = StringUtils.getBytes(strs[i]);
+               }
+
+               return bytes;
+       }
+}
diff --git a/src/be/nikiroo/utils/test/TestCase.java b/src/be/nikiroo/utils/test/TestCase.java
new file mode 100644 (file)
index 0000000..fe7b9af
--- /dev/null
@@ -0,0 +1,535 @@
+package be.nikiroo.utils.test;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import be.nikiroo.utils.IOUtils;
+
+/**
+ * A {@link TestCase} that can be run with {@link TestLauncher}.
+ * 
+ * @author niki
+ */
+abstract public class TestCase {
+       /**
+        * The type of {@link Exception} used to signal a failed assertion or a
+        * force-fail.
+        * 
+        * @author niki
+        */
+       class AssertException extends Exception {
+               private static final long serialVersionUID = 1L;
+
+               public AssertException(String reason, Exception source) {
+                       super(reason, source);
+               }
+
+               public AssertException(String reason) {
+                       super(reason);
+               }
+       }
+
+       private String name;
+
+       /**
+        * Create a new {@link TestCase}.
+        * 
+        * @param name
+        *            the test name
+        */
+       public TestCase(String name) {
+               this.name = name;
+       }
+
+       /**
+        * This constructor can be used if you require a no-param constructor. In
+        * this case, you are allowed to set the name manually via
+        * {@link TestCase#setName}.
+        */
+       protected TestCase() {
+               this("no name");
+       }
+
+       /**
+        * Setup the test (called before the test is run).
+        * 
+        * @throws Exception
+        *             in case of error
+        */
+       public void setUp() throws Exception {
+       }
+
+       /**
+        * Tear-down the test (called when the test has been ran).
+        * 
+        * @throws Exception
+        *             in case of error
+        */
+       public void tearDown() throws Exception {
+       }
+
+       /**
+        * The test name.
+        * 
+        * @return the name
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * The test name.
+        * 
+        * @param name
+        *            the new name (internal use only)
+        * 
+        * @return this (so we can chain and so we can initialize it in a member
+        *         variable if this is an anonymous inner class)
+        */
+       protected TestCase setName(String name) {
+               this.name = name;
+               return this;
+       }
+
+       /**
+        * Actually do the test.
+        * 
+        * @throws Exception
+        *             in case of error
+        */
+       abstract public void test() throws Exception;
+
+       /**
+        * Force a failure.
+        * 
+        * @throws AssertException
+        *             every time
+        */
+       public void fail() throws AssertException {
+               fail(null);
+       }
+
+       /**
+        * Force a failure.
+        * 
+        * @param reason
+        *            the failure reason
+        * 
+        * @throws AssertException
+        *             every time
+        */
+       public void fail(String reason) throws AssertException {
+               fail(reason, null);
+       }
+
+       /**
+        * Force a failure.
+        * 
+        * @param reason
+        *            the failure reason
+        * @param e
+        *            the exception that caused the failure (can be NULL)
+        * 
+        * @throws AssertException
+        *             every time
+        */
+       public void fail(String reason, Exception e) throws AssertException {
+               throw new AssertException("Failed!" + //
+                               reason != null ? "\n" + reason : "", e);
+       }
+
+       /**
+        * Check that 2 {@link Object}s are equals.
+        * 
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(Object expected, Object actual)
+                       throws AssertException {
+               assertEquals(null, expected, actual);
+       }
+
+       /**
+        * Check that 2 {@link Object}s are equals.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, Object expected, Object actual)
+                       throws AssertException {
+               if ((expected == null && actual != null)
+                               || (expected != null && !expected.equals(actual))) {
+                       if (errorMessage == null) {
+                               throw new AssertException(generateAssertMessage(expected,
+                                               actual));
+                       }
+
+                       throw new AssertException(errorMessage, new AssertException(
+                                       generateAssertMessage(expected, actual)));
+               }
+       }
+
+       /**
+        * Check that 2 longs are equals.
+        * 
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(long expected, long actual) throws AssertException {
+               assertEquals(Long.valueOf(expected), Long.valueOf(actual));
+       }
+
+       /**
+        * Check that 2 longs are equals.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, long expected, long actual)
+                       throws AssertException {
+               assertEquals(errorMessage, Long.valueOf(expected), Long.valueOf(actual));
+       }
+
+       /**
+        * Check that 2 booleans are equals.
+        * 
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(boolean expected, boolean actual)
+                       throws AssertException {
+               assertEquals(Boolean.valueOf(expected), Boolean.valueOf(actual));
+       }
+
+       /**
+        * Check that 2 booleans are equals.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, boolean expected,
+                       boolean actual) throws AssertException {
+               assertEquals(errorMessage, Boolean.valueOf(expected),
+                               Boolean.valueOf(actual));
+       }
+
+       /**
+        * Check that 2 doubles are equals.
+        * 
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(double expected, double actual)
+                       throws AssertException {
+               assertEquals(Double.valueOf(expected), Double.valueOf(actual));
+       }
+
+       /**
+        * Check that 2 doubles are equals.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, double expected, double actual)
+                       throws AssertException {
+               assertEquals(errorMessage, Double.valueOf(expected),
+                               Double.valueOf(actual));
+       }
+
+       /**
+        * Check that 2 {@link List}s are equals.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(List<?> expected, List<?> actual)
+                       throws AssertException {
+               assertEquals("Assertion failed", expected, actual);
+       }
+
+       /**
+        * Check that 2 {@link List}s are equals.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, List<?> expected,
+                       List<?> actual) throws AssertException {
+
+               if (expected.size() != actual.size()) {
+                       assertEquals(errorMessage + ": not same number of items",
+                                       list(expected), list(actual));
+               }
+
+               int size = expected.size();
+               for (int i = 0; i < size; i++) {
+                       assertEquals(errorMessage + ": item " + i
+                                       + " (0-based) is not correct", expected.get(i),
+                                       actual.get(i));
+               }
+       }
+
+       /**
+        * Check that 2 {@link File}s are equals, by doing a line-by-line
+        * comparison.
+        * 
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * @param errorMessage
+        *            the error message to display if they differ
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(File expected, File actual) throws AssertException {
+               assertEquals(generateAssertMessage(expected, actual), expected, actual);
+       }
+
+       /**
+        * Check that 2 {@link File}s are equals, by doing a line-by-line
+        * comparison.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, File expected, File actual)
+                       throws AssertException {
+               assertEquals(errorMessage, expected, actual, null);
+       }
+
+       /**
+        * Check that 2 {@link File}s are equals, by doing a line-by-line
+        * comparison.
+        * 
+        * @param errorMessage
+        *            the error message to display if they differ
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * @param skipCompare
+        *            skip the lines starting with some values for the given files
+        *            (relative path from base directory in recursive mode)
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertEquals(String errorMessage, File expected, File actual,
+                       Map<String, List<String>> skipCompare) throws AssertException {
+               assertEquals(errorMessage, expected, actual, skipCompare, null);
+       }
+
+       private void assertEquals(String errorMessage, File expected, File actual,
+                       Map<String, List<String>> skipCompare, String removeFromName)
+                       throws AssertException {
+
+               if (expected.isDirectory() || actual.isDirectory()) {
+                       assertEquals(errorMessage + ": type mismatch: expected a "
+                                       + (expected.isDirectory() ? "directory" : "file")
+                                       + ", received a "
+                                       + (actual.isDirectory() ? "directory" : "file"),
+                                       expected.isDirectory(), actual.isDirectory());
+
+                       List<String> expectedFiles = Arrays.asList(expected.list());
+                       Collections.sort(expectedFiles);
+                       List<String> actualFiles = Arrays.asList(actual.list());
+                       Collections.sort(actualFiles);
+
+                       assertEquals(errorMessage, expectedFiles, actualFiles);
+                       for (int i = 0; i < actualFiles.size(); i++) {
+                               File expectedFile = new File(expected, expectedFiles.get(i));
+                               File actualFile = new File(actual, actualFiles.get(i));
+
+                               assertEquals(errorMessage, expectedFile, actualFile,
+                                               skipCompare, expected.getAbsolutePath());
+                       }
+               } else {
+                       try {
+                               List<String> expectedLines = Arrays.asList(IOUtils
+                                               .readSmallFile(expected).split("\n"));
+                               List<String> resultLines = Arrays.asList(IOUtils.readSmallFile(
+                                               actual).split("\n"));
+
+                               String name = expected.getAbsolutePath();
+                               if (removeFromName != null && name.startsWith(removeFromName)) {
+                                       name = expected.getName()
+                                                       + name.substring(removeFromName.length());
+                               }
+
+                               assertEquals(errorMessage + ": " + name
+                                               + ": the number of lines is not the same",
+                                               expectedLines.size(), resultLines.size());
+
+                               for (int j = 0; j < expectedLines.size(); j++) {
+                                       String expectedLine = expectedLines.get(j);
+                                       String resultLine = resultLines.get(j);
+
+                                       boolean skip = false;
+                                       if (skipCompare != null) {
+                                               for (Entry<String, List<String>> skipThose : skipCompare
+                                                               .entrySet()) {
+                                                       for (String skipStart : skipThose.getValue()) {
+                                                               if (name.endsWith(skipThose.getKey())
+                                                                               && expectedLine.startsWith(skipStart)
+                                                                               && resultLine.startsWith(skipStart)) {
+                                                                       skip = true;
+                                                               }
+                                                       }
+                                               }
+                                       }
+
+                                       if (skip) {
+                                               continue;
+                                       }
+
+                                       assertEquals(errorMessage + ": line " + (j + 1)
+                                                       + " is not the same in file " + name, expectedLine,
+                                                       resultLine);
+                               }
+                       } catch (Exception e) {
+                               throw new AssertException(errorMessage, e);
+                       }
+               }
+       }
+
+       /**
+        * Check that given {@link Object} is not NULL.
+        * 
+        * @param errorMessage
+        *            the error message to display if it is NULL
+        * @param actual
+        *            the actual value
+        * 
+        * @throws AssertException
+        *             in case they differ
+        */
+       public void assertNotNull(String errorMessage, Object actual)
+                       throws AssertException {
+               if (actual == null) {
+                       String defaultReason = String.format("" //
+                                       + "Assertion failed!%n" //
+                                       + "Object should not have been NULL");
+
+                       if (errorMessage == null) {
+                               throw new AssertException(defaultReason);
+                       }
+
+                       throw new AssertException(errorMessage, new AssertException(
+                                       defaultReason));
+               }
+       }
+
+       /**
+        * Generate the default assert message for 2 different values that were
+        * supposed to be equals.
+        * 
+        * @param expected
+        *            the expected value
+        * @param actual
+        *            the actual value
+        * 
+        * @return the message
+        */
+       public static String generateAssertMessage(Object expected, Object actual) {
+               return String.format("" //
+                               + "Assertion failed!%n" //
+                               + "Expected value: [%s]%n" //
+                               + "Actual value:   [%s]", expected, actual);
+       }
+
+       private static String list(List<?> items) {
+               StringBuilder builder = new StringBuilder();
+               for (Object item : items) {
+                       if (builder.length() == 0) {
+                               builder.append(items.size() + " item(s): ");
+                       } else {
+                               builder.append(", ");
+                       }
+
+                       builder.append("" + item);
+
+                       if (builder.length() > 60) {
+                               builder.setLength(57);
+                               builder.append("...");
+                               break;
+                       }
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/utils/test/TestLauncher.java b/src/be/nikiroo/utils/test/TestLauncher.java
new file mode 100644 (file)
index 0000000..895b565
--- /dev/null
@@ -0,0 +1,434 @@
+package be.nikiroo.utils.test;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A {@link TestLauncher} starts a series of {@link TestCase}s and displays the
+ * result to the user.
+ * 
+ * @author niki
+ */
+public class TestLauncher {
+       /**
+        * {@link Exception} happening during the setup process.
+        * 
+        * @author niki
+        */
+       private class SetupException extends Exception {
+               private static final long serialVersionUID = 1L;
+
+               public SetupException(Throwable e) {
+                       super(e);
+               }
+       }
+
+       /**
+        * {@link Exception} happening during the tear-down process.
+        * 
+        * @author niki
+        */
+       private class TearDownException extends Exception {
+               private static final long serialVersionUID = 1L;
+
+               public TearDownException(Throwable e) {
+                       super(e);
+               }
+       }
+
+       private List<TestLauncher> series;
+       private List<TestCase> tests;
+       private TestLauncher parent;
+
+       private int columns;
+       private String okString;
+       private String koString;
+       private String name;
+       private boolean cont;
+
+       protected int executed;
+       protected int total;
+
+       private int currentSeries = 0;
+       private boolean details = false;
+
+       /**
+        * Create a new {@link TestLauncher} with default parameters.
+        * 
+        * @param name
+        *            the test suite name
+        * @param args
+        *            the arguments to configure the number of columns and the ok/ko
+        *            {@link String}s
+        */
+       public TestLauncher(String name, String[] args) {
+               this.name = name;
+
+               int cols = 80;
+               if (args != null && args.length >= 1) {
+                       try {
+                               cols = Integer.parseInt(args[0]);
+                       } catch (NumberFormatException e) {
+                               System.err.println("Test configuration: given number "
+                                               + "of columns is not parseable: " + args[0]);
+                       }
+               }
+
+               setColumns(cols);
+
+               String okString = "[ ok ]";
+               String koString = "[ !! ]";
+               if (args != null && args.length >= 3) {
+                       okString = args[1];
+                       koString = args[2];
+               }
+
+               setOkString(okString);
+               setKoString(koString);
+
+               series = new ArrayList<TestLauncher>();
+               tests = new ArrayList<TestCase>();
+               cont = true;
+       }
+
+       /**
+        * Display the details of the errors
+        * 
+        * @return TRUE to display them, false to simply mark the test as failed
+        */
+       public boolean isDetails() {
+               if (parent != null) {
+                       return parent.isDetails();
+               }
+
+               return details;
+       }
+
+       /**
+        * Display the details of the errors
+        * 
+        * @param details
+        *            TRUE to display them, false to simply mark the test as failed
+        */
+       public void setDetails(boolean details) {
+               if (parent != null) {
+                       parent.setDetails(details);
+               }
+
+               this.details = details;
+       }
+
+       /**
+        * Called before actually starting the tests themselves.
+        * 
+        * @throws Exception
+        *             in case of error
+        */
+       protected void start() throws Exception {
+       }
+
+       /**
+        * Called when the tests are passed (or failed to do so).
+        * 
+        * @throws Exception
+        *             in case of error
+        */
+       protected void stop() throws Exception {
+       }
+
+       protected void addTest(TestCase test) {
+               tests.add(test);
+       }
+
+       protected void addSeries(TestLauncher series) {
+               this.series.add(series);
+               series.parent = this;
+       }
+
+       /**
+        * Launch the series of {@link TestCase}s and the {@link TestCase}s.
+        * 
+        * @return the number of errors
+        */
+       public int launch() {
+               return launch(0);
+       }
+
+       /**
+        * Launch the series of {@link TestCase}s and the {@link TestCase}s.
+        * 
+        * @param depth
+        *            the level at which is the launcher (0 = main launcher)
+        * 
+        * @return the number of errors
+        */
+       public int launch(int depth) {
+               int errors = 0;
+               executed = 0;
+               total = tests.size();
+
+               print(depth);
+
+               try {
+                       start();
+
+                       errors += launchTests(depth);
+                       if (tests.size() > 0 && depth == 0) {
+                               System.out.println("");
+                       }
+
+                       currentSeries = 0;
+                       for (TestLauncher serie : series) {
+                               errors += serie.launch(depth + 1);
+                               executed += serie.executed;
+                               total += serie.total;
+                               currentSeries++;
+                       }
+               } catch (Exception e) {
+                       print(depth, "__start");
+                       print(depth, e);
+               } finally {
+                       try {
+                               stop();
+                       } catch (Exception e) {
+                               print(depth, "__stop");
+                               print(depth, e);
+                       }
+               }
+
+               print(depth, executed, errors, total);
+
+               return errors;
+       }
+
+       /**
+        * Launch the {@link TestCase}s.
+        * 
+        * @param depth
+        *            the level at which is the launcher (0 = main launcher)
+        * 
+        * @return the number of errors
+        */
+       protected int launchTests(int depth) {
+               int errors = 0;
+               for (TestCase test : tests) {
+                       print(depth, test.getName());
+
+                       Throwable ex = null;
+                       try {
+                               try {
+                                       test.setUp();
+                               } catch (Throwable e) {
+                                       throw new SetupException(e);
+                               }
+                               test.test();
+                               try {
+                                       test.tearDown();
+                               } catch (Throwable e) {
+                                       throw new TearDownException(e);
+                               }
+                       } catch (Throwable e) {
+                               ex = e;
+                       }
+
+                       if (ex != null) {
+                               errors++;
+                       }
+
+                       print(depth, ex);
+
+                       executed++;
+
+                       if (ex != null && !cont) {
+                               break;
+                       }
+               }
+
+               return errors;
+       }
+
+       /**
+        * Specify a custom number of columns to use for the display of messages.
+        * 
+        * @param columns
+        *            the number of columns
+        */
+       public void setColumns(int columns) {
+               this.columns = columns;
+       }
+
+       /**
+        * Continue to run the tests when an error is detected.
+        * 
+        * @param cont
+        *            yes or no
+        */
+       public void setContinueAfterFail(boolean cont) {
+               this.cont = cont;
+       }
+
+       /**
+        * Set a custom "[ ok ]" {@link String} when a test passed.
+        * 
+        * @param okString
+        *            the {@link String} to display at the end of a success
+        */
+       public void setOkString(String okString) {
+               this.okString = okString;
+       }
+
+       /**
+        * Set a custom "[ !! ]" {@link String} when a test failed.
+        * 
+        * @param koString
+        *            the {@link String} to display at the end of a failure
+        */
+       public void setKoString(String koString) {
+               this.koString = koString;
+       }
+
+       /**
+        * Print the test suite header.
+        * 
+        * @param depth
+        *            the level at which is the launcher (0 = main launcher)
+        */
+       protected void print(int depth) {
+               if (depth == 0) {
+                       System.out.println("[ Test suite: " + name + " ]");
+                       System.out.println("");
+               } else {
+                       System.out.println(prefix(depth, false) + name + ":");
+               }
+       }
+
+       /**
+        * Print the name of the {@link TestCase} we will start immediately after.
+        * 
+        * @param depth
+        *            the level at which is the launcher (0 = main launcher)
+        * @param name
+        *            the {@link TestCase} name
+        */
+       protected void print(int depth, String name) {
+               name = prefix(depth, false)
+                               + (name == null ? "" : name).replace("\t", "    ");
+
+               StringBuilder dots = new StringBuilder();
+               while ((name.length() + dots.length()) < columns - 11) {
+                       dots.append('.');
+               }
+
+               System.out.print(name + dots.toString());
+       }
+
+       /**
+        * Print the result of the {@link TestCase} we just ran.
+        * 
+        * @param depth
+        *            the level at which is the launcher (0 = main launcher)
+        * @param error
+        *            the {@link Exception} it ran into if any
+        */
+       private void print(int depth, Throwable error) {
+               if (error != null) {
+                       System.out.println(" " + koString);
+                       if (isDetails()) {
+                               StringWriter sw = new StringWriter();
+                               PrintWriter pw = new PrintWriter(sw);
+                               error.printStackTrace(pw);
+                               String lines = sw.toString();
+                               for (String line : lines.split("\n")) {
+                                       System.out.println(prefix(depth, false) + "\t\t" + line);
+                               }
+                       }
+               } else {
+                       System.out.println(" " + okString);
+               }
+       }
+
+       /**
+        * Print the total result for this test suite.
+        * 
+        * @param depth
+        *            the level at which is the launcher (0 = main launcher)
+        * @param executed
+        *            the number of tests actually ran
+        * @param errors
+        *            the number of errors encountered
+        * @param total
+        *            the total number of tests in the suite
+        */
+       private void print(int depth, int executed, int errors, int total) {
+               int ok = executed - errors;
+               int pc = (int) ((100.0 * ok) / executed);
+               if (pc == 0 && ok > 0) {
+                       pc = 1;
+               }
+               int pcTotal = (int) ((100.0 * ok) / total);
+               if (pcTotal == 0 && ok > 0) {
+                       pcTotal = 1;
+               }
+
+               String resume = "Tests passed: " + ok + "/" + executed + " (" + pc
+                               + "%) on a total of " + total + " (" + pcTotal + "% total)";
+               if (depth == 0) {
+                       System.out.println(resume);
+               } else {
+                       String arrow = "┗▶ ";
+                       System.out.println(prefix(depth, currentSeries == 0) + arrow
+                                       + resume);
+                       System.out.println(prefix(depth, currentSeries == 0));
+               }
+       }
+
+       private int last = -1;
+
+       /**
+        * Return the prefix to print before the current line.
+        * 
+        * @param depth
+        *            the current depth
+        * @param first
+        *            this line is the first of its tabulation level
+        * 
+        * @return the prefix
+        */
+       private String prefix(int depth, boolean first) {
+               String space = tabs(depth - 1);
+
+               String line = "";
+               if (depth > 0) {
+                       if (depth > 1) {
+                               if (depth != last && first) {
+                                       line = "╻"; // first line
+                               } else {
+                                       line = "┃"; // continuation
+                               }
+                       }
+
+                       space += line + tabs(1);
+               }
+
+               last = depth;
+               return space;
+       }
+
+       /**
+        * Return the given number of space-converted tabs in a {@link String}.
+        * 
+        * @param depth
+        *            the number of tabs to return
+        * 
+        * @return the string
+        */
+       private String tabs(int depth) {
+               StringBuilder builder = new StringBuilder();
+               for (int i = 0; i < depth; i++) {
+                       builder.append("    ");
+               }
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java b/src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java
new file mode 100644 (file)
index 0000000..c715585
--- /dev/null
@@ -0,0 +1,115 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.streams.BufferedInputStream;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BufferedInputStreamTest extends TestLauncher {
+       public BufferedInputStreamTest(String[] args) {
+               super("BufferedInputStream test", args);
+
+               addTest(new TestCase("Simple InputStream reading") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] expected = new byte[] { 42, 12, 0, 127 };
+                               BufferedInputStream in = new BufferedInputStream(
+                                               new ByteArrayInputStream(expected));
+                               checkArrays(this, "FIRST", in, expected);
+                       }
+               });
+
+               addTest(new TestCase("Simple byte array reading") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] expected = new byte[] { 42, 12, 0, 127 };
+                               BufferedInputStream in = new BufferedInputStream(expected);
+                               checkArrays(this, "FIRST", in, expected);
+                       }
+               });
+
+               addTest(new TestCase("Byte array is(byte[])") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] expected = new byte[] { 42, 12, 0, 127 };
+                               BufferedInputStream in = new BufferedInputStream(expected);
+                               assertEquals(
+                                               "The array should be considered identical to its source",
+                                               true, in.is(expected));
+                               assertEquals(
+                                               "The array should be considered different to that one",
+                                               false, in.is(new byte[] { 42, 12, 0, 121 }));
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("InputStream is(byte[])") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] expected = new byte[] { 42, 12, 0, 127 };
+                               BufferedInputStream in = new BufferedInputStream(
+                                               new ByteArrayInputStream(expected));
+                               assertEquals(
+                                               "The array should be considered identical to its source",
+                                               true, in.is(expected));
+                               assertEquals(
+                                               "The array should be considered different to that one",
+                                               false, in.is(new byte[] { 42, 12, 0, 121 }));
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("Byte array is(String)") {
+                       @Override
+                       public void test() throws Exception {
+                               String expected = "Testy";
+                               BufferedInputStream in = new BufferedInputStream(
+                                               expected.getBytes("UTF-8"));
+                               assertEquals(
+                                               "The array should be considered identical to its source",
+                                               true, in.is(expected));
+                               assertEquals(
+                                               "The array should be considered different to that one",
+                                               false, in.is("Autre"));
+                               assertEquals(
+                                               "The array should be considered different to that one",
+                                               false, in.is("Test"));
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("InputStream is(String)") {
+                       @Override
+                       public void test() throws Exception {
+                               String expected = "Testy";
+                               BufferedInputStream in = new BufferedInputStream(
+                                               new ByteArrayInputStream(expected.getBytes("UTF-8")));
+                               assertEquals(
+                                               "The array should be considered identical to its source",
+                                               true, in.is(expected));
+                               assertEquals(
+                                               "The array should be considered different to that one",
+                                               false, in.is("Autre"));
+                               assertEquals(
+                                               "The array should be considered different to that one",
+                                               false, in.is("Testy."));
+                               in.close();
+                       }
+               });
+       }
+
+       static void checkArrays(TestCase test, String prefix, InputStream in,
+                       byte[] expected) throws Exception {
+               byte[] actual = IOUtils.toByteArray(in);
+               test.assertEquals("The " + prefix
+                               + " resulting array has not the correct number of items",
+                               expected.length, actual.length);
+               for (int i = 0; i < actual.length; i++) {
+                       test.assertEquals(prefix + ": item " + i
+                                       + " (0-based) is not the same", expected[i], actual[i]);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java b/src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java
new file mode 100644 (file)
index 0000000..5646e61
--- /dev/null
@@ -0,0 +1,136 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.utils.streams.BufferedOutputStream;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BufferedOutputStreamTest extends TestLauncher {
+       public BufferedOutputStreamTest(String[] args) {
+               super("BufferedOutputStream test", args);
+
+               addTest(new TestCase("Single write") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               BufferedOutputStream out = new BufferedOutputStream(bout);
+
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, data);
+                       }
+               });
+
+               addTest(new TestCase("Single write of 5000 bytes") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               BufferedOutputStream out = new BufferedOutputStream(bout);
+
+                               byte[] data = new byte[5000];
+                               for (int i = 0; i < data.length; i++) {
+                                       data[i] = (byte) (i % 255);
+                               }
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, data);
+                       }
+               });
+
+               addTest(new TestCase("Multiple writes") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               BufferedOutputStream out = new BufferedOutputStream(bout);
+
+                               byte[] data1 = new byte[] { 42, 12, 0, 127 };
+                               byte[] data2 = new byte[] { 15, 55 };
+                               byte[] data3 = new byte[] {};
+
+                               byte[] dataAll = new byte[] { 42, 12, 0, 127, 15, 55 };
+
+                               out.write(data1);
+                               out.write(data2);
+                               out.write(data3);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, dataAll);
+                       }
+               });
+
+               addTest(new TestCase("Multiple writes for a 5000 bytes total") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               BufferedOutputStream out = new BufferedOutputStream(bout);
+
+                               byte[] data = new byte[] { 42, 12, 0, 127, 51, 2, 32, 66, 7, 87 };
+
+                               List<Byte> bytes = new ArrayList<Byte>();
+
+                               // write 400 * 10 + 1000 bytes = 5000
+                               for (int i = 0; i < 400; i++) {
+                                       for (int j = 0; j < data.length; j++) {
+                                               bytes.add(data[j]);
+                                       }
+                                       out.write(data);
+                               }
+
+                               for (int i = 0; i < 1000; i++) {
+                                       for (int j = 0; j < data.length; j++) {
+                                               bytes.add(data[j]);
+                                       }
+                                       out.write(data);
+                               }
+
+                               out.close();
+
+                               byte[] abytes = new byte[bytes.size()];
+                               for (int i = 0; i < bytes.size(); i++) {
+                                       abytes[i] = bytes.get(i);
+                               }
+
+                               checkArrays(this, "FIRST", bout, abytes);
+                       }
+               });
+       }
+
+       static void checkArrays(TestCase test, String prefix,
+                       ByteArrayOutputStream bout, byte[] expected) throws Exception {
+               byte[] actual = bout.toByteArray();
+
+               if (false) {
+                       System.out.print("\nExpected data: [ ");
+                       for (int i = 0; i < expected.length; i++) {
+                               if (i > 0)
+                                       System.out.print(", ");
+                               System.out.print(expected[i]);
+                       }
+                       System.out.println(" ]");
+
+                       System.out.print("Actual data  : [ ");
+                       for (int i = 0; i < actual.length; i++) {
+                               if (i > 0)
+                                       System.out.print(", ");
+                               System.out.print(actual[i]);
+                       }
+                       System.out.println(" ]");
+               }
+
+               test.assertEquals("The " + prefix
+                               + " resulting array has not the correct number of items",
+                               expected.length, actual.length);
+               for (int i = 0; i < actual.length; i++) {
+                       test.assertEquals(prefix + ": item " + i
+                                       + " (0-based) is not the same", expected[i], actual[i]);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/BundleTest.java b/src/be/nikiroo/utils/test_code/BundleTest.java
new file mode 100644 (file)
index 0000000..2e25eb0
--- /dev/null
@@ -0,0 +1,249 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.Bundles;
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BundleTest extends TestLauncher {
+       private File tmp;
+       private B b = new B();
+
+       public BundleTest(String[] args) {
+               this("Bundle test", args);
+       }
+
+       protected BundleTest(String name, String[] args) {
+               super(name, args);
+
+               for (TestCase test : getSimpleTests()) {
+                       addTest(test);
+               }
+
+               addSeries(new TestLauncher("After saving/reloading the resources", args) {
+                       {
+                               for (TestCase test : getSimpleTests()) {
+                                       addTest(test);
+                               }
+                       }
+
+                       @Override
+                       protected void start() throws Exception {
+                               tmp = File.createTempFile("nikiroo-utils", ".test");
+                               tmp.delete();
+                               tmp.mkdir();
+                               b.updateFile(tmp.getAbsolutePath());
+                               Bundles.setDirectory(tmp.getAbsolutePath());
+                               b.reload(false);
+                       }
+
+                       @Override
+                       protected void stop() {
+                               IOUtils.deltree(tmp);
+                       }
+               });
+
+               addSeries(new TestLauncher("Read/Write support", args) {
+                       {
+                               addTest(new TestCase("Reload") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               String def = b.getString(E.ONE);
+                                               String val = "Something";
+
+                                               b.setString(E.ONE, val);
+                                               b.updateFile();
+                                               b.reload(true);
+
+                                               assertEquals("We should have reset the bundle", def,
+                                                               b.getString(E.ONE));
+
+                                               b.reload(false);
+
+                                               assertEquals("We should have reloaded the same files",
+                                                               val, b.getString(E.ONE));
+
+                                               // reset values for next tests
+                                               b.reload(true);
+                                               b.updateFile();
+                                       }
+                               });
+
+                               addTest(new TestCase("Set/Get") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               String val = "Newp";
+                                               b.setString(E.ONE, val);
+                                               String setGet = b.getString(E.ONE);
+
+                                               assertEquals(val, setGet);
+
+                                               // reset values for next tests
+                                               b.restoreSnapshot(null);
+                                       }
+                               });
+
+                               addTest(new TestCase("Snapshots") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               String val = "Newp";
+                                               String def = b.getString(E.ONE);
+
+                                               b.setString(E.ONE, val);
+                                               Object snap = b.takeSnapshot();
+
+                                               b.restoreSnapshot(null);
+                                               assertEquals(
+                                                               "restoreChanges(null) should clear the changes",
+                                                               def, b.getString(E.ONE));
+                                               b.restoreSnapshot(snap);
+                                               assertEquals(
+                                                               "restoreChanges(snapshot) should restore the changes",
+                                                               val, b.getString(E.ONE));
+
+                                               // reset values for next tests
+                                               b.restoreSnapshot(null);
+                                       }
+                               });
+
+                               addTest(new TestCase("updateFile with changes") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               String val = "Go to disk! (UTF-8 test: 日本語)";
+
+                                               String def = b.getString(E.ONE);
+                                               b.setString(E.ONE, val);
+                                               b.updateFile(tmp.getAbsolutePath());
+                                               b.reload(false);
+
+                                               assertEquals(val, b.getString(E.ONE));
+
+                                               // reset values for next tests
+                                               b.setString(E.ONE, def);
+                                               b.updateFile(tmp.getAbsolutePath());
+                                               b.reload(false);
+                                       }
+                               });
+                       }
+
+                       @Override
+                       protected void start() throws Exception {
+                               tmp = File.createTempFile("nikiroo-utils", ".test");
+                               tmp.delete();
+                               tmp.mkdir();
+                               b.updateFile(tmp.getAbsolutePath());
+                               Bundles.setDirectory(tmp.getAbsolutePath());
+                               b.reload(false);
+                       }
+
+                       @Override
+                       protected void stop() {
+                               IOUtils.deltree(tmp);
+                       }
+               });
+       }
+
+       private List<TestCase> getSimpleTests() {
+               String pre = "";
+
+               List<TestCase> list = new ArrayList<TestCase>();
+
+               list.add(new TestCase(pre + "getString simple") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals("un", b.getString(E.ONE));
+                       }
+               });
+
+               list.add(new TestCase(pre + "getStringX with null suffix") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals("un", b.getStringX(E.ONE, null));
+                       }
+               });
+
+               list.add(new TestCase(pre + "getStringX with empty suffix") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(null, b.getStringX(E.ONE, ""));
+                       }
+               });
+
+               list.add(new TestCase(pre + "getStringX with existing suffix") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals("un + suffix", b.getStringX(E.ONE, "suffix"));
+                       }
+               });
+
+               list.add(new TestCase(pre + "getStringX with not existing suffix") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(null, b.getStringX(E.ONE, "fake"));
+                       }
+               });
+
+               list.add(new TestCase(pre + "getString with UTF-8 content") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals("日本語 Nihongo", b.getString(E.JAPANESE));
+                       }
+               });
+
+               return list;
+       }
+
+       /**
+        * {@link Bundle}.
+        * 
+        * @author niki
+        */
+       private class B extends Bundle<E> {
+               protected B() {
+                       super(E.class, N.bundle_test, null);
+               }
+
+               @Override
+               // ...and make it public
+               public Object takeSnapshot() {
+                       return super.takeSnapshot();
+               }
+
+               @Override
+               // ...and make it public
+               public void restoreSnapshot(Object snap) {
+                       super.restoreSnapshot(snap);
+               }
+       }
+
+       /**
+        * Key enum for the {@link Bundle}.
+        * 
+        * @author niki
+        */
+       private enum E {
+               @Meta
+               ONE, //
+               @Meta
+               ONE_SUFFIX, //
+               @Meta
+               TWO, //
+               @Meta
+               JAPANESE
+       }
+
+       /**
+        * Name enum for the {@link Bundle}.
+        * 
+        * @author niki
+        */
+       private enum N {
+               bundle_test
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/CryptUtilsTest.java b/src/be/nikiroo/utils/test_code/CryptUtilsTest.java
new file mode 100644 (file)
index 0000000..0c53461
--- /dev/null
@@ -0,0 +1,155 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+
+import be.nikiroo.utils.CryptUtils;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class CryptUtilsTest extends TestLauncher {
+       private String key;
+       private CryptUtils crypt;
+
+       public CryptUtilsTest(String[] args) {
+               super("CryptUtils test", args);
+
+               String longKey = "some long string with more than 128 bits (=32 bytes) of data";
+
+               addSeries(new CryptUtilsTest(args, "Manual input wuth NULL key", null,
+                               1));
+               addSeries(new CryptUtilsTest(args, "Streams with NULL key", null, true));
+
+               addSeries(new CryptUtilsTest(args, "Manual input with emptykey", "", 1));
+               addSeries(new CryptUtilsTest(args, "Streams with empty key", "", true));
+
+               addSeries(new CryptUtilsTest(args, "Manual input with long key",
+                               longKey, 1));
+               addSeries(new CryptUtilsTest(args, "Streams with long key", longKey,
+                               true));
+       }
+
+       @Override
+       protected void addTest(final TestCase test) {
+               super.addTest(new TestCase(test.getName()) {
+                       @Override
+                       public void test() throws Exception {
+                               test.test();
+                       }
+
+                       @Override
+                       public void setUp() throws Exception {
+                               crypt = new CryptUtils(key);
+                               test.setUp();
+                       }
+
+                       @Override
+                       public void tearDown() throws Exception {
+                               test.tearDown();
+                               crypt = null;
+                       }
+               });
+       }
+
+       private CryptUtilsTest(String[] args, String title, String key,
+                       @SuppressWarnings("unused") int dummy) {
+               super(title, args);
+               this.key = key;
+
+               final String longData = "Le premier jour, Le Grand Barbu dans le cloud fit la lumière, et il vit que c'était bien. Ou quelque chose comme ça. Je préfère la Science-Fiction en général, je trouve ça plus sain :/";
+
+               addTest(new TestCase("Short") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = "data";
+                               byte[] encrypted = crypt.encrypt(orig);
+                               String decrypted = crypt.decrypts(encrypted);
+
+                               assertEquals(orig, decrypted);
+                       }
+               });
+
+               addTest(new TestCase("Short, base64") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = "data";
+                               String encrypted = crypt.encrypt64(orig);
+                               String decrypted = crypt.decrypt64s(encrypted);
+
+                               assertEquals(orig, decrypted);
+                       }
+               });
+
+               addTest(new TestCase("Empty") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = "";
+                               byte[] encrypted = crypt.encrypt(orig);
+                               String decrypted = crypt.decrypts(encrypted);
+
+                               assertEquals(orig, decrypted);
+                       }
+               });
+
+               addTest(new TestCase("Empty, base64") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = "";
+                               String encrypted = crypt.encrypt64(orig);
+                               String decrypted = crypt.decrypt64s(encrypted);
+
+                               assertEquals(orig, decrypted);
+                       }
+               });
+
+               addTest(new TestCase("Long") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = longData;
+                               byte[] encrypted = crypt.encrypt(orig);
+                               String decrypted = crypt.decrypts(encrypted);
+
+                               assertEquals(orig, decrypted);
+                       }
+               });
+
+               addTest(new TestCase("Long, base64") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = longData;
+                               String encrypted = crypt.encrypt64(orig);
+                               String decrypted = crypt.decrypt64s(encrypted);
+
+                               assertEquals(orig, decrypted);
+                       }
+               });
+       }
+
+       private CryptUtilsTest(String[] args, String title, String key,
+                       @SuppressWarnings("unused") boolean dummy) {
+               super(title, args);
+               this.key = key;
+
+               addTest(new TestCase("Simple test") {
+                       @Override
+                       public void test() throws Exception {
+                               InputStream in = new ByteArrayInputStream(new byte[] { 42, 127,
+                                               12 });
+                               crypt.encrypt(in);
+                               ByteArrayOutputStream out = new ByteArrayOutputStream();
+                               IOUtils.write(in, out);
+                               byte[] result = out.toByteArray();
+
+                               assertEquals(
+                                               "We wrote 3 bytes, we expected 3 bytes back but got: "
+                                                               + result.length, result.length, result.length);
+
+                               assertEquals(42, result[0]);
+                               assertEquals(127, result[1]);
+                               assertEquals(12, result[2]);
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/IOUtilsTest.java b/src/be/nikiroo/utils/test_code/IOUtilsTest.java
new file mode 100644 (file)
index 0000000..9f22896
--- /dev/null
@@ -0,0 +1,24 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.InputStream;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class IOUtilsTest extends TestLauncher {
+       public IOUtilsTest(String[] args) {
+               super("IOUtils test", args);
+
+               addTest(new TestCase("openResource") {
+                       @Override
+                       public void test() throws Exception {
+                               InputStream in = IOUtils.openResource("VERSION");
+                               assertNotNull(
+                                               "The VERSION file is supposed to be present in the binaries",
+                                               in);
+                               in.close();
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/NextableInputStreamTest.java b/src/be/nikiroo/utils/test_code/NextableInputStreamTest.java
new file mode 100644 (file)
index 0000000..463a123
--- /dev/null
@@ -0,0 +1,345 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.streams.NextableInputStream;
+import be.nikiroo.utils.streams.NextableInputStreamStep;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+public class NextableInputStreamTest extends TestLauncher {
+       public NextableInputStreamTest(String[] args) {
+               super("NextableInputStream test", args);
+
+               addTest(new TestCase("Simple byte array reading") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] expected = new byte[] { 42, 12, 0, 127 };
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(expected), null);
+                               checkNext(this, "READ", in, expected);
+                       }
+               });
+
+               addTest(new TestCase("Stop at 12") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] expected = new byte[] { 42, 12, 0, 127 };
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(expected),
+                                               new NextableInputStreamStep(12));
+
+                               checkNext(this, "FIRST", in, new byte[] { 42 });
+                       }
+               });
+
+               addTest(new TestCase("Stop at 12, resume, stop again, resume") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new NextableInputStreamStep(12));
+
+                               checkNext(this, "FIRST", in, new byte[] { 42 });
+                               checkNext(this, "SECOND", in, new byte[] { 0, 127 });
+                               checkNext(this, "THIRD", in, new byte[] { 51, 11 });
+                       }
+               });
+
+               addTest(new TestCase("Encapsulation") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 4, 127, 12, 5 };
+                               NextableInputStream in4 = new NextableInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new NextableInputStreamStep(4));
+                               NextableInputStream subIn12 = new NextableInputStream(in4,
+                                               new NextableInputStreamStep(12));
+
+                               in4.next();
+                               checkNext(this, "SUB FIRST", subIn12, new byte[] { 42 });
+                               checkNext(this, "SUB SECOND", subIn12, new byte[] { 0 });
+
+                               assertEquals("The subIn still has some data", false,
+                                               subIn12.next());
+
+                               checkNext(this, "MAIN LAST", in4, new byte[] { 127, 12, 5 });
+                       }
+               });
+
+               addTest(new TestCase("UTF-8 text lines test") {
+                       @Override
+                       public void test() throws Exception {
+                               String ln1 = "Ligne première";
+                               String ln2 = "Ligne la deuxième du nom";
+                               byte[] data = (ln1 + "\n" + ln2).getBytes("UTF-8");
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new NextableInputStreamStep('\n'));
+
+                               checkNext(this, "FIRST", in, ln1.getBytes("UTF-8"));
+                               checkNext(this, "SECOND", in, ln2.getBytes("UTF-8"));
+                       }
+               });
+
+               addTest(new TestCase("nextAll()") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new NextableInputStreamStep(12));
+
+                               checkNext(this, "FIRST", in, new byte[] { 42 });
+                               checkNextAll(this, "REST", in, new byte[] { 0, 127, 12, 51, 11,
+                                               12 });
+                               assertEquals("The stream still has some data", false, in.next());
+                       }
+               });
+
+               addTest(new TestCase("getBytesRead()") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new NextableInputStreamStep(12));
+
+                               in.nextAll();
+                               IOUtils.toByteArray(in);
+
+                               assertEquals("The number of bytes read is not correct",
+                                               data.length, in.getBytesRead());
+                       }
+               });
+
+               addTest(new TestCase("bytes array input") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(data,
+                                               new NextableInputStreamStep(12));
+
+                               checkNext(this, "FIRST", in, new byte[] { 42 });
+                               checkNext(this, "SECOND", in, new byte[] { 0, 127 });
+                               checkNext(this, "THIRD", in, new byte[] { 51, 11 });
+                       }
+               });
+
+               addTest(new TestCase("Skip data") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(data, null);
+                               in.next();
+
+                               byte[] rest = new byte[] { 12, 51, 11, 12 };
+
+                               in.skip(4);
+                               assertEquals("STARTS_WITH OK_1", true, in.startsWith(rest));
+                               assertEquals("STARTS_WITH KO_1", false,
+                                               in.startsWith(new byte[] { 0 }));
+                               assertEquals("STARTS_WITH KO_2", false, in.startsWith(data));
+                               assertEquals("STARTS_WITH KO_3", false,
+                                               in.startsWith(new byte[] { 1, 2, 3 }));
+                               assertEquals("STARTS_WITH OK_2", true, in.startsWith(rest));
+                               assertEquals("READ REST", IOUtils.readSmallStream(in),
+                                               new String(rest));
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("Starts with") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(data, null);
+                               in.next();
+
+                               // yes
+                               assertEquals("It actually starts with that", true,
+                                               in.startsWith(new byte[] { 42 }));
+                               assertEquals("It actually starts with that", true,
+                                               in.startsWith(new byte[] { 42, 12 }));
+                               assertEquals("It actually is the same array", true,
+                                               in.startsWith(data));
+
+                               // no
+                               assertEquals("It actually does not start with that", false,
+                                               in.startsWith(new byte[] { 12 }));
+                               assertEquals(
+                                               "It actually does not start with that",
+                                               false,
+                                               in.startsWith(new byte[] { 42, 12, 0, 127, 12, 51, 11,
+                                                               11 }));
+
+                               // too big
+                               try {
+                                       in.startsWith(new byte[] { 42, 12, 0, 127, 12, 51, 11, 12,
+                                                       0 });
+                                       fail("Searching a prefix bigger than the array should throw an IOException");
+                               } catch (IOException e) {
+                               }
+
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("Starts with strings") {
+                       @Override
+                       public void test() throws Exception {
+                               String text = "Fanfan et Toto vont à la mer";
+                               byte[] data = text.getBytes("UTF-8");
+                               NextableInputStream in = new NextableInputStream(data, null);
+                               in.next();
+
+                               // yes
+                               assertEquals("It actually starts with that", true,
+                                               in.startsWith("F"));
+                               assertEquals("It actually starts with that", true,
+                                               in.startsWith("Fanfan et"));
+                               assertEquals("It actually is the same text", true,
+                                               in.startsWith(text));
+
+                               // no
+                               assertEquals("It actually does not start with that", false,
+                                               in.startsWith("Toto"));
+                               assertEquals("It actually does not start with that", false,
+                                               in.startsWith("Fanfan et Toto vont à la mee"));
+
+                               // too big
+                               try {
+                                       in.startsWith("Fanfan et Toto vont à la mer.");
+                                       fail("Searching a prefix bigger than the array should throw an IOException");
+                               } catch (IOException e) {
+                               }
+
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("Starts With strings + steps") {
+                       @Override
+                       public void test() throws Exception {
+                               String data = "{\nREF: fanfan\n}";
+                               NextableInputStream in = new NextableInputStream(
+                                               data.getBytes("UTF-8"), new NextableInputStreamStep(
+                                                               '\n'));
+                               in.next();
+
+                               assertEquals("STARTS_WITH OK", true, in.startsWith("{"));
+                               in.skip(1);
+                               assertEquals("STARTS_WITH WHEN SPENT", false,
+                                               in.startsWith("{"));
+
+                               checkNext(this, "PARTIAL CONTENT", in,
+                                               "REF: fanfan".getBytes("UTF-8"));
+                       }
+               });
+
+               addTest(new TestCase("InputStream is(String)") {
+                       @Override
+                       public void test() throws Exception {
+                               String data = "{\nREF: fanfan\n}";
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data.getBytes("UTF-8")),
+                                               new NextableInputStreamStep('\n'));
+
+                               in.next();
+                               assertEquals("Item 1 OK", true, in.is("{"));
+                               assertEquals("Item 1 KO_1", false, in.is("|"));
+                               assertEquals("Item 1 KO_2", false, in.is("{}"));
+                               in.skip(1);
+                               in.next();
+                               assertEquals("Item 2 OK", true, in.is("REF: fanfan"));
+                               assertEquals("Item 2 KO", false, in.is("REF: fanfan."));
+                               IOUtils.readSmallStream(in);
+                               in.next();
+                               assertEquals("Item 3 OK", true, in.is("}"));
+
+                               in.close();
+                       }
+               });
+
+               addTest(new TestCase("Bytes NextAll test") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 };
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new NextableInputStreamStep(12));
+
+                               checkNext(this, "FIRST", in, new byte[] { 42 });
+                               checkNextAll(this, "SECOND", in, new byte[] { 0, 127, 12, 51,
+                                               11, 12 });
+                       }
+               });
+
+               addTest(new TestCase("String NextAll test") {
+                       @Override
+                       public void test() throws Exception {
+                               String d1 = "^java.lang.String";
+                               String d2 = "\"http://example.com/query.html\"";
+                               String data = d1 + ":" + d2;
+                               NextableInputStream in = new NextableInputStream(
+                                               new ByteArrayInputStream(data.getBytes("UTF-8")),
+                                               new NextableInputStreamStep(':'));
+
+                               checkNext(this, "FIRST", in, d1.getBytes("UTF-8"));
+                               checkNextAll(this, "SECOND", in, d2.getBytes("UTF-8"));
+                       }
+               });
+
+               addTest(new TestCase("NextAll in Next test") {
+                       @Override
+                       public void test() throws Exception {
+                               String line1 = "première ligne";
+                               String d1 = "^java.lang.String";
+                               String d2 = "\"http://example.com/query.html\"";
+                               String line3 = "end of lines";
+                               String data = line1 + "\n" + d1 + ":" + d2 + "\n" + line3;
+
+                               NextableInputStream inL = new NextableInputStream(
+                                               new ByteArrayInputStream(data.getBytes("UTF-8")),
+                                               new NextableInputStreamStep('\n'));
+
+                               checkNext(this, "Line 1", inL, line1.getBytes("UTF-8"));
+                               inL.next();
+
+                               NextableInputStream in = new NextableInputStream(inL,
+                                               new NextableInputStreamStep(':'));
+
+                               checkNext(this, "Line 2 FIRST", in, d1.getBytes("UTF-8"));
+                               checkNextAll(this, "Line 2 SECOND", in, d2.getBytes("UTF-8"));
+                       }
+               });
+       }
+
+       static void checkNext(TestCase test, String prefix, NextableInputStream in,
+                       byte[] expected) throws Exception {
+               test.assertEquals("Cannot get " + prefix + " entry", true, in.next());
+               checkArrays(test, prefix, in, expected);
+       }
+
+       static void checkNextAll(TestCase test, String prefix,
+                       NextableInputStream in, byte[] expected) throws Exception {
+               test.assertEquals("Cannot get " + prefix + " entries", true,
+                               in.nextAll());
+               checkArrays(test, prefix, in, expected);
+       }
+
+       static void checkArrays(TestCase test, String prefix,
+                       NextableInputStream in, byte[] expected) throws Exception {
+               byte[] actual = IOUtils.toByteArray(in);
+               test.assertEquals("The " + prefix
+                               + " resulting array has not the correct number of items",
+                               expected.length, actual.length);
+               for (int i = 0; i < actual.length; i++) {
+                       test.assertEquals("Item " + i + " (0-based) is not the same",
+                                       expected[i], actual[i]);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/ProgressTest.java b/src/be/nikiroo/utils/test_code/ProgressTest.java
new file mode 100644 (file)
index 0000000..22e36cb
--- /dev/null
@@ -0,0 +1,319 @@
+package be.nikiroo.utils.test_code;
+
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class ProgressTest extends TestLauncher {
+       public ProgressTest(String[] args) {
+               super("Progress reporting", args);
+
+               addSeries(new TestLauncher("Simple progress", args) {
+                       {
+                               addTest(new TestCase("Relative values and direct values") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               Progress p = new Progress();
+                                               assertEquals(0, p.getProgress());
+                                               assertEquals(0, p.getRelativeProgress());
+                                               p.setProgress(33);
+                                               assertEquals(33, p.getProgress());
+                                               assertEquals(0.33, p.getRelativeProgress());
+                                               p.setMax(3);
+                                               p.setProgress(1);
+                                               assertEquals(1, p.getProgress());
+                                               assertEquals(
+                                                               generateAssertMessage("0.33..",
+                                                                               p.getRelativeProgress()), true,
+                                                               p.getRelativeProgress() >= 0.332);
+                                               assertEquals(
+                                                               generateAssertMessage("0.33..",
+                                                                               p.getRelativeProgress()), true,
+                                                               p.getRelativeProgress() <= 0.334);
+                                       }
+                               });
+
+                               addTest(new TestCase("Listeners at first level") {
+                                       int pg;
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               Progress p = new Progress();
+                                               p.addProgressListener(new Progress.ProgressListener() {
+                                                       @Override
+                                                       public void progress(Progress progress, String name) {
+                                                               pg = progress.getProgress();
+                                                       }
+                                               });
+
+                                               p.setProgress(42);
+                                               assertEquals(42, pg);
+                                               p.setProgress(0);
+                                               assertEquals(0, pg);
+                                       }
+                               });
+                       }
+               });
+
+               addSeries(new TestLauncher("Progress with children", args) {
+                       {
+                               addTest(new TestCase("One child") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               Progress p = new Progress();
+                                               Progress child = new Progress();
+
+                                               p.addProgress(child, 100);
+
+                                               child.setProgress(42);
+                                               assertEquals(42, p.getProgress());
+                                       }
+                               });
+
+                               addTest(new TestCase("Multiple children") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               Progress p = new Progress();
+                                               Progress child1 = new Progress();
+                                               Progress child2 = new Progress();
+                                               Progress child3 = new Progress();
+
+                                               p.addProgress(child1, 20);
+                                               p.addProgress(child2, 60);
+                                               p.addProgress(child3, 20);
+
+                                               child1.setProgress(50);
+                                               assertEquals(10, p.getProgress());
+                                               child2.setProgress(100);
+                                               assertEquals(70, p.getProgress());
+                                               child3.setProgress(100);
+                                               assertEquals(90, p.getProgress());
+                                               child1.setProgress(100);
+                                               assertEquals(100, p.getProgress());
+                                       }
+                               });
+
+                               addTest(new TestCase("Listeners with children") {
+                                       int pg;
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               final Progress p = new Progress();
+                                               Progress child1 = new Progress();
+                                               Progress child2 = new Progress();
+                                               p.addProgress(child1, 50);
+                                               p.addProgress(child2, 50);
+
+                                               p.addProgressListener(new Progress.ProgressListener() {
+                                                       @Override
+                                                       public void progress(Progress progress, String name) {
+                                                               pg = p.getProgress();
+                                                       }
+                                               });
+
+                                               child1.setProgress(50);
+                                               assertEquals(25, pg);
+                                               child2.setProgress(100);
+                                               assertEquals(75, pg);
+                                               child1.setProgress(100);
+                                               assertEquals(100, pg);
+                                       }
+                               });
+
+                               addTest(new TestCase("Listeners with children, not 1-100") {
+                                       int pg;
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               final Progress p = new Progress();
+                                               p.setMax(1000);
+
+                                               Progress child1 = new Progress();
+                                               child1.setMax(2);
+
+                                               Progress child2 = new Progress();
+                                               p.addProgress(child1, 500);
+                                               p.addProgress(child2, 500);
+
+                                               p.addProgressListener(new Progress.ProgressListener() {
+                                                       @Override
+                                                       public void progress(Progress progress, String name) {
+                                                               pg = p.getProgress();
+                                                       }
+                                               });
+
+                                               child1.setProgress(1);
+                                               assertEquals(250, pg);
+                                               child2.setProgress(100);
+                                               assertEquals(750, pg);
+                                               child1.setProgress(2);
+                                               assertEquals(1000, pg);
+                                       }
+                               });
+
+                               addTest(new TestCase(
+                                               "Listeners with children, not 1-100, local progress") {
+                                       int pg;
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               final Progress p = new Progress();
+                                               p.setMax(1000);
+
+                                               Progress child1 = new Progress();
+                                               child1.setMax(2);
+
+                                               Progress child2 = new Progress();
+                                               p.addProgress(child1, 400);
+                                               p.addProgress(child2, 400);
+                                               // 200 = local progress
+
+                                               p.addProgressListener(new Progress.ProgressListener() {
+                                                       @Override
+                                                       public void progress(Progress progress, String name) {
+                                                               pg = p.getProgress();
+                                                       }
+                                               });
+
+                                               child1.setProgress(1);
+                                               assertEquals(200, pg);
+                                               child2.setProgress(100);
+                                               assertEquals(600, pg);
+                                               p.setProgress(100);
+                                               assertEquals(700, pg);
+                                               child1.setProgress(2);
+                                               assertEquals(900, pg);
+                                               p.setProgress(200);
+                                               assertEquals(1000, pg);
+                                       }
+                               });
+
+                               addTest(new TestCase("Listeners with 5+ children, 4+ depth") {
+                                       int pg;
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               final Progress p = new Progress();
+                                               Progress child1 = new Progress();
+                                               Progress child2 = new Progress();
+                                               p.addProgress(child1, 50);
+                                               p.addProgress(child2, 50);
+                                               Progress child11 = new Progress();
+                                               child1.addProgress(child11, 100);
+                                               Progress child111 = new Progress();
+                                               child11.addProgress(child111, 100);
+                                               Progress child1111 = new Progress();
+                                               child111.addProgress(child1111, 20);
+                                               Progress child1112 = new Progress();
+                                               child111.addProgress(child1112, 20);
+                                               Progress child1113 = new Progress();
+                                               child111.addProgress(child1113, 20);
+                                               Progress child1114 = new Progress();
+                                               child111.addProgress(child1114, 20);
+                                               Progress child1115 = new Progress();
+                                               child111.addProgress(child1115, 20);
+
+                                               p.addProgressListener(new Progress.ProgressListener() {
+                                                       @Override
+                                                       public void progress(Progress progress, String name) {
+                                                               pg = p.getProgress();
+                                                       }
+                                               });
+
+                                               child1111.setProgress(100);
+                                               child1112.setProgress(50);
+                                               child1113.setProgress(25);
+                                               child1114.setProgress(25);
+                                               child1115.setProgress(50);
+                                               assertEquals(25, pg);
+                                               child2.setProgress(100);
+                                               assertEquals(75, pg);
+                                               child1111.setProgress(100);
+                                               child1112.setProgress(100);
+                                               child1113.setProgress(100);
+                                               child1114.setProgress(100);
+                                               child1115.setProgress(100);
+                                               assertEquals(100, pg);
+                                       }
+                               });
+
+                               addTest(new TestCase("Listeners with children, multi-thread") {
+                                       int pg;
+                                       boolean decrease;
+                                       Object lock1 = new Object();
+                                       Object lock2 = new Object();
+                                       int currentStep1;
+                                       int currentStep2;
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               final Progress p = new Progress(0, 200);
+
+                                               final Progress child1 = new Progress();
+                                               final Progress child2 = new Progress();
+                                               p.addProgress(child1, 100);
+                                               p.addProgress(child2, 100);
+
+                                               p.addProgressListener(new Progress.ProgressListener() {
+                                                       @Override
+                                                       public void progress(Progress progress, String name) {
+                                                               int now = p.getProgress();
+                                                               if (now < pg) {
+                                                                       decrease = true;
+                                                               }
+                                                               pg = now;
+                                                       }
+                                               });
+
+                                               // Run 200 concurrent threads, 2 at a time allowed to
+                                               // make progress (each on a different child)
+                                               for (int i = 0; i <= 100; i++) {
+                                                       final int step = i;
+                                                       new Thread(new Runnable() {
+                                                               @Override
+                                                               public void run() {
+                                                                       synchronized (lock1) {
+                                                                               if (step > currentStep1) {
+                                                                                       currentStep1 = step;
+                                                                                       child1.setProgress(step);
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }).start();
+
+                                                       new Thread(new Runnable() {
+                                                               @Override
+                                                               public void run() {
+                                                                       synchronized (lock2) {
+                                                                               if (step > currentStep2) {
+                                                                                       currentStep2 = step;
+                                                                                       child2.setProgress(step);
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }).start();
+                                               }
+
+                                               int i;
+                                               int timeout = 20; // in 1/10th of seconds
+                                               for (i = 0; i < timeout
+                                                               && (currentStep1 + currentStep2) < 200; i++) {
+                                                       Thread.sleep(100);
+                                               }
+
+                                               assertEquals("The test froze at step " + currentStep1
+                                                               + " + " + currentStep2, true, i < timeout);
+                                               assertEquals(
+                                                               "There should not have any decresing steps",
+                                                               decrease, false);
+                                               assertEquals("The progress should have reached 200",
+                                                               200, p.getProgress());
+                                               assertEquals(
+                                                               "The progress should have reached completion",
+                                                               true, p.isDone());
+                                       }
+                               });
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java b/src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java
new file mode 100644 (file)
index 0000000..e6e2112
--- /dev/null
@@ -0,0 +1,106 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.streams.ReplaceInputStream;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class ReplaceInputStreamTest extends TestLauncher {
+       public ReplaceInputStreamTest(String[] args) {
+               super("ReplaceInputStream test", args);
+
+               addTest(new TestCase("Empty replace") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+                               ReplaceInputStream in = new ReplaceInputStream(
+                                               new ByteArrayInputStream(data), new byte[0],
+                                               new byte[0]);
+
+                               checkArrays(this, "FIRST", in, data);
+                       }
+               });
+
+               addTest(new TestCase("Simple replace") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+                               ReplaceInputStream in = new ReplaceInputStream(
+                                               new ByteArrayInputStream(data), new byte[] { 0 },
+                                               new byte[] { 10 });
+
+                               checkArrays(this, "FIRST", in, new byte[] { 42, 12, 10, 127 });
+                       }
+               });
+
+               addTest(new TestCase("3/4 replace") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+                               ReplaceInputStream in = new ReplaceInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new byte[] { 12, 0, 127 }, new byte[] { 10, 10, 10 });
+
+                               checkArrays(this, "FIRST", in, new byte[] { 42, 10, 10, 10 });
+                       }
+               });
+
+               addTest(new TestCase("Lnger replace") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+                               ReplaceInputStream in = new ReplaceInputStream(
+                                               new ByteArrayInputStream(data), new byte[] { 0 },
+                                               new byte[] { 10, 10, 10 });
+
+                               checkArrays(this, "FIRST", in, new byte[] { 42, 12, 10, 10, 10,
+                                               127 });
+                       }
+               });
+
+               addTest(new TestCase("Shorter replace") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+                               ReplaceInputStream in = new ReplaceInputStream(
+                                               new ByteArrayInputStream(data),
+                                               new byte[] { 42, 12, 0 }, new byte[] { 10 });
+
+                               checkArrays(this, "FIRST", in, new byte[] { 10, 127 });
+                       }
+               });
+
+               addTest(new TestCase("String replace") {
+                       @Override
+                       public void test() throws Exception {
+                               byte[] data = "I like red".getBytes("UTF-8");
+                               ReplaceInputStream in = new ReplaceInputStream(
+                                               new ByteArrayInputStream(data),
+                                               "red".getBytes("UTF-8"), "blue".getBytes("UTF-8"));
+
+                               checkArrays(this, "FIRST", in, "I like blue".getBytes("UTF-8"));
+
+                               data = "I like blue".getBytes("UTF-8");
+                               in = new ReplaceInputStream(new ByteArrayInputStream(data),
+                                               "blue".getBytes("UTF-8"), "red".getBytes("UTF-8"));
+
+                               checkArrays(this, "FIRST", in, "I like red".getBytes("UTF-8"));
+                       }
+               });
+       }
+
+       static void checkArrays(TestCase test, String prefix, InputStream in,
+                       byte[] expected) throws Exception {
+               byte[] actual = IOUtils.toByteArray(in);
+               test.assertEquals("The " + prefix
+                               + " resulting array has not the correct number of items",
+                               expected.length, actual.length);
+               for (int i = 0; i < actual.length; i++) {
+                       test.assertEquals("Item " + i + " (0-based) is not the same",
+                                       expected[i], actual[i]);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java b/src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java
new file mode 100644 (file)
index 0000000..1db3397
--- /dev/null
@@ -0,0 +1,168 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayOutputStream;
+
+import be.nikiroo.utils.streams.ReplaceOutputStream;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class ReplaceOutputStreamTest extends TestLauncher {
+       public ReplaceOutputStreamTest(String[] args) {
+               super("ReplaceOutputStream test", args);
+
+               addTest(new TestCase("Single write, empty bytes replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout,
+                                               new byte[0], new byte[0]);
+
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, data);
+                       }
+               });
+
+               addTest(new TestCase("Multiple writes, empty Strings replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout, "", "");
+
+                               byte[] data1 = new byte[] { 42, 12, 0, 127 };
+                               byte[] data2 = new byte[] { 15, 55 };
+                               byte[] data3 = new byte[] {};
+
+                               byte[] dataAll = new byte[] { 42, 12, 0, 127, 15, 55 };
+
+                               out.write(data1);
+                               out.write(data2);
+                               out.write(data3);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, dataAll);
+                       }
+               });
+
+               addTest(new TestCase("Single write, bytes replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout,
+                                               new byte[] { 12 }, new byte[] { 55 });
+
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, new byte[] { 42, 55, 0, 127 });
+                       }
+               });
+
+               addTest(new TestCase("Multiple writes, Strings replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout, "(-)",
+                                               "(.)");
+
+                               byte[] data1 = "un mot ".getBytes("UTF-8");
+                               byte[] data2 = "(-) of twee ".getBytes("UTF-8");
+                               byte[] data3 = "(-) makes the difference".getBytes("UTF-8");
+
+                               out.write(data1);
+                               out.write(data2);
+                               out.write(data3);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout,
+                                               "un mot (.) of twee (.) makes the difference"
+                                                               .getBytes("UTF-8"));
+                       }
+               });
+
+               addTest(new TestCase("Single write, longer bytes replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout,
+                                               new byte[] { 12 }, new byte[] { 55, 55, 66 });
+
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, new byte[] { 42, 55, 55, 66,
+                                               0, 127 });
+                       }
+               });
+
+               addTest(new TestCase("Single write, shorter bytes replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout,
+                                               new byte[] { 12, 0 }, new byte[] { 55 });
+
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, new byte[] { 42, 55, 127 });
+                       }
+               });
+
+               addTest(new TestCase("Single write, remove bytes replaces") {
+                       @Override
+                       public void test() throws Exception {
+                               ByteArrayOutputStream bout = new ByteArrayOutputStream();
+                               ReplaceOutputStream out = new ReplaceOutputStream(bout,
+                                               new byte[] { 12 }, new byte[] {});
+
+                               byte[] data = new byte[] { 42, 12, 0, 127 };
+
+                               out.write(data);
+                               out.close();
+
+                               checkArrays(this, "FIRST", bout, new byte[] { 42, 0, 127 });
+                       }
+               });
+       }
+
+       static void checkArrays(TestCase test, String prefix,
+                       ByteArrayOutputStream bout, byte[] expected) throws Exception {
+               byte[] actual = bout.toByteArray();
+
+               if (false) {
+                       System.out.print("\nExpected data: [ ");
+                       for (int i = 0; i < expected.length; i++) {
+                               if (i > 0)
+                                       System.out.print(", ");
+                               System.out.print(expected[i]);
+                       }
+                       System.out.println(" ]");
+
+                       System.out.print("Actual data  : [ ");
+                       for (int i = 0; i < actual.length; i++) {
+                               if (i > 0)
+                                       System.out.print(", ");
+                               System.out.print(actual[i]);
+                       }
+                       System.out.println(" ]");
+               }
+
+               test.assertEquals("The " + prefix
+                               + " resulting array has not the correct number of items",
+                               expected.length, actual.length);
+               for (int i = 0; i < actual.length; i++) {
+                       test.assertEquals(prefix + ": item " + i
+                                       + " (0-based) is not the same", expected[i], actual[i]);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/SerialServerTest.java b/src/be/nikiroo/utils/test_code/SerialServerTest.java
new file mode 100644 (file)
index 0000000..c10a158
--- /dev/null
@@ -0,0 +1,637 @@
+package be.nikiroo.utils.test_code;
+
+import java.net.URL;
+
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ConnectActionClientObject;
+import be.nikiroo.utils.serial.server.ConnectActionClientString;
+import be.nikiroo.utils.serial.server.ConnectActionServerObject;
+import be.nikiroo.utils.serial.server.ConnectActionServerString;
+import be.nikiroo.utils.serial.server.ServerBridge;
+import be.nikiroo.utils.serial.server.ServerObject;
+import be.nikiroo.utils.serial.server.ServerString;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class SerialServerTest extends TestLauncher {
+       public SerialServerTest(String[] args) {
+               super("SerialServer test", args);
+
+               for (String key : new String[] { null,
+                               "some super secret encryption key" }) {
+                       for (boolean bridge : new Boolean[] { false, true }) {
+                               final String skey = (key != null ? "(encrypted)"
+                                               : "(plain text)");
+                               final String sbridge = (bridge ? " with bridge" : "");
+
+                               addSeries(new SerialServerTest(args, key, skey, bridge,
+                                               sbridge, "ServerString"));
+
+                               addSeries(new SerialServerTest(args, key, skey, bridge,
+                                               sbridge, new Object() {
+                                                       @Override
+                                                       public String toString() {
+                                                               return "ServerObject";
+                                                       }
+                                               }));
+                       }
+               }
+       }
+
+       private SerialServerTest(final String[] args, final String key,
+                       final String skey, final boolean bridge, final String sbridge,
+                       final String title) {
+
+               super(title + " " + skey + sbridge, args);
+
+               addTest(new TestCase("Simple connection " + skey) {
+                       @Override
+                       public void test() throws Exception {
+                               final String[] rec = new String[1];
+
+                               ServerString server = new ServerString(this.getName(), 0, key) {
+                                       @Override
+                                       protected String onRequest(
+                                                       ConnectActionServerString action, Version version,
+                                                       String data, long id) throws Exception {
+                                               return null;
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                       }
+                               };
+
+                               int port = server.getPort();
+                               assertEquals("A port should have been assigned", true, port > 0);
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+
+                                       port = br.getPort();
+                                       assertEquals(
+                                                       "A port should have been assigned to the bridge",
+                                                       true, port > 0);
+
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientString(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               rec[0] = "ok";
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               assertNotNull("The client action was not run", rec[0]);
+                               assertEquals("ok", rec[0]);
+                       }
+               });
+
+               addTest(new TestCase("Simple exchange " + skey) {
+                       final String[] sent = new String[1];
+                       final String[] recd = new String[1];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerString server = new ServerString(this.getName(), 0, key) {
+                                       @Override
+                                       protected String onRequest(
+                                                       ConnectActionServerString action, Version version,
+                                                       String data, long id) throws Exception {
+                                               sent[0] = data;
+                                               return "pong";
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientString(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               recd[0] = send("ping");
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               assertEquals("ping", sent[0]);
+                               assertEquals("pong", recd[0]);
+                       }
+               });
+
+               addTest(new TestCase("Multiple exchanges " + skey) {
+                       final String[] sent = new String[3];
+                       final String[] recd = new String[3];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerString server = new ServerString(this.getName(), 0, key) {
+                                       @Override
+                                       protected String onRequest(
+                                                       ConnectActionServerString action, Version version,
+                                                       String data, long id) throws Exception {
+                                               sent[0] = data;
+                                               action.send("pong");
+                                               sent[1] = action.rec();
+                                               return "pong2";
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientString(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               recd[0] = send("ping");
+                                                               recd[1] = send("ping2");
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               assertEquals("ping", sent[0]);
+                               assertEquals("pong", recd[0]);
+                               assertEquals("ping2", sent[1]);
+                               assertEquals("pong2", recd[1]);
+                       }
+               });
+
+               addTest(new TestCase("Multiple call from client " + skey) {
+                       final String[] sent = new String[3];
+                       final String[] recd = new String[3];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerString server = new ServerString(this.getName(), 0, key) {
+                                       @Override
+                                       protected String onRequest(
+                                                       ConnectActionServerString action, Version version,
+                                                       String data, long id) throws Exception {
+                                               sent[Integer.parseInt(data)] = data;
+                                               return "" + (Integer.parseInt(data) * 2);
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientString(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               for (int i = 0; i < 3; i++) {
+                                                                       recd[i] = send("" + i);
+                                                               }
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               assertEquals("0", sent[0]);
+                               assertEquals("0", recd[0]);
+                               assertEquals("1", sent[1]);
+                               assertEquals("2", recd[1]);
+                               assertEquals("2", sent[2]);
+                               assertEquals("4", recd[2]);
+                       }
+               });
+       }
+
+       private SerialServerTest(final String[] args, final String key,
+                       final String skey, final boolean bridge, final String sbridge,
+                       final Object title) {
+
+               super(title + " " + skey + sbridge, args);
+
+               addTest(new TestCase("Simple connection " + skey) {
+                       @Override
+                       public void test() throws Exception {
+                               final Object[] rec = new Object[1];
+
+                               ServerObject server = new ServerObject(this.getName(), 0, key) {
+                                       @Override
+                                       protected Object onRequest(
+                                                       ConnectActionServerObject action, Version version,
+                                                       Object data, long id) throws Exception {
+                                               return null;
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                       }
+                               };
+
+                               int port = server.getPort();
+                               assertEquals("A port should have been assigned", true, port > 0);
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientObject(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               rec[0] = true;
+                                                       }
+
+                                                       @Override
+                                                       protected void onError(Exception e) {
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               assertNotNull("The client action was not run", rec[0]);
+                               assertEquals(true, (boolean) ((Boolean) rec[0]));
+                       }
+               });
+
+               addTest(new TestCase("Simple exchange " + skey) {
+                       final Object[] sent = new Object[1];
+                       final Object[] recd = new Object[1];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerObject server = new ServerObject(this.getName(), 0, key) {
+                                       @Override
+                                       protected Object onRequest(
+                                                       ConnectActionServerObject action, Version version,
+                                                       Object data, long id) throws Exception {
+                                               sent[0] = data;
+                                               return "pong";
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientObject(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               recd[0] = send("ping");
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               assertEquals("ping", sent[0]);
+                               assertEquals("pong", recd[0]);
+                       }
+               });
+
+               addTest(new TestCase("Multiple exchanges " + skey) {
+                       final Object[] sent = new Object[3];
+                       final Object[] recd = new Object[3];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerObject server = new ServerObject(this.getName(), 0, key) {
+                                       @Override
+                                       protected Object onRequest(
+                                                       ConnectActionServerObject action, Version version,
+                                                       Object data, long id) throws Exception {
+                                               sent[0] = data;
+                                               action.send("pong");
+                                               sent[1] = action.rec();
+                                               return "pong2";
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientObject(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               recd[0] = send("ping");
+                                                               recd[1] = send("ping2");
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               assertEquals("ping", sent[0]);
+                               assertEquals("pong", recd[0]);
+                               assertEquals("ping2", sent[1]);
+                               assertEquals("pong2", recd[1]);
+                       }
+               });
+
+               addTest(new TestCase("Object array of URLs " + skey) {
+                       final Object[] sent = new Object[1];
+                       final Object[] recd = new Object[1];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerObject server = new ServerObject(this.getName(), 0, key) {
+                                       @Override
+                                       protected Object onRequest(
+                                                       ConnectActionServerObject action, Version version,
+                                                       Object data, long id) throws Exception {
+                                               sent[0] = data;
+                                               return new Object[] { "ACK" };
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientObject(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               recd[0] = send(new Object[] {
+                                                                               "key",
+                                                                               new URL(
+                                                                                               "https://example.com/from_client"),
+                                                                               "https://example.com/from_client" });
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               Object[] sento = (Object[]) (sent[0]);
+                               Object[] recdo = (Object[]) (recd[0]);
+
+                               assertEquals("key", sento[0]);
+                               assertEquals("https://example.com/from_client",
+                                               ((URL) sento[1]).toString());
+                               assertEquals("https://example.com/from_client", sento[2]);
+                               assertEquals("ACK", recdo[0]);
+                       }
+               });
+
+               addTest(new TestCase("Multiple call from client " + skey) {
+                       final Object[] sent = new Object[3];
+                       final Object[] recd = new Object[3];
+                       final Exception[] err = new Exception[1];
+
+                       @Override
+                       public void test() throws Exception {
+                               ServerObject server = new ServerObject(this.getName(), 0, key) {
+                                       @Override
+                                       protected Object onRequest(
+                                                       ConnectActionServerObject action, Version version,
+                                                       Object data, long id) throws Exception {
+                                               sent[(Integer) data] = data;
+                                               return ((Integer) data) * 2;
+                                       }
+
+                                       @Override
+                                       protected void onError(Exception e) {
+                                               err[0] = e;
+                                       }
+                               };
+
+                               int port = server.getPort();
+
+                               server.start();
+
+                               ServerBridge br = null;
+                               if (bridge) {
+                                       br = new ServerBridge(0, key, "", port, key);
+                                       br.setTraceHandler(null);
+                                       port = br.getPort();
+                                       br.start();
+                               }
+
+                               try {
+                                       try {
+                                               new ConnectActionClientObject(null, port, key) {
+                                                       @Override
+                                                       public void action(Version version)
+                                                                       throws Exception {
+                                                               for (int i = 0; i < 3; i++) {
+                                                                       recd[i] = send(i);
+                                                               }
+                                                       }
+                                               }.connect();
+                                       } finally {
+                                               server.stop();
+                                       }
+                               } finally {
+                                       if (br != null) {
+                                               br.stop();
+                                       }
+                               }
+
+                               if (err[0] != null) {
+                                       fail("An exception was thrown: " + err[0].getMessage(),
+                                                       err[0]);
+                               }
+
+                               assertEquals(0, sent[0]);
+                               assertEquals(0, recd[0]);
+                               assertEquals(1, sent[1]);
+                               assertEquals(2, recd[1]);
+                               assertEquals(2, sent[2]);
+                               assertEquals(4, recd[2]);
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/SerialTest.java b/src/be/nikiroo/utils/test_code/SerialTest.java
new file mode 100644 (file)
index 0000000..bf08f5c
--- /dev/null
@@ -0,0 +1,281 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.NotSerializableException;
+import java.net.URL;
+import java.util.Arrays;
+
+import be.nikiroo.utils.serial.Exporter;
+import be.nikiroo.utils.serial.Importer;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class SerialTest extends TestLauncher {
+       /**
+        * Required for Import/Export of objects.
+        */
+       public SerialTest() {
+               this(null);
+       }
+
+       private void encodeRecodeTest(TestCase test, Object data) throws Exception {
+               byte[] encoded = toBytes(data, true);
+               Object redata = fromBytes(toBytes(data, false));
+               byte[] reencoded = toBytes(redata, true);
+
+               // We suppose text mode
+               if (encoded.length < 256 && reencoded.length < 256) {
+                       test.assertEquals("Different data after encode/decode/encode",
+                                       new String(encoded, "UTF-8"),
+                                       new String(reencoded, "UTF-8"));
+               } else {
+                       test.assertEquals("Different data after encode/decode/encode",
+                                       true, Arrays.equals(encoded, reencoded));
+               }
+       }
+
+       // try to remove pointer addresses
+       private byte[] toBytes(Object data, boolean clearRefs)
+                       throws NotSerializableException, IOException {
+               ByteArrayOutputStream out = new ByteArrayOutputStream();
+               new Exporter(out).append(data);
+               out.flush();
+
+               if (clearRefs) {
+                       String tmp = new String(out.toByteArray(), "UTF-8");
+                       tmp = tmp.replaceAll("@[0-9]*", "@REF");
+                       return tmp.getBytes("UTF-8");
+               }
+
+               return out.toByteArray();
+       }
+
+       private Object fromBytes(byte[] data) throws NoSuchFieldException,
+                       NoSuchMethodException, ClassNotFoundException,
+                       NullPointerException, IOException {
+
+               InputStream in = new ByteArrayInputStream(data);
+               try {
+                       return new Importer().read(in).getValue();
+               } finally {
+                       in.close();
+               }
+       }
+
+       public SerialTest(String[] args) {
+               super("Serial test", args);
+
+               addTest(new TestCase("Simple class Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               Data data = new Data(42);
+                               encodeRecodeTest(this, data);
+                       }
+               });
+
+               addTest(new TestCase() {
+                       @SuppressWarnings("unused")
+                       private TestCase me = setName("Anonymous inner class");
+
+                       @Override
+                       public void test() throws Exception {
+                               Data data = new Data() {
+                                       @SuppressWarnings("unused")
+                                       int value = 42;
+                               };
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase() {
+                       @SuppressWarnings("unused")
+                       private TestCase me = setName("Array of anonymous inner classes");
+
+                       @Override
+                       public void test() throws Exception {
+                               Data[] data = new Data[] { new Data() {
+                                       @SuppressWarnings("unused")
+                                       int value = 42;
+                               } };
+
+                               byte[] encoded = toBytes(data, false);
+                               Object redata = fromBytes(encoded);
+
+                               // Comparing the 2 arrays won't be useful, because the @REFs
+                               // will be ZIP-encoded; so we parse and re-encode each object
+
+                               byte[] encoded1 = toBytes(data[0], true);
+                               byte[] reencoded1 = toBytes(((Object[]) redata)[0], true);
+
+                               assertEquals("Different data after encode/decode/encode", true,
+                                               Arrays.equals(encoded1, reencoded1));
+                       }
+               });
+               addTest(new TestCase("URL Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               URL data = new URL("https://fanfan.be/");
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("URL-String Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               String data = new URL("https://fanfan.be/").toString();
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("URL/URL-String arrays Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               final String url = "https://fanfan.be/";
+                               Object[] data = new Object[] { new URL(url), url };
+
+                               byte[] encoded = toBytes(data, false);
+                               Object redata = fromBytes(encoded);
+
+                               // Comparing the 2 arrays won't be useful, because the @REFs
+                               // will be ZIP-encoded; so we parse and re-encode each object
+
+                               byte[] encoded1 = toBytes(data[0], true);
+                               byte[] reencoded1 = toBytes(((Object[]) redata)[0], true);
+                               byte[] encoded2 = toBytes(data[1], true);
+                               byte[] reencoded2 = toBytes(((Object[]) redata)[1], true);
+
+                               assertEquals("Different data 1 after encode/decode/encode",
+                                               true, Arrays.equals(encoded1, reencoded1));
+                               assertEquals("Different data 2 after encode/decode/encode",
+                                               true, Arrays.equals(encoded2, reencoded2));
+                       }
+               });
+               addTest(new TestCase("Import/Export with nested objects") {
+                       @Override
+                       public void test() throws Exception {
+                               Data data = new DataObject(new Data(21));
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("Import/Export String in object") {
+                       @Override
+                       public void test() throws Exception {
+                               Data data = new DataString("fanfan");
+                               encodeRecodeTest(this, data);
+                               data = new DataString("http://example.com/query.html");
+                               encodeRecodeTest(this, data);
+                               data = new DataString("Test|Ché|http://|\"\\\"Pouch\\");
+                               encodeRecodeTest(this, data);
+                               data = new DataString("Test|Ché\\n|\nhttp://|\"\\\"Pouch\\");
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("Import/Export with nested objects forming a loop") {
+                       @Override
+                       public void test() throws Exception {
+                               DataLoop data = new DataLoop("looping");
+                               data.next = new DataLoop("level 2");
+                               data.next.next = data;
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("Array in Object Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               Object data = new DataArray();// new String[] { "un", "deux" };
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("Array Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               Object data = new String[] { "un", "deux" };
+                               encodeRecodeTest(this, data);
+                       }
+               });
+               addTest(new TestCase("Enum Import/Export") {
+                       @Override
+                       public void test() throws Exception {
+                               Object data = EnumToSend.FANFAN;
+                               encodeRecodeTest(this, data);
+                       }
+               });
+       }
+
+       class DataArray {
+               public String[] data = new String[] { "un", "deux" };
+       }
+
+       class Data {
+               private int value;
+
+               private Data() {
+               }
+
+               public Data(int value) {
+                       this.value = value;
+               }
+
+               @Override
+               public boolean equals(Object obj) {
+                       if (obj instanceof Data) {
+                               Data other = (Data) obj;
+                               return other.value == this.value;
+                       }
+
+                       return false;
+               }
+
+               @Override
+               public int hashCode() {
+                       return new Integer(value).hashCode();
+               }
+       }
+
+       @SuppressWarnings("unused")
+       class DataObject extends Data {
+               private Data data;
+
+               @SuppressWarnings("synthetic-access")
+               private DataObject() {
+               }
+
+               @SuppressWarnings("synthetic-access")
+               public DataObject(Data data) {
+                       this.data = data;
+               }
+       }
+
+       @SuppressWarnings("unused")
+       class DataString extends Data {
+               private String data;
+
+               @SuppressWarnings("synthetic-access")
+               private DataString() {
+               }
+
+               @SuppressWarnings("synthetic-access")
+               public DataString(String data) {
+                       this.data = data;
+               }
+       }
+
+       @SuppressWarnings("unused")
+       class DataLoop extends Data {
+               public DataLoop next;
+               private String value;
+
+               @SuppressWarnings("synthetic-access")
+               private DataLoop() {
+               }
+
+               @SuppressWarnings("synthetic-access")
+               public DataLoop(String value) {
+                       this.value = value;
+               }
+       }
+
+       enum EnumToSend {
+               FANFAN, TULIPE,
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/StringUtilsTest.java b/src/be/nikiroo/utils/test_code/StringUtilsTest.java
new file mode 100644 (file)
index 0000000..a441195
--- /dev/null
@@ -0,0 +1,304 @@
+package be.nikiroo.utils.test_code;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.StringUtils.Alignment;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class StringUtilsTest extends TestLauncher {
+       public StringUtilsTest(String[] args) {
+               super("StringUtils test", args);
+
+               addTest(new TestCase("Time serialisation") {
+                       @Override
+                       public void test() throws Exception {
+                               for (long fullTime : new Long[] { 0l, 123456l, 123456000l,
+                                               new Date().getTime() }) {
+                                       // precise to the second, no more
+                                       long time = (fullTime / 1000) * 1000;
+
+                                       String displayTime = StringUtils.fromTime(time);
+                                       assertNotNull("The stringified time for " + time
+                                                       + " should not be null", displayTime);
+                                       assertEquals("The stringified time for " + time
+                                                       + " should not be empty", false, displayTime.trim()
+                                                       .isEmpty());
+
+                                       assertEquals("The time " + time
+                                                       + " should be loop-convertable", time,
+                                                       StringUtils.toTime(displayTime));
+
+                                       assertEquals("The time " + displayTime
+                                                       + " should be loop-convertable", displayTime,
+                                                       StringUtils.fromTime(StringUtils
+                                                                       .toTime(displayTime)));
+                               }
+                       }
+               });
+
+               addTest(new TestCase("MD5") {
+                       @Override
+                       public void test() throws Exception {
+                               String mess = "The String we got is not what 'md5sum' said it should heve been";
+                               assertEquals(mess, "34ded48fcff4221d644be9a37e1cb1d9",
+                                               StringUtils.getMd5Hash("fanfan la tulipe"));
+                               assertEquals(mess, "7691b0cb74ed0f94b4d8cd858abe1165",
+                                               StringUtils.getMd5Hash("je te do-o-o-o-o-o-nne"));
+                       }
+               });
+
+               addTest(new TestCase("Padding") {
+                       @Override
+                       public void test() throws Exception {
+                               for (String data : new String[] { "fanfan", "la tulipe",
+                                               "1234567890", "12345678901234567890", "1", "" }) {
+                                       String result = StringUtils.padString(data, -1);
+                                       assertEquals("A size of -1 is expected to produce a noop",
+                                                       true, data.equals(result));
+                                       for (int size : new Integer[] { 0, 1, 5, 10, 40 }) {
+                                               result = StringUtils.padString(data, size);
+                                               assertEquals(
+                                                               "Padding a String at a certain size should give a String of the given size",
+                                                               size, result.length());
+                                               assertEquals(
+                                                               "Padding a String should not change the content",
+                                                               true, data.trim().startsWith(result.trim()));
+
+                                               result = StringUtils.padString(data, size, false, null);
+                                               assertEquals(
+                                                               "Padding a String without cutting should not shorten the String",
+                                                               true, data.length() <= result.length());
+                                               assertEquals(
+                                                               "Padding a String without cutting should keep the whole content",
+                                                               true, data.trim().equals(result.trim()));
+
+                                               result = StringUtils.padString(data, size, false,
+                                                               Alignment.RIGHT);
+                                               if (size > data.length()) {
+                                                       assertEquals(
+                                                                       "Padding a String to the end should work as expected",
+                                                                       true, result.endsWith(data));
+                                               }
+
+                                               result = StringUtils.padString(data, size, false,
+                                                               Alignment.JUSTIFY);
+                                               if (size > data.length()) {
+                                                       String unspacedData = data.trim();
+                                                       String unspacedResult = result.trim();
+                                                       for (int i = 0; i < size; i++) {
+                                                               unspacedData = unspacedData.replace("  ", " ");
+                                                               unspacedResult = unspacedResult.replace("  ",
+                                                                               " ");
+                                                       }
+
+                                                       assertEquals(
+                                                                       "Justified text trimmed with all spaces collapsed "
+                                                                                       + "sould be identical to original text "
+                                                                                       + "trimmed with all spaces collapsed",
+                                                                       unspacedData, unspacedResult);
+                                               }
+
+                                               result = StringUtils.padString(data, size, false,
+                                                               Alignment.CENTER);
+                                               if (size > data.length()) {
+                                                       int before = 0;
+                                                       for (int i = 0; i < result.length()
+                                                                       && result.charAt(i) == ' '; i++) {
+                                                               before++;
+                                                       }
+
+                                                       int after = 0;
+                                                       for (int i = result.length() - 1; i >= 0
+                                                                       && result.charAt(i) == ' '; i--) {
+                                                               after++;
+                                                       }
+
+                                                       if (result.trim().isEmpty()) {
+                                                               after = before / 2;
+                                                               if (before > (2 * after)) {
+                                                                       before = after + 1;
+                                                               } else {
+                                                                       before = after;
+                                                               }
+                                                       }
+
+                                                       assertEquals(
+                                                                       "Padding a String on center should work as expected",
+                                                                       result.length(), before + data.length()
+                                                                                       + after);
+                                                       assertEquals(
+                                                                       "Padding a String on center should not uncenter the content",
+                                                                       true, Math.abs(before - after) <= 1);
+                                               }
+                                       }
+                               }
+                       }
+               });
+
+               addTest(new TestCase("Justifying") {
+                       @Override
+                       public void test() throws Exception {
+                               Map<String, Map<Integer, Entry<Alignment, List<String>>>> source = new HashMap<String, Map<Integer, Entry<Alignment, List<String>>>>();
+                               addValue(source, Alignment.LEFT, "testy", -1, "testy");
+                               addValue(source, Alignment.RIGHT, "testy", -1, "testy");
+                               addValue(source, Alignment.CENTER, "testy", -1, "testy");
+                               addValue(source, Alignment.JUSTIFY, "testy", -1, "testy");
+                               addValue(source, Alignment.LEFT, "testy", 5, "testy");
+                               addValue(source, Alignment.LEFT, "testy", 3, "te-", "sty");
+                               addValue(source, Alignment.LEFT,
+                                               "Un petit texte qui se mettra sur plusieurs lignes",
+                                               10, "Un petit", "texte qui", "se mettra", "sur",
+                                               "plusieurs", "lignes");
+                               addValue(source, Alignment.LEFT,
+                                               "Un petit texte qui se mettra sur plusieurs lignes", 7,
+                                               "Un", "petit", "texte", "qui se", "mettra", "sur",
+                                               "plusie-", "urs", "lignes");
+                               addValue(source, Alignment.RIGHT,
+                                               "Un petit texte qui se mettra sur plusieurs lignes", 7,
+                                               "     Un", "  petit", "  texte", " qui se", " mettra",
+                                               "    sur", "plusie-", "    urs", " lignes");
+                               addValue(source, Alignment.CENTER,
+                                               "Un petit texte qui se mettra sur plusieurs lignes", 7,
+                                               "  Un   ", " petit ", " texte ", "qui se ", "mettra ",
+                                               "  sur  ", "plusie-", "  urs  ", "lignes ");
+                               addValue(source, Alignment.JUSTIFY,
+                                               "Un petit texte qui se mettra sur plusieurs lignes", 7,
+                                               "Un pet-", "it tex-", "te  qui", "se met-", "tra sur",
+                                               "plusie-", "urs li-", "gnes");
+                               addValue(source, Alignment.JUSTIFY,
+                                               "Un petit texte qui se mettra sur plusieurs lignes",
+                                               14, "Un       petit", "texte  qui  se",
+                                               "mettra     sur", "plusieurs lig-", "nes");
+                               addValue(source, Alignment.JUSTIFY, "le dash-test", 9,
+                                               "le  dash-", "test");
+
+                               for (String data : source.keySet()) {
+                                       for (int size : source.get(data).keySet()) {
+                                               Alignment align = source.get(data).get(size).getKey();
+                                               List<String> values = source.get(data).get(size)
+                                                               .getValue();
+
+                                               List<String> result = StringUtils.justifyText(data,
+                                                               size, align);
+
+                                               // System.out.println("[" + data + " (" + size + ")" +
+                                               // "] -> [");
+                                               // for (int i = 0; i < result.size(); i++) {
+                                               // String resultLine = result.get(i);
+                                               // System.out.println(i + ": " + resultLine);
+                                               // }
+                                               // System.out.println("]");
+
+                                               assertEquals(values, result);
+                                       }
+                               }
+                       }
+               });
+
+               addTest(new TestCase("unhtml") {
+                       @Override
+                       public void test() throws Exception {
+                               Map<String, String> data = new HashMap<String, String>();
+                               data.put("aa", "aa");
+                               data.put("test with spaces ", "test with spaces ");
+                               data.put("<a href='truc://target/'>link</a>", "link");
+                               data.put("<html>Digimon</html>", "Digimon");
+                               data.put("", "");
+                               data.put(" ", " ");
+
+                               for (Entry<String, String> entry : data.entrySet()) {
+                                       String result = StringUtils.unhtml(entry.getKey());
+                                       assertEquals("Result is not what we expected",
+                                                       entry.getValue(), result);
+                               }
+                       }
+               });
+
+               addTest(new TestCase("zip64") {
+                       @Override
+                       public void test() throws Exception {
+                               String orig = "test";
+                               String zipped = StringUtils.zip64(orig);
+                               String unzipped = StringUtils.unzip64s(zipped);
+                               assertEquals(orig, unzipped);
+                       }
+               });
+
+               addTest(new TestCase("format/toNumber simple") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(263l, StringUtils.toNumber("263"));
+                               assertEquals(21200l, StringUtils.toNumber("21200"));
+                               assertEquals(0l, StringUtils.toNumber("0"));
+                               assertEquals("263", StringUtils.formatNumber(263l));
+                               assertEquals("21 k", StringUtils.formatNumber(21000l));
+                               assertEquals("0", StringUtils.formatNumber(0l));
+                       }
+               });
+
+               addTest(new TestCase("format/toNumber not 000") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(263200l, StringUtils.toNumber("263.2 k"));
+                               assertEquals(42000l, StringUtils.toNumber("42.0 k"));
+                               assertEquals(12000000l, StringUtils.toNumber("12 M"));
+                               assertEquals(2000000000l, StringUtils.toNumber("2 G"));
+                               assertEquals("263 k", StringUtils.formatNumber(263012l));
+                               assertEquals("42 k", StringUtils.formatNumber(42012l));
+                               assertEquals("12 M", StringUtils.formatNumber(12012121l));
+                               assertEquals("7 G", StringUtils.formatNumber(7364635928l));
+                       }
+               });
+
+               addTest(new TestCase("format/toNumber decimals") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(263200l, StringUtils.toNumber("263.2 k"));
+                               assertEquals(1200l, StringUtils.toNumber("1.2 k"));
+                               assertEquals(42700000l, StringUtils.toNumber("42.7 M"));
+                               assertEquals(1220l, StringUtils.toNumber("1.22 k"));
+                               assertEquals(1432l, StringUtils.toNumber("1.432 k"));
+                               assertEquals(6938l, StringUtils.toNumber("6.938 k"));
+                               assertEquals("1.3 k", StringUtils.formatNumber(1300l, 1));
+                               assertEquals("263.2020 k", StringUtils.formatNumber(263202l, 4));
+                               assertEquals("1.26 k", StringUtils.formatNumber(1267l, 2));
+                               assertEquals("42.7 M", StringUtils.formatNumber(42712121l, 1));
+                               assertEquals("5.09 G", StringUtils.formatNumber(5094837485l, 2));
+                       }
+               });
+       }
+
+       static private void addValue(
+                       Map<String, Map<Integer, Entry<Alignment, List<String>>>> source,
+                       final Alignment align, String input, int size,
+                       final String... result) {
+               if (!source.containsKey(input)) {
+                       source.put(input,
+                                       new HashMap<Integer, Entry<Alignment, List<String>>>());
+               }
+
+               source.get(input).put(size, new Entry<Alignment, List<String>>() {
+                       @Override
+                       public Alignment getKey() {
+                               return align;
+                       }
+
+                       @Override
+                       public List<String> getValue() {
+                               return Arrays.asList(result);
+                       }
+
+                       @Override
+                       public List<String> setValue(List<String> value) {
+                               return null;
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/TempFilesTest.java b/src/be/nikiroo/utils/test_code/TempFilesTest.java
new file mode 100644 (file)
index 0000000..dad4cac
--- /dev/null
@@ -0,0 +1,109 @@
+package be.nikiroo.utils.test_code;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.TempFiles;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class TempFilesTest extends TestLauncher {
+       public TempFilesTest(String[] args) {
+               super("TempFiles", args);
+
+               addTest(new TestCase("Name is correct") {
+                       @Override
+                       public void test() throws Exception {
+                               RootTempFiles files = new RootTempFiles("testy");
+                               try {
+                                       assertEquals("The root was not created", true, files
+                                                       .getRoot().exists());
+                                       assertEquals(
+                                                       "The root is not prefixed with the expected name",
+                                                       true, files.getRoot().getName().startsWith("testy"));
+
+                               } finally {
+                                       files.close();
+                               }
+                       }
+               });
+
+               addTest(new TestCase("Clean after itself no use") {
+                       @Override
+                       public void test() throws Exception {
+                               RootTempFiles files = new RootTempFiles("testy2");
+                               try {
+                                       assertEquals("The root was not created", true, files
+                                                       .getRoot().exists());
+                               } finally {
+                                       files.close();
+                                       assertEquals("The root was not deleted", false, files
+                                                       .getRoot().exists());
+                               }
+                       }
+               });
+
+               addTest(new TestCase("Clean after itself after usage") {
+                       @Override
+                       public void test() throws Exception {
+                               RootTempFiles files = new RootTempFiles("testy3");
+                               try {
+                                       assertEquals("The root was not created", true, files
+                                                       .getRoot().exists());
+                                       files.createTempFile("test");
+                               } finally {
+                                       files.close();
+                                       assertEquals("The root was not deleted", false, files
+                                                       .getRoot().exists());
+                                       assertEquals("The main root in /tmp was not deleted",
+                                                       false, files.getRoot().getParentFile().exists());
+                               }
+                       }
+               });
+
+               addTest(new TestCase("Temporary directories") {
+                       @Override
+                       public void test() throws Exception {
+                               RootTempFiles files = new RootTempFiles("testy4");
+                               File file = null;
+                               try {
+                                       File dir = files.createTempDir("test");
+                                       file = new File(dir, "fanfan");
+                                       file.createNewFile();
+                                       assertEquals(
+                                                       "Cannot create a file in a temporary directory",
+                                                       true, file.exists());
+                               } finally {
+                                       files.close();
+                                       if (file != null) {
+                                               assertEquals(
+                                                               "A file created in a temporary directory should be deleted on close",
+                                                               false, file.exists());
+                                       }
+                                       assertEquals("The root was not deleted", false, files
+                                                       .getRoot().exists());
+                               }
+                       }
+               });
+       }
+
+       private class RootTempFiles extends TempFiles {
+               private File root = null;
+
+               public RootTempFiles(String name) throws IOException {
+                       super(name);
+               }
+
+               public File getRoot() {
+                       if (root != null)
+                               return root;
+                       return super.root;
+               }
+
+               @Override
+               public synchronized void close() throws IOException {
+                       root = super.root;
+                       super.close();
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/Test.java b/src/be/nikiroo/utils/test_code/Test.java
new file mode 100644 (file)
index 0000000..8d99cba
--- /dev/null
@@ -0,0 +1,68 @@
+package be.nikiroo.utils.test_code;
+
+import be.nikiroo.utils.Cache;
+import be.nikiroo.utils.CacheMemory;
+import be.nikiroo.utils.Downloader;
+import be.nikiroo.utils.Proxy;
+import be.nikiroo.utils.main.bridge;
+import be.nikiroo.utils.main.img2aa;
+import be.nikiroo.utils.main.justify;
+import be.nikiroo.utils.test.TestLauncher;
+import be.nikiroo.utils.ui.UIUtils;
+
+/**
+ * Tests for nikiroo-utils.
+ * 
+ * @author niki
+ */
+public class Test extends TestLauncher {
+       /**
+        * Start the tests.
+        * 
+        * @param args
+        *            the arguments (which are passed as-is to the other test
+        *            classes)
+        */
+       public Test(String[] args) {
+               super("Nikiroo-utils", args);
+
+               // setDetails(true);
+
+               addSeries(new ProgressTest(args));
+               addSeries(new BundleTest(args));
+               addSeries(new IOUtilsTest(args));
+               addSeries(new VersionTest(args));
+               addSeries(new SerialTest(args));
+               addSeries(new SerialServerTest(args));
+               addSeries(new StringUtilsTest(args));
+               addSeries(new TempFilesTest(args));
+               addSeries(new CryptUtilsTest(args));
+               addSeries(new BufferedInputStreamTest(args));
+               addSeries(new NextableInputStreamTest(args));
+               addSeries(new ReplaceInputStreamTest(args));
+               addSeries(new BufferedOutputStreamTest(args));
+               addSeries(new ReplaceOutputStreamTest(args));
+
+               // TODO: test cache and downloader
+               Cache cache = null;
+               CacheMemory memcache = null;
+               Downloader downloader = null;
+               
+               // To include the sources:
+               img2aa siu;
+               justify ssu;
+               bridge aa;
+               Proxy proxy;
+               UIUtils uiUtils;
+       }
+
+       /**
+        * Main entry point of the program.
+        * 
+        * @param args
+        *            the arguments passed to the {@link TestLauncher}s.
+        */
+       static public void main(String[] args) {
+               System.exit(new Test(args).launch());
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/VersionTest.java b/src/be/nikiroo/utils/test_code/VersionTest.java
new file mode 100644 (file)
index 0000000..2d84476
--- /dev/null
@@ -0,0 +1,140 @@
+package be.nikiroo.utils.test_code;
+
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class VersionTest extends TestLauncher {
+       public VersionTest(String[] args) {
+               super("Version test", args);
+
+               addTest(new TestCase("String <-> int") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals("Cannot parse version 1.2.3 from int to String",
+                                               "1.2.3", new Version(1, 2, 3).toString());
+                               assertEquals(
+                                               "Cannot parse major version \"1.2.3\" from String to int",
+                                               1, new Version("1.2.3").getMajor());
+                               assertEquals(
+                                               "Cannot parse minor version \"1.2.3\" from String to int",
+                                               2, new Version("1.2.3").getMinor());
+                               assertEquals(
+                                               "Cannot parse patch version \"1.2.3\" from String to int",
+                                               3, new Version("1.2.3").getPatch());
+                       }
+               });
+
+               addTest(new TestCase("Bad input") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(
+                                               "Bad input should return an empty version",
+                                               true,
+                                               new Version(
+                                                               "Doors 98 SE Special Deluxe Edition Pro++ Not-Home")
+                                                               .isEmpty());
+
+                               assertEquals(
+                                               "Bad input should return [unknown]",
+                                               "[unknown]",
+                                               new Version(
+                                                               "Doors 98 SE Special Deluxe Edition Pro++ Not-Home")
+                                                               .toString());
+                       }
+               });
+
+               addTest(new TestCase("Read current version") {
+                       @Override
+                       public void test() throws Exception {
+                               assertNotNull("The version should not be NULL (in any case!)",
+                                               Version.getCurrentVersion());
+                               assertEquals("The current version should not be empty", false,
+                                               Version.getCurrentVersion().isEmpty());
+                       }
+               });
+
+               addTest(new TestCase("Tag version") {
+                       @Override
+                       public void test() throws Exception {
+                               Version version = new Version("1.0.0-debian0");
+                               assertEquals("debian", version.getTag());
+                               assertEquals(0, version.getTagVersion());
+                               version = new Version("1.0.0-debian.0");
+                               assertEquals("debian.", version.getTag());
+                               assertEquals(0, version.getTagVersion());
+                               version = new Version("1.0.0-debian-0");
+                               assertEquals("debian-", version.getTag());
+                               assertEquals(0, version.getTagVersion());
+                               version = new Version("1.0.0-debian-12");
+                               assertEquals("debian-", version.getTag());
+                               assertEquals(12, version.getTagVersion());
+
+                               // tag with no tag version
+                               version = new Version("1.0.0-dev");
+                               assertEquals(1, version.getMajor());
+                               assertEquals(0, version.getMinor());
+                               assertEquals(0, version.getPatch());
+                               assertEquals("dev", version.getTag());
+                               assertEquals(-1, version.getTagVersion());
+                       }
+               });
+
+               addTest(new TestCase("Comparing versions") {
+                       @Override
+                       public void test() throws Exception {
+                               assertEquals(true,
+                                               new Version(1, 1, 1).isNewerThan(new Version(1, 1, 0)));
+                               assertEquals(true,
+                                               new Version(2, 0, 0).isNewerThan(new Version(1, 1, 1)));
+                               assertEquals(true,
+                                               new Version(10, 7, 8).isNewerThan(new Version(9, 9, 9)));
+                               assertEquals(true,
+                                               new Version(0, 0, 0).isOlderThan(new Version(0, 0, 1)));
+                               assertEquals(1,
+                                               new Version(1, 1, 1).compareTo(new Version(0, 1, 1)));
+                               assertEquals(-1,
+                                               new Version(0, 0, 1).compareTo(new Version(0, 1, 1)));
+                               assertEquals(0,
+                                               new Version(0, 0, 1).compareTo(new Version(0, 0, 1)));
+                               assertEquals(true,
+                                               new Version(0, 0, 1).equals(new Version(0, 0, 1)));
+                               assertEquals(false,
+                                               new Version(0, 2, 1).equals(new Version(0, 0, 1)));
+
+                               assertEquals(true,
+                                               new Version(1, 0, 1, "my.tag.", 2).equals(new Version(
+                                                               1, 0, 1, "my.tag.", 2)));
+                               assertEquals(false,
+                                               new Version(1, 0, 1, "my.tag.", 2).equals(new Version(
+                                                               1, 0, 0, "my.tag.", 2)));
+                               assertEquals(false,
+                                               new Version(1, 0, 1, "my.tag.", 2).equals(new Version(
+                                                               1, 0, 1, "not-my.tag.", 2)));
+                       }
+               });
+
+               addTest(new TestCase("toString") {
+                       @Override
+                       public void test() throws Exception {
+                               // Check leading 0s:
+                               Version version = new Version("01.002.4");
+                               assertEquals("Leading 0s not working", "1.2.4",
+                                               version.toString());
+
+                               // Check spacing
+                               version = new Version("1 . 2.4 ");
+                               assertEquals("Additional spaces not working", "1.2.4",
+                                               version.toString());
+
+                               String[] tests = new String[] { "1.0.0", "1.2.3", "1.0.0-dev",
+                                               "1.1.2-niki0" };
+                               for (String test : tests) {
+                                       version = new Version(test);
+                                       assertEquals("toString and back conversion failed", test,
+                                                       version.toString());
+                               }
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/utils/test_code/bundle_test.properties b/src/be/nikiroo/utils/test_code/bundle_test.properties
new file mode 100644 (file)
index 0000000..5222c59
--- /dev/null
@@ -0,0 +1,3 @@
+ONE = un
+ONE_SUFFIX = un + suffix
+JAPANESE = 日本語 Nihongo
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/ui/ConfigEditor.java b/src/be/nikiroo/utils/ui/ConfigEditor.java
new file mode 100644 (file)
index 0000000..c687c98
--- /dev/null
@@ -0,0 +1,165 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+import javax.swing.border.EmptyBorder;
+import javax.swing.border.TitledBorder;
+
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.MetaInfo;
+
+/**
+ * A configuration panel for a {@link Bundle}.
+ * <p>
+ * All the items in the given {@link Bundle} will be displayed in editable
+ * controls, with options to Save, Reset and/or Reset to the application default
+ * values.
+ * 
+ * @author niki
+ * 
+ * @param <E>
+ *            the type of {@link Bundle} to edit
+ */
+public class ConfigEditor<E extends Enum<E>> extends JPanel {
+       private static final long serialVersionUID = 1L;
+       private List<MetaInfo<E>> items;
+
+       /**
+        * Create a new {@link ConfigEditor} for this {@link Bundle}.
+        * 
+        * @param type
+        *            a class instance of the item type to work on
+        * @param bundle
+        *            the {@link Bundle} to sort through
+        * @param title
+        *            the title to display before the options
+        */
+       public ConfigEditor(Class<E> type, final Bundle<E> bundle, String title) {
+               this.setLayout(new BorderLayout());
+
+               JPanel main = new JPanel();
+               main.setLayout(new BoxLayout(main, BoxLayout.PAGE_AXIS));
+               main.setBorder(new EmptyBorder(5, 5, 5, 5));
+
+               main.add(new JLabel(title));
+
+               items = new ArrayList<MetaInfo<E>>();
+               List<MetaInfo<E>> groupedItems = MetaInfo.getItems(type, bundle);
+               for (MetaInfo<E> item : groupedItems) {
+                       // will init this.items
+                       addItem(main, item, 0);
+               }
+
+               JPanel buttons = new JPanel();
+               buttons.setLayout(new BoxLayout(buttons, BoxLayout.PAGE_AXIS));
+               buttons.setBorder(new EmptyBorder(5, 5, 5, 5));
+
+               buttons.add(createButton("Reset", new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               for (MetaInfo<E> item : items) {
+                                       item.reload();
+                               }
+                       }
+               }));
+
+               buttons.add(createButton("Default", new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               Object snap = bundle.takeSnapshot();
+                               bundle.reload(true);
+                               for (MetaInfo<E> item : items) {
+                                       item.reload();
+                               }
+                               bundle.reload(false);
+                               bundle.restoreSnapshot(snap);
+                       }
+               }));
+
+               buttons.add(createButton("Save", new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               for (MetaInfo<E> item : items) {
+                                       item.save(true);
+                               }
+
+                               try {
+                                       bundle.updateFile();
+                               } catch (IOException e1) {
+                                       e1.printStackTrace();
+                               }
+                       }
+               }));
+
+               JScrollPane scroll = new JScrollPane(main);
+               scroll.getVerticalScrollBar().setUnitIncrement(16);
+
+               this.add(scroll, BorderLayout.CENTER);
+               this.add(buttons, BorderLayout.SOUTH);
+       }
+
+       private void addItem(JPanel main, MetaInfo<E> item, int nhgap) {
+               if (item.isGroup()) {
+                       JPanel bpane = new JPanel(new BorderLayout());
+                       bpane.setBorder(new TitledBorder(item.getName()));
+                       JPanel pane = new JPanel();
+                       pane.setBorder(new EmptyBorder(5, 5, 5, 5));
+                       pane.setLayout(new BoxLayout(pane, BoxLayout.Y_AXIS));
+
+                       String info = item.getDescription();
+                       info = StringUtils.justifyTexts(info, 100);
+                       if (!info.isEmpty()) {
+                               info = info + "\n";
+                               JTextArea text = new JTextArea(info);
+                               text.setWrapStyleWord(true);
+                               text.setOpaque(false);
+                               text.setForeground(new Color(100, 100, 180));
+                               text.setEditable(false);
+                               pane.add(text);
+                       }
+
+                       for (MetaInfo<E> subitem : item) {
+                               addItem(pane, subitem, nhgap + 11);
+                       }
+                       bpane.add(pane, BorderLayout.CENTER);
+                       main.add(bpane);
+               } else {
+                       items.add(item);
+                       main.add(ConfigItem.createItem(item, nhgap));
+               }
+       }
+
+       /**
+        * Add an action button for this action.
+        * 
+        * @param title
+        *            the action title
+        * @param listener
+        *            the action
+        */
+       private JComponent createButton(String title, ActionListener listener) {
+               JButton button = new JButton(title);
+               button.addActionListener(listener);
+
+               JPanel panel = new JPanel();
+               panel.setLayout(new BorderLayout());
+               panel.setBorder(new EmptyBorder(2, 10, 2, 10));
+               panel.add(button, BorderLayout.CENTER);
+
+               return panel;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItem.java b/src/be/nikiroo/utils/ui/ConfigItem.java
new file mode 100644 (file)
index 0000000..1f69886
--- /dev/null
@@ -0,0 +1,764 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Cursor;
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.swing.BoxLayout;
+import javax.swing.ImageIcon;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.StringUtils.Alignment;
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.MetaInfo;
+
+/**
+ * A graphical item that reflect a configuration option from the given
+ * {@link Bundle}.
+ * <p>
+ * This graphical item can be edited, and the result will be saved back into the
+ * linked {@link MetaInfo}; you still have to save the {@link MetaInfo} should
+ * you wish to, of course.
+ * 
+ * @author niki
+ * 
+ * @param <E>
+ *            the type of {@link Bundle} to edit
+ */
+public abstract class ConfigItem<E extends Enum<E>> extends JPanel {
+       private static final long serialVersionUID = 1L;
+
+       private static int minimumHeight = -1;
+
+       /** A small 16x16 "?" blue in PNG, base64 encoded. */
+       private static String img64info = //
+       ""
+                       + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI"
+                       + "WXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wURFRg6IrtcdgAAATdJREFUOMvtkj8sQ1EUxr9z/71G"
+                       + "m1RDogYxq7WDDYMYTSajSG4n6YRYzSaSLibWbiaDIGwdiLIYDFKDNJEgKu969xi8UNHy7H7LPcN3"
+                       + "v/Odcy+hG9oOIeIcBCJS9MAvlZtOMtHxsrFrJHGqe0RVGnHAHpcIbPlng8BS3HmKBJYzabGUzcrJ"
+                       + "XK+ckIrqANYR2JEv2nYDEVck0WKGfHzyq82Go+btxoX3XAcAIqTj8wPqOH6mtMeM4bGCLhyfhTMA"
+                       + "qlLhKHqujCfaweCAmV0p50dPzsNpEKpK01V/n55HIvTnfDC2odKlfeYadZN/T+AqDACUsnkhqaU1"
+                       + "LRIVuX1x7ciuSWQxVIrunONrfq3dI6oh+T94Z8453vEem/HTqT8ZpFJ0qDXtGkPbAGAMeSRngQCA"
+                       + "eUvgn195AwlZWyvjtQdhAAAAAElFTkSuQmCC";
+
+       /** A small 16x16 "+" image with colours */
+       private static String img64add = //
+       ""
+                       + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI"
+                       + "WXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wUeES0QBFvvnAAAAB1pVFh0Q29tbWVudAAAAAAAQ3Jl"
+                       + "YXRlZCB3aXRoIEdJTVBkLmUHAAACH0lEQVQ4y42Tz0sVURTHP+fMmC7CQMpH1EjgIimCsEVBEIg/"
+                       + "qIbcBAW2Uai1m/oH2rlJXLQpeRJt2gQhTO0iTTKC1I2JBf5gKCJCRPvhPOed22LmvV70Fn7hwr3c"
+                       + "+z3ne+73HCFHEClxaASRHgduA91AW369BkwDI3Foy0GkEofmACQnSxyaCyItAkMClMzYdeCAJgVP"
+                       + "tJJrPA7tVoUjNZlngXMAiRmXClfoK/Tjq09x7T6LW+8RxOVJ5+LQzgSRojm5WCEDlMrQVbjIQNtN"
+                       + "rh0d5FTzaTLBmWKgM4h0Ig4NzWseohYCJUuqx123Sx0MBpF2+MAdyWUnlqX4lf4bIDHjR+rwJJPR"
+                       + "qNCgCjDsA10lM/oKIRcO9lByCYklnG/pqQa4euQ6J5tPoKI0yD6ef33Ku40Z80R7CSJNWyZxT+Ki"
+                       + "2ytGP911hyZxQaRp1RtPPPYKD4+sGJwPrDUp7Q9Xxnj9fYrUUnaszEAwQHfrZQAerT/g7cYMiuCp"
+                       + "z8LmLI0qBqz6wLQn2v5he57FrXkAtlPH2ZZOuskCzG2+4dnnx3iSuSgCKqLAlAIjmXPiVIRsgYjU"
+                       + "usrfO0Gq7cA9jUNbBsZrmiQnac1e6n3FeBzakpf39OSBG9IPHAZwzlFoagVg5edHXn57wZed9dpA"
+                       + "C3FoYRDpf8M0AQwKwu9yubxjeA7Y72ENqlp3mOqMcwcwDPQCx8gGchV4BYzGoS1V3gL8AVA5C5/0"
+                       + "oRFoAAAAAElFTkSuQmCC";
+
+       /** A small 32x32 "-" image with colours */
+       private static String img64remove = //
+       ""
+                       + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI"
+                       + "WXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wUeESw5X/JGsQAAAB1pVFh0Q29tbWVudAAAAAAAQ3Jl"
+                       + "YXRlZCB3aXRoIEdJTVBkLmUHAAACKUlEQVQ4y5WTO2iTYRSG3+//v/+SJrG5SSABh1JQBHFJNUNR"
+                       + "YodCLoMoTkK0YKhQtBmsl01wKVZRBwcrgosg3SwFW9Cippe0VmlpB6uYqYIaNSZtbv/lOKRx0iR9"
+                       + "4YOzvOc8vOd8wLbG4nYGAKP9tshKr3Pq0zFXORt0UzbopvUeZ2ml1/niUcIWAYBzwwqr+xgAjCSt"
+                       + "wpXjWzx105Ha+1XsMgT8U6IJfPAacyfO50OXJi3VwbtbxMbidtZ3tiClbzi/eAuCmxgai4AfNvNn"
+                       + "KJn3X5xWKgwA0lHHYud3MdDUXMcmIOMx0oGJXJCN9tuiJ98p4//DbtTk2cFKhB/OSBcMgQHVMkir"
+                       + "AqwJBhGYrIIkCQc2eJK3aewI9Crko2FIh0K1Jo0mcwmV6XFUlmfRXhK7eXuRKaRVIYdiUGKnW8Kn"
+                       + "0ia0t6/hKHJVqCcLzncQgLhtIvBfbWbZZahq+cl96AuvQLre2Mw59NUlkCwjZ6USL0uYgSj26B/X"
+                       + "oK+vtkYgMAhMRF4x5oWlPdod0UQtfUFo7YEBBKz59BEGAAtRx1xHVgzu5AYyHmMmMJHrZolhhU3t"
+                       + "05XJe7s2PJuCq9k1MgKyNjOXiBf8kWW5JDy4XKHBl2ql6+pvX8ZjzDOqrcWsFQAAE/T3H3z2GG/6"
+                       + "zhT8sfdKeehWkUQAeJ7WcH23xTz1uPBwf1hclA3mBZjPojFOIOSsVPpmN1OznfpA+Gn+2kCHqg/d"
+                       + "LhIA/AFU5d0V6gTjtQAAAABJRU5ErkJggg==";
+
+       /** The original value before current changes. */
+       private Object orig;
+       private List<Object> origs = new ArrayList<Object>();
+       private List<Integer> dirtyBits;
+
+       /** The fields (one for non-array, a list for arrays). */
+       private JComponent field;
+       private List<JComponent> fields = new ArrayList<JComponent>();
+
+       /** The fields to panel map to get the actual item added to 'main'. */
+       private Map<Integer, JComponent> itemFields = new HashMap<Integer, JComponent>();
+
+       /** The main panel with all the fields in it. */
+       private JPanel main;
+
+       /** The {@link MetaInfo} linked to the field. */
+       protected MetaInfo<E> info;
+
+       /**
+        * Create a new {@link ConfigItem} for the given {@link MetaInfo}.
+        * 
+        * @param nhgap
+        *            negative horisontal gap in pixel to use for the label, i.e.,
+        *            the step lock sized labels will start smaller by that amount
+        *            (the use case would be to align controls that start at a
+        *            different horisontal position)
+        */
+       public void init(int nhgap) {
+               if (info.isArray()) {
+                       this.setLayout(new BorderLayout());
+                       add(label(nhgap), BorderLayout.WEST);
+
+                       main = new JPanel();
+
+                       main.setLayout(new BoxLayout(main, BoxLayout.Y_AXIS));
+                       int size = info.getListSize(false);
+                       for (int i = 0; i < size; i++) {
+                               addItem(i);
+                       }
+                       main.revalidate();
+                       main.repaint();
+
+                       final JButton add = new JButton();
+                       setImage(add, img64add, "+");
+
+                       add.addActionListener(new ActionListener() {
+                               @Override
+                               public void actionPerformed(ActionEvent e) {
+                                       addItem(fields.size());
+                                       main.revalidate();
+                                       main.repaint();
+                               }
+                       });
+
+                       JPanel tmp = new JPanel(new BorderLayout());
+                       tmp.add(add, BorderLayout.WEST);
+
+                       JPanel mainPlus = new JPanel(new BorderLayout());
+                       mainPlus.add(main, BorderLayout.CENTER);
+                       mainPlus.add(tmp, BorderLayout.SOUTH);
+
+                       add(mainPlus, BorderLayout.CENTER);
+               } else {
+                       this.setLayout(new BorderLayout());
+                       add(label(nhgap), BorderLayout.WEST);
+
+                       JComponent field = createField(-1);
+                       add(field, BorderLayout.CENTER);
+               }
+       }
+
+       private void addItem(final int item) {
+               JPanel minusPanel = new JPanel(new BorderLayout());
+               itemFields.put(item, minusPanel);
+
+               JComponent field = createField(item);
+
+               final JButton remove = new JButton();
+               setImage(remove, img64remove, "-");
+
+               remove.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               removeItem(item);
+                       }
+               });
+
+               minusPanel.add(field, BorderLayout.CENTER);
+               minusPanel.add(remove, BorderLayout.EAST);
+
+               main.add(minusPanel);
+               main.revalidate();
+               main.repaint();
+       }
+
+       private void removeItem(int item) {
+               int last = itemFields.size() - 1;
+
+               for (int i = item; i <= last; i++) {
+                       Object value = null;
+                       if (i < last) {
+                               value = getFromField(i + 1);
+                       }
+                       setToField(value, i);
+                       setToInfo(value, i);
+                       setDirtyItem(i);
+               }
+
+               main.remove(itemFields.remove(last));
+               main.revalidate();
+               main.repaint();
+       }
+
+       /**
+        * Prepare a new {@link ConfigItem} instance, linked to the given
+        * {@link MetaInfo}.
+        * 
+        * @param info
+        *            the info
+        * @param autoDirtyHandling
+        *            TRUE to automatically manage the setDirty/Save operations,
+        *            FALSE if you want to do it yourself via
+        *            {@link ConfigItem#setDirtyItem(int)}
+        */
+       protected ConfigItem(MetaInfo<E> info, boolean autoDirtyHandling) {
+               this.info = info;
+               if (!autoDirtyHandling) {
+                       dirtyBits = new ArrayList<Integer>();
+               }
+       }
+
+       /**
+        * Create an empty graphical component to be used later by
+        * {@link ConfigItem#createField(int)}.
+        * <p>
+        * Note that {@link ConfigItem#reload(int)} will be called after it was
+        * created by {@link ConfigItem#createField(int)}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the graphical component
+        */
+       abstract protected JComponent createEmptyField(int item);
+
+       /**
+        * Get the information from the {@link MetaInfo} in the subclass preferred
+        * format.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the information in the subclass preferred format
+        */
+       abstract protected Object getFromInfo(int item);
+
+       /**
+        * Set the value to the {@link MetaInfo}.
+        * 
+        * @param value
+        *            the value in the subclass preferred format
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       abstract protected void setToInfo(Object value, int item);
+
+       /**
+        * The value present in the given item's related field in the subclass
+        * preferred format.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the value present in the given item's related field in the
+        *         subclass preferred format
+        */
+       abstract protected Object getFromField(int item);
+
+       /**
+        * Set the value (in the subclass preferred format) into the field.
+        * 
+        * @param value
+        *            the value in the subclass preferred format
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       abstract protected void setToField(Object value, int item);
+
+       /**
+        * Create a new field for the given graphical component at the given index
+        * (note that the component is usually created by
+        * {@link ConfigItem#createEmptyField(int)}).
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param field
+        *            the graphical component
+        */
+       private void setField(int item, JComponent field) {
+               if (item < 0) {
+                       this.field = field;
+                       return;
+               }
+
+               for (int i = fields.size(); i <= item; i++) {
+                       fields.add(null);
+               }
+
+               fields.set(item, field);
+       }
+
+       /**
+        * Retrieve the associated graphical component that was created with
+        * {@link ConfigItem#createEmptyField(int)}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the graphical component
+        */
+       protected JComponent getField(int item) {
+               if (item < 0) {
+                       return field;
+               }
+
+               if (item < fields.size()) {
+                       return fields.get(item);
+               }
+
+               return null;
+       }
+
+       /**
+        * The original value (before any changes to the {@link MetaInfo}) for this
+        * item.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the original value
+        */
+       private Object getOrig(int item) {
+               if (item < 0) {
+                       return orig;
+               }
+
+               if (item < origs.size()) {
+                       return origs.get(item);
+               }
+
+               return null;
+       }
+
+       /**
+        * The original value (before any changes to the {@link MetaInfo}) for this
+        * item.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * @param value
+        *            the new original value
+        */
+       private void setOrig(Object value, int item) {
+               if (item < 0) {
+                       orig = value;
+               } else {
+                       while (item >= origs.size()) {
+                               origs.add(null);
+                       }
+
+                       origs.set(item, value);
+               }
+       }
+
+       /**
+        * Manually specify that the given item is "dirty" and thus should be saved
+        * when asked.
+        * <p>
+        * Has no effect if the class is using automatic dirty handling (see
+        * {@link ConfigItem#ConfigItem(MetaInfo, boolean)}).
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       protected void setDirtyItem(int item) {
+               if (dirtyBits != null) {
+                       dirtyBits.add(item);
+               }
+       }
+
+       /**
+        * Check if the value changed since the last load/save into the linked
+        * {@link MetaInfo}.
+        * <p>
+        * Note that we consider NULL and an Empty {@link String} to be equals.
+        * 
+        * @param value
+        *            the value to test
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return TRUE if it has
+        */
+       protected boolean hasValueChanged(Object value, int item) {
+               // We consider "" and NULL to be equals
+               Object orig = getOrig(item);
+               if (orig == null) {
+                       orig = "";
+               }
+               return !orig.equals(value == null ? "" : value);
+       }
+
+       /**
+        * Reload the values to what they currently are in the {@link MetaInfo}.
+        */
+       private void reload() {
+               if (info.isArray()) {
+                       while (!itemFields.isEmpty()) {
+                               main.remove(itemFields.remove(itemFields.size() - 1));
+                       }
+                       main.revalidate();
+                       main.repaint();
+                       for (int item = 0; item < info.getListSize(false); item++) {
+                               reload(item);
+                       }
+               } else {
+                       reload(-1);
+               }
+       }
+
+       /**
+        * Reload the values to what they currently are in the {@link MetaInfo}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       private void reload(int item) {
+               if (item >= 0 && !itemFields.containsKey(item)) {
+                       addItem(item);
+               }
+
+               Object value = getFromInfo(item);
+               setToField(value, item);
+               setOrig(value == null ? "" : value, item);
+       }
+
+       /**
+        * If the item has been modified, set the {@link MetaInfo} to dirty then
+        * modify it to, reflect the changes so it can be saved later.
+        * <p>
+        * This method does <b>not</b> call {@link MetaInfo#save(boolean)}.
+        */
+       private void save() {
+               if (info.isArray()) {
+                       boolean dirty = itemFields.size() != info.getListSize(false);
+                       for (int item = 0; item < itemFields.size(); item++) {
+                               if (getDirtyBit(item)) {
+                                       dirty = true;
+                               }
+                       }
+
+                       if (dirty) {
+                               info.setDirty();
+                               info.setString(null, -1);
+
+                               for (int item = 0; item < itemFields.size(); item++) {
+                                       Object value = null;
+                                       if (getField(item) != null) {
+                                               value = getFromField(item);
+                                               if ("".equals(value)) {
+                                                       value = null;
+                                               }
+                                       }
+
+                                       setToInfo(value, item);
+                                       setOrig(value, item);
+                               }
+                       }
+               } else {
+                       if (getDirtyBit(-1)) {
+                               Object value = getFromField(-1);
+
+                               info.setDirty();
+                               setToInfo(value, -1);
+                               setOrig(value, -1);
+                       }
+               }
+       }
+
+       /**
+        * Check if the item is dirty, and clear the dirty bit if set.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return TRUE if it was dirty, FALSE if not
+        */
+       private boolean getDirtyBit(int item) {
+               if (dirtyBits != null) {
+                       return dirtyBits.remove((Integer) item);
+               }
+
+               Object value = null;
+               if (getField(item) != null) {
+                       value = getFromField(item);
+               }
+
+               return hasValueChanged(value, item);
+       }
+
+       /**
+        * Create a new field for the given item.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the newly created field
+        */
+       protected JComponent createField(final int item) {
+               JComponent field = createEmptyField(item);
+               setField(item, field);
+               reload(item);
+
+               info.addReloadedListener(new Runnable() {
+                       @Override
+                       public void run() {
+                               reload();
+                       }
+               });
+               info.addSaveListener(new Runnable() {
+                       @Override
+                       public void run() {
+                               save();
+                       }
+               });
+
+               int height = Math
+                               .max(getMinimumHeight(), field.getMinimumSize().height);
+               field.setPreferredSize(new Dimension(200, height));
+
+               return field;
+       }
+
+       /**
+        * Create a label which width is constrained in lock steps.
+        * 
+        * @param nhgap
+        *            negative horisontal gap in pixel to use for the label, i.e.,
+        *            the step lock sized labels will start smaller by that amount
+        *            (the use case would be to align controls that start at a
+        *            different horisontal position)
+        * 
+        * @return the label
+        */
+       protected JComponent label(int nhgap) {
+               final JLabel label = new JLabel(info.getName());
+
+               Dimension ps = label.getPreferredSize();
+               if (ps == null) {
+                       ps = label.getSize();
+               }
+
+               ps.height = Math.max(ps.height, getMinimumHeight());
+
+               int w = ps.width;
+               int step = 150;
+               for (int i = 2 * step - nhgap; i < 10 * step; i += step) {
+                       if (w < i) {
+                               w = i;
+                               break;
+                       }
+               }
+
+               final Runnable showInfo = new Runnable() {
+                       @Override
+                       public void run() {
+                               StringBuilder builder = new StringBuilder();
+                               String text = (info.getDescription().replace("\\n", "\n"))
+                                               .trim();
+                               for (String line : StringUtils.justifyText(text, 80,
+                                               Alignment.LEFT)) {
+                                       if (builder.length() > 0) {
+                                               builder.append("\n");
+                                       }
+                                       builder.append(line);
+                               }
+                               text = builder.toString();
+                               JOptionPane.showMessageDialog(ConfigItem.this, text,
+                                               info.getName(), JOptionPane.INFORMATION_MESSAGE);
+                       }
+               };
+
+               JLabel help = new JLabel("");
+               help.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+               setImage(help, img64info, "?");
+
+               help.addMouseListener(new MouseAdapter() {
+                       @Override
+                       public void mouseClicked(MouseEvent e) {
+                               showInfo.run();
+                       }
+               });
+
+               JPanel pane2 = new JPanel(new BorderLayout());
+               pane2.add(help, BorderLayout.WEST);
+               pane2.add(new JLabel(" "), BorderLayout.CENTER);
+
+               JPanel contentPane = new JPanel(new BorderLayout());
+               contentPane.add(label, BorderLayout.WEST);
+               contentPane.add(pane2, BorderLayout.CENTER);
+
+               ps.width = w + 30; // 30 for the (?) sign
+               contentPane.setSize(ps);
+               contentPane.setPreferredSize(ps);
+
+               JPanel pane = new JPanel(new BorderLayout());
+               pane.add(contentPane, BorderLayout.NORTH);
+
+               return pane;
+       }
+
+       /**
+        * Create a new {@link ConfigItem} for the given {@link MetaInfo}.
+        * 
+        * @param <E>
+        *            the type of {@link Bundle} to edit
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        * @param nhgap
+        *            negative horisontal gap in pixel to use for the label, i.e.,
+        *            the step lock sized labels will start smaller by that amount
+        *            (the use case would be to align controls that start at a
+        *            different horisontal position)
+        * 
+        * @return the new {@link ConfigItem}
+        */
+       static public <E extends Enum<E>> ConfigItem<E> createItem(
+                       MetaInfo<E> info, int nhgap) {
+
+               ConfigItem<E> configItem;
+               switch (info.getFormat()) {
+               case BOOLEAN:
+                       configItem = new ConfigItemBoolean<E>(info);
+                       break;
+               case COLOR:
+                       configItem = new ConfigItemColor<E>(info);
+                       break;
+               case FILE:
+                       configItem = new ConfigItemBrowse<E>(info, false);
+                       break;
+               case DIRECTORY:
+                       configItem = new ConfigItemBrowse<E>(info, true);
+                       break;
+               case COMBO_LIST:
+                       configItem = new ConfigItemCombobox<E>(info, true);
+                       break;
+               case FIXED_LIST:
+                       configItem = new ConfigItemCombobox<E>(info, false);
+                       break;
+               case INT:
+                       configItem = new ConfigItemInteger<E>(info);
+                       break;
+               case PASSWORD:
+                       configItem = new ConfigItemPassword<E>(info);
+                       break;
+               case LOCALE:
+                       configItem = new ConfigItemLocale<E>(info);
+                       break;
+               case STRING:
+               default:
+                       configItem = new ConfigItemString<E>(info);
+                       break;
+               }
+
+               configItem.init(nhgap);
+               return configItem;
+       }
+
+       /**
+        * Set an image to the given {@link JButton}, with a fallback text if it
+        * fails.
+        * 
+        * @param button
+        *            the button to set
+        * @param image64
+        *            the image in BASE64 (should be PNG or similar)
+        * @param fallbackText
+        *            text to use in case the image cannot be created
+        */
+       static protected void setImage(JLabel button, String image64,
+                       String fallbackText) {
+               try {
+                       Image img = new Image(image64);
+                       try {
+                               BufferedImage bImg = ImageUtilsAwt.fromImage(img);
+                               button.setIcon(new ImageIcon(bImg));
+                       } finally {
+                               img.close();
+                       }
+               } catch (IOException e) {
+                       // This is an hard-coded image, should not happen
+                       button.setText(fallbackText);
+               }
+       }
+
+       /**
+        * Set an image to the given {@link JButton}, with a fallback text if it
+        * fails.
+        * 
+        * @param button
+        *            the button to set
+        * @param image64
+        *            the image in BASE64 (should be PNG or similar)
+        * @param fallbackText
+        *            text to use in case the image cannot be created
+        */
+       static protected void setImage(JButton button, String image64,
+                       String fallbackText) {
+               try {
+                       Image img = new Image(image64);
+                       try {
+                               BufferedImage bImg = ImageUtilsAwt.fromImage(img);
+                               button.setIcon(new ImageIcon(bImg));
+                       } finally {
+                               img.close();
+                       }
+               } catch (IOException e) {
+                       // This is an hard-coded image, should not happen
+                       button.setText(fallbackText);
+               }
+       }
+
+       static private int getMinimumHeight() {
+               if (minimumHeight < 0) {
+                       minimumHeight = new JTextField("Test").getMinimumSize().height;
+               }
+
+               return minimumHeight;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemBoolean.java b/src/be/nikiroo/utils/ui/ConfigItemBoolean.java
new file mode 100644 (file)
index 0000000..255ec13
--- /dev/null
@@ -0,0 +1,66 @@
+package be.nikiroo.utils.ui;
+
+import javax.swing.JCheckBox;
+import javax.swing.JComponent;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemBoolean<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Create a new {@link ConfigItemBoolean} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemBoolean(MetaInfo<E> info) {
+               super(info, true);
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               JCheckBox field = (JCheckBox) getField(item);
+               if (field != null) {
+                       return field.isSelected();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return info.getBoolean(item, true);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               JCheckBox field = (JCheckBox) getField(item);
+               if (field != null) {
+                       // Should not happen if config enum is correct
+                       // (but this is not enforced)
+                       if (value == null) {
+                               value = false;
+                       }
+
+                       field.setSelected((Boolean) value);
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setBoolean((Boolean) value, item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(int item) {
+               // Should not happen!
+               if (getFromInfo(item) == null) {
+                       System.err
+                                       .println("No default value given for BOOLEAN parameter \""
+                                                       + info.getName() + "\", we consider it is FALSE");
+               }
+
+               return new JCheckBox();
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemBrowse.java b/src/be/nikiroo/utils/ui/ConfigItemBrowse.java
new file mode 100644 (file)
index 0000000..6c8af99
--- /dev/null
@@ -0,0 +1,116 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JFileChooser;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemBrowse<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+
+       private boolean dir;
+       private Map<JComponent, JTextField> fields = new HashMap<JComponent, JTextField>();
+
+       /**
+        * Create a new {@link ConfigItemBrowse} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        * @param dir
+        *            TRUE for directory browsing, FALSE for file browsing
+        */
+       public ConfigItemBrowse(MetaInfo<E> info, boolean dir) {
+               super(info, false);
+               this.dir = dir;
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               JTextField field = fields.get(getField(item));
+               if (field != null) {
+                       return new File(field.getText());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               String path = info.getString(item, false);
+               if (path != null && !path.isEmpty()) {
+                       return new File(path);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               JTextField field = fields.get(getField(item));
+               if (field != null) {
+                       field.setText(value == null ? "" : ((File) value).getPath());
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setString(((File) value).getPath(), item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(final int item) {
+               final JPanel pane = new JPanel(new BorderLayout());
+               final JTextField field = new JTextField();
+               field.addKeyListener(new KeyAdapter() {
+                       @Override
+                       public void keyTyped(KeyEvent e) {
+                               File file = null;
+                               if (!field.getText().isEmpty()) {
+                                       file = new File(field.getText());
+                               }
+
+                               if (hasValueChanged(file, item)) {
+                                       setDirtyItem(item);
+                               }
+                       }
+               });
+
+               final JButton browseButton = new JButton("...");
+               browseButton.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               JFileChooser chooser = new JFileChooser();
+                               chooser.setCurrentDirectory((File) getFromInfo(item));
+                               chooser.setFileSelectionMode(dir ? JFileChooser.DIRECTORIES_ONLY
+                                               : JFileChooser.FILES_ONLY);
+                               if (chooser.showOpenDialog(ConfigItemBrowse.this) == JFileChooser.APPROVE_OPTION) {
+                                       File file = chooser.getSelectedFile();
+                                       if (file != null) {
+                                               setToField(file, item);
+                                               if (hasValueChanged(file, item)) {
+                                                       setDirtyItem(item);
+                                               }
+                                       }
+                               }
+                       }
+               });
+
+               pane.add(browseButton, BorderLayout.WEST);
+               pane.add(field, BorderLayout.CENTER);
+
+               fields.put(pane, field);
+               return pane;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemColor.java b/src/be/nikiroo/utils/ui/ConfigItemColor.java
new file mode 100644 (file)
index 0000000..500efff
--- /dev/null
@@ -0,0 +1,168 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.awt.image.BufferedImage;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.swing.Icon;
+import javax.swing.ImageIcon;
+import javax.swing.JButton;
+import javax.swing.JColorChooser;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemColor<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+
+       private Map<JComponent, JTextField> fields = new HashMap<JComponent, JTextField>();
+       private Map<JComponent, JButton> panels = new HashMap<JComponent, JButton>();
+
+       /**
+        * Create a new {@link ConfigItemColor} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemColor(MetaInfo<E> info) {
+               super(info, true);
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               JTextField field = fields.get(getField(item));
+               if (field != null) {
+                       return field.getText();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return info.getString(item, true);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               JTextField field = fields.get(getField(item));
+               if (field != null) {
+                       field.setText(value == null ? "" : value.toString());
+               }
+
+               JButton colorWheel = panels.get(getField(item));
+               if (colorWheel != null) {
+                       colorWheel.setIcon(getIcon(17, getFromInfoColor(item)));
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setString((String) value, item);
+       }
+
+       /**
+        * Get the colour currently present in the linked info for the given item.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return a colour
+        */
+       private int getFromInfoColor(int item) {
+               Integer color = info.getColor(item, true);
+               if (color == null) {
+                       return new Color(255, 255, 255, 255).getRGB();
+               }
+
+               return color;
+       }
+
+       @Override
+       protected JComponent createEmptyField(final int item) {
+               final JPanel pane = new JPanel(new BorderLayout());
+               final JTextField field = new JTextField();
+
+               final JButton colorWheel = new JButton();
+               colorWheel.setIcon(getIcon(17, getFromInfoColor(item)));
+               colorWheel.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               int icol = getFromInfoColor(item);
+                               Color initialColor = new Color(icol, true);
+                               Color newColor = JColorChooser.showDialog(ConfigItemColor.this,
+                                               info.getName(), initialColor);
+                               if (newColor != null) {
+                                       info.setColor(newColor.getRGB(), item);
+                                       field.setText(info.getString(item, false));
+                                       colorWheel.setIcon(getIcon(17, info.getColor(item, true)));
+                               }
+                       }
+               });
+
+               field.addKeyListener(new KeyAdapter() {
+                       @Override
+                       public void keyTyped(KeyEvent e) {
+                               info.setString(field.getText() + e.getKeyChar(), item);
+                               int color = getFromInfoColor(item);
+                               colorWheel.setIcon(getIcon(17, color));
+                       }
+               });
+
+               pane.add(colorWheel, BorderLayout.WEST);
+               pane.add(field, BorderLayout.CENTER);
+
+               fields.put(pane, field);
+               panels.put(pane, colorWheel);
+               return pane;
+       }
+
+       /**
+        * Return an {@link Icon} to use as a colour badge for the colour field
+        * controls.
+        * 
+        * @param size
+        *            the size of the badge
+        * @param color
+        *            the colour of the badge, which can be NULL (will return
+        *            transparent white)
+        * 
+        * @return the badge
+        */
+       static private Icon getIcon(int size, Integer color) {
+               // Allow null values
+               if (color == null) {
+                       color = new Color(255, 255, 255, 255).getRGB();
+               }
+
+               Color c = new Color(color, true);
+               int avg = (c.getRed() + c.getGreen() + c.getBlue()) / 3;
+               Color border = (avg >= 128 ? Color.BLACK : Color.WHITE);
+
+               BufferedImage img = new BufferedImage(size, size,
+                               BufferedImage.TYPE_4BYTE_ABGR);
+
+               Graphics2D g = img.createGraphics();
+               try {
+                       g.setColor(c);
+                       g.fillRect(0, 0, img.getWidth(), img.getHeight());
+                       g.setColor(border);
+                       g.drawRect(0, 0, img.getWidth() - 1, img.getHeight() - 1);
+               } finally {
+                       g.dispose();
+               }
+
+               return new ImageIcon(img);
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemCombobox.java b/src/be/nikiroo/utils/ui/ConfigItemCombobox.java
new file mode 100644 (file)
index 0000000..b77e0a8
--- /dev/null
@@ -0,0 +1,68 @@
+package be.nikiroo.utils.ui;
+
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemCombobox<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+
+       private boolean editable;
+       private String[] allowedValues;
+
+       /**
+        * Create a new {@link ConfigItemCombobox} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        * @param editable
+        *            allows the user to type in another value not in the list
+        */
+       public ConfigItemCombobox(MetaInfo<E> info, boolean editable) {
+               super(info, true);
+               this.editable = editable;
+               this.allowedValues = info.getAllowedValues();
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               // rawtypes for Java 1.6 (and 1.7 ?) support
+               @SuppressWarnings("rawtypes")
+               JComboBox field = (JComboBox) getField(item);
+               if (field != null) {
+                       return field.getSelectedItem();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return info.getString(item, false);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               // rawtypes for Java 1.6 (and 1.7 ?) support
+               @SuppressWarnings("rawtypes")
+               JComboBox field = (JComboBox) getField(item);
+               if (field != null) {
+                       field.setSelectedItem(value);
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setString((String) value, item);
+       }
+
+       // rawtypes for Java 1.6 (and 1.7 ?) support
+       @SuppressWarnings({ "unchecked", "rawtypes" })
+       @Override
+       protected JComponent createEmptyField(int item) {
+               JComboBox field = new JComboBox(allowedValues);
+               field.setEditable(editable);
+               return field;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemInteger.java b/src/be/nikiroo/utils/ui/ConfigItemInteger.java
new file mode 100644 (file)
index 0000000..9b838a5
--- /dev/null
@@ -0,0 +1,53 @@
+package be.nikiroo.utils.ui;
+
+import javax.swing.JComponent;
+import javax.swing.JSpinner;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemInteger<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Create a new {@link ConfigItemInteger} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemInteger(MetaInfo<E> info) {
+               super(info, true);
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               JSpinner field = (JSpinner) getField(item);
+               if (field != null) {
+                       return field.getValue();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return info.getInteger(item, true);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               JSpinner field = (JSpinner) getField(item);
+               if (field != null) {
+                       field.setValue(value == null ? 0 : (Integer) value);
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setInteger((Integer) value, item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(int item) {
+               return new JSpinner();
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemLocale.java b/src/be/nikiroo/utils/ui/ConfigItemLocale.java
new file mode 100644 (file)
index 0000000..eef8da0
--- /dev/null
@@ -0,0 +1,62 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Component;
+import java.util.Locale;
+
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JList;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemLocale<E extends Enum<E>> extends ConfigItemCombobox<E> {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Create a new {@link ConfigItemLocale} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemLocale(MetaInfo<E> info) {
+               super(info, true);
+       }
+
+       // rawtypes for Java 1.6 (and 1.7 ?) support
+       @SuppressWarnings({ "unchecked", "rawtypes" })
+       @Override
+       protected JComponent createEmptyField(int item) {
+               JComboBox field = (JComboBox) super.createEmptyField(item);
+               field.setRenderer(new DefaultListCellRenderer() {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public Component getListCellRendererComponent(JList list,
+                                       Object value, int index, boolean isSelected,
+                                       boolean cellHasFocus) {
+
+                               String svalue = value == null ? "" : value.toString();
+                               String[] tab = svalue.split("-");
+                               Locale locale = null;
+                               if (tab.length == 1) {
+                                       locale = new Locale(tab[0]);
+                               } else if (tab.length == 2) {
+                                       locale = new Locale(tab[0], tab[1]);
+                               } else if (tab.length == 3) {
+                                       locale = new Locale(tab[0], tab[1], tab[2]);
+                               }
+
+                               String displayValue = svalue;
+                               if (locale != null) {
+                                       displayValue = locale.getDisplayName();
+                               }
+
+                               return super.getListCellRendererComponent(list, displayValue,
+                                               index, isSelected, cellHasFocus);
+                       }
+               });
+
+               return field;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemPassword.java b/src/be/nikiroo/utils/ui/ConfigItemPassword.java
new file mode 100644 (file)
index 0000000..348b78f
--- /dev/null
@@ -0,0 +1,109 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+import javax.swing.JPasswordField;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemPassword<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+       /** A small 16x16 pass-protecet icon in PNG, base64 encoded. */
+       private static String img64passProtected = //
+       ""
+                       + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAnNCSVQICFXsRgQAAAD5SURBVCjP"
+                       + "ndG9LoNxGIbxHxJTG9U0IsJAdCSNqZEa9BR87BaHYfW5ESYkmjQh4giwIU00MWFwAPWRSmpgaf6G"
+                       + "6ts36eZ+xuu+lvuhlTGjOFHAsXldWVDRa82WhE9pZFxrtmBeUY87+yqCH3UzMh4E1VYhp2ZVVfi7"
+                       + "C0PuBc9G2v6KoOlIQUoyhovyLb+uZla/TbsRHnOgJkfSi4YpbDiXjuwJDS+SlASLYC9mw5KgxJlg"
+                       + "CWJ4OyqckvKkIWswwmXrmPbl0QBkHcbsHRv6Fbz6MNnesWMnpMw51vRmphuXo7FujHf+cCt4NGza"
+                       + "lbp3l5b1xR/1rWrYf/MLWpplWwswQpMAAAAASUVORK5CYII=";
+
+       /** A small 16x16 pass-unprotecet icon in PNG, base64 encoded. */
+       private static String img64passUnprotected = //
+       ""
+                       + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAA"
+                       + "CxMAAAsTAQCanBgAAAAHdElNRQfjBR8MIilwhCwdAAABK0lEQVQoz5XQv0uUAQCH8c/7qod4nect"
+                       + "gop3BIKDFBIiRyiKtATmcEiBDW7+Ae5ODt5gW0SLigouKTg6SJvkjw4Co8mcNeWgc+o839dBBXPz"
+                       + "+Y7PM33r3NCpWcWKM1lfHapJq0B4G/TbEDoyZlyHQxuGtdw6eSMC33yyJxa79MW+wIj8TdDrxJSS"
+                       + "+N5KppQNEchrkrMosmzRT0/0eGdSaFrob6DXloSqgu9mNWlUNqPPpmYNJkg5UvEMResystYVpbwW"
+                       + "qWpjVWwcfNQqLS1rAXwQOw4N4SWoqZeUVFMGuzgg65/IqIw5a3LarZnDcxd+ScMrkcikhB8+m1eU"
+                       + "MODUua67q967EttR0KHFoCVX/nhxp1N4o/rfUTueekC332KRM9veqnuoAwQyHs81DiddylUvrecA"
+                       + "AAAASUVORK5CYII=";
+
+       private Map<JComponent, JPasswordField> fields = new HashMap<JComponent, JPasswordField>();
+
+       /**
+        * Create a new {@link ConfigItemPassword} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemPassword(MetaInfo<E> info) {
+               super(info, true);
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               JPasswordField field = fields.get(getField(item));
+               if (field != null) {
+                       return new String(field.getPassword());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return info.getString(item, false);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               JPasswordField field = fields.get(getField(item));
+               if (field != null) {
+                       field.setText(value == null ? "" : value.toString());
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setString((String) value, item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(int item) {
+               JPanel pane = new JPanel(new BorderLayout());
+               final JPasswordField field = new JPasswordField();
+               field.setEchoChar('*');
+
+               final JButton show = new JButton();
+               final Boolean[] visible = new Boolean[] { false };
+               setImage(show, img64passProtected, "/");
+               show.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               visible[0] = !visible[0];
+                               if (visible[0]) {
+                                       field.setEchoChar((char) 0);
+                                       setImage(show, img64passUnprotected, "o");
+                               } else {
+                                       field.setEchoChar('*');
+                                       setImage(show, img64passProtected, "/");
+                               }
+                       }
+               });
+
+               pane.add(field, BorderLayout.CENTER);
+               pane.add(show, BorderLayout.EAST);
+
+               fields.put(pane, field);
+               return pane;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ConfigItemString.java b/src/be/nikiroo/utils/ui/ConfigItemString.java
new file mode 100644 (file)
index 0000000..99a8cc3
--- /dev/null
@@ -0,0 +1,53 @@
+package be.nikiroo.utils.ui;
+
+import javax.swing.JComponent;
+import javax.swing.JTextField;
+
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemString<E extends Enum<E>> extends ConfigItem<E> {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Create a new {@link ConfigItemString} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemString(MetaInfo<E> info) {
+               super(info, true);
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               JTextField field = (JTextField) getField(item);
+               if (field != null) {
+                       return field.getText();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return info.getString(item, false);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               JTextField field = (JTextField) getField(item);
+               if (field != null) {
+                       field.setText(value == null ? "" : value.toString());
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               info.setString((String) value, item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(int item) {
+               return new JTextField();
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ImageTextAwt.java b/src/be/nikiroo/utils/ui/ImageTextAwt.java
new file mode 100644 (file)
index 0000000..4c0c824
--- /dev/null
@@ -0,0 +1,512 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Image;
+import java.awt.image.BufferedImage;
+import java.awt.image.ImageObserver;
+
+/**
+ * This class converts an {@link Image} into a textual representation that can
+ * be displayed to the user in a TUI.
+ * 
+ * @author niki
+ */
+public class ImageTextAwt {
+       private Image image;
+       private Dimension size;
+       private String text;
+       private boolean ready;
+       private Mode mode;
+       private boolean invert;
+
+       /**
+        * The rendering modes supported by this {@link ImageTextAwt} to convert
+        * {@link Image}s into text.
+        * 
+        * @author niki
+        * 
+        */
+       public enum Mode {
+               /**
+                * Use 5 different "colours" which are actually Unicode
+                * {@link Character}s representing
+                * <ul>
+                * <li>space (blank)</li>
+                * <li>low shade (░)</li>
+                * <li>medium shade (▒)</li>
+                * <li>high shade (▓)</li>
+                * <li>full block (█)</li>
+                * </ul>
+                */
+               DITHERING,
+               /**
+                * Use "block" Unicode {@link Character}s up to quarter blocks, thus in
+                * effect doubling the resolution both in vertical and horizontal space.
+                * Note that since 2 {@link Character}s next to each other are square,
+                * we will use 4 blocks per 2 blocks for w/h resolution.
+                */
+               DOUBLE_RESOLUTION,
+               /**
+                * Use {@link Character}s from both {@link Mode#DOUBLE_RESOLUTION} and
+                * {@link Mode#DITHERING}.
+                */
+               DOUBLE_DITHERING,
+               /**
+                * Only use ASCII {@link Character}s.
+                */
+               ASCII,
+       }
+
+       /**
+        * Create a new {@link ImageTextAwt} with the given parameters. Defaults to
+        * {@link Mode#DOUBLE_DITHERING} and no colour inversion.
+        * 
+        * @param image
+        *            the source {@link Image}
+        * @param size
+        *            the final text size to target
+        */
+       public ImageTextAwt(Image image, Dimension size) {
+               this(image, size, Mode.DOUBLE_DITHERING, false);
+       }
+
+       /**
+        * Create a new {@link ImageTextAwt} with the given parameters.
+        * 
+        * @param image
+        *            the source {@link Image}
+        * @param size
+        *            the final text size to target
+        * @param mode
+        *            the mode of conversion
+        * @param invert
+        *            TRUE to invert colours rendering
+        */
+       public ImageTextAwt(Image image, Dimension size, Mode mode, boolean invert) {
+               setImage(image);
+               setSize(size);
+               setMode(mode);
+               setColorInvert(invert);
+       }
+
+       /**
+        * Change the source {@link Image}.
+        * 
+        * @param image
+        *            the new {@link Image}
+        */
+       public void setImage(Image image) {
+               this.text = null;
+               this.ready = false;
+               this.image = image;
+       }
+
+       /**
+        * Change the target size of this {@link ImageTextAwt}.
+        * 
+        * @param size
+        *            the new size
+        */
+       public void setSize(Dimension size) {
+               this.text = null;
+               this.ready = false;
+               this.size = size;
+       }
+
+       /**
+        * Change the image-to-text mode.
+        * 
+        * @param mode
+        *            the new {@link Mode}
+        */
+       public void setMode(Mode mode) {
+               this.mode = mode;
+               this.text = null;
+               this.ready = false;
+       }
+
+       /**
+        * Set the colour-invert mode.
+        * 
+        * @param invert
+        *            TRUE to inverse the colours
+        */
+       public void setColorInvert(boolean invert) {
+               this.invert = invert;
+               this.text = null;
+               this.ready = false;
+       }
+
+       /**
+        * Check if the colours are inverted.
+        * 
+        * @return TRUE if the colours are inverted
+        */
+       public boolean isColorInvert() {
+               return invert;
+       }
+
+       /**
+        * Return the textual representation of the included {@link Image}.
+        * 
+        * @return the {@link String} representation
+        */
+       public String getText() {
+               if (text == null) {
+                       if (image == null || size == null || size.width == 0
+                                       || size.height == 0) {
+                               return "";
+                       }
+
+                       int mult = 1;
+                       if (mode == Mode.DOUBLE_RESOLUTION || mode == Mode.DOUBLE_DITHERING) {
+                               mult = 2;
+                       }
+
+                       Dimension srcSize = getSize(image);
+                       srcSize = new Dimension(srcSize.width * 2, srcSize.height);
+                       int x = 0;
+                       int y = 0;
+
+                       int w = size.width * mult;
+                       int h = size.height * mult;
+
+                       // Default = original ratio or original size if none
+                       if (w < 0 || h < 0) {
+                               if (w < 0 && h < 0) {
+                                       w = srcSize.width * mult;
+                                       h = srcSize.height * mult;
+                               } else {
+                                       double ratioSrc = (double) srcSize.width
+                                                       / (double) srcSize.height;
+                                       if (w < 0) {
+                                               w = (int) Math.round(h * ratioSrc);
+                                       } else {
+                                               h = (int) Math.round(w / ratioSrc);
+                                       }
+                               }
+                       }
+
+                       // Fail safe: we consider this to be too much
+                       if (w > 1000 || h > 1000) {
+                               return "[IMAGE TOO BIG]";
+                       }
+
+                       BufferedImage buff = new BufferedImage(w, h,
+                                       BufferedImage.TYPE_INT_ARGB);
+
+                       Graphics gfx = buff.getGraphics();
+
+                       double ratioAsked = (double) (w) / (double) (h);
+                       double ratioSrc = (double) srcSize.height / (double) srcSize.width;
+                       double ratio = ratioAsked * ratioSrc;
+                       if (srcSize.width < srcSize.height) {
+                               h = (int) Math.round(ratio * h);
+                               y = (buff.getHeight() - h) / 2;
+                       } else {
+                               w = (int) Math.round(w / ratio);
+                               x = (buff.getWidth() - w) / 2;
+                       }
+
+                       if (gfx.drawImage(image, x, y, w, h, new ImageObserver() {
+                               @Override
+                               public boolean imageUpdate(Image img, int infoflags, int x,
+                                               int y, int width, int height) {
+                                       ImageTextAwt.this.ready = true;
+                                       return true;
+                               }
+                       })) {
+                               ready = true;
+                       }
+
+                       while (!ready) {
+                               try {
+                                       Thread.sleep(100);
+                               } catch (InterruptedException e) {
+                               }
+                       }
+
+                       gfx.dispose();
+
+                       StringBuilder builder = new StringBuilder();
+
+                       for (int row = 0; row + (mult - 1) < buff.getHeight(); row += mult) {
+                               if (row > 0) {
+                                       builder.append('\n');
+                               }
+
+                               for (int col = 0; col + (mult - 1) < buff.getWidth(); col += mult) {
+                                       if (mult == 1) {
+                                               char car = ' ';
+                                               float brightness = getBrightness(buff.getRGB(col, row));
+                                               if (mode == Mode.DITHERING)
+                                                       car = getDitheringChar(brightness, " ░▒▓█");
+                                               if (mode == Mode.ASCII)
+                                                       car = getDitheringChar(brightness, " .-+=o8#");
+
+                                               builder.append(car);
+                                       } else if (mult == 2) {
+                                               builder.append(getBlockChar( //
+                                                               buff.getRGB(col, row),//
+                                                               buff.getRGB(col + 1, row),//
+                                                               buff.getRGB(col, row + 1),//
+                                                               buff.getRGB(col + 1, row + 1),//
+                                                               mode == Mode.DOUBLE_DITHERING//
+                                               ));
+                                       }
+                               }
+                       }
+
+                       text = builder.toString();
+               }
+
+               return text;
+       }
+
+       @Override
+       public String toString() {
+               return getText();
+       }
+
+       /**
+        * Return the size of the given {@link Image}.
+        * 
+        * @param img
+        *            the image to measure
+        * 
+        * @return the size
+        */
+       static private Dimension getSize(Image img) {
+               Dimension size = null;
+               while (size == null) {
+                       int w = img.getWidth(null);
+                       int h = img.getHeight(null);
+                       if (w > -1 && h > -1) {
+                               size = new Dimension(w, h);
+                       } else {
+                               try {
+                                       Thread.sleep(100);
+                               } catch (InterruptedException e) {
+                               }
+                       }
+               }
+
+               return size;
+       }
+
+       /**
+        * Return the {@link Character} corresponding to the given brightness level
+        * from the evenly-separated given {@link Character}s.
+        * 
+        * @param brightness
+        *            the brightness level
+        * @param cars
+        *            the {@link Character}s to choose from, from less bright to
+        *            most bright; <b>MUST</b> contain at least one
+        *            {@link Character}
+        * 
+        * @return the {@link Character} to use
+        */
+       private char getDitheringChar(float brightness, String cars) {
+               int index = Math.round(brightness * (cars.length() - 1));
+               return cars.charAt(index);
+       }
+
+       /**
+        * Return the {@link Character} corresponding to the 4 given colours in
+        * {@link Mode#DOUBLE_RESOLUTION} or {@link Mode#DOUBLE_DITHERING} mode.
+        * 
+        * @param upperleft
+        *            the upper left colour
+        * @param upperright
+        *            the upper right colour
+        * @param lowerleft
+        *            the lower left colour
+        * @param lowerright
+        *            the lower right colour
+        * @param dithering
+        *            TRUE to use {@link Mode#DOUBLE_DITHERING}, FALSE for
+        *            {@link Mode#DOUBLE_RESOLUTION}
+        * 
+        * @return the {@link Character} to use
+        */
+       private char getBlockChar(int upperleft, int upperright, int lowerleft,
+                       int lowerright, boolean dithering) {
+               int choice = 0;
+
+               if (getBrightness(upperleft) > 0.5f) {
+                       choice += 1;
+               }
+               if (getBrightness(upperright) > 0.5f) {
+                       choice += 2;
+               }
+               if (getBrightness(lowerleft) > 0.5f) {
+                       choice += 4;
+               }
+               if (getBrightness(lowerright) > 0.5f) {
+                       choice += 8;
+               }
+
+               switch (choice) {
+               case 0:
+                       return ' ';
+               case 1:
+                       return '▘';
+               case 2:
+                       return '▝';
+               case 3:
+                       return '▀';
+               case 4:
+                       return '▖';
+               case 5:
+                       return '▌';
+               case 6:
+                       return '▞';
+               case 7:
+                       return '▛';
+               case 8:
+                       return '▗';
+               case 9:
+                       return '▚';
+               case 10:
+                       return '▐';
+               case 11:
+                       return '▜';
+               case 12:
+                       return '▄';
+               case 13:
+                       return '▙';
+               case 14:
+                       return '▟';
+               case 15:
+                       if (dithering) {
+                               float avg = 0;
+                               avg += getBrightness(upperleft);
+                               avg += getBrightness(upperright);
+                               avg += getBrightness(lowerleft);
+                               avg += getBrightness(lowerright);
+                               avg /= 4;
+
+                               // Since all the quarters are > 0.5, avg is between 0.5 and 1.0
+                               // So, expand the range of the value
+                               avg = (avg - 0.5f) * 2;
+
+                               // Do not use the " " char, as it would make a
+                               // "all quarters > 0.5" pixel go black
+                               return getDitheringChar(avg, "░▒▓█");
+                       }
+
+                       return '█';
+               }
+
+               return ' ';
+       }
+
+       /**
+        * Temporary array used so not to create a lot of new ones.
+        */
+       private float[] tmp = new float[4];
+
+       /**
+        * Return the brightness value to use from the given ARGB colour.
+        * 
+        * @param argb
+        *            the argb colour
+        * 
+        * @return the brightness to sue for computations
+        */
+       private float getBrightness(int argb) {
+               if (invert) {
+                       return 1 - rgb2hsb(argb, tmp)[2];
+               }
+
+               return rgb2hsb(argb, tmp)[2];
+       }
+
+       /**
+        * Convert the given ARGB colour in HSL/HSB, either into the supplied array
+        * or into a new one if array is NULL.
+        * 
+        * <p>
+        * ARGB pixels are given in 0xAARRGGBB format, while the returned array will
+        * contain Hue, Saturation, Lightness/Brightness, Alpha, in this order. H,
+        * S, L and A are all ranging from 0 to 1 (indeed, H is in 1/360th).
+        * </p>
+        * pixel
+        * 
+        * @param argb
+        *            the ARGB colour pixel to convert
+        * @param array
+        *            the array to convert into or NULL to create a new one
+        * 
+        * @return the array containing the HSL/HSB converted colour
+        */
+       static float[] rgb2hsb(int argb, float[] array) {
+               int a, r, g, b;
+               a = ((argb & 0xff000000) >> 24);
+               r = ((argb & 0x00ff0000) >> 16);
+               g = ((argb & 0x0000ff00) >> 8);
+               b = ((argb & 0x000000ff));
+
+               if (array == null) {
+                       array = new float[4];
+               }
+
+               Color.RGBtoHSB(r, g, b, array);
+
+               array[3] = a;
+
+               return array;
+
+               // // other implementation:
+               //
+               // float a, r, g, b;
+               // a = ((argb & 0xff000000) >> 24) / 255.0f;
+               // r = ((argb & 0x00ff0000) >> 16) / 255.0f;
+               // g = ((argb & 0x0000ff00) >> 8) / 255.0f;
+               // b = ((argb & 0x000000ff)) / 255.0f;
+               //
+               // float rgbMin, rgbMax;
+               // rgbMin = Math.min(r, Math.min(g, b));
+               // rgbMax = Math.max(r, Math.max(g, b));
+               //
+               // float l;
+               // l = (rgbMin + rgbMax) / 2;
+               //
+               // float s;
+               // if (rgbMin == rgbMax) {
+               // s = 0;
+               // } else {
+               // if (l <= 0.5) {
+               // s = (rgbMax - rgbMin) / (rgbMax + rgbMin);
+               // } else {
+               // s = (rgbMax - rgbMin) / (2.0f - rgbMax - rgbMin);
+               // }
+               // }
+               //
+               // float h;
+               // if (r > g && r > b) {
+               // h = (g - b) / (rgbMax - rgbMin);
+               // } else if (g > b) {
+               // h = 2.0f + (b - r) / (rgbMax - rgbMin);
+               // } else {
+               // h = 4.0f + (r - g) / (rgbMax - rgbMin);
+               // }
+               // h /= 6; // from 0 to 1
+               //
+               // return new float[] { h, s, l, a };
+               //
+               // // // natural mode:
+               // //
+               // // int aa = (int) Math.round(100 * a);
+               // // int hh = (int) (360 * h);
+               // // if (hh < 0)
+               // // hh += 360;
+               // // int ss = (int) Math.round(100 * s);
+               // // int ll = (int) Math.round(100 * l);
+               // //
+               // // return new int[] { hh, ss, ll, aa };
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ImageUtilsAwt.java b/src/be/nikiroo/utils/ui/ImageUtilsAwt.java
new file mode 100644 (file)
index 0000000..4cf12c0
--- /dev/null
@@ -0,0 +1,180 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.geom.AffineTransform;
+import java.awt.image.AffineTransformOp;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.imageio.ImageIO;
+
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class offer some utilities based around images and uses java.awt.
+ * 
+ * @author niki
+ */
+public class ImageUtilsAwt extends ImageUtils {
+       @Override
+       protected boolean check() {
+               // Will not work if ImageIO is not available
+               ImageIO.getCacheDirectory();
+               return true;
+       }
+
+       @Override
+       public void saveAsImage(Image img, File target, String format)
+                       throws IOException {
+               try {
+                       BufferedImage image = fromImage(img);
+
+                       boolean ok = false;
+                       try {
+
+                               ok = ImageIO.write(image, format, target);
+                       } catch (IOException e) {
+                               ok = false;
+                       }
+
+                       // Some formats are not reliable
+                       // Second chance: PNG
+                       if (!ok && !format.equals("png")) {
+                               try {
+                                       ok = ImageIO.write(image, "png", target);
+                               } catch (IllegalArgumentException e) {
+                                       throw e;
+                               } catch (Exception e) {
+                                       throw new IOException("Undocumented exception occured, "
+                                                       + "converting to IOException", e);
+                               }
+                       }
+
+                       if (!ok) {
+                               throw new IOException(
+                                               "Cannot find a writer for this image and format: "
+                                                               + format);
+                       }
+               } catch (IOException e) {
+                       throw new IOException("Cannot write image to " + target, e);
+               }
+       }
+
+       /**
+        * Convert the given {@link Image} into a {@link BufferedImage} object,
+        * respecting the EXIF transformations if any.
+        * 
+        * @param img
+        *            the {@link Image}
+        * 
+        * @return the {@link Image} object
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       public static BufferedImage fromImage(Image img) throws IOException {
+               InputStream in = img.newInputStream();
+               BufferedImage image;
+               try {
+                       int orientation;
+                       try {
+                               orientation = getExifTransorm(in);
+                       } catch (Exception e) {
+                               // no EXIF transform, ok
+                               orientation = -1;
+                       }
+
+                       in.reset();
+
+                       try {
+                               image = ImageIO.read(in);
+                       } catch (IllegalArgumentException e) {
+                               throw e;
+                       } catch (Exception e) {
+                               throw new IOException("Undocumented exception occured, "
+                                               + "converting to IOException", e);
+                       }
+
+                       if (image == null) {
+                               String extra = "";
+                               if (img.getSize() <= 2048) {
+                                       try {
+                                               extra = ", content: "
+                                                               + new String(img.getData(), "UTF-8");
+                                       } catch (Exception e) {
+                                               extra = ", content unavailable";
+                                       }
+                               }
+                               String ssize = StringUtils.formatNumber(img.getSize());
+                               throw new IOException(
+                                               "Failed to convert input to image, size was: " + ssize
+                                                               + extra);
+                       }
+
+                       // 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:
+                               affineTransform = null;
+                               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(width,
+                                               height, image.getType());
+                               transformedImage = affineTransformOp.filter(image,
+                                               transformedImage);
+
+                               image = transformedImage;
+                       }
+                       //
+               } finally {
+                       in.close();
+               }
+
+               return image;
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ProgressBar.java b/src/be/nikiroo/utils/ui/ProgressBar.java
new file mode 100644 (file)
index 0000000..219cde9
--- /dev/null
@@ -0,0 +1,183 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+import javax.swing.SwingUtilities;
+
+import be.nikiroo.utils.Progress;
+
+/**
+ * A graphical control to show the progress of a {@link Progress}.
+ * <p>
+ * This control is <b>NOT</b> thread-safe.
+ * 
+ * @author niki
+ */
+public class ProgressBar extends JPanel {
+       private static final long serialVersionUID = 1L;
+
+       private Map<Progress, JProgressBar> bars;
+       private List<ActionListener> actionListeners;
+       private List<ActionListener> updateListeners;
+       private Progress pg;
+       private Object lock = new Object();
+
+       public ProgressBar() {
+               bars = new HashMap<Progress, JProgressBar>();
+               actionListeners = new ArrayList<ActionListener>();
+               updateListeners = new ArrayList<ActionListener>();
+       }
+
+       public void setProgress(final Progress pg) {
+               this.pg = pg;
+
+               SwingUtilities.invokeLater(new Runnable() {
+                       @Override
+                       public void run() {
+                               if (pg != null) {
+                                       final JProgressBar bar = new JProgressBar();
+                                       bar.setStringPainted(true);
+
+                                       bars.clear();
+                                       bars.put(pg, bar);
+
+                                       bar.setMinimum(pg.getMin());
+                                       bar.setMaximum(pg.getMax());
+                                       bar.setValue(pg.getProgress());
+                                       bar.setString(pg.getName());
+
+                                       pg.addProgressListener(new Progress.ProgressListener() {
+                                               @Override
+                                               public void progress(Progress progress, String name) {
+                                                       final Progress.ProgressListener l = this;
+                                                       SwingUtilities.invokeLater(new Runnable() {
+                                                               @Override
+                                                               public void run() {
+                                                                       Map<Progress, JProgressBar> newBars = new HashMap<Progress, JProgressBar>();
+                                                                       newBars.put(pg, bar);
+
+                                                                       bar.setMinimum(pg.getMin());
+                                                                       bar.setMaximum(pg.getMax());
+                                                                       bar.setValue(pg.getProgress());
+                                                                       bar.setString(pg.getName());
+
+                                                                       synchronized (lock) {
+                                                                       for (Progress pgChild : getChildrenAsOrderedList(pg)) {
+                                                                               JProgressBar barChild = bars
+                                                                                               .get(pgChild);
+                                                                               if (barChild == null) {
+                                                                                       barChild = new JProgressBar();
+                                                                                       barChild.setStringPainted(true);
+                                                                               }
+
+                                                                               newBars.put(pgChild, barChild);
+
+                                                                               barChild.setMinimum(pgChild.getMin());
+                                                                               barChild.setMaximum(pgChild.getMax());
+                                                                               barChild.setValue(pgChild.getProgress());
+                                                                               barChild.setString(pgChild.getName());
+                                                                       }
+                                                                       
+                                                                       if (ProgressBar.this.pg == null) {
+                                                                               bars.clear();
+                                                                       } else {
+                                                                               bars = newBars;
+                                                                       }
+                                                                       }
+                                                                       
+                                                                       if (ProgressBar.this.pg != null) {
+                                                                               if (pg.isDone()) {
+                                                                                       pg.removeProgressListener(l);
+                                                                                       for (ActionListener listener : actionListeners) {
+                                                                                               listener.actionPerformed(new ActionEvent(
+                                                                                                               ProgressBar.this, 0,
+                                                                                                               "done"));
+                                                                                       }
+                                                                               }
+
+                                                                               update();
+                                                                       }
+                                                               }
+                                                       });
+                                               }
+                                       });
+                               }
+
+                               update();
+                       }
+               });
+       }
+
+       public void addActionListener(ActionListener l) {
+               actionListeners.add(l);
+       }
+
+       public void clearActionListeners() {
+               actionListeners.clear();
+       }
+
+       public void addUpdateListener(ActionListener l) {
+               updateListeners.add(l);
+       }
+
+       public void clearUpdateListeners() {
+               updateListeners.clear();
+       }
+
+       public int getProgress() {
+               if (pg == null) {
+                       return 0;
+               }
+
+               return pg.getProgress();
+       }
+
+       // only named ones
+       private List<Progress> getChildrenAsOrderedList(Progress pg) {
+               List<Progress> children = new ArrayList<Progress>();
+               
+               synchronized (lock) {
+                       for (Progress child : pg.getChildren()) {
+                       if (child.getName() != null && !child.getName().isEmpty()) {
+                               children.add(child);
+                       }
+                       children.addAll(getChildrenAsOrderedList(child));
+               }
+               }
+               
+               return children;
+       }
+
+       private void update() {
+               synchronized (lock) {
+                       invalidate();
+                       removeAll();
+
+                       if (pg != null) {
+                               setLayout(new GridLayout(bars.size(), 1));
+                               add(bars.get(pg), 0);
+                               for (Progress child : getChildrenAsOrderedList(pg)) {
+                                       JProgressBar jbar = bars.get(child);
+                                       if (jbar != null) {
+                                               add(jbar);
+                                       }
+                               }
+                       }
+
+                       validate();
+                       repaint();
+               }
+
+               for (ActionListener listener : updateListeners) {
+                       listener.actionPerformed(new ActionEvent(this, 0, "update"));
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/UIUtils.java b/src/be/nikiroo/utils/ui/UIUtils.java
new file mode 100644 (file)
index 0000000..24cbf64
--- /dev/null
@@ -0,0 +1,118 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Color;
+import java.awt.GradientPaint;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Paint;
+import java.awt.RadialGradientPaint;
+import java.awt.RenderingHints;
+
+import javax.swing.UIManager;
+import javax.swing.UnsupportedLookAndFeelException;
+
+/**
+ * Some Java Swing utilities.
+ * 
+ * @author niki
+ */
+public class UIUtils {
+       /**
+        * Set a fake "native look &amp; feel" for the application if possible
+        * (check for the one currently in use, then try GTK).
+        * <p>
+        * <b>Must</b> be called prior to any GUI work.
+        */
+       static public void setLookAndFeel() {
+               // native look & feel
+               try {
+                       String noLF = "javax.swing.plaf.metal.MetalLookAndFeel";
+                       String lf = UIManager.getSystemLookAndFeelClassName();
+                       if (lf.equals(noLF))
+                               lf = "com.sun.java.swing.plaf.gtk.GTKLookAndFeel";
+                       UIManager.setLookAndFeel(lf);
+               } catch (InstantiationException e) {
+               } catch (ClassNotFoundException e) {
+               } catch (UnsupportedLookAndFeelException e) {
+               } catch (IllegalAccessException e) {
+               }
+       }
+
+       /**
+        * Draw a 3D-looking ellipse at the given location, if the given
+        * {@link Graphics} object is compatible (with {@link Graphics2D}); draw a
+        * simple ellipse if not.
+        * 
+        * @param g
+        *            the {@link Graphics} to draw on
+        * @param color
+        *            the base colour
+        * @param x
+        *            the X coordinate
+        * @param y
+        *            the Y coordinate
+        * @param width
+        *            the width radius
+        * @param height
+        *            the height radius
+        */
+       static public void drawEllipse3D(Graphics g, Color color, int x, int y,
+                       int width, int height) {
+               if (g instanceof Graphics2D) {
+                       Graphics2D g2 = (Graphics2D) g;
+                       g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                                       RenderingHints.VALUE_ANTIALIAS_ON);
+
+                       // Retains the previous state
+                       Paint oldPaint = g2.getPaint();
+
+                       // Base shape
+                       g2.setColor(color);
+                       g2.fillOval(x, y, width, height);
+
+                       // Compute dark/bright colours
+                       Paint p = null;
+                       Color dark = color.darker();
+                       Color bright = color.brighter();
+                       Color darkEnd = new Color(dark.getRed(), dark.getGreen(),
+                                       dark.getBlue(), 0);
+                       Color darkPartial = new Color(dark.getRed(), dark.getGreen(),
+                                       dark.getBlue(), 64);
+                       Color brightEnd = new Color(bright.getRed(), bright.getGreen(),
+                                       bright.getBlue(), 0);
+
+                       // Adds shadows at the bottom left
+                       p = new GradientPaint(0, height, dark, width, 0, darkEnd);
+                       g2.setPaint(p);
+                       g2.fillOval(x, y, width, height);
+
+                       // Adds highlights at the top right
+                       p = new GradientPaint(width, 0, bright, 0, height, brightEnd);
+                       g2.setPaint(p);
+                       g2.fillOval(x, y, width, height);
+
+                       // Darken the edges
+                       p = new RadialGradientPaint(x + width / 2f, y + height / 2f,
+                                       Math.min(width / 2f, height / 2f), new float[] { 0f, 1f },
+                                       new Color[] { darkEnd, darkPartial },
+                                       RadialGradientPaint.CycleMethod.NO_CYCLE);
+                       g2.setPaint(p);
+                       g2.fillOval(x, y, width, height);
+
+                       // Adds inner highlight at the top right
+                       p = new RadialGradientPaint(x + 3f * width / 4f, y + height / 4f,
+                                       Math.min(width / 4f, height / 4f),
+                                       new float[] { 0.0f, 0.8f },
+                                       new Color[] { bright, brightEnd },
+                                       RadialGradientPaint.CycleMethod.NO_CYCLE);
+                       g2.setPaint(p);
+                       g2.fillOval(x * 2, y, width, height);
+
+                       // Reset original paint
+                       g2.setPaint(oldPaint);
+               } else {
+                       g.setColor(color);
+                       g.fillOval(x, y, width, height);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/WrapLayout.java b/src/be/nikiroo/utils/ui/WrapLayout.java
new file mode 100644 (file)
index 0000000..7f34d79
--- /dev/null
@@ -0,0 +1,205 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Insets;
+
+import javax.swing.JScrollPane;
+import javax.swing.SwingUtilities;
+
+/**
+ * FlowLayout subclass that fully supports wrapping of components.
+ * 
+ * @author https://tips4java.wordpress.com/2008/11/06/wrap-layout/
+ */
+public class WrapLayout extends FlowLayout {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Constructs a new <code>WrapLayout</code> with a left alignment and a
+        * default 5-unit horizontal and vertical gap.
+        */
+       public WrapLayout() {
+               super();
+       }
+
+       /**
+        * Constructs a new <code>FlowLayout</code> with the specified alignment and
+        * a default 5-unit horizontal and vertical gap. The value of the alignment
+        * argument must be one of <code>WrapLayout</code>, <code>WrapLayout</code>,
+        * or <code>WrapLayout</code>.
+        * 
+        * @param align
+        *            the alignment value
+        */
+       public WrapLayout(int align) {
+               super(align);
+       }
+
+       /**
+        * Creates a new flow layout manager with the indicated alignment and the
+        * indicated horizontal and vertical gaps.
+        * <p>
+        * The value of the alignment argument must be one of
+        * <code>WrapLayout</code>, <code>WrapLayout</code>, or
+        * <code>WrapLayout</code>.
+        * 
+        * @param align
+        *            the alignment value
+        * @param hgap
+        *            the horizontal gap between components
+        * @param vgap
+        *            the vertical gap between components
+        */
+       public WrapLayout(int align, int hgap, int vgap) {
+               super(align, hgap, vgap);
+       }
+
+       /**
+        * Returns the preferred dimensions for this layout given the <i>visible</i>
+        * components in the specified target container.
+        * 
+        * @param target
+        *            the component which needs to be laid out
+        * @return the preferred dimensions to lay out the subcomponents of the
+        *         specified container
+        */
+       @Override
+       public Dimension preferredLayoutSize(Container target) {
+               return layoutSize(target, true);
+       }
+
+       /**
+        * Returns the minimum dimensions needed to layout the <i>visible</i>
+        * components contained in the specified target container.
+        * 
+        * @param target
+        *            the component which needs to be laid out
+        * @return the minimum dimensions to lay out the subcomponents of the
+        *         specified container
+        */
+       @Override
+       public Dimension minimumLayoutSize(Container target) {
+               Dimension minimum = layoutSize(target, false);
+               minimum.width -= (getHgap() + 1);
+               return minimum;
+       }
+
+       /**
+        * Returns the minimum or preferred dimension needed to layout the target
+        * container.
+        *
+        * @param target
+        *            target to get layout size for
+        * @param preferred
+        *            should preferred size be calculated
+        * @return the dimension to layout the target container
+        */
+       private Dimension layoutSize(Container target, boolean preferred) {
+               synchronized (target.getTreeLock()) {
+                       // Each row must fit with the width allocated to the containter.
+                       // When the container width = 0, the preferred width of the
+                       // container
+                       // has not yet been calculated so lets ask for the maximum.
+
+                       int targetWidth = target.getSize().width;
+                       Container container = target;
+
+                       while (container.getSize().width == 0
+                                       && container.getParent() != null) {
+                               container = container.getParent();
+                       }
+
+                       targetWidth = container.getSize().width;
+
+                       if (targetWidth == 0)
+                               targetWidth = Integer.MAX_VALUE;
+
+                       int hgap = getHgap();
+                       int vgap = getVgap();
+                       Insets insets = target.getInsets();
+                       int horizontalInsetsAndGap = insets.left + insets.right
+                                       + (hgap * 2);
+                       int maxWidth = targetWidth - horizontalInsetsAndGap;
+
+                       // Fit components into the allowed width
+
+                       Dimension dim = new Dimension(0, 0);
+                       int rowWidth = 0;
+                       int rowHeight = 0;
+
+                       int nmembers = target.getComponentCount();
+
+                       for (int i = 0; i < nmembers; i++) {
+                               Component m = target.getComponent(i);
+
+                               if (m.isVisible()) {
+                                       Dimension d = preferred ? m.getPreferredSize() : m
+                                                       .getMinimumSize();
+
+                                       // Can't add the component to current row. Start a new
+                                       // row.
+
+                                       if (rowWidth + d.width > maxWidth) {
+                                               addRow(dim, rowWidth, rowHeight);
+                                               rowWidth = 0;
+                                               rowHeight = 0;
+                                       }
+
+                                       // Add a horizontal gap for all components after the
+                                       // first
+
+                                       if (rowWidth != 0) {
+                                               rowWidth += hgap;
+                                       }
+
+                                       rowWidth += d.width;
+                                       rowHeight = Math.max(rowHeight, d.height);
+                               }
+                       }
+
+                       addRow(dim, rowWidth, rowHeight);
+
+                       dim.width += horizontalInsetsAndGap;
+                       dim.height += insets.top + insets.bottom + vgap * 2;
+
+                       // When using a scroll pane or the DecoratedLookAndFeel we need
+                       // to
+                       // make sure the preferred size is less than the size of the
+                       // target containter so shrinking the container size works
+                       // correctly. Removing the horizontal gap is an easy way to do
+                       // this.
+
+                       Container scrollPane = SwingUtilities.getAncestorOfClass(
+                                       JScrollPane.class, target);
+
+                       if (scrollPane != null && target.isValid()) {
+                               dim.width -= (hgap + 1);
+                       }
+
+                       return dim;
+               }
+       }
+
+       /*
+        * A new row has been completed. Use the dimensions of this row to update
+        * the preferred size for the container.
+        * 
+        * @param dim update the width and height when appropriate
+        * 
+        * @param rowWidth the width of the row to add
+        * 
+        * @param rowHeight the height of the row to add
+        */
+       private void addRow(Dimension dim, int rowWidth, int rowHeight) {
+               dim.width = Math.max(dim.width, rowWidth);
+
+               if (dim.height > 0) {
+                       dim.height += getVgap();
+               }
+
+               dim.height += rowHeight;
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java b/src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java
new file mode 100644 (file)
index 0000000..b416cbc
--- /dev/null
@@ -0,0 +1,82 @@
+package be.nikiroo.utils.ui.test;
+
+import java.awt.BorderLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.JButton;
+import javax.swing.JFrame;
+
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.ui.ProgressBar;
+
+public class ProgressBarManualTest extends JFrame {
+       private static final long serialVersionUID = 1L;
+       private int i = 0;
+
+       public ProgressBarManualTest() {
+               final ProgressBar bar = new ProgressBar();
+               final Progress pg = new Progress("name");
+               final Progress pg2 = new Progress("second level", 0, 2);
+               final Progress pg3 = new Progress("third level");
+
+               setLayout(new BorderLayout());
+               this.add(bar, BorderLayout.SOUTH);
+
+               final JButton b = new JButton("Set pg to 10%");
+               b.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               switch (i) {
+                               case 0:
+                                       pg.setProgress(10);
+                                       pg2.setProgress(0);
+                                       b.setText("Set pg to 20%");
+                                       break;
+                               case 1:
+                                       pg.setProgress(20);
+                                       b.setText("Add pg2 (0-2)");
+                                       break;
+                               case 2:
+                                       pg.addProgress(pg2, 80);
+                                       pg2.setProgress(0);
+                                       b.setText("Add pg3 (0-100)");
+                                       break;
+                               case 3:
+                                       pg2.addProgress(pg3, 2);
+                                       pg3.setProgress(0);
+                                       b.setText("Set pg3 to 10%");
+                                       break;
+                               case 4:
+                                       pg3.setProgress(10);
+                                       b.setText("Set pg3 to 20%");
+                                       break;
+                               case 5:
+                                       pg3.setProgress(20);
+                                       b.setText("Set pg3 to 60%");
+                                       break;
+                               case 6:
+                                       pg3.setProgress(60);
+                                       b.setText("Set pg3 to 100%");
+                                       break;
+                               case 7:
+                                       pg3.setProgress(100);
+                                       b.setText("[done]");
+                                       break;
+                               }
+
+                               i++;
+                       }
+               });
+               this.add(b, BorderLayout.CENTER);
+
+               setSize(800, 600);
+               setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+
+               bar.setProgress(pg);
+       }
+
+       public static void main(String[] args) {
+               new ProgressBarManualTest().setVisible(true);
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/test/TestUI.java b/src/be/nikiroo/utils/ui/test/TestUI.java
new file mode 100644 (file)
index 0000000..c260295
--- /dev/null
@@ -0,0 +1,8 @@
+package be.nikiroo.utils.ui.test;
+
+public class TestUI {
+       // TODO: make a GUI tester
+       public TestUI() {
+               ProgressBarManualTest a = new ProgressBarManualTest();
+       }
+}