Merge branch 'subtree'
authorNiki Roo <niki@nikiroo.be>
Thu, 14 May 2020 13:35:20 +0000 (15:35 +0200)
committerNiki Roo <niki@nikiroo.be>
Thu, 14 May 2020 13:35:20 +0000 (15:35 +0200)
248 files changed:
Cache.java [new file with mode: 0644]
CacheMemory.java [new file with mode: 0644]
CookieUtils.java [new file with mode: 0644]
CryptUtils.java [new file with mode: 0644]
DataLoader.java [deleted file]
Downloader.java [new file with mode: 0644]
HashUtils.java [new file with mode: 0644]
IOUtils.java [new file with mode: 0644]
Image.java [new file with mode: 0644]
ImageUtils.java [new file with mode: 0644]
Instance.java [deleted file]
LICENSE [deleted file]
LoginResult.java [new file with mode: 0644]
Main.java [deleted file]
MarkableFileInputStream.java [new file with mode: 0644]
NanoHTTPD.java [new file with mode: 0644]
Progress.java [new file with mode: 0644]
Proxy.java [new file with mode: 0644]
StringJustifier.java [new file with mode: 0644]
StringUtils.java [new file with mode: 0644]
TempFiles.java [new file with mode: 0644]
TraceHandler.java [new file with mode: 0644]
VERSION [deleted file]
Version.java [new file with mode: 0644]
VersionCheck.java [new file with mode: 0644]
android/ImageUtilsAndroid.class [new file with mode: 0644]
android/ImageUtilsAndroid.java [new file with mode: 0644]
android/test/TestAndroid.class [new file with mode: 0644]
android/test/TestAndroid.java [new file with mode: 0644]
bundles/Config.java [deleted file]
bundles/ConfigBundle.java [deleted file]
bundles/StringId.java [deleted file]
bundles/StringIdBundle.java [deleted file]
bundles/StringIdGui.java [deleted file]
bundles/StringIdGuiBundle.java [deleted file]
bundles/Target.java [deleted file]
bundles/UiConfig.java [deleted file]
bundles/UiConfigBundle.java [deleted file]
bundles/UiConfigBundleDesc.java [deleted file]
bundles/package-info.java [deleted file]
bundles/resources_core.properties [deleted file]
bundles/resources_core_fr.properties [deleted file]
bundles/resources_gui.properties [deleted file]
bundles/resources_gui_fr.properties [deleted file]
bundles/ui_description.properties [deleted file]
data/Chapter.java [deleted file]
data/JsonIO.java [deleted file]
data/MetaData.java [deleted file]
data/Paragraph.java [deleted file]
data/Story.java [deleted file]
data/package-info.java [deleted file]
derename.sh [deleted file]
library/BasicLibrary.java [deleted file]
library/CacheLibrary.java [deleted file]
library/LocalLibrary.java [deleted file]
library/MetaResultList.java [deleted file]
library/RemoteLibrary.java [deleted file]
library/RemoteLibraryException.java [deleted file]
library/RemoteLibraryServer.java [deleted file]
library/WebLibrary.java [deleted file]
library/WebLibraryServer.java [deleted file]
library/WebLibraryServerHtml.java [deleted file]
library/WebLibraryUrls.java [deleted file]
library/package-info.java [deleted file]
library/web/WebLibraryServerIndex.java [deleted file]
library/web/actual_size-32x32.png [deleted file]
library/web/actual_size-64x64.png [deleted file]
library/web/arrow_double_left-32x32.png [deleted file]
library/web/arrow_double_left-64x64.png [deleted file]
library/web/arrow_double_right-32x32.png [deleted file]
library/web/arrow_double_right-64x64.png [deleted file]
library/web/arrow_left-32x32.png [deleted file]
library/web/arrow_left-64x64.png [deleted file]
library/web/arrow_right-32x32.png [deleted file]
library/web/arrow_right-64x64.png [deleted file]
library/web/avicon.ico [deleted file]
library/web/back-32x32.png [deleted file]
library/web/back-64x64.png [deleted file]
library/web/clear-32x32.png [deleted file]
library/web/clear.png [deleted file]
library/web/favicon.ico [deleted file]
library/web/fit_to_height-32x32.png [deleted file]
library/web/fit_to_height-64x64.png [deleted file]
library/web/fit_to_width-32x32.png [deleted file]
library/web/fit_to_width-64x64.png [deleted file]
library/web/icon_alternative.png [deleted file]
library/web/icon_default.png [deleted file]
library/web/icon_magic_book.png [deleted file]
library/web/icon_pony_book.png [deleted file]
library/web/icon_pony_library.png [deleted file]
library/web/index.post.html [deleted file]
library/web/index.pre.html [deleted file]
library/web/search-32x32.png [deleted file]
library/web/search-64x64.png [deleted file]
library/web/style.css [deleted file]
library/web/unknown-32x32.png [deleted file]
library/web/unknown-64x64.png [deleted file]
libs.txt [deleted file]
main/bridge.java [new file with mode: 0644]
main/img2aa.java [new file with mode: 0644]
main/justify.java [new file with mode: 0644]
output/BasicOutput.java [deleted file]
output/Cbz.java [deleted file]
output/Epub.java [deleted file]
output/Html.java [deleted file]
output/InfoCover.java [deleted file]
output/InfoText.java [deleted file]
output/LaTeX.java [deleted file]
output/Sysout.java [deleted file]
output/Text.java [deleted file]
output/epub.style.css [deleted file]
output/html.style.css [deleted file]
output/package-info.java [deleted file]
package-info.java [deleted file]
reader/BasicReader.java [deleted file]
reader/CliReader.java [deleted file]
reader/TextOutput.java [deleted file]
resources/Bundle.java [new file with mode: 0644]
resources/BundleHelper.java [new file with mode: 0644]
resources/Bundles.java [new file with mode: 0644]
resources/FixedResourceBundleControl.java [new file with mode: 0644]
resources/Meta.java [new file with mode: 0644]
resources/MetaInfo.java [new file with mode: 0644]
resources/TransBundle.java [new file with mode: 0644]
resources/TransBundle_ResourceList.java [new file with mode: 0644]
resources/package-info.java [new file with mode: 0644]
searchable/BasicSearchable.java [deleted file]
searchable/Fanfiction.java [deleted file]
searchable/MangaLel.java [deleted file]
searchable/SearchableTag.java [deleted file]
serial/CustomSerializer.java [new file with mode: 0644]
serial/Exporter.java [new file with mode: 0644]
serial/Importer.java [new file with mode: 0644]
serial/SerialUtils.java [new file with mode: 0644]
serial/server/ConnectAction.java [new file with mode: 0644]
serial/server/ConnectActionClient.java [new file with mode: 0644]
serial/server/ConnectActionClientObject.java [new file with mode: 0644]
serial/server/ConnectActionClientString.java [new file with mode: 0644]
serial/server/ConnectActionServer.java [new file with mode: 0644]
serial/server/ConnectActionServerObject.java [new file with mode: 0644]
serial/server/ConnectActionServerString.java [new file with mode: 0644]
serial/server/Server.java [new file with mode: 0644]
serial/server/ServerBridge.java [new file with mode: 0644]
serial/server/ServerObject.java [new file with mode: 0644]
serial/server/ServerString.java [new file with mode: 0644]
streams/Base64.java [new file with mode: 0644]
streams/Base64InputStream.java [new file with mode: 0644]
streams/Base64OutputStream.java [new file with mode: 0644]
streams/BufferedInputStream.java [new file with mode: 0644]
streams/BufferedOutputStream.java [new file with mode: 0644]
streams/MarkableFileInputStream.java [new file with mode: 0644]
streams/NextableInputStream.java [new file with mode: 0644]
streams/NextableInputStreamStep.java [new file with mode: 0644]
streams/ReplaceInputStream.java [new file with mode: 0644]
streams/ReplaceOutputStream.java [new file with mode: 0644]
streams/StreamUtils.java [new file with mode: 0644]
subtree.txt [deleted file]
supported/BasicSupport.java [deleted file]
supported/BasicSupportHelper.java [deleted file]
supported/BasicSupportImages.java [deleted file]
supported/BasicSupportPara.java [deleted file]
supported/BasicSupport_Deprecated.java [deleted file]
supported/Cbz.java [deleted file]
supported/E621.java [deleted file]
supported/EHentai.java [deleted file]
supported/Epub.java [deleted file]
supported/Fanfiction.java [deleted file]
supported/Fimfiction.java [deleted file]
supported/FimfictionApi.java [deleted file]
supported/Html.java [deleted file]
supported/InfoReader.java [deleted file]
supported/InfoText.java [deleted file]
supported/MangaHub.java [deleted file]
supported/MangaLel.java [deleted file]
supported/SupportType.java [deleted file]
supported/Text.java [deleted file]
supported/YiffStar.java [deleted file]
supported/package-info.java [deleted file]
test/BasicSupportDeprecatedTest.java [deleted file]
test/BasicSupportUtilitiesTest.java [deleted file]
test/ConversionTest.java [deleted file]
test/LibraryTest.java [deleted file]
test/Test.java [deleted file]
test/TestCase.java [new file with mode: 0644]
test/TestLauncher.java [new file with mode: 0644]
test/expected_test.story/cbz.cbz [deleted file]
test/expected_test.story/epub.epub [deleted file]
test/expected_test.story/html/html.info [deleted file]
test/expected_test.story/html/html.txt [deleted file]
test/expected_test.story/html/index.html [deleted file]
test/expected_test.story/html/style.css [deleted file]
test/expected_test.story/info_text [deleted file]
test/expected_test.story/info_text.info [deleted file]
test/expected_test.story/latex.tex [deleted file]
test/expected_test.story/text.txt [deleted file]
test/test.story [deleted file]
test_code/BufferedInputStreamTest.java [new file with mode: 0644]
test_code/BufferedOutputStreamTest.java [new file with mode: 0644]
test_code/BundleTest.java [new file with mode: 0644]
test_code/CryptUtilsTest.java [new file with mode: 0644]
test_code/IOUtilsTest.java [new file with mode: 0644]
test_code/NextableInputStreamTest.java [new file with mode: 0644]
test_code/ProgressTest.java [new file with mode: 0644]
test_code/ReplaceInputStreamTest.java [new file with mode: 0644]
test_code/ReplaceOutputStreamTest.java [new file with mode: 0644]
test_code/SerialServerTest.java [new file with mode: 0644]
test_code/SerialTest.java [new file with mode: 0644]
test_code/StringUtilsTest.java [new file with mode: 0644]
test_code/TempFilesTest.java [new file with mode: 0644]
test_code/Test.java [new file with mode: 0644]
test_code/VersionTest.java [new file with mode: 0644]
test_code/bundle_test.properties [new file with mode: 0644]
ui/BreadCrumbsBar.java [new file with mode: 0644]
ui/ConfigEditor.java [new file with mode: 0644]
ui/ConfigItem.java [new file with mode: 0644]
ui/ConfigItemBase.java [new file with mode: 0644]
ui/ConfigItemBoolean.java [new file with mode: 0644]
ui/ConfigItemBrowse.java [new file with mode: 0644]
ui/ConfigItemColor.java [new file with mode: 0644]
ui/ConfigItemCombobox.java [new file with mode: 0644]
ui/ConfigItemInteger.java [new file with mode: 0644]
ui/ConfigItemLocale.java [new file with mode: 0644]
ui/ConfigItemPassword.java [new file with mode: 0644]
ui/ConfigItemString.java [new file with mode: 0644]
ui/DataNode.java [new file with mode: 0644]
ui/DataTree.java [new file with mode: 0644]
ui/DelayWorker.java [new file with mode: 0644]
ui/ImageTextAwt.java [new file with mode: 0644]
ui/ImageUtilsAwt.java [new file with mode: 0644]
ui/ListModel.java [new file with mode: 0644]
ui/ListSnapshot.java [new file with mode: 0644]
ui/ListenerItem.java [new file with mode: 0644]
ui/ListenerPanel.java [new file with mode: 0644]
ui/NavBar.java [new file with mode: 0644]
ui/ProgressBar.java [new file with mode: 0644]
ui/TreeCellSpanner.java [new file with mode: 0644]
ui/TreeModelTransformer.java [new file with mode: 0644]
ui/TreeSnapshot.java [new file with mode: 0644]
ui/UIUtils.java [new file with mode: 0644]
ui/WaitingDialog.java [new file with mode: 0644]
ui/WrapLayout.java [new file with mode: 0644]
ui/ZoomBox.java [new file with mode: 0644]
ui/compat/DefaultListModel6.java [new file with mode: 0644]
ui/compat/JList6.java [new file with mode: 0644]
ui/compat/ListCellRenderer6.java [new file with mode: 0644]
ui/compat/ListModel6.java [new file with mode: 0644]
ui/test/ProgressBarManualTest.java [new file with mode: 0644]
ui/test/TestUI.java [new file with mode: 0644]

diff --git a/Cache.java b/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/CacheMemory.java b/CacheMemory.java
new file mode 100644 (file)
index 0000000..de4fae3
--- /dev/null
@@ -0,0 +1,124 @@
+package be.nikiroo.utils;
+
+import java.io.ByteArrayInputStream;
+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;
+
+       /**
+        * Create a new {@link CacheMemory}.
+        */
+       public CacheMemory() {
+               data = new HashMap<String, byte[]>();
+       }
+
+       @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(getKey(uniqueID));
+               }
+
+               return null;
+       }
+
+       @Override
+       public InputStream load(URL url, boolean allowTooOld, boolean stable) {
+               if (check(url, allowTooOld, stable)) {
+                       return load(getKey(url));
+               }
+
+               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 "UID:" + uniqueID;
+       }
+
+       /**
+        * Return a key mapping to the given urm.
+        * 
+        * @param url the url
+        * 
+        * @return the key
+        */
+       private String getKey(URL url) {
+               return "URL:" + url.toString();
+       }
+
+       /**
+        * Load the given key.
+        * 
+        * @param key the key to load
+        * @return the loaded data
+        */
+       private InputStream load(String key) {
+               byte[] data = this.data.get(key);
+               if (data != null) {
+                       return new ByteArrayInputStream(data);
+               }
+
+               return null;
+       }
+}
diff --git a/CookieUtils.java b/CookieUtils.java
new file mode 100644 (file)
index 0000000..8d307a2
--- /dev/null
@@ -0,0 +1,62 @@
+package be.nikiroo.utils;
+
+import java.util.Date;
+
+/**
+ * Some utilities for cookie management.
+ * 
+ * @author niki
+ */
+public class CookieUtils {
+       /**
+        * The number of seconds for the period (we accept the current or the
+        * previous period as valid for a cookie, via "offset").
+        */
+       static public int GRACE_PERIOD = 3600 * 1000; // between 1 and 2h
+
+       /**
+        * Generate a new cookie value from the user (email) and an offset.
+        * <p>
+        * You should use an offset of "0" when creating the cookie, and an offset
+        * of "0" or "-1" if required when checking for the value (the idea is to
+        * allow a cookie to persist across two timespans; if not, the cookie will
+        * be expired the very second we switch to a new timespan).
+        * 
+        * @param value
+        *            the value to generate a cookie for -- you must be able to
+        *            regenerate it in order to check it later
+        * @param offset
+        *            the offset (should be 0 for creating, 0 then -1 if needed for
+        *            checking)
+        * 
+        * @return the new cookie
+        */
+       static public String generateCookie(String value, int offset) {
+               long unixTime = (long) Math.floor(new Date().getTime() / GRACE_PERIOD)
+                               + offset;
+               return HashUtils.sha512(value + Long.toString(unixTime));
+       }
+
+       /**
+        * Check the given cookie.
+        * 
+        * @param value
+        *            the value to generate a cookie for -- you must be able to
+        *            regenerate it in order to check it later
+        * @param cookie
+        *            the cookie to validate
+        * 
+        * @return TRUE if it is correct
+        */
+       static public boolean validateCookie(String value, String cookie) {
+               if (cookie != null)
+                       cookie = cookie.trim();
+
+               String newCookie = generateCookie(value, 0);
+               if (!newCookie.equals(cookie)) {
+                       newCookie = generateCookie(value, -1);
+               }
+
+               return newCookie.equals(cookie);
+       }
+}
diff --git a/CryptUtils.java b/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/DataLoader.java b/DataLoader.java
deleted file mode 100644 (file)
index 901e8da..0000000
+++ /dev/null
@@ -1,396 +0,0 @@
-package be.nikiroo.fanfix;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.Map;
-
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.supported.BasicSupport;
-import be.nikiroo.utils.Cache;
-import be.nikiroo.utils.CacheMemory;
-import be.nikiroo.utils.Downloader;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.ImageUtils;
-import be.nikiroo.utils.TraceHandler;
-
-/**
- * This cache will manage Internet (and local) downloads, as well as put the
- * downloaded files into a cache.
- * <p>
- * As long the cached resource is not too old, it will use it instead of
- * retrieving the file again.
- * 
- * @author niki
- */
-public class DataLoader {
-       private Downloader downloader;
-       private Downloader downloaderNoCache;
-       private Cache cache;
-       private boolean offline;
-
-       /**
-        * Create a new {@link DataLoader} object.
-        * 
-        * @param dir
-        *            the directory to use as cache
-        * @param UA
-        *            the User-Agent to use to download the resources
-        * @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 LARGE 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 DataLoader(File dir, String UA, int hoursChanging, int hoursStable)
-                       throws IOException {
-               downloader = new Downloader(UA, new Cache(dir, hoursChanging,
-                               hoursStable));
-               downloaderNoCache = new Downloader(UA);
-
-               cache = downloader.getCache();
-       }
-
-       /**
-        * Create a new {@link DataLoader} object without disk cache (will keep a
-        * memory cache for manual cache operations).
-        * 
-        * @param UA
-        *            the User-Agent to use to download the resources
-        */
-       public DataLoader(String UA) {
-               downloader = new Downloader(UA);
-               downloaderNoCache = downloader;
-               cache = new CacheMemory();
-       }
-       
-       /**
-        * This {@link Downloader} is forbidden to try and connect to the network.
-        * <p>
-        * If TRUE, it will only check the cache (even in no-cache mode!).
-        * <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 (even in no-cache mode!).
-        * <p>
-        * Default is FALSE.
-        * 
-        * @param offline TRUE for offline, FALSE for online
-        */
-       public void setOffline(boolean offline) {
-               this.offline = offline;
-               downloader.setOffline(offline);
-               downloaderNoCache.setOffline(offline);
-               
-               // If we don't, we cannot support no-cache using code in OFFLINE mode
-               if (offline) {
-                       downloaderNoCache.setCache(cache);
-               } else {
-                       downloaderNoCache.setCache(null);
-               }
-       }
-
-       /**
-        * The traces handler for this {@link Cache}.
-        * 
-        * @param tracer
-        *            the new traces handler
-        */
-       public void setTraceHandler(TraceHandler tracer) {
-               downloader.setTraceHandler(tracer);
-               downloaderNoCache.setTraceHandler(tracer);
-               cache.setTraceHandler(tracer);
-               if (downloader.getCache() != null) {
-                       downloader.getCache().setTraceHandler(tracer);
-               }
-
-       }
-
-       /**
-        * Open a resource (will load it from the cache if possible, or save it into
-        * the cache after downloading if not).
-        * <p>
-        * The cached resource will be assimilated to the given original {@link URL}
-        * 
-        * @param url
-        *            the resource to open
-        * @param support
-        *            the support to use to download the resource (can be NULL)
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @return the opened resource, NOT NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public InputStream open(URL url, BasicSupport support, boolean stable)
-                       throws IOException {
-               return open(url, url, support, stable, null, null, null);
-       }
-
-       /**
-        * Open a resource (will load it from the cache if possible, or save it into
-        * the cache after downloading if not).
-        * <p>
-        * The cached resource will be assimilated to the given original {@link URL}
-        * 
-        * @param url
-        *            the resource 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 support
-        *            the support to use to download the resource
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @return the opened resource, NOT NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public InputStream open(URL url, URL originalUrl, BasicSupport support,
-                       boolean stable) throws IOException {
-               return open(url, originalUrl, support, stable, null, null, null);
-       }
-
-       /**
-        * Open a resource (will load it from the cache if possible, or save it into
-        * the cache after downloading if not).
-        * <p>
-        * The cached resource will be assimilated to the given original {@link URL}
-        * 
-        * @param url
-        *            the resource 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 support
-        *            the support to use to download the resource (can be NULL)
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * @param postParams
-        *            the POST parameters
-        * @param getParams
-        *            the GET parameters (priority over POST)
-        * @param oauth
-        *            OAuth authorization (aka, "bearer XXXXXXX")
-        * 
-        * @return the opened resource, NOT NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public InputStream open(URL url, URL originalUrl, BasicSupport support,
-                       boolean stable, Map<String, String> postParams,
-                       Map<String, String> getParams, String oauth) throws IOException {
-
-               Map<String, String> cookiesValues = null;
-               URL currentReferer = url;
-
-               if (support != null) {
-                       cookiesValues = support.getCookies();
-                       currentReferer = support.getCurrentReferer();
-                       // priority: arguments
-                       if (oauth == null) {
-                               oauth = support.getOAuth();
-                       }
-               }
-
-               return downloader.open(url, originalUrl, currentReferer, cookiesValues,
-                               postParams, getParams, oauth, stable);
-       }
-
-       /**
-        * Open the given {@link URL} without using the cache, but still using and
-        * updating the cookies.
-        * 
-        * @param url
-        *            the {@link URL} to open
-        * @param support
-        *            the {@link BasicSupport} used for 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
-        */
-       public InputStream openNoCache(URL url, BasicSupport support,
-                       Map<String, String> postParams, Map<String, String> getParams,
-                       String oauth) throws IOException {
-
-               Map<String, String> cookiesValues = null;
-               URL currentReferer = url;
-               if (support != null) {
-                       cookiesValues = support.getCookies();
-                       currentReferer = support.getCurrentReferer();
-                       // priority: arguments
-                       if (oauth == null) {
-                               oauth = support.getOAuth();
-                       }
-               }
-
-               return downloaderNoCache.open(url, currentReferer, cookiesValues,
-                               postParams, getParams, oauth);
-       }
-
-       /**
-        * Refresh the resource into cache if needed.
-        * 
-        * @param url
-        *            the resource to open
-        * @param support
-        *            the support to use to download the resource (can be NULL)
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public void refresh(URL url, BasicSupport support, boolean stable)
-                       throws IOException {
-               if (!check(url, stable)) {
-                       open(url, url, support, stable, null, null, null).close();
-               }
-       }
-
-       /**
-        * Check the resource to see if it is in the cache.
-        * 
-        * @param url
-        *            the resource to check
-        * @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 stable) {
-               return downloader.getCache() != null
-                               && downloader.getCache().check(url, false, stable);
-       }
-
-       /**
-        * Save the given resource as an image on disk using the default image
-        * format for content or cover -- will automatically add the extension, too.
-        * 
-        * @param img
-        *            the resource
-        * @param target
-        *            the target file without extension
-        * @param cover
-        *            use the cover image format instead of the content image format
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public void saveAsImage(Image img, File target, boolean cover)
-                       throws IOException {
-               String format;
-               if (cover) {
-                       format = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-               } else {
-                       format = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_CONTENT)
-                                       .toLowerCase();
-               }
-               saveAsImage(img, new File(target.toString() + "." + format), format);
-       }
-
-       /**
-        * 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 void saveAsImage(Image img, File target, String format)
-                       throws IOException {
-               ImageUtils.getInstance().saveAsImage(img, target, format);
-       }
-
-       /**
-        * Manually add this item to the cache.
-        * 
-        * @param in
-        *            the input data
-        * @param uniqueID
-        *            a unique ID for this resource
-        * 
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public void addToCache(InputStream in, String uniqueID) throws IOException {
-               cache.save(in, uniqueID);
-       }
-
-       /**
-        * Return the {@link InputStream} corresponding to the given unique ID, or
-        * NULL if none found.
-        * 
-        * @param uniqueID
-        *            the unique ID
-        * 
-        * @return the content or NULL
-        */
-       public InputStream getFromCache(String uniqueID) {
-               return cache.load(uniqueID, true, true);
-       }
-
-       /**
-        * 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 removeFromCache(String uniqueID) {
-               return cache.remove(uniqueID);
-       }
-
-       /**
-        * Clean the cache (delete the cached items).
-        * 
-        * @param onlyOld
-        *            only clean the files that are considered too old
-        * 
-        * @return the number of cleaned items
-        */
-       public int cleanCache(boolean onlyOld) {
-               return cache.clean(onlyOld);
-       }
-}
diff --git a/Downloader.java b/Downloader.java
new file mode 100644 (file)
index 0000000..4191d0a
--- /dev/null
@@ -0,0 +1,480 @@
+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 (!) -- can be NULL
+        */
+       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 (!) -- can be NULL
+        * @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);
+               }
+
+               if (UA != null) {
+                       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/HashUtils.java b/HashUtils.java
new file mode 100644 (file)
index 0000000..df8d7c6
--- /dev/null
@@ -0,0 +1,89 @@
+package be.nikiroo.utils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Small class to easily hash some values in a few different ways.
+ * <p>
+ * Does <b>not</b> handle the salt itself, you have to add it yourself.
+ * 
+ * @author niki
+ */
+public class HashUtils {
+       /**
+        * Hash the given value.
+        * 
+        * @param value
+        *            the value to hash
+        * 
+        * @return the hash that can be used to confirm a value
+        * 
+        * @throws RuntimeException
+        *             if UTF-8 support is not available (!) or SHA-512 support is
+        *             not available
+        * @throws NullPointerException
+        *             if email or pass is NULL
+        */
+       static public String sha512(String value) {
+               return hash("SHA-512", value);
+       }
+
+       /**
+        * Hash the given value.
+        * 
+        * @param value
+        *            the value to hash
+        * 
+        * @return the hash that can be used to confirm the a value
+        * 
+        * @throws RuntimeException
+        *             if UTF-8 support is not available (!) or MD5 support is not
+        *             available
+        * @throws NullPointerException
+        *             if email or pass is NULL
+        */
+       static public String md5(String value) {
+               return hash("MD5", value);
+       }
+
+       /**
+        * Hash the given value.
+        * 
+        * @param algo
+        *            the hash algorithm to use ("MD5" and "SHA-512" are supported)
+        * @param value
+        *            the value to hash
+        * 
+        * @return the hash that can be used to confirm a value
+        * 
+        * @throws RuntimeException
+        *             if UTF-8 support is not available (!) or the algorithm
+        *             support is not available
+        * @throws NullPointerException
+        *             if email or pass is NULL
+        */
+       static private String hash(String algo, String value) {
+               try {
+                       MessageDigest md = MessageDigest.getInstance(algo);
+                       md.update(value.getBytes("UTF-8"));
+                       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() % 2 == 1)
+                                       hexString.append('0');
+                               hexString.append(hex);
+                       }
+
+                       return hexString.toString();
+               } catch (NoSuchAlgorithmException e) {
+                       throw new RuntimeException(algo + " hashing not available", e);
+               } catch (UnsupportedEncodingException e) {
+                       throw new RuntimeException(
+                                       "UTF-8 encoding is required in a compatible JVM", e);
+               }
+       }
+}
diff --git a/IOUtils.java b/IOUtils.java
new file mode 100644 (file)
index 0000000..3d252ea
--- /dev/null
@@ -0,0 +1,493 @@
+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 resource next to the given {@link Class}.
+        * 
+        * @param location
+        *            the location where to look for the resource
+        * @param name
+        *            the resource name (only the filename, no path)
+        * 
+        * @return the opened resource if found, NULL if not
+        */
+       public static InputStream openResource(
+                       @SuppressWarnings("rawtypes") Class location, String name) {
+               String loc = location.getName().replace(".", "/")
+                               .replaceAll("/[^/]*$", "/");
+               return openResource(loc + name);
+       }
+
+       /**
+        * Open the given /-separated resource (from the binary root).
+        * 
+        * @param name
+        *            the resource name (the full path, with "/" as separator)
+        * 
+        * @return the opened resource if found, NULL 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/Image.java b/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/ImageUtils.java b/ImageUtils.java
new file mode 100644 (file)
index 0000000..877c8fa
--- /dev/null
@@ -0,0 +1,266 @@
+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;
+
+       /**
+        * Scale a dimension.
+        * 
+        * 
+        * @param imageWidth
+        *            the actual image width
+        * @param imageHeight
+        *            the actual image height
+        * @param areaWidth
+        *            the base width of the target dimension for snap sizes
+        * @param areaHeight
+        *            the base height of the target dimension for snap sizes
+        * @param zoom
+        *            the zoom factor (ignored on snap mode)
+        * @param snapMode
+        *            NULL for no snap mode, TRUE to snap to width and FALSE for
+        *            snap to height)
+        * 
+        * @return the scaled size, width is [0] and height is [1] (minimum is 1x1)
+        */
+       protected static Integer[] scaleSize(int imageWidth, int imageHeight,
+                       int areaWidth, int areaHeight, double zoom, Boolean snapMode) {
+               int width;
+               int height;
+               if (snapMode == null) {
+                       width = (int) Math.round(imageWidth * zoom);
+                       height = (int) Math.round(imageHeight * zoom);
+               } else if (snapMode) {
+                       width = areaWidth;
+                       height = (int) Math
+                                       .round((((double) areaWidth) / imageWidth) * imageHeight);
+               } else {
+                       height = areaHeight;
+                       width = (int) Math
+                                       .round((((double) areaHeight) / imageHeight) * imageWidth);
+
+               }
+
+               if (width < 1)
+                       width = 1;
+               if (height < 1)
+                       height = 1;
+
+               return new Integer[] { width, height };
+       }
+       
+       /**
+        * 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/Instance.java b/Instance.java
deleted file mode 100644 (file)
index e0a0727..0000000
+++ /dev/null
@@ -1,704 +0,0 @@
-package be.nikiroo.fanfix;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.Date;
-
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.ConfigBundle;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.bundles.StringIdBundle;
-import be.nikiroo.fanfix.bundles.StringIdGuiBundle;
-import be.nikiroo.fanfix.bundles.UiConfig;
-import be.nikiroo.fanfix.bundles.UiConfigBundle;
-import be.nikiroo.fanfix.library.BasicLibrary;
-import be.nikiroo.fanfix.library.CacheLibrary;
-import be.nikiroo.fanfix.library.LocalLibrary;
-import be.nikiroo.fanfix.library.RemoteLibrary;
-import be.nikiroo.fanfix.library.WebLibrary;
-import be.nikiroo.utils.Cache;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Proxy;
-import be.nikiroo.utils.TempFiles;
-import be.nikiroo.utils.TraceHandler;
-import be.nikiroo.utils.resources.Bundles;
-
-/**
- * Global state for the program (services and singletons).
- * 
- * @author niki
- */
-public class Instance {
-       static private Instance instance;
-       static private Object instancelock = new Object();
-
-       private ConfigBundle config;
-       private UiConfigBundle uiconfig;
-       private StringIdBundle trans;
-       private DataLoader cache;
-       private StringIdGuiBundle transGui;
-       private BasicLibrary lib;
-       private File coverDir;
-       private File readerTmp;
-       private File remoteDir;
-       private String configDir;
-       private TraceHandler tracer;
-       private TempFiles tempFiles;
-
-       /**
-        * Initialise the instance -- if already initialised, nothing will happen.
-        * <p>
-        * Before calling this method, you may call
-        * {@link Bundles#setDirectory(String)} if wanted.
-        * <p>
-        * Note that this method will honour some environment variables, the 3 most
-        * important ones probably being:
-        * <ul>
-        * <li><tt>DEBUG</tt>: will enable DEBUG output if set to 1 (or Y or TRUE or
-        * ON, case insensitive)</li>
-        * <li><tt>CONFIG_DIR</tt>: will use this directory as configuration
-        * directory (supports $HOME notation, defaults to $HOME/.fanfix</li>
-        * <li><tt>BOOKS_DIR</tt>: will use this directory as library directory
-        * (supports $HOME notation, defaults to $HOME/Books</li>
-        * </ul>
-        */
-       static public void init() {
-               init(false);
-       }
-
-       /**
-        * Initialise the instance -- if already initialised, nothing will happen
-        * unless you pass TRUE to <tt>force</tt>.
-        * <p>
-        * Before calling this method, you may call
-        * {@link Bundles#setDirectory(String)} if wanted.
-        * <p>
-        * Note: forcing the initialisation can be dangerous, so make sure to only
-        * make it under controlled circumstances -- for instance, at the start of
-        * the program, you could call {@link Instance#init()}, change some settings
-        * because you want to force those settings (it will also forbid users to
-        * change them!) and then call {@link Instance#init(boolean)} with
-        * <tt>force</tt> set to TRUE.
-        * 
-        * @param force
-        *            force the initialisation even if already initialised
-        */
-       static public void init(boolean force) {
-               synchronized (instancelock) {
-                       if (instance == null || force) {
-                               instance = new Instance();
-                       }
-               }
-
-       }
-
-       /**
-        * Force-initialise the {@link Instance} to a known value.
-        * <p>
-        * Usually for DEBUG/Test purposes.
-        * 
-        * @param instance
-        *            the actual Instance to use
-        */
-       static public void init(Instance instance) {
-               Instance.instance = instance;
-       }
-
-       /**
-        * The (mostly unique) instance of this {@link Instance}.
-        * 
-        * @return the (mostly unique) instance
-        */
-       public static Instance getInstance() {
-               return instance;
-       }
-
-       /**
-        * Actually initialise the instance.
-        * <p>
-        * Before calling this method, you may call
-        * {@link Bundles#setDirectory(String)} if wanted.
-        */
-       protected Instance() {
-               // Before we can configure it:
-               Boolean debug = checkEnv("DEBUG");
-               boolean trace = debug != null && debug;
-               tracer = new TraceHandler(true, trace, trace);
-
-               // config dir:
-               configDir = getConfigDir();
-               if (!new File(configDir).exists()) {
-                       new File(configDir).mkdirs();
-               }
-
-               // Most of the rest is dependent upon this:
-               createConfigs(configDir, false);
-
-               // Proxy support
-               Proxy.use(config.getString(Config.NETWORK_PROXY));
-
-               // update tracer:
-               if (debug == null) {
-                       debug = config.getBoolean(Config.DEBUG_ERR, false);
-                       trace = config.getBoolean(Config.DEBUG_TRACE, false);
-               }
-
-               tracer = new TraceHandler(true, debug, trace);
-
-               // default Library
-               remoteDir = new File(configDir, "remote");
-               lib = createDefaultLibrary(remoteDir);
-
-               // create cache and TMP
-               File tmp = getFile(Config.CACHE_DIR, configDir, "tmp");
-               Image.setTemporaryFilesRoot(new File(tmp.getParent(), "tmp.images"));
-
-               String ua = config.getString(Config.NETWORK_USER_AGENT, "");
-               try {
-                       int hours = config.getInteger(Config.CACHE_MAX_TIME_CHANGING, 0);
-                       int hoursLarge = config.getInteger(Config.CACHE_MAX_TIME_STABLE, 0);
-                       cache = new DataLoader(tmp, ua, hours, hoursLarge);
-               } catch (IOException e) {
-                       tracer.error(new IOException(
-                                       "Cannot create cache (will continue without cache)", e));
-                       cache = new DataLoader(ua);
-               }
-
-               cache.setTraceHandler(tracer);
-
-               // readerTmp / coverDir
-               readerTmp = getFile(UiConfig.CACHE_DIR_LOCAL_READER, configDir,
-                               "tmp-reader");
-               coverDir = getFile(Config.DEFAULT_COVERS_DIR, configDir, "covers");
-               coverDir.mkdirs();
-
-               try {
-                       tempFiles = new TempFiles("fanfix");
-               } catch (IOException e) {
-                       tracer.error(
-                                       new IOException("Cannot create temporary directory", e));
-               }
-       }
-
-       /**
-        * The traces handler for this {@link Cache}.
-        * <p>
-        * It is never NULL.
-        * 
-        * @return the traces handler (never NULL)
-        */
-       public TraceHandler getTraceHandler() {
-               return tracer;
-       }
-
-       /**
-        * The traces handler for this {@link Cache}.
-        * 
-        * @param tracer
-        *            the new traces handler or NULL
-        */
-       public void setTraceHandler(TraceHandler tracer) {
-               if (tracer == null) {
-                       tracer = new TraceHandler(false, false, false);
-               }
-
-               this.tracer = tracer;
-               cache.setTraceHandler(tracer);
-       }
-
-       /**
-        * Get the (unique) configuration service for the program.
-        * 
-        * @return the configuration service
-        */
-       public ConfigBundle getConfig() {
-               return config;
-       }
-
-       /**
-        * Get the (unique) UI configuration service for the program.
-        * 
-        * @return the configuration service
-        */
-       public UiConfigBundle getUiConfig() {
-               return uiconfig;
-       }
-
-       /**
-        * Reset the configuration.
-        * 
-        * @param resetTrans
-        *            also reset the translation files
-        */
-       public void resetConfig(boolean resetTrans) {
-               String dir = Bundles.getDirectory();
-               Bundles.setDirectory(null);
-               try {
-                       try {
-                               ConfigBundle config = new ConfigBundle();
-                               config.updateFile(configDir);
-                       } catch (IOException e) {
-                               tracer.error(e);
-                       }
-                       try {
-                               UiConfigBundle uiconfig = new UiConfigBundle();
-                               uiconfig.updateFile(configDir);
-                       } catch (IOException e) {
-                               tracer.error(e);
-                       }
-
-                       if (resetTrans) {
-                               try {
-                                       StringIdBundle trans = new StringIdBundle(null);
-                                       trans.updateFile(configDir);
-                               } catch (IOException e) {
-                                       tracer.error(e);
-                               }
-                       }
-               } finally {
-                       Bundles.setDirectory(dir);
-               }
-       }
-
-       /**
-        * Get the (unique) {@link DataLoader} for the program.
-        * 
-        * @return the {@link DataLoader}
-        */
-       public DataLoader getCache() {
-               return cache;
-       }
-
-       /**
-        * Get the (unique) {link StringIdBundle} for the program.
-        * <p>
-        * This is used for the translations of the core parts of Fanfix.
-        * 
-        * @return the {link StringIdBundle}
-        */
-       public StringIdBundle getTrans() {
-               return trans;
-       }
-
-       /**
-        * Get the (unique) {link StringIdGuiBundle} for the program.
-        * <p>
-        * This is used for the translations of the GUI parts of Fanfix.
-        * 
-        * @return the {link StringIdGuiBundle}
-        */
-       public StringIdGuiBundle getTransGui() {
-               return transGui;
-       }
-
-       /**
-        * Get the (unique) {@link BasicLibrary} for the program.
-        * 
-        * @return the {@link BasicLibrary}
-        */
-       public BasicLibrary getLibrary() {
-               if (lib == null) {
-                       throw new NullPointerException("We don't have a library to return");
-               }
-
-               return lib;
-       }
-
-       /**
-        * Change the default {@link BasicLibrary} for this program.
-        * <p>
-        * Be careful.
-        * 
-        * @param lib
-        *            the new {@link BasicLibrary}
-        */
-       public void setLibrary(BasicLibrary lib) {
-               this.lib = lib;
-       }
-
-       /**
-        * Return the directory where to look for default cover pages.
-        * 
-        * @return the default covers directory
-        */
-       public File getCoverDir() {
-               return coverDir;
-       }
-
-       /**
-        * Return the directory where to store temporary files for the local reader.
-        * 
-        * @return the directory
-        */
-       public File getReaderDir() {
-               return readerTmp;
-       }
-
-       /**
-        * Return the directory where to store temporary files for the remote
-        * {@link LocalLibrary}.
-        * 
-        * @param host
-        *            the remote for this host
-        * 
-        * @return the directory
-        */
-       public File getRemoteDir(String host) {
-               return getRemoteDir(remoteDir, host);
-       }
-
-       /**
-        * Return the directory where to store temporary files for the remote
-        * {@link LocalLibrary}.
-        * 
-        * @param remoteDir
-        *            the base remote directory
-        * @param host
-        *            the remote for this host
-        * 
-        * @return the directory
-        */
-       private File getRemoteDir(File remoteDir, String host) {
-               remoteDir.mkdirs();
-
-               if (host != null) {
-                       host = host.replace("fanfix://", "");
-                       host = host.replace("http://", "");
-                       host = host.replace("https://", "");
-                       host = host.replaceAll("[^a-zA-Z0-9=+.-]", "_");
-                       
-                       return new File(remoteDir, host);
-               }
-
-               return remoteDir;
-       }
-
-       /**
-        * Check if we need to check that a new version of Fanfix is available.
-        * 
-        * @return TRUE if we need to
-        */
-       public boolean isVersionCheckNeeded() {
-               try {
-                       long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0)
-                                       * 24 * 60 * 60 * 1000;
-                       if (wait >= 0) {
-                               String lastUpString = IOUtils
-                                               .readSmallFile(new File(configDir, "LAST_UPDATE"));
-                               long delay = new Date().getTime()
-                                               - Long.parseLong(lastUpString);
-                               if (delay > wait) {
-                                       return true;
-                               }
-                       } else {
-                               return false;
-                       }
-               } catch (Exception e) {
-                       // No file or bad file:
-                       return true;
-               }
-
-               return false;
-       }
-
-       /**
-        * Notify that we checked for a new version of Fanfix.
-        */
-       public void setVersionChecked() {
-               try {
-                       IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE",
-                                       Long.toString(new Date().getTime()));
-               } catch (IOException e) {
-                       tracer.error(e);
-               }
-       }
-
-       /**
-        * The facility to use temporary files in this program.
-        * <p>
-        * <b>MUST</b> be closed at end of program.
-        * 
-        * @return the facility
-        */
-       public TempFiles getTempFiles() {
-               return tempFiles;
-       }
-
-       /**
-        * The configuration directory (will check, in order of preference, the
-        * system properties, the environment and then defaults to
-        * {@link Instance#getHome()}/.fanfix).
-        * 
-        * @return the config directory
-        */
-       private String getConfigDir() {
-               String configDir = System.getProperty("CONFIG_DIR");
-
-               if (configDir == null) {
-                       configDir = System.getenv("CONFIG_DIR");
-               }
-
-               if (configDir == null) {
-                       configDir = new File(getHome(), ".fanfix").getPath();
-               }
-
-               return configDir;
-       }
-
-       /**
-        * Create the config variables ({@link Instance#config},
-        * {@link Instance#uiconfig}, {@link Instance#trans} and
-        * {@link Instance#transGui}).
-        * 
-        * @param configDir
-        *            the directory where to find the configuration files
-        * @param refresh
-        *            TRUE to reset the configuration files from the default
-        *            included ones
-        */
-       private void createConfigs(String configDir, boolean refresh) {
-               if (!refresh) {
-                       Bundles.setDirectory(configDir);
-               }
-
-               try {
-                       config = new ConfigBundle();
-                       config.updateFile(configDir);
-               } catch (IOException e) {
-                       tracer.error(e);
-               }
-
-               try {
-                       uiconfig = new UiConfigBundle();
-                       uiconfig.updateFile(configDir);
-               } catch (IOException e) {
-                       tracer.error(e);
-               }
-
-               // No updateFile for this one! (we do not want the user to have custom
-               // translations that won't accept updates from newer versions)
-               trans = new StringIdBundle(getLang());
-               transGui = new StringIdGuiBundle(getLang());
-
-               // Fix an old bug (we used to store custom translation files by
-               // default):
-               if (trans.getString(StringId.INPUT_DESC_CBZ) == null) {
-                       trans.deleteFile(configDir);
-               }
-
-               Boolean noutf = checkEnv("NOUTF");
-               if (noutf != null && noutf) {
-                       trans.setUnicode(false);
-                       transGui.setUnicode(false);
-               }
-
-               Bundles.setDirectory(configDir);
-       }
-
-       /**
-        * Create the default library as specified by the config.
-        * 
-        * @param remoteDir
-        *            the base remote directory if needed
-        * 
-        * @return the default {@link BasicLibrary}
-        */
-       private BasicLibrary createDefaultLibrary(File remoteDir) {
-               BasicLibrary lib = null;
-
-               boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED,
-                               false);
-               if (useRemote) {
-                       String host = null;
-                       int port = -1;
-                       try {
-                               host = config.getString(Config.REMOTE_LIBRARY_HOST,
-                                               "fanfix://localhost");
-                               port = config.getInteger(Config.REMOTE_LIBRARY_PORT, -1);
-                               String key = config.getString(Config.REMOTE_LIBRARY_KEY);
-
-                               if (!host.startsWith("http://") && !host.startsWith("https://")
-                                               && !host.startsWith("fanfix://")) {
-                                       host = "fanfix://" + host;
-                               }
-
-                               tracer.trace("Selecting remote library " + host + ":" + port);
-
-                               if (host.startsWith("fanfix://")) {
-                                       lib = new RemoteLibrary(key, host, port);
-                               } else {
-                                       lib = new WebLibrary(key, host, port);
-                               }
-
-                               lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib,
-                                               uiconfig);
-                       } catch (Exception e) {
-                               tracer.error(
-                                               new IOException("Cannot create remote library for: "
-                                                               + host + ":" + port, e));
-                       }
-               } else {
-                       String libDir = System.getenv("BOOKS_DIR");
-                       if (libDir == null || libDir.isEmpty()) {
-                               libDir = getFile(Config.LIBRARY_DIR, configDir, "$HOME/Books")
-                                               .getPath();
-                       }
-                       try {
-                               lib = new LocalLibrary(new File(libDir), config);
-                       } catch (Exception e) {
-                               tracer.error(new IOException(
-                                               "Cannot create library for directory: " + libDir, e));
-                       }
-               }
-
-               return lib;
-       }
-
-       /**
-        * Return a path, but support the special $HOME variable.
-        * 
-        * @param id
-        *            the key for the path, which may contain "$HOME"
-        * @param configDir
-        *            the directory to use as base if not absolute
-        * @param def
-        *            the default value if none (will be configDir-rooted if needed)
-        * @return the path, with expanded "$HOME" if needed
-        */
-       protected File getFile(Config id, String configDir, String def) {
-               String path = config.getString(id, def);
-               return getFile(path, configDir);
-       }
-
-       /**
-        * Return a path, but support the special $HOME variable.
-        * 
-        * @param id
-        *            the key for the path, which may contain "$HOME"
-        * @param configDir
-        *            the directory to use as base if not absolute
-        * @param def
-        *            the default value if none (will be configDir-rooted if needed)
-        * @return the path, with expanded "$HOME" if needed
-        */
-       protected File getFile(UiConfig id, String configDir, String def) {
-               String path = uiconfig.getString(id, def);
-               return getFile(path, configDir);
-       }
-
-       /**
-        * Return a path, but support the special $HOME variable.
-        * 
-        * @param path
-        *            the path, which may contain "$HOME"
-        * @param configDir
-        *            the directory to use as base if not absolute
-        * @return the path, with expanded "$HOME" if needed
-        */
-       protected File getFile(String path, String configDir) {
-               File file = null;
-               if (path != null && !path.isEmpty()) {
-                       path = path.replace('/', File.separatorChar);
-                       if (path.contains("$HOME")) {
-                               path = path.replace("$HOME", getHome());
-                       } else if (!path.startsWith("/")) {
-                               path = new File(configDir, path).getPath();
-                       }
-
-                       file = new File(path);
-               }
-
-               return file;
-       }
-
-       /**
-        * Return the home directory from the environment (FANFIX_DIR) or the system
-        * properties.
-        * <p>
-        * The environment variable is tested first. Then, the custom property
-        * "fanfix.home" is tried, followed by the usual "user.home" then
-        * "java.io.tmp" if nothing else is found.
-        * 
-        * @return the home
-        */
-       protected String getHome() {
-               String home = System.getenv("FANFIX_DIR");
-               if (home != null && new File(home).isFile()) {
-                       home = null;
-               }
-
-               if (home == null || home.trim().isEmpty()) {
-                       home = System.getProperty("fanfix.home");
-                       if (home != null && new File(home).isFile()) {
-                               home = null;
-                       }
-               }
-
-               if (home == null || home.trim().isEmpty()) {
-                       home = System.getProperty("user.home");
-                       if (!new File(home).isDirectory()) {
-                               home = null;
-                       }
-               }
-
-               if (home == null || home.trim().isEmpty()) {
-                       home = System.getProperty("java.io.tmpdir");
-                       if (!new File(home).isDirectory()) {
-                               home = null;
-                       }
-               }
-
-               if (home == null) {
-                       home = "";
-               }
-
-               return home;
-       }
-
-       /**
-        * The language to use for the application (NULL = default system language).
-        * 
-        * @return the language
-        */
-       protected String getLang() {
-               String lang = config.getString(Config.LANG);
-
-               if (lang == null || lang.isEmpty()) {
-                       if (System.getenv("LANG") != null
-                                       && !System.getenv("LANG").isEmpty()) {
-                               lang = System.getenv("LANG");
-                       }
-               }
-
-               if (lang != null && lang.isEmpty()) {
-                       lang = null;
-               }
-
-               return lang;
-       }
-
-       /**
-        * Check that the given environment variable is "enabled".
-        * 
-        * @param key
-        *            the variable to check
-        * 
-        * @return TRUE if it is
-        */
-       protected Boolean checkEnv(String key) {
-               String value = System.getenv(key);
-               if (value != null) {
-                       value = value.trim().toLowerCase();
-                       if ("yes".equals(value) || "true".equals(value)
-                                       || "on".equals(value) || "1".equals(value)
-                                       || "y".equals(value)) {
-                               return true;
-                       }
-
-                       return false;
-               }
-
-               return null;
-       }
-}
diff --git a/LICENSE b/LICENSE
deleted file mode 100644 (file)
index 9cecc1d..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,674 +0,0 @@
-                    GNU GENERAL PUBLIC LICENSE
-                       Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
-                            Preamble
-
-  The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
-  The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works.  By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.  We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors.  You can apply it to
-your programs, too.
-
-  When we speak of free software, we are referring to freedom, not
-price.  Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
-  To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights.  Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
-  For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received.  You must make sure that they, too, receive
-or can get the source code.  And you must show them these terms so they
-know their rights.
-
-  Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
-  For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software.  For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
-  Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so.  This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software.  The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable.  Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products.  If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
-  Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary.  To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
-  The precise terms and conditions for copying, distribution and
-modification follow.
-
-                       TERMS AND CONDITIONS
-
-  0. Definitions.
-
-  "This License" refers to version 3 of the GNU General Public License.
-
-  "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
-  "The Program" refers to any copyrightable work licensed under this
-License.  Each licensee is addressed as "you".  "Licensees" and
-"recipients" may be individuals or organizations.
-
-  To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy.  The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
-  A "covered work" means either the unmodified Program or a work based
-on the Program.
-
-  To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy.  Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
-  To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies.  Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
-  An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License.  If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
-  1. Source Code.
-
-  The "source code" for a work means the preferred form of the work
-for making modifications to it.  "Object code" means any non-source
-form of a work.
-
-  A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
-  The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form.  A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
-  The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities.  However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work.  For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
-  The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
-  The Corresponding Source for a work in source code form is that
-same work.
-
-  2. Basic Permissions.
-
-  All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met.  This License explicitly affirms your unlimited
-permission to run the unmodified Program.  The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work.  This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
-  You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force.  You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright.  Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
-  Conveying under any other circumstances is permitted solely under
-the conditions stated below.  Sublicensing is not allowed; section 10
-makes it unnecessary.
-
-  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
-  No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
-  When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
-  4. Conveying Verbatim Copies.
-
-  You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
-  You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
-  5. Conveying Modified Source Versions.
-
-  You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
-    a) The work must carry prominent notices stating that you modified
-    it, and giving a relevant date.
-
-    b) The work must carry prominent notices stating that it is
-    released under this License and any conditions added under section
-    7.  This requirement modifies the requirement in section 4 to
-    "keep intact all notices".
-
-    c) You must license the entire work, as a whole, under this
-    License to anyone who comes into possession of a copy.  This
-    License will therefore apply, along with any applicable section 7
-    additional terms, to the whole of the work, and all its parts,
-    regardless of how they are packaged.  This License gives no
-    permission to license the work in any other way, but it does not
-    invalidate such permission if you have separately received it.
-
-    d) If the work has interactive user interfaces, each must display
-    Appropriate Legal Notices; however, if the Program has interactive
-    interfaces that do not display Appropriate Legal Notices, your
-    work need not make them do so.
-
-  A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit.  Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
-  6. Conveying Non-Source Forms.
-
-  You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
-    a) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by the
-    Corresponding Source fixed on a durable physical medium
-    customarily used for software interchange.
-
-    b) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by a
-    written offer, valid for at least three years and valid for as
-    long as you offer spare parts or customer support for that product
-    model, to give anyone who possesses the object code either (1) a
-    copy of the Corresponding Source for all the software in the
-    product that is covered by this License, on a durable physical
-    medium customarily used for software interchange, for a price no
-    more than your reasonable cost of physically performing this
-    conveying of source, or (2) access to copy the
-    Corresponding Source from a network server at no charge.
-
-    c) Convey individual copies of the object code with a copy of the
-    written offer to provide the Corresponding Source.  This
-    alternative is allowed only occasionally and noncommercially, and
-    only if you received the object code with such an offer, in accord
-    with subsection 6b.
-
-    d) Convey the object code by offering access from a designated
-    place (gratis or for a charge), and offer equivalent access to the
-    Corresponding Source in the same way through the same place at no
-    further charge.  You need not require recipients to copy the
-    Corresponding Source along with the object code.  If the place to
-    copy the object code is a network server, the Corresponding Source
-    may be on a different server (operated by you or a third party)
-    that supports equivalent copying facilities, provided you maintain
-    clear directions next to the object code saying where to find the
-    Corresponding Source.  Regardless of what server hosts the
-    Corresponding Source, you remain obligated to ensure that it is
-    available for as long as needed to satisfy these requirements.
-
-    e) Convey the object code using peer-to-peer transmission, provided
-    you inform other peers where the object code and Corresponding
-    Source of the work are being offered to the general public at no
-    charge under subsection 6d.
-
-  A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
-  A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling.  In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage.  For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product.  A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
-  "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source.  The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
-  If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information.  But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
-  The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed.  Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
-  Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
-  7. Additional Terms.
-
-  "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law.  If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
-  When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it.  (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.)  You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
-  Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
-    a) Disclaiming warranty or limiting liability differently from the
-    terms of sections 15 and 16 of this License; or
-
-    b) Requiring preservation of specified reasonable legal notices or
-    author attributions in that material or in the Appropriate Legal
-    Notices displayed by works containing it; or
-
-    c) Prohibiting misrepresentation of the origin of that material, or
-    requiring that modified versions of such material be marked in
-    reasonable ways as different from the original version; or
-
-    d) Limiting the use for publicity purposes of names of licensors or
-    authors of the material; or
-
-    e) Declining to grant rights under trademark law for use of some
-    trade names, trademarks, or service marks; or
-
-    f) Requiring indemnification of licensors and authors of that
-    material by anyone who conveys the material (or modified versions of
-    it) with contractual assumptions of liability to the recipient, for
-    any liability that these contractual assumptions directly impose on
-    those licensors and authors.
-
-  All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10.  If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term.  If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
-  If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
-  Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
-  8. Termination.
-
-  You may not propagate or modify a covered work except as expressly
-provided under this License.  Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
-  However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
-  Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
-  Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License.  If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
-  9. Acceptance Not Required for Having Copies.
-
-  You are not required to accept this License in order to receive or
-run a copy of the Program.  Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance.  However,
-nothing other than this License grants you permission to propagate or
-modify any covered work.  These actions infringe copyright if you do
-not accept this License.  Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
-  10. Automatic Licensing of Downstream Recipients.
-
-  Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License.  You are not responsible
-for enforcing compliance by third parties with this License.
-
-  An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations.  If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
-  You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License.  For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
-  11. Patents.
-
-  A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based.  The
-work thus licensed is called the contributor's "contributor version".
-
-  A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version.  For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
-  Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
-  In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement).  To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
-  If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients.  "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
-  If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
-  A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License.  You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
-  Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
-  12. No Surrender of Others' Freedom.
-
-  If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License.  If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all.  For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
-  13. Use with the GNU Affero General Public License.
-
-  Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work.  The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
-  14. Revised Versions of this License.
-
-  The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time.  Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
-  Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation.  If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
-  If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
-  Later license versions may give you additional or different
-permissions.  However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
-  15. Disclaimer of Warranty.
-
-  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
-  16. Limitation of Liability.
-
-  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
-  17. Interpretation of Sections 15 and 16.
-
-  If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
-                     END OF TERMS AND CONDITIONS
-
-            How to Apply These Terms to Your New Programs
-
-  If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
-  To do so, attach the following notices to the program.  It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-    {one line to give the program's name and a brief idea of what it does.}
-    Copyright (C) {year}  {name of author}
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU General Public License for more details.
-
-    You should have received a copy of the GNU General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-Also add information on how to contact you by electronic and paper mail.
-
-  If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
-    {project}  Copyright (C) {year}  {fullname}
-    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
-    This is free software, and you are welcome to redistribute it
-    under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License.  Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
-  You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-<http://www.gnu.org/licenses/>.
-
-  The GNU General Public License does not permit incorporating your program
-into proprietary programs.  If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library.  If this is what you want to do, use the GNU Lesser General
-Public License instead of this License.  But first, please read
-<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/LoginResult.java b/LoginResult.java
new file mode 100644 (file)
index 0000000..ddc148b
--- /dev/null
@@ -0,0 +1,211 @@
+package be.nikiroo.utils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A simple login facility using cookies.
+ * 
+ * @author niki
+ */
+public class LoginResult {
+       private boolean success;
+       private String cookie;
+       private boolean badLogin;
+       private boolean badCookie;
+       private String option;
+
+       /**
+        * Generate a failed login.
+        * 
+        * @param badLogin
+        *            TRUE if the login failed because of a who/key/subkey error
+        * @param badCookie
+        *            TRUE if the login failed because of a bad cookie
+        * 
+        */
+       public LoginResult(boolean badLogin, boolean badCookie) {
+               this.badLogin = badLogin;
+               this.badCookie = badCookie;
+       }
+
+       /**
+        * Generate a successful login for the given user.
+        * 
+        * @param who
+        *            the user (can be NULL)
+        * @param key
+        *            the password (can be NULL)
+        * @param subkey
+        *            a sub-password (can be NULL)
+        * @param option
+        *            an option assigned to this login (can be NULL)
+        */
+       public LoginResult(String who, String key, String subkey, String option) {
+               this.option = option;
+               this.cookie = generateCookie(who, key, subkey, option);
+               this.success = true;
+       }
+
+       /**
+        * Generate a login via this token and checks its validity.
+        * <p>
+        * Will fail with a NULL <tt>token</tt>, but
+        * {@link LoginResult#isBadCookie()} will still be false.
+        * 
+        * @param cookie
+        *            the token to check (if NULL, will simply fail but
+        *            {@link LoginResult#isBadCookie()} will still be false)
+        * @param who
+        *            the user (can be NULL)
+        * @param key
+        *            the password (can be NULL)
+        */
+       public LoginResult(String cookie, String who, String key) {
+               this(cookie, who, key, null, true);
+       }
+
+       /**
+        * Generate a login via this token and checks its validity.
+        * <p>
+        * Will fail with a NULL <tt>token</tt>, but
+        * {@link LoginResult#isBadCookie()} will still be false.
+        * 
+        * @param cookie
+        *            the token to check (if NULL, will simply fail but
+        *            {@link LoginResult#isBadCookie()} will still be false)
+        * @param who
+        *            the user (can be NULL)
+        * @param key
+        *            the password (can be NULL)
+        * @param subkeys
+        *            the list of candidate subkey (can be NULL)
+        * @param allowNoSubkey
+        *            allow the login if no subkey was present in the token
+        */
+       public LoginResult(String cookie, String who, String key,
+                       List<String> subkeys, boolean allowNoSubkey) {
+               if (cookie != null) {
+                       String hashes[] = cookie.split("~");
+                       if (hashes.length >= 2) {
+                               String wookie = hashes[0];
+                               String rehashed = hashes[1];
+                               String opts = hashes.length > 2 ? hashes[2] : "";
+
+                               if (CookieUtils.validateCookie(who + key, wookie)) {
+                                       if (subkeys == null) {
+                                               subkeys = new ArrayList<String>();
+                                       }
+
+                                       if (allowNoSubkey) {
+                                               subkeys = new ArrayList<String>(subkeys);
+                                               subkeys.add("");
+                                       }
+
+                                       for (String subkey : subkeys) {
+                                               if (CookieUtils.validateCookie(wookie + subkey + opts,
+                                                               rehashed)) {
+                                                       this.cookie = generateCookie(who, key, subkey,
+                                                                       opts);
+                                                       this.option = opts;
+                                                       this.success = true;
+                                               }
+                                       }
+                               }
+                       }
+
+                       this.badCookie = !success;
+               }
+
+               // No token -> no bad token
+       }
+
+       /**
+        * The login wa successful.
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isSuccess() {
+               return success;
+       }
+
+       /**
+        * The refreshed token if the login is successful (NULL if not).
+        * 
+        * @return the token, or NULL
+        */
+       public String getCookie() {
+               return cookie;
+       }
+
+       /**
+        * An option that was used to generate this login (always NULL if the login
+        * was not successful).
+        * <p>
+        * It can come from a manually generated {@link LoginResult}, but also from
+        * a {@link LoginResult} generated with a token.
+        * 
+        * @return the option
+        */
+       public String getOption() {
+               return option;
+       }
+
+       /**
+        * The login failed because of a who/key/subkey error.
+        * 
+        * @return TRUE if it failed because of a who/key/subkey error
+        */
+       public boolean isBadLogin() {
+               return badLogin;
+       }
+
+       /**
+        * The login failed because the cookie was not accepted
+        * 
+        * @return TRUE if it failed because the cookie was not accepted
+        */
+       public boolean isBadCookie() {
+               return badCookie;
+       }
+
+       @Override
+       public String toString() {
+               if (success)
+                       return "Login succeeded";
+
+               if (badLogin && badCookie)
+                       return "Login failed because of bad login and bad cookie";
+
+               if (badLogin)
+                       return "Login failed because of bad login";
+
+               if (badCookie)
+                       return "Login failed because of bad cookie";
+
+               return "Login failed without giving a reason";
+       }
+
+       /**
+        * Generate a cookie.
+        * 
+        * @param who
+        *            the user name (can be NULL)
+        * @param key
+        *            the password (can be NULL)
+        * @param subkey
+        *            a subkey (can be NULL)
+        * @param option
+        *            an option linked to the login (can be NULL)
+        * 
+        * @return a fresh cookie
+        */
+       private String generateCookie(String who, String key, String subkey,
+                       String option) {
+               String wookie = CookieUtils.generateCookie(who + key, 0);
+               return wookie + "~"
+                               + CookieUtils.generateCookie(
+                                               wookie + (subkey == null ? "" : subkey) + option, 0)
+                               + "~" + option;
+       }
+}
\ No newline at end of file
diff --git a/Main.java b/Main.java
deleted file mode 100644 (file)
index 7be305a..0000000
--- a/Main.java
+++ /dev/null
@@ -1,1104 +0,0 @@
-package be.nikiroo.fanfix;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.net.ssl.SSLException;
-
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.library.BasicLibrary;
-import be.nikiroo.fanfix.library.CacheLibrary;
-import be.nikiroo.fanfix.library.LocalLibrary;
-import be.nikiroo.fanfix.library.RemoteLibrary;
-import be.nikiroo.fanfix.library.RemoteLibraryServer;
-import be.nikiroo.fanfix.library.WebLibraryServer;
-import be.nikiroo.fanfix.output.BasicOutput;
-import be.nikiroo.fanfix.output.BasicOutput.OutputType;
-import be.nikiroo.fanfix.reader.BasicReader;
-import be.nikiroo.fanfix.reader.CliReader;
-import be.nikiroo.fanfix.searchable.BasicSearchable;
-import be.nikiroo.fanfix.supported.BasicSupport;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.Version;
-import be.nikiroo.utils.VersionCheck;
-
-/**
- * Main program entry point.
- * 
- * @author niki
- */
-public class Main {
-       private enum MainAction {
-               IMPORT, EXPORT, CONVERT, READ, READ_URL, LIST, HELP, START, VERSION, SERVER, STOP_SERVER, REMOTE, SET_SOURCE, SET_TITLE, SET_AUTHOR, SEARCH, SEARCH_TAG
-       }
-
-       /**
-        * Main program entry point.
-        * <p>
-        * Known environment variables:
-        * <ul>
-        * <li>NOUTF: if set to 1 or 'true', the program will prefer non-unicode
-        * {@link String}s when possible</li>
-        * <li>CONFIG_DIR: a path where to look for the <tt>.properties</tt> files
-        * before taking the usual ones; they will also be saved/updated into this
-        * path when the program starts</li>
-        * <li>DEBUG: if set to 1 or 'true', the program will override the DEBUG_ERR
-        * configuration value with 'true'</li>
-        * </ul>
-        * <p>
-        * <ul>
-        * <li>--import [URL]: import into library</li>
-        * <li>--export [id] [output_type] [target]: export story to target</li>
-        * <li>--convert [URL] [output_type] [target] (+info): convert URL into
-        * target</li>
-        * <li>--read [id] ([chapter number]): read the given story from the library
-        * </li>
-        * <li>--read-url [URL] ([chapter number]): convert on the fly and read the
-        * story, without saving it</li>
-        * <li>--search: list the supported websites (where)</li>
-        * <li>--search [where] [keywords] (page [page]) (item [item]): search on
-        * the supported website and display the given results page of stories it
-        * found, or the story details if asked</li>
-        * <li>--search-tag [where]: list all the tags supported by this website</li>
-        * <li>--search-tag [index 1]... (page [page]) (item [item]): search for the
-        * given stories or subtags, tag by tag, and display information about a
-        * specific page of results or about a specific item if requested</li>
-        * <li>--list ([type]): list the stories present in the library</li>
-        * <li>--set-source [id] [new source]: change the source of the given story</li>
-        * <li>--set-title [id] [new title]: change the title of the given story</li>
-        * <li>--set-author [id] [new author]: change the author of the given story</li>
-        * <li>--version: get the version of the program</li>
-        * <li>--server: start the server mode (see config file for parameters)</li>
-        * <li>--stop-server: stop the running server on this port if any</li>
-        * <li>--remote [key] [host] [port]: use the given remote library</li>
-        * </ul>
-        * 
-        * @param args
-        *            see method description
-        */
-       public static void main(String[] args) {
-               new Main().start(args);
-       }
-
-       /**
-        * Start the default handling for the application.
-        * <p>
-        * If specific actions were asked (with correct parameters), they will be
-        * forwarded to the different protected methods that you can override.
-        * <p>
-        * At the end of the method, {@link Main#exit(int)} will be called; by
-        * default, it calls {@link System#exit(int)} if the status is not 0.
-        * 
-        * @param args
-        *            the arguments received from the system
-        */
-       public void start(String [] args) {
-               // Only one line, but very important:
-               Instance.init();
-
-               String urlString = null;
-               String luid = null;
-               String sourceString = null;
-               String titleString = null;
-               String authorString = null;
-               String chapString = null;
-               String target = null;
-               String key = null;
-               MainAction action = MainAction.START;
-               Boolean plusInfo = null;
-               String host = null;
-               Integer port = null;
-               SupportType searchOn = null;
-               String search = null;
-               List<Integer> tags = new ArrayList<Integer>();
-               Integer page = null;
-               Integer item = null;
-
-               boolean noMoreActions = false;
-
-               int exitCode = 0;
-               for (int i = 0; exitCode == 0 && i < args.length; i++) {
-                       if (args[i] == null)
-                               continue;
-
-                       // Action (--) handling:
-                       if (!noMoreActions && args[i].startsWith("--")) {
-                               if (args[i].equals("--")) {
-                                       noMoreActions = true;
-                               } else {
-                                       try {
-                                               action = MainAction.valueOf(args[i].substring(2)
-                                                               .toUpperCase().replace("-", "_"));
-                                       } catch (Exception e) {
-                                               Instance.getInstance().getTraceHandler()
-                                                               .error(new IllegalArgumentException("Unknown action: " + args[i], e));
-                                               exitCode = 255;
-                                       }
-                               }
-
-                               continue;
-                       }
-
-                       switch (action) {
-                       case IMPORT:
-                               if (urlString == null) {
-                                       urlString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case EXPORT:
-                               if (luid == null) {
-                                       luid = args[i];
-                               } else if (sourceString == null) {
-                                       sourceString = args[i];
-                               } else if (target == null) {
-                                       target = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case CONVERT:
-                               if (urlString == null) {
-                                       urlString = args[i];
-                               } else if (sourceString == null) {
-                                       sourceString = args[i];
-                               } else if (target == null) {
-                                       target = args[i];
-                               } else if (plusInfo == null) {
-                                       if ("+info".equals(args[i])) {
-                                               plusInfo = true;
-                                       } else {
-                                               exitCode = 255;
-                                       }
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case LIST:
-                               if (sourceString == null) {
-                                       sourceString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case SET_SOURCE:
-                               if (luid == null) {
-                                       luid = args[i];
-                               } else if (sourceString == null) {
-                                       sourceString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case SET_TITLE:
-                               if (luid == null) {
-                                       luid = args[i];
-                               } else if (sourceString == null) {
-                                       titleString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case SET_AUTHOR:
-                               if (luid == null) {
-                                       luid = args[i];
-                               } else if (sourceString == null) {
-                                       authorString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case READ:
-                               if (luid == null) {
-                                       luid = args[i];
-                               } else if (chapString == null) {
-                                       chapString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case READ_URL:
-                               if (urlString == null) {
-                                       urlString = args[i];
-                               } else if (chapString == null) {
-                                       chapString = args[i];
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case SEARCH:
-                               if (searchOn == null) {
-                                       searchOn = SupportType.valueOfAllOkUC(args[i]);
-
-                                       if (searchOn == null) {
-                                               Instance.getInstance().getTraceHandler().error("Website not known: <" + args[i] + ">");
-                                               exitCode = 41;
-                                               break;
-                                       }
-
-                                       if (BasicSearchable.getSearchable(searchOn) == null) {
-                                               Instance.getInstance().getTraceHandler().error("Website not supported: " + searchOn);
-                                               exitCode = 42;
-                                               break;
-                                       }
-                               } else if (search == null) {
-                                       search = args[i];
-                               } else if (page != null && page == -1) {
-                                       try {
-                                               page = Integer.parseInt(args[i]);
-                                       } catch (Exception e) {
-                                               page = -2;
-                                       }
-                               } else if (item != null && item == -1) {
-                                       try {
-                                               item = Integer.parseInt(args[i]);
-                                       } catch (Exception e) {
-                                               item = -2;
-                                       }
-                               } else if (page == null || item == null) {
-                                       if (page == null && "page".equals(args[i])) {
-                                               page = -1;
-                                       } else if (item == null && "item".equals(args[i])) {
-                                               item = -1;
-                                       } else {
-                                               exitCode = 255;
-                                       }
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case SEARCH_TAG:
-                               if (searchOn == null) {
-                                       searchOn = SupportType.valueOfAllOkUC(args[i]);
-
-                                       if (searchOn == null) {
-                                               Instance.getInstance().getTraceHandler().error("Website not known: <" + args[i] + ">");
-                                               exitCode = 255;
-                                       }
-
-                                       if (BasicSearchable.getSearchable(searchOn) == null) {
-                                               Instance.getInstance().getTraceHandler().error("Website not supported: " + searchOn);
-                                               exitCode = 255;
-                                       }
-                               } else if (page == null && item == null) {
-                                       if ("page".equals(args[i])) {
-                                               page = -1;
-                                       } else if ("item".equals(args[i])) {
-                                               item = -1;
-                                       } else {
-                                               try {
-                                                       int index = Integer.parseInt(args[i]);
-                                                       tags.add(index);
-                                               } catch (NumberFormatException e) {
-                                                       Instance.getInstance().getTraceHandler().error("Invalid tag index: " + args[i]);
-                                                       exitCode = 255;
-                                               }
-                                       }
-                               } else if (page != null && page == -1) {
-                                       try {
-                                               page = Integer.parseInt(args[i]);
-                                       } catch (Exception e) {
-                                               page = -2;
-                                       }
-                               } else if (item != null && item == -1) {
-                                       try {
-                                               item = Integer.parseInt(args[i]);
-                                       } catch (Exception e) {
-                                               item = -2;
-                                       }
-                               } else if (page == null || item == null) {
-                                       if (page == null && "page".equals(args[i])) {
-                                               page = -1;
-                                       } else if (item == null && "item".equals(args[i])) {
-                                               item = -1;
-                                       } else {
-                                               exitCode = 255;
-                                       }
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       case HELP:
-                               exitCode = 255;
-                               break;
-                       case START:
-                               exitCode = 255; // not supposed to be selected by user
-                               break;
-                       case VERSION:
-                               exitCode = 255; // no arguments for this option
-                               break;
-                       case SERVER:
-                               exitCode = 255; // no arguments for this option
-                               break;
-                       case STOP_SERVER:
-                               exitCode = 255; // no arguments for this option
-                               break;
-                       case REMOTE:
-                               if (key == null) {
-                                       key = args[i];
-                               } else if (host == null) {
-                                       host = args[i];
-                               } else if (port == null) {
-                                       port = Integer.parseInt(args[i]);
-
-                                       BasicLibrary lib = new RemoteLibrary(key, host, port);
-                                       lib = new CacheLibrary(
-                                                       Instance.getInstance().getRemoteDir(host), lib,
-                                                       Instance.getInstance().getUiConfig());
-
-                                       Instance.getInstance().setLibrary(lib);
-
-                                       action = MainAction.START;
-                               } else {
-                                       exitCode = 255;
-                               }
-                               break;
-                       }
-               }
-
-               final Progress mainProgress = new Progress(0, 80);
-               mainProgress.addProgressListener(new Progress.ProgressListener() {
-                       private int current = mainProgress.getMin();
-
-                       @Override
-                       public void progress(Progress progress, String name) {
-                               int diff = progress.getProgress() - current;
-                               current += diff;
-
-                               if (diff <= 0)
-                                       return;
-
-                               StringBuilder builder = new StringBuilder();
-                               for (int i = 0; i < diff; i++) {
-                                       builder.append('.');
-                               }
-
-                               System.err.print(builder.toString());
-
-                               if (progress.isDone()) {
-                                       System.err.println("");
-                               }
-                       }
-               });
-               Progress pg = new Progress();
-               mainProgress.addProgress(pg, mainProgress.getMax());
-
-               VersionCheck updates = checkUpdates();
-
-               if (exitCode == 0) {
-                       switch (action) {
-                       case IMPORT:
-                               if (updates != null) {
-                                       // we consider it read
-                                       Instance.getInstance().setVersionChecked(); 
-                               }
-                               
-                               try {
-                                       exitCode = imprt(BasicReader.getUrl(urlString), pg);
-                               } catch (MalformedURLException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                                       exitCode = 1;
-                               }
-                               
-                               break;
-                       case EXPORT:
-                               if (updates != null) {
-                                       // we consider it read
-                                       Instance.getInstance().setVersionChecked(); 
-                               }
-                               
-                               OutputType exportType = OutputType.valueOfNullOkUC(sourceString, null);
-                               if (exportType == null) {
-                                       Instance.getInstance().getTraceHandler().error(new Exception(trans(StringId.OUTPUT_DESC, sourceString)));
-                                       exitCode = 1;
-                                       break;
-                               }
-                               
-                               exitCode = export(luid, exportType, target, pg);
-                               
-                               break;
-                       case CONVERT:
-                               if (updates != null) {
-                                       // we consider it read
-                                       Instance.getInstance().setVersionChecked(); 
-                               }
-                               
-                               OutputType convertType = OutputType.valueOfAllOkUC(sourceString, null);
-                               if (convertType == null) {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException(trans(StringId.ERR_BAD_OUTPUT_TYPE, sourceString)));
-
-                                       exitCode = 2;
-                                       break;
-                               }
-                               
-                               exitCode = convert(urlString, convertType, target,
-                                               plusInfo == null ? false : plusInfo, pg);
-                               
-                               break;
-                       case LIST:
-                               exitCode = list(sourceString);
-                               break;
-                       case SET_SOURCE:
-                               try {
-                                       Instance.getInstance().getLibrary().changeSource(luid, sourceString, pg);
-                               } catch (IOException e1) {
-                                       Instance.getInstance().getTraceHandler().error(e1);
-                                       exitCode = 21;
-                               }
-                               break;
-                       case SET_TITLE:
-                               try {
-                                       Instance.getInstance().getLibrary().changeTitle(luid, titleString, pg);
-                               } catch (IOException e1) {
-                                       Instance.getInstance().getTraceHandler().error(e1);
-                                       exitCode = 22;
-                               }
-                               break;
-                       case SET_AUTHOR:
-                               try {
-                                       Instance.getInstance().getLibrary().changeAuthor(luid, authorString, pg);
-                               } catch (IOException e1) {
-                                       Instance.getInstance().getTraceHandler().error(e1);
-                                       exitCode = 23;
-                               }
-                               break;
-                       case READ:
-                               if (luid == null || luid.isEmpty()) {
-                                       syntax(false);
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               try {
-                                       Integer chap = null;
-                                       if (chapString != null) {
-                                               try {
-                                                       chap = Integer.parseInt(chapString);
-                                               } catch (NumberFormatException e) {
-                                                       Instance.getInstance().getTraceHandler().error(new IOException(
-                                                                       "Chapter number cannot be parsed: " + chapString, e));
-                                                       exitCode = 2;
-                                                       break;
-                                               }
-                                       }
-                                       
-                                       BasicLibrary lib = Instance.getInstance().getLibrary();
-                                       exitCode = read(lib.getStory(luid, null), chap);
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException("Failed to read book", e));
-                                       exitCode = 2;
-                               }
-
-                               break;
-                       case READ_URL:
-                               if (urlString == null || urlString.isEmpty()) {
-                                       syntax(false);
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               try {
-                                       Integer chap = null;
-                                       if (chapString != null) {
-                                               try {
-                                                       chap = Integer.parseInt(chapString);
-                                               } catch (NumberFormatException e) {
-                                                       Instance.getInstance().getTraceHandler().error(new IOException(
-                                                                       "Chapter number cannot be parsed: " + chapString, e));
-                                                       exitCode = 2;
-                                                       break;
-                                               }
-                                       }
-                                       
-                                       BasicSupport support = BasicSupport
-                                                       .getSupport(BasicReader.getUrl(urlString));
-                                       if (support == null) {
-                                               Instance.getInstance().getTraceHandler()
-                                                               .error("URL not supported: " + urlString);
-                                               exitCode = 2;
-                                               break;
-                                       }
-
-                                       exitCode = read(support.process(null), chap);
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException("Failed to read book", e));
-                                       exitCode = 2;
-                               }
-
-                               break;
-                       case SEARCH:
-                               page = page == null ? 1 : page;
-                               if (page < 0) {
-                                       Instance.getInstance().getTraceHandler().error("Incorrect page number");
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               item = item == null ? 0 : item;
-                               if (item < 0) {
-                                       Instance.getInstance().getTraceHandler().error("Incorrect item number");
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               if (searchOn == null) {
-                                       try {
-                                               search();
-                                       } catch (IOException e) {
-                                               Instance.getInstance().getTraceHandler().error(e);
-                                               exitCode = 1;
-                                       }
-                               } else if (search != null) {
-                                       try {
-                                               searchKeywords(searchOn, search, page, item);
-                                       } catch (IOException e) {
-                                               Instance.getInstance().getTraceHandler().error(e);
-                                               exitCode = 20;
-                                       }
-                               } else {
-                                       exitCode = 255;
-                               }
-
-                               break;
-                       case SEARCH_TAG:
-                               if (searchOn == null) {
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               page = page == null ? 1 : page;
-                               if (page < 0) {
-                                       Instance.getInstance().getTraceHandler().error("Incorrect page number");
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               item = item == null ? 0 : item;
-                               if (item < 0) {
-                                       Instance.getInstance().getTraceHandler().error("Incorrect item number");
-                                       exitCode = 255;
-                                       break;
-                               }
-
-                               try {
-                                       searchTags(searchOn, page, item,
-                                       tags.toArray(new Integer[] {}));
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                               }
-
-                               break;
-                       case HELP:
-                               syntax(true);
-                               exitCode = 0;
-                               break;
-                       case VERSION:
-                               if (updates != null) {
-                                       // we consider it read
-                                       Instance.getInstance().setVersionChecked(); 
-                               }
-                               
-                               System.out
-                                               .println(String.format("Fanfix version %s"
-                                                               + "%nhttps://github.com/nikiroo/fanfix/"
-                                                               + "%n\tWritten by Nikiroo",
-                                                               Version.getCurrentVersion()));
-                               break;
-                       case START:
-                               try {
-                                       start();
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                                       exitCode = 66;
-                               }
-                               break;
-                       case SERVER:
-                               try {
-                                       startServer();
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                               }
-                               
-                               break;
-                       case STOP_SERVER:
-                               // Can be given via "--remote XX XX XX"
-                               if (key == null)
-                                       key = Instance.getInstance().getConfig().getString(Config.SERVER_KEY);
-                               if (port == null)
-                                       port = Instance.getInstance().getConfig().getInteger(Config.SERVER_PORT);
-
-                               if (port == null) {
-                                       System.err.println("No port given nor configured in the config file");
-                                       exitCode = 15;
-                                       break;
-                               }
-                               try {
-                                       stopServer(key, host, port);
-                               } catch (SSLException e) {
-                                       Instance.getInstance().getTraceHandler().error(
-                                                       "Bad access key for remote library");
-                                       exitCode = 43;
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                                       exitCode = 44;
-                               }
-
-                               break;
-                       case REMOTE:
-                               exitCode = 255; // should not be reachable (REMOTE -> START)
-                               break;
-                       }
-               }
-
-               try {
-                       Instance.getInstance().getTempFiles().close();
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(new IOException(
-                                       "Cannot dispose of the temporary files", e));
-               }
-
-               if (exitCode == 255) {
-                       syntax(false);
-               }
-
-               exit(exitCode);
-       }
-       
-       /**
-        * A normal invocation of the program (without parameters or at least
-        * without "action" parameters).
-        * <p>
-        * You will probably want to override that one if you offer a user
-        * interface.
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected void start() throws IOException {
-               new CliReader().listBooks(null);
-       }
-
-       /**
-        * Will check if updates are available, synchronously.
-        * <p>
-        * For this, it will simply forward the call to
-        * {@link Main#checkUpdates(String)} with a value of "nikiroo/fanfix".
-        * <p>
-        * You may want to override it so you call the forward method with the right
-        * parameters (or also if you want it to be asynchronous).
-        * 
-        * @return the newer version information or NULL if nothing new
-        */
-       protected VersionCheck checkUpdates() {
-               return checkUpdates("nikiroo/fanfix");
-       }
-
-       /**
-        * Will check if updates are available on a specific GitHub project.
-        * <p>
-        * Will be called by {@link Main#checkUpdates()}, but if you override that
-        * one you mall call it with another project.
-        * 
-        * @param githubProject
-        *            the GitHub project, for instance "nikiroo/fanfix"
-        * 
-        * @return the newer version information or NULL if nothing new
-        */
-       protected VersionCheck checkUpdates(String githubProject) {
-               try {
-                       VersionCheck updates = VersionCheck.check(githubProject,
-                                       Instance.getInstance().getTrans().getLocale());
-                       if (updates.isNewVersionAvailable()) {
-                               notifyUpdates(updates);
-                               return updates;
-                       }
-               } catch (IOException e) {
-                       // Maybe no internet. Do not report any update.
-               }
-
-               return null;
-       }
-
-       /**
-        * Notify the user about available updates.
-        * <p>
-        * Will only be called when a version is available.
-        * <p>
-        * Note that you can call {@link Instance#setVersionChecked()} on it if the
-        * user has read the information (by default, it is marked read only on
-        * certain other actions).
-        * 
-        * @param updates
-        *            the new version information
-        */
-       protected void notifyUpdates(VersionCheck updates) {
-               // Sent to syserr so not to cause problem if one tries to capture a
-               // story content in text mode
-               System.err.println(
-                               "A new version of the program is available at https://github.com/nikiroo/fanfix/releases");
-               System.err.println("");
-               for (Version v : updates.getNewer()) {
-                       System.err.println("\tVersion " + v);
-                       System.err.println("\t-------------");
-                       System.err.println("");
-                       for (String it : updates.getChanges().get(v)) {
-                               System.err.println("\t- " + it);
-                       }
-                       System.err.println("");
-               }
-       }
-       
-       /**
-        * Import the given resource into the {@link LocalLibrary}.
-        * 
-        * @param url
-        *            the resource to import
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the exit return code (0 = success)
-        */
-       protected static int imprt(URL url, Progress pg) {
-               try {
-                       MetaData meta = Instance.getInstance().getLibrary().imprt(url, pg);
-                       System.out.println(meta.getLuid() + ": \"" + meta.getTitle() + "\" imported.");
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-                       return 1;
-               }
-
-               return 0;
-       }
-
-       /**
-        * Export the {@link Story} from the {@link LocalLibrary} to the given
-        * target.
-        * 
-        * @param luid
-        *            the story LUID
-        * @param type
-        *            the {@link OutputType} to use
-        * @param target
-        *            the target
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the exit return code (0 = success)
-        */
-       protected static int export(String luid, OutputType type, String target,
-                       Progress pg) {
-               try {
-                       Instance.getInstance().getLibrary().export(luid, type, target, pg);
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-                       return 4;
-               }
-
-               return 0;
-       }
-       
-       /**
-        * List the stories of the given source from the {@link LocalLibrary}
-        * (unless NULL is passed, in which case all stories will be listed).
-        * 
-        * @param source
-        *            the source to list the known stories of, or NULL to list all
-        *            stories
-        * 
-        * @return the exit return code (0 = success)
-        */
-       protected int list(String source) {
-               try {
-                       new CliReader().listBooks(source);
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-                       return 66;
-               }
-
-               return 0;
-       }
-
-       /**
-        * Start the current reader for this {@link Story}.
-        * 
-        * @param story
-        *            the story to read
-        * @param chap
-        *            which {@link Chapter} to read (starting at 1), or NULL to get
-        *            the {@link Story} description
-        * 
-        * @return the exit return code (0 = success)
-        */
-       protected int read(Story story, Integer chap) {
-               if (story != null) {
-                       try {
-                               if (chap == null) {
-                                       new CliReader().listChapters(story);
-                               } else {
-                                       new CliReader().printChapter(story, chap);
-                               }
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler()
-                                               .error(new IOException("Failed to read book", e));
-                               return 2;
-                       }
-               } else {
-                       Instance.getInstance().getTraceHandler()
-                                       .error("Cannot find book: " + story);
-                       return 2;
-               }
-
-               return 0;
-       }
-
-       /**
-        * Convert the {@link Story} into another format.
-        * 
-        * @param urlString
-        *            the source {@link Story} to convert
-        * @param type
-        *            the {@link OutputType} to convert to
-        * @param target
-        *            the target file
-        * @param infoCover
-        *            TRUE to also export the cover and info file, even if the given
-        *            {@link OutputType} does not usually save them
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the exit return code (0 = success)
-        */
-       protected int convert(String urlString, OutputType type,
-                       String target, boolean infoCover, Progress pg) {
-               int exitCode = 0;
-
-               Instance.getInstance().getTraceHandler().trace("Convert: " + urlString);
-               String sourceName = urlString;
-               try {
-                       URL source = BasicReader.getUrl(urlString);
-                       sourceName = source.toString();
-                       if (sourceName.startsWith("file://")) {
-                               sourceName = sourceName.substring("file://".length());
-                       }
-
-                       try {
-                               BasicSupport support = BasicSupport.getSupport(source);
-
-                               if (support != null) {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .trace("Support found: " + support.getClass());
-                                       Progress pgIn = new Progress();
-                                       Progress pgOut = new Progress();
-                                       if (pg != null) {
-                                               pg.setMax(2);
-                                               pg.addProgress(pgIn, 1);
-                                               pg.addProgress(pgOut, 1);
-                                       }
-
-                                       Story story = support.process(pgIn);
-                                       try {
-                                               target = new File(target).getAbsolutePath();
-                                               BasicOutput.getOutput(type, infoCover, infoCover)
-                                                               .process(story, target, pgOut);
-                                       } catch (IOException e) {
-                                               Instance.getInstance().getTraceHandler()
-                                                               .error(new IOException(
-                                                                               trans(StringId.ERR_SAVING, target), e));
-                                               exitCode = 5;
-                                       }
-                               } else {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException(
-                                                                       trans(StringId.ERR_NOT_SUPPORTED, source)));
-
-                                       exitCode = 4;
-                               }
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(new IOException(
-                                               trans(StringId.ERR_LOADING, sourceName), e));
-                               exitCode = 3;
-                       }
-               } catch (MalformedURLException e) {
-                       Instance.getInstance().getTraceHandler().error(new IOException(trans(StringId.ERR_BAD_URL, sourceName), e));
-                       exitCode = 1;
-               }
-
-               return exitCode;
-       }
-
-       /**
-        * Display the correct syntax of the program to the user to stdout, or an
-        * error message if the syntax used was wrong on stderr.
-        * 
-        * @param showHelp
-        *            TRUE to show the syntax help, FALSE to show "syntax error"
-        */
-       protected void syntax(boolean showHelp) {
-               if (showHelp) {
-                       StringBuilder builder = new StringBuilder();
-                       for (SupportType type : SupportType.values()) {
-                               builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
-                                               type.getDesc()));
-                               builder.append('\n');
-                       }
-
-                       String typesIn = builder.toString();
-                       builder.setLength(0);
-
-                       for (OutputType type : OutputType.values()) {
-                               builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
-                                               type.getDesc(true)));
-                               builder.append('\n');
-                       }
-
-                       String typesOut = builder.toString();
-
-                       System.out.println(trans(StringId.HELP_SYNTAX, typesIn, typesOut));
-               } else {
-                       System.err.println(trans(StringId.ERR_SYNTAX));
-               }
-       }
-
-       /**
-        * Starts a search operation (i.e., list the available web sites we can
-        * search on).
-        * 
-        * @throws IOException
-        *             in case of I/O errors
-        */
-       protected void search() throws IOException {
-               new CliReader().listSearchables();
-       }
-
-       /**
-        * Search for books by keywords on the given supported web site.
-        * 
-        * @param searchOn
-        *            the web site to search on
-        * @param search
-        *            the keyword to look for
-        * @param page
-        *            the page of results to get, or 0 to inquire about the number
-        *            of pages
-        * @param item
-        *            the index of the book we are interested by, or 0 to query
-        *            about how many books are in that page of results
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected void searchKeywords(SupportType searchOn, String search,
-                       int page, Integer item) throws IOException {
-               new CliReader().searchBooksByKeyword(searchOn, search, page, item);
-       }
-
-       /**
-        * Search for books by tags on the given supported web site.
-        * 
-        * @param searchOn
-        *            the web site to search on
-        * @param page
-        *            the page of results to get, or 0 to inquire about the number
-        *            of pages
-        * @param item
-        *            the index of the book we are interested by, or 0 to query
-        *            about how many books are in that page of results
-        * @param tags
-        *            the tags to look for
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected void searchTags(SupportType searchOn, Integer page, Integer item,
-                       Integer[] tags) throws IOException {
-               new CliReader().searchBooksByTag(searchOn, page, item, tags);
-       }
-
-       /**
-        * Start a Fanfix server.
-        * 
-        * @throws IOException
-        *             in case of I/O errors
-        * @throws SSLException
-        *             when the key was not accepted
-        */
-       private void startServer() throws IOException {
-               String mode = Instance.getInstance().getConfig()
-                               .getString(Config.SERVER_MODE, "fanfix");
-               if (mode.equals("fanfix")) {
-                       RemoteLibraryServer server = new RemoteLibraryServer();
-                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
-                       server.run();
-               } else if (mode.equals("http")) {
-                       WebLibraryServer server = new WebLibraryServer(false);
-                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
-                       server.run();
-               } else if (mode.equals("https")) {
-                       WebLibraryServer server = new WebLibraryServer(true);
-                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
-                       server.run();
-               } else {
-                       throw new IOException("Unknown server mode: " + mode);
-               }
-       }
-
-       /**
-        * Stop a running Fanfix server.
-        * 
-        * @param key
-        *            the key to contact the Fanfix server
-        * @param host
-        *            the host on which it runs (NULL means localhost)
-        * @param port
-        *            the port on which it runs
-        *            
-        * @throws IOException
-        *             in case of I/O errors
-        * @throws SSLException
-        *             when the key was not accepted
-        */
-       private void stopServer(
-                       String key, String host, Integer port)
-                       throws IOException, SSLException {
-               new RemoteLibrary(key, host, port).exit();
-       }
-
-       /**
-        * We are done and ready to exit.
-        * <p>
-        * By default, it will call {@link System#exit(int)} if the status is not 0.
-        * 
-        * @param status
-        *            the exit status
-        */
-       protected void exit(int status) {
-               if (status != 0) {
-                       System.exit(status);
-               }
-       }
-       
-       /**
-        * Simple shortcut method to call {link Instance#getTrans()#getString()}.
-        * 
-        * @param id
-        *            the ID to translate
-        * 
-        * @return the translated result
-        */
-       static private String trans(StringId id, Object... params) {
-               return Instance.getInstance().getTrans().getString(id, params);
-       }
-}
diff --git a/MarkableFileInputStream.java b/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/NanoHTTPD.java b/NanoHTTPD.java
new file mode 100644 (file)
index 0000000..8d183c1
--- /dev/null
@@ -0,0 +1,2358 @@
+package be.nikiroo.utils;
+
+/*
+ * #%L
+ * NanoHttpd-Core
+ * %%
+ * Copyright (C) 2012 - 2015 nanohttpd
+ * %%
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ * 
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ *    list of conditions and the following disclaimer.
+ * 
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ * 
+ * 3. Neither the name of the nanohttpd nor the names of its contributors
+ *    may be used to endorse or promote products derived from this software without
+ *    specific prior written permission.
+ * 
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+ * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.RandomAccessFile;
+import java.io.UnsupportedEncodingException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.security.KeyStore;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Properties;
+import java.util.StringTokenizer;
+import java.util.TimeZone;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPOutputStream;
+
+import javax.net.ssl.KeyManager;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLServerSocketFactory;
+import javax.net.ssl.TrustManagerFactory;
+
+import be.nikiroo.utils.NanoHTTPD.Response.IStatus;
+import be.nikiroo.utils.NanoHTTPD.Response.Status;
+
+/**
+ * A simple, tiny, nicely embeddable HTTP server in Java
+ * <p/>
+ * <p/>
+ * NanoHTTPD
+ * <p>
+ * Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen,
+ * 2010 by Konstantinos Togias
+ * </p>
+ * <p/>
+ * <p/>
+ * <b>Features + limitations: </b>
+ * <ul>
+ * <p/>
+ * <li>Only one Java file</li>
+ * <li>Java 5 compatible</li>
+ * <li>Released as open source, Modified BSD licence</li>
+ * <li>No fixed config files, logging, authorization etc. (Implement yourself if
+ * you need them.)</li>
+ * <li>Supports parameter parsing of GET and POST methods (+ rudimentary PUT
+ * support in 1.25)</li>
+ * <li>Supports both dynamic content and file serving</li>
+ * <li>Supports file upload (since version 1.2, 2010)</li>
+ * <li>Supports partial content (streaming)</li>
+ * <li>Supports ETags</li>
+ * <li>Never caches anything</li>
+ * <li>Doesn't limit bandwidth, request time or simultaneous connections</li>
+ * <li>Default code serves files and shows all HTTP parameters and headers</li>
+ * <li>File server supports directory listing, index.html and index.htm</li>
+ * <li>File server supports partial content (streaming)</li>
+ * <li>File server supports ETags</li>
+ * <li>File server does the 301 redirection trick for directories without '/'</li>
+ * <li>File server supports simple skipping for files (continue download)</li>
+ * <li>File server serves also very long files without memory overhead</li>
+ * <li>Contains a built-in list of most common MIME types</li>
+ * <li>All header names are converted to lower case so they don't vary between
+ * browsers/clients</li>
+ * <p/>
+ * </ul>
+ * <p/>
+ * <p/>
+ * <b>How to use: </b>
+ * <ul>
+ * <p/>
+ * <li>Subclass and implement serve() and embed to your own program</li>
+ * <p/>
+ * </ul>
+ * <p/>
+ * See the separate "LICENSE.md" file for the distribution license (Modified BSD
+ * licence)
+ */
+public abstract class NanoHTTPD {
+
+    /**
+     * Pluggable strategy for asynchronously executing requests.
+     */
+    public interface AsyncRunner {
+
+        void closeAll();
+
+        void closed(ClientHandler clientHandler);
+
+        void exec(ClientHandler code);
+    }
+
+    /**
+     * The runnable that will be used for every new client connection.
+     */
+    public class ClientHandler implements Runnable {
+
+        private final InputStream inputStream;
+
+        private final Socket acceptSocket;
+
+        public ClientHandler(InputStream inputStream, Socket acceptSocket) {
+            this.inputStream = inputStream;
+            this.acceptSocket = acceptSocket;
+        }
+
+        public void close() {
+            safeClose(this.inputStream);
+            safeClose(this.acceptSocket);
+        }
+
+        @Override
+        public void run() {
+            OutputStream outputStream = null;
+            try {
+                outputStream = this.acceptSocket.getOutputStream();
+                TempFileManager tempFileManager = NanoHTTPD.this.tempFileManagerFactory.create();
+                HTTPSession session = new HTTPSession(tempFileManager, this.inputStream, outputStream, this.acceptSocket.getInetAddress());
+                while (!this.acceptSocket.isClosed()) {
+                    session.execute();
+                }
+            } catch (Exception e) {
+                // When the socket is closed by the client,
+                // we throw our own SocketException
+                // to break the "keep alive" loop above. If
+                // the exception was anything other
+                // than the expected SocketException OR a
+                // SocketTimeoutException, print the
+                // stacktrace
+                if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage())) && !(e instanceof SocketTimeoutException)) {
+                    NanoHTTPD.LOG.log(Level.SEVERE, "Communication with the client broken, or an bug in the handler code", e);
+                }
+            } finally {
+                safeClose(outputStream);
+                safeClose(this.inputStream);
+                safeClose(this.acceptSocket);
+                NanoHTTPD.this.asyncRunner.closed(this);
+            }
+        }
+    }
+
+    public static class Cookie {
+
+        public static String getHTTPTime(int days) {
+            Calendar calendar = Calendar.getInstance();
+            SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
+            dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
+            calendar.add(Calendar.DAY_OF_MONTH, days);
+            return dateFormat.format(calendar.getTime());
+        }
+
+        private final String n, v, e;
+
+        public Cookie(String name, String value) {
+            this(name, value, 30);
+        }
+
+        public Cookie(String name, String value, int numDays) {
+            this.n = name;
+            this.v = value;
+            this.e = getHTTPTime(numDays);
+        }
+
+        public Cookie(String name, String value, String expires) {
+            this.n = name;
+            this.v = value;
+            this.e = expires;
+        }
+
+        public String getHTTPHeader() {
+            String fmt = "%s=%s; expires=%s";
+            return String.format(fmt, this.n, this.v, this.e);
+        }
+    }
+
+    /**
+     * Provides rudimentary support for cookies. Doesn't support 'path',
+     * 'secure' nor 'httpOnly'. Feel free to improve it and/or add unsupported
+     * features.
+     * 
+     * @author LordFokas
+     */
+    public class CookieHandler implements Iterable<String> {
+
+        private final HashMap<String, String> cookies = new HashMap<String, String>();
+
+        private final ArrayList<Cookie> queue = new ArrayList<Cookie>();
+
+        public CookieHandler(Map<String, String> httpHeaders) {
+            String raw = httpHeaders.get("cookie");
+            if (raw != null) {
+                String[] tokens = raw.split(";");
+                for (String token : tokens) {
+                    String[] data = token.trim().split("=");
+                    if (data.length == 2) {
+                        this.cookies.put(data[0], data[1]);
+                    }
+                }
+            }
+        }
+
+        /**
+         * Set a cookie with an expiration date from a month ago, effectively
+         * deleting it on the client side.
+         * 
+         * @param name
+         *            The cookie name.
+         */
+        public void delete(String name) {
+            set(name, "-delete-", -30);
+        }
+
+        @Override
+        public Iterator<String> iterator() {
+            return this.cookies.keySet().iterator();
+        }
+
+        /**
+         * Read a cookie from the HTTP Headers.
+         * 
+         * @param name
+         *            The cookie's name.
+         * @return The cookie's value if it exists, null otherwise.
+         */
+        public String read(String name) {
+            return this.cookies.get(name);
+        }
+
+        public void set(Cookie cookie) {
+            this.queue.add(cookie);
+        }
+
+        /**
+         * Sets a cookie.
+         * 
+         * @param name
+         *            The cookie's name.
+         * @param value
+         *            The cookie's value.
+         * @param expires
+         *            How many days until the cookie expires.
+         */
+        public void set(String name, String value, int expires) {
+            this.queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires)));
+        }
+
+        /**
+         * Internally used by the webserver to add all queued cookies into the
+         * Response's HTTP Headers.
+         * 
+         * @param response
+         *            The Response object to which headers the queued cookies
+         *            will be added.
+         */
+        public void unloadQueue(Response response) {
+            for (Cookie cookie : this.queue) {
+                response.addHeader("Set-Cookie", cookie.getHTTPHeader());
+            }
+        }
+    }
+
+    /**
+     * Default threading strategy for NanoHTTPD.
+     * <p/>
+     * <p>
+     * By default, the server spawns a new Thread for every incoming request.
+     * These are set to <i>daemon</i> status, and named according to the request
+     * number. The name is useful when profiling the application.
+     * </p>
+     */
+    public static class DefaultAsyncRunner implements AsyncRunner {
+
+        private long requestCount;
+
+        private final List<ClientHandler> running = Collections.synchronizedList(new ArrayList<NanoHTTPD.ClientHandler>());
+
+        /**
+         * @return a list with currently running clients.
+         */
+        public List<ClientHandler> getRunning() {
+            return running;
+        }
+
+        @Override
+        public void closeAll() {
+            // copy of the list for concurrency
+            for (ClientHandler clientHandler : new ArrayList<ClientHandler>(this.running)) {
+                clientHandler.close();
+            }
+        }
+
+        @Override
+        public void closed(ClientHandler clientHandler) {
+            this.running.remove(clientHandler);
+        }
+
+        @Override
+        public void exec(ClientHandler clientHandler) {
+            ++this.requestCount;
+            Thread t = new Thread(clientHandler);
+            t.setDaemon(true);
+            t.setName("NanoHttpd Request Processor (#" + this.requestCount + ")");
+            this.running.add(clientHandler);
+            t.start();
+        }
+    }
+
+    /**
+     * Default strategy for creating and cleaning up temporary files.
+     * <p/>
+     * <p>
+     * By default, files are created by <code>File.createTempFile()</code> in
+     * the directory specified.
+     * </p>
+     */
+    public static class DefaultTempFile implements TempFile {
+
+        private final File file;
+
+        private final OutputStream fstream;
+
+        public DefaultTempFile(File tempdir) throws IOException {
+            this.file = File.createTempFile("NanoHTTPD-", "", tempdir);
+            this.fstream = new FileOutputStream(this.file);
+        }
+
+        @Override
+        public void delete() throws Exception {
+            safeClose(this.fstream);
+            if (!this.file.delete()) {
+                throw new Exception("could not delete temporary file: " + this.file.getAbsolutePath());
+            }
+        }
+
+        @Override
+        public String getName() {
+            return this.file.getAbsolutePath();
+        }
+
+        @Override
+        public OutputStream open() throws Exception {
+            return this.fstream;
+        }
+    }
+
+    /**
+     * Default strategy for creating and cleaning up temporary files.
+     * <p/>
+     * <p>
+     * This class stores its files in the standard location (that is, wherever
+     * <code>java.io.tmpdir</code> points to). Files are added to an internal
+     * list, and deleted when no longer needed (that is, when
+     * <code>clear()</code> is invoked at the end of processing a request).
+     * </p>
+     */
+    public static class DefaultTempFileManager implements TempFileManager {
+
+        private final File tmpdir;
+
+        private final List<TempFile> tempFiles;
+
+        public DefaultTempFileManager() {
+            this.tmpdir = new File(System.getProperty("java.io.tmpdir"));
+            if (!tmpdir.exists()) {
+                tmpdir.mkdirs();
+            }
+            this.tempFiles = new ArrayList<TempFile>();
+        }
+
+        @Override
+        public void clear() {
+            for (TempFile file : this.tempFiles) {
+                try {
+                    file.delete();
+                } catch (Exception ignored) {
+                    NanoHTTPD.LOG.log(Level.WARNING, "could not delete file ", ignored);
+                }
+            }
+            this.tempFiles.clear();
+        }
+
+        @Override
+        public TempFile createTempFile(String filename_hint) throws Exception {
+            DefaultTempFile tempFile = new DefaultTempFile(this.tmpdir);
+            this.tempFiles.add(tempFile);
+            return tempFile;
+        }
+    }
+
+    /**
+     * Default strategy for creating and cleaning up temporary files.
+     */
+    private class DefaultTempFileManagerFactory implements TempFileManagerFactory {
+
+        @Override
+        public TempFileManager create() {
+            return new DefaultTempFileManager();
+        }
+    }
+
+    /**
+     * Creates a normal ServerSocket for TCP connections
+     */
+    public static class DefaultServerSocketFactory implements ServerSocketFactory {
+
+        @Override
+        public ServerSocket create() throws IOException {
+            return new ServerSocket();
+        }
+
+    }
+
+    /**
+     * Creates a new SSLServerSocket
+     */
+    public static class SecureServerSocketFactory implements ServerSocketFactory {
+
+        private SSLServerSocketFactory sslServerSocketFactory;
+
+        private String[] sslProtocols;
+
+        public SecureServerSocketFactory(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) {
+            this.sslServerSocketFactory = sslServerSocketFactory;
+            this.sslProtocols = sslProtocols;
+        }
+
+        @Override
+        public ServerSocket create() throws IOException {
+            SSLServerSocket ss = null;
+            ss = (SSLServerSocket) this.sslServerSocketFactory.createServerSocket();
+            if (this.sslProtocols != null) {
+                ss.setEnabledProtocols(this.sslProtocols);
+            } else {
+                ss.setEnabledProtocols(ss.getSupportedProtocols());
+            }
+            ss.setUseClientMode(false);
+            ss.setWantClientAuth(false);
+            ss.setNeedClientAuth(false);
+            return ss;
+        }
+
+    }
+
+    private static final String CONTENT_DISPOSITION_REGEX = "([ |\t]*Content-Disposition[ |\t]*:)(.*)";
+
+    private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern.compile(CONTENT_DISPOSITION_REGEX, Pattern.CASE_INSENSITIVE);
+
+    private static final String CONTENT_TYPE_REGEX = "([ |\t]*content-type[ |\t]*:)(.*)";
+
+    private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile(CONTENT_TYPE_REGEX, Pattern.CASE_INSENSITIVE);
+
+    private static final String CONTENT_DISPOSITION_ATTRIBUTE_REGEX = "[ |\t]*([a-zA-Z]*)[ |\t]*=[ |\t]*['|\"]([^\"^']*)['|\"]";
+
+    private static final Pattern CONTENT_DISPOSITION_ATTRIBUTE_PATTERN = Pattern.compile(CONTENT_DISPOSITION_ATTRIBUTE_REGEX);
+
+    protected static class ContentType {
+
+        private static final String ASCII_ENCODING = "US-ASCII";
+
+        private static final String MULTIPART_FORM_DATA_HEADER = "multipart/form-data";
+
+        private static final String CONTENT_REGEX = "[ |\t]*([^/^ ^;^,]+/[^ ^;^,]+)";
+
+        private static final Pattern MIME_PATTERN = Pattern.compile(CONTENT_REGEX, Pattern.CASE_INSENSITIVE);
+
+        private static final String CHARSET_REGEX = "[ |\t]*(charset)[ |\t]*=[ |\t]*['|\"]?([^\"^'^;^,]*)['|\"]?";
+
+        private static final Pattern CHARSET_PATTERN = Pattern.compile(CHARSET_REGEX, Pattern.CASE_INSENSITIVE);
+
+        private static final String BOUNDARY_REGEX = "[ |\t]*(boundary)[ |\t]*=[ |\t]*['|\"]?([^\"^'^;^,]*)['|\"]?";
+
+        private static final Pattern BOUNDARY_PATTERN = Pattern.compile(BOUNDARY_REGEX, Pattern.CASE_INSENSITIVE);
+
+        private final String contentTypeHeader;
+
+        private final String contentType;
+
+        private final String encoding;
+
+        private final String boundary;
+
+        public ContentType(String contentTypeHeader) {
+            this.contentTypeHeader = contentTypeHeader;
+            if (contentTypeHeader != null) {
+                contentType = getDetailFromContentHeader(contentTypeHeader, MIME_PATTERN, "", 1);
+                encoding = getDetailFromContentHeader(contentTypeHeader, CHARSET_PATTERN, null, 2);
+            } else {
+                contentType = "";
+                encoding = "UTF-8";
+            }
+            if (MULTIPART_FORM_DATA_HEADER.equalsIgnoreCase(contentType)) {
+                boundary = getDetailFromContentHeader(contentTypeHeader, BOUNDARY_PATTERN, null, 2);
+            } else {
+                boundary = null;
+            }
+        }
+
+        private String getDetailFromContentHeader(String contentTypeHeader, Pattern pattern, String defaultValue, int group) {
+            Matcher matcher = pattern.matcher(contentTypeHeader);
+            return matcher.find() ? matcher.group(group) : defaultValue;
+        }
+
+        public String getContentTypeHeader() {
+            return contentTypeHeader;
+        }
+
+        public String getContentType() {
+            return contentType;
+        }
+
+        public String getEncoding() {
+            return encoding == null ? ASCII_ENCODING : encoding;
+        }
+
+        public String getBoundary() {
+            return boundary;
+        }
+
+        public boolean isMultipart() {
+            return MULTIPART_FORM_DATA_HEADER.equalsIgnoreCase(contentType);
+        }
+
+        public ContentType tryUTF8() {
+            if (encoding == null) {
+                return new ContentType(this.contentTypeHeader + "; charset=UTF-8");
+            }
+            return this;
+        }
+    }
+
+    protected class HTTPSession implements IHTTPSession {
+
+        private static final int REQUEST_BUFFER_LEN = 512;
+
+        private static final int MEMORY_STORE_LIMIT = 1024;
+
+        public static final int BUFSIZE = 8192;
+
+        public static final int MAX_HEADER_SIZE = 1024;
+
+        private final TempFileManager tempFileManager;
+
+        private final OutputStream outputStream;
+
+        private final BufferedInputStream inputStream;
+
+        private int splitbyte;
+
+        private int rlen;
+
+        private String uri;
+
+        private Method method;
+
+        private Map<String, List<String>> parms;
+
+        private Map<String, String> headers;
+
+        private CookieHandler cookies;
+
+        private String queryParameterString;
+
+        private String remoteIp;
+
+        private String remoteHostname;
+
+        private String protocolVersion;
+
+        public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) {
+            this.tempFileManager = tempFileManager;
+            this.inputStream = new BufferedInputStream(inputStream, HTTPSession.BUFSIZE);
+            this.outputStream = outputStream;
+        }
+
+        public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) {
+            this.tempFileManager = tempFileManager;
+            this.inputStream = new BufferedInputStream(inputStream, HTTPSession.BUFSIZE);
+            this.outputStream = outputStream;
+            this.remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString();
+            this.remoteHostname = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "localhost" : inetAddress.getHostName().toString();
+            this.headers = new HashMap<String, String>();
+        }
+
+        /**
+         * Decodes the sent headers and loads the data into Key/value pairs
+         */
+        private void decodeHeader(BufferedReader in, Map<String, String> pre, Map<String, List<String>> parms, Map<String, String> headers) throws ResponseException {
+            try {
+                // Read the request line
+                String inLine = in.readLine();
+                if (inLine == null) {
+                    return;
+                }
+
+                StringTokenizer st = new StringTokenizer(inLine);
+                if (!st.hasMoreTokens()) {
+                    throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html");
+                }
+
+                pre.put("method", st.nextToken());
+
+                if (!st.hasMoreTokens()) {
+                    throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html");
+                }
+
+                String uri = st.nextToken();
+
+                // Decode parameters from the URI
+                int qmi = uri.indexOf('?');
+                if (qmi >= 0) {
+                    decodeParms(uri.substring(qmi + 1), parms);
+                    uri = decodePercent(uri.substring(0, qmi));
+                } else {
+                    uri = decodePercent(uri);
+                }
+
+                // If there's another token, its protocol version,
+                // followed by HTTP headers.
+                // NOTE: this now forces header names lower case since they are
+                // case insensitive and vary by client.
+                if (st.hasMoreTokens()) {
+                    protocolVersion = st.nextToken();
+                } else {
+                    protocolVersion = "HTTP/1.1";
+                    NanoHTTPD.LOG.log(Level.FINE, "no protocol version specified, strange. Assuming HTTP/1.1.");
+                }
+                String line = in.readLine();
+                while (line != null && !line.trim().isEmpty()) {
+                    int p = line.indexOf(':');
+                    if (p >= 0) {
+                        headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim());
+                    }
+                    line = in.readLine();
+                }
+
+                pre.put("uri", uri);
+            } catch (IOException ioe) {
+                throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe);
+            }
+        }
+
+        /**
+         * Decodes the Multipart Body data and put it into Key/Value pairs.
+         */
+        private void decodeMultipartFormData(ContentType contentType, ByteBuffer fbuf, Map<String, List<String>> parms, Map<String, String> files) throws ResponseException {
+            int pcount = 0;
+            try {
+                int[] boundaryIdxs = getBoundaryPositions(fbuf, contentType.getBoundary().getBytes());
+                if (boundaryIdxs.length < 2) {
+                    throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but contains less than two boundary strings.");
+                }
+
+                byte[] partHeaderBuff = new byte[MAX_HEADER_SIZE];
+                for (int boundaryIdx = 0; boundaryIdx < boundaryIdxs.length - 1; boundaryIdx++) {
+                    fbuf.position(boundaryIdxs[boundaryIdx]);
+                    int len = (fbuf.remaining() < MAX_HEADER_SIZE) ? fbuf.remaining() : MAX_HEADER_SIZE;
+                    fbuf.get(partHeaderBuff, 0, len);
+                    BufferedReader in =
+                            new BufferedReader(new InputStreamReader(new ByteArrayInputStream(partHeaderBuff, 0, len), Charset.forName(contentType.getEncoding())), len);
+
+                    int headerLines = 0;
+                    // First line is boundary string
+                    String mpline = in.readLine();
+                    headerLines++;
+                    if (mpline == null || !mpline.contains(contentType.getBoundary())) {
+                        throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but chunk does not start with boundary.");
+                    }
+
+                    String partName = null, fileName = null, partContentType = null;
+                    // Parse the reset of the header lines
+                    mpline = in.readLine();
+                    headerLines++;
+                    while (mpline != null && mpline.trim().length() > 0) {
+                        Matcher matcher = CONTENT_DISPOSITION_PATTERN.matcher(mpline);
+                        if (matcher.matches()) {
+                            String attributeString = matcher.group(2);
+                            matcher = CONTENT_DISPOSITION_ATTRIBUTE_PATTERN.matcher(attributeString);
+                            while (matcher.find()) {
+                                String key = matcher.group(1);
+                                if ("name".equalsIgnoreCase(key)) {
+                                    partName = matcher.group(2);
+                                } else if ("filename".equalsIgnoreCase(key)) {
+                                    fileName = matcher.group(2);
+                                    // add these two line to support multiple
+                                    // files uploaded using the same field Id
+                                    if (!fileName.isEmpty()) {
+                                        if (pcount > 0)
+                                            partName = partName + String.valueOf(pcount++);
+                                        else
+                                            pcount++;
+                                    }
+                                }
+                            }
+                        }
+                        matcher = CONTENT_TYPE_PATTERN.matcher(mpline);
+                        if (matcher.matches()) {
+                            partContentType = matcher.group(2).trim();
+                        }
+                        mpline = in.readLine();
+                        headerLines++;
+                    }
+                    int partHeaderLength = 0;
+                    while (headerLines-- > 0) {
+                        partHeaderLength = scipOverNewLine(partHeaderBuff, partHeaderLength);
+                    }
+                    // Read the part data
+                    if (partHeaderLength >= len - 4) {
+                        throw new ResponseException(Response.Status.INTERNAL_ERROR, "Multipart header size exceeds MAX_HEADER_SIZE.");
+                    }
+                    int partDataStart = boundaryIdxs[boundaryIdx] + partHeaderLength;
+                    int partDataEnd = boundaryIdxs[boundaryIdx + 1] - 4;
+
+                    fbuf.position(partDataStart);
+
+                    List<String> values = parms.get(partName);
+                    if (values == null) {
+                        values = new ArrayList<String>();
+                        parms.put(partName, values);
+                    }
+
+                    if (partContentType == null) {
+                        // Read the part into a string
+                        byte[] data_bytes = new byte[partDataEnd - partDataStart];
+                        fbuf.get(data_bytes);
+
+                        values.add(new String(data_bytes, contentType.getEncoding()));
+                    } else {
+                        // Read it into a file
+                        String path = saveTmpFile(fbuf, partDataStart, partDataEnd - partDataStart, fileName);
+                        if (!files.containsKey(partName)) {
+                            files.put(partName, path);
+                        } else {
+                            int count = 2;
+                            while (files.containsKey(partName + count)) {
+                                count++;
+                            }
+                            files.put(partName + count, path);
+                        }
+                        values.add(fileName);
+                    }
+                }
+            } catch (ResponseException re) {
+                throw re;
+            } catch (Exception e) {
+                throw new ResponseException(Response.Status.INTERNAL_ERROR, e.toString());
+            }
+        }
+
+        private int scipOverNewLine(byte[] partHeaderBuff, int index) {
+            while (partHeaderBuff[index] != '\n') {
+                index++;
+            }
+            return ++index;
+        }
+
+        /**
+         * Decodes parameters in percent-encoded URI-format ( e.g.
+         * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given
+         * Map.
+         */
+        private void decodeParms(String parms, Map<String, List<String>> p) {
+            if (parms == null) {
+                this.queryParameterString = "";
+                return;
+            }
+
+            this.queryParameterString = parms;
+            StringTokenizer st = new StringTokenizer(parms, "&");
+            while (st.hasMoreTokens()) {
+                String e = st.nextToken();
+                int sep = e.indexOf('=');
+                String key = null;
+                String value = null;
+
+                if (sep >= 0) {
+                    key = decodePercent(e.substring(0, sep)).trim();
+                    value = decodePercent(e.substring(sep + 1));
+                } else {
+                    key = decodePercent(e).trim();
+                    value = "";
+                }
+
+                List<String> values = p.get(key);
+                if (values == null) {
+                    values = new ArrayList<String>();
+                    p.put(key, values);
+                }
+
+                values.add(value);
+            }
+        }
+
+        @Override
+        public void execute() throws IOException {
+            Response r = null;
+            try {
+                // Read the first 8192 bytes.
+                // The full header should fit in here.
+                // Apache's default header limit is 8KB.
+                // Do NOT assume that a single read will get the entire header
+                // at once!
+                byte[] buf = new byte[HTTPSession.BUFSIZE];
+                this.splitbyte = 0;
+                this.rlen = 0;
+
+                int read = -1;
+                this.inputStream.mark(HTTPSession.BUFSIZE);
+                try {
+                    read = this.inputStream.read(buf, 0, HTTPSession.BUFSIZE);
+                } catch (SSLException e) {
+                    throw e;
+                } catch (IOException e) {
+                    safeClose(this.inputStream);
+                    safeClose(this.outputStream);
+                    throw new SocketException("NanoHttpd Shutdown");
+                }
+                if (read == -1) {
+                    // socket was been closed
+                    safeClose(this.inputStream);
+                    safeClose(this.outputStream);
+                    throw new SocketException("NanoHttpd Shutdown");
+                }
+                while (read > 0) {
+                    this.rlen += read;
+                    this.splitbyte = findHeaderEnd(buf, this.rlen);
+                    if (this.splitbyte > 0) {
+                        break;
+                    }
+                    read = this.inputStream.read(buf, this.rlen, HTTPSession.BUFSIZE - this.rlen);
+                }
+
+                if (this.splitbyte < this.rlen) {
+                    this.inputStream.reset();
+                    this.inputStream.skip(this.splitbyte);
+                }
+
+                this.parms = new HashMap<String, List<String>>();
+                if (null == this.headers) {
+                    this.headers = new HashMap<String, String>();
+                } else {
+                    this.headers.clear();
+                }
+
+                // Create a BufferedReader for parsing the header.
+                BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, this.rlen)));
+
+                // Decode the header into parms and header java properties
+                Map<String, String> pre = new HashMap<String, String>();
+                decodeHeader(hin, pre, this.parms, this.headers);
+
+                if (null != this.remoteIp) {
+                    this.headers.put("remote-addr", this.remoteIp);
+                    this.headers.put("http-client-ip", this.remoteIp);
+                }
+
+                this.method = Method.lookup(pre.get("method"));
+                if (this.method == null) {
+                    throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error. HTTP verb " + pre.get("method") + " unhandled.");
+                }
+
+                this.uri = pre.get("uri");
+
+                this.cookies = new CookieHandler(this.headers);
+
+                String connection = this.headers.get("connection");
+                boolean keepAlive = "HTTP/1.1".equals(protocolVersion) && (connection == null || !connection.matches("(?i).*close.*"));
+
+                // Ok, now do the serve()
+
+                // TODO: long body_size = getBodySize();
+                // TODO: long pos_before_serve = this.inputStream.totalRead()
+                // (requires implementation for totalRead())
+                r = serve(this);
+                // TODO: this.inputStream.skip(body_size -
+                // (this.inputStream.totalRead() - pos_before_serve))
+
+                if (r == null) {
+                    throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response.");
+                } else {
+                    String acceptEncoding = this.headers.get("accept-encoding");
+                    this.cookies.unloadQueue(r);
+                    r.setRequestMethod(this.method);
+                    r.setGzipEncoding(useGzipWhenAccepted(r) && acceptEncoding != null && acceptEncoding.contains("gzip"));
+                    r.setKeepAlive(keepAlive);
+                    r.send(this.outputStream);
+                }
+                if (!keepAlive || r.isCloseConnection()) {
+                    throw new SocketException("NanoHttpd Shutdown");
+                }
+            } catch (SocketException e) {
+                // throw it out to close socket object (finalAccept)
+                throw e;
+            } catch (SocketTimeoutException ste) {
+                // treat socket timeouts the same way we treat socket exceptions
+                // i.e. close the stream & finalAccept object by throwing the
+                // exception up the call stack.
+                throw ste;
+            } catch (SSLException ssle) {
+                Response resp = newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SSL PROTOCOL FAILURE: " + ssle.getMessage());
+                resp.send(this.outputStream);
+                safeClose(this.outputStream);
+            } catch (IOException ioe) {
+                Response resp = newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
+                resp.send(this.outputStream);
+                safeClose(this.outputStream);
+            } catch (ResponseException re) {
+                Response resp = newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage());
+                resp.send(this.outputStream);
+                safeClose(this.outputStream);
+            } finally {
+                safeClose(r);
+                this.tempFileManager.clear();
+            }
+        }
+
+        /**
+         * Find byte index separating header from body. It must be the last byte
+         * of the first two sequential new lines.
+         */
+        private int findHeaderEnd(final byte[] buf, int rlen) {
+            int splitbyte = 0;
+            while (splitbyte + 1 < rlen) {
+
+                // RFC2616
+                if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && splitbyte + 3 < rlen && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') {
+                    return splitbyte + 4;
+                }
+
+                // tolerance
+                if (buf[splitbyte] == '\n' && buf[splitbyte + 1] == '\n') {
+                    return splitbyte + 2;
+                }
+                splitbyte++;
+            }
+            return 0;
+        }
+
+        /**
+         * Find the byte positions where multipart boundaries start. This reads
+         * a large block at a time and uses a temporary buffer to optimize
+         * (memory mapped) file access.
+         */
+        private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) {
+            int[] res = new int[0];
+            if (b.remaining() < boundary.length) {
+                return res;
+            }
+
+            int search_window_pos = 0;
+            byte[] search_window = new byte[4 * 1024 + boundary.length];
+
+            int first_fill = (b.remaining() < search_window.length) ? b.remaining() : search_window.length;
+            b.get(search_window, 0, first_fill);
+            int new_bytes = first_fill - boundary.length;
+
+            do {
+                // Search the search_window
+                for (int j = 0; j < new_bytes; j++) {
+                    for (int i = 0; i < boundary.length; i++) {
+                        if (search_window[j + i] != boundary[i])
+                            break;
+                        if (i == boundary.length - 1) {
+                            // Match found, add it to results
+                            int[] new_res = new int[res.length + 1];
+                            System.arraycopy(res, 0, new_res, 0, res.length);
+                            new_res[res.length] = search_window_pos + j;
+                            res = new_res;
+                        }
+                    }
+                }
+                search_window_pos += new_bytes;
+
+                // Copy the end of the buffer to the start
+                System.arraycopy(search_window, search_window.length - boundary.length, search_window, 0, boundary.length);
+
+                // Refill search_window
+                new_bytes = search_window.length - boundary.length;
+                new_bytes = (b.remaining() < new_bytes) ? b.remaining() : new_bytes;
+                b.get(search_window, boundary.length, new_bytes);
+            } while (new_bytes > 0);
+            return res;
+        }
+
+        @Override
+        public CookieHandler getCookies() {
+            return this.cookies;
+        }
+
+        @Override
+        public final Map<String, String> getHeaders() {
+            return this.headers;
+        }
+
+        @Override
+        public final InputStream getInputStream() {
+            return this.inputStream;
+        }
+
+        @Override
+        public final Method getMethod() {
+            return this.method;
+        }
+
+        /**
+         * @deprecated use {@link #getParameters()} instead.
+         */
+        @Override
+        @Deprecated
+        public final Map<String, String> getParms() {
+            Map<String, String> result = new HashMap<String, String>();
+            for (String key : this.parms.keySet()) {
+                result.put(key, this.parms.get(key).get(0));
+            }
+
+            return result;
+        }
+
+        @Override
+        public final Map<String, List<String>> getParameters() {
+            return this.parms;
+        }
+
+        @Override
+        public String getQueryParameterString() {
+            return this.queryParameterString;
+        }
+
+        private RandomAccessFile getTmpBucket() {
+            try {
+                TempFile tempFile = this.tempFileManager.createTempFile(null);
+                return new RandomAccessFile(tempFile.getName(), "rw");
+            } catch (Exception e) {
+                throw new Error(e); // we won't recover, so throw an error
+            }
+        }
+
+        @Override
+        public final String getUri() {
+            return this.uri;
+        }
+
+        /**
+         * Deduce body length in bytes. Either from "content-length" header or
+         * read bytes.
+         */
+        public long getBodySize() {
+            if (this.headers.containsKey("content-length")) {
+                return Long.parseLong(this.headers.get("content-length"));
+            } else if (this.splitbyte < this.rlen) {
+                return this.rlen - this.splitbyte;
+            }
+            return 0;
+        }
+
+        @Override
+        public void parseBody(Map<String, String> files) throws IOException, ResponseException {
+            RandomAccessFile randomAccessFile = null;
+            try {
+                long size = getBodySize();
+                ByteArrayOutputStream baos = null;
+                DataOutput requestDataOutput = null;
+
+                // Store the request in memory or a file, depending on size
+                if (size < MEMORY_STORE_LIMIT) {
+                    baos = new ByteArrayOutputStream();
+                    requestDataOutput = new DataOutputStream(baos);
+                } else {
+                    randomAccessFile = getTmpBucket();
+                    requestDataOutput = randomAccessFile;
+                }
+
+                // Read all the body and write it to request_data_output
+                byte[] buf = new byte[REQUEST_BUFFER_LEN];
+                while (this.rlen >= 0 && size > 0) {
+                    this.rlen = this.inputStream.read(buf, 0, (int) Math.min(size, REQUEST_BUFFER_LEN));
+                    size -= this.rlen;
+                    if (this.rlen > 0) {
+                        requestDataOutput.write(buf, 0, this.rlen);
+                    }
+                }
+
+                ByteBuffer fbuf = null;
+                if (baos != null) {
+                    fbuf = ByteBuffer.wrap(baos.toByteArray(), 0, baos.size());
+                } else {
+                    fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length());
+                    randomAccessFile.seek(0);
+                }
+
+                // If the method is POST, there may be parameters
+                // in data section, too, read it:
+                if (Method.POST.equals(this.method)) {
+                    ContentType contentType = new ContentType(this.headers.get("content-type"));
+                    if (contentType.isMultipart()) {
+                        String boundary = contentType.getBoundary();
+                        if (boundary == null) {
+                            throw new ResponseException(Response.Status.BAD_REQUEST,
+                                    "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html");
+                        }
+                        decodeMultipartFormData(contentType, fbuf, this.parms, files);
+                    } else {
+                        byte[] postBytes = new byte[fbuf.remaining()];
+                        fbuf.get(postBytes);
+                        String postLine = new String(postBytes, contentType.getEncoding()).trim();
+                        // Handle application/x-www-form-urlencoded
+                        if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType.getContentType())) {
+                            decodeParms(postLine, this.parms);
+                        } else if (postLine.length() != 0) {
+                            // Special case for raw POST data => create a
+                            // special files entry "postData" with raw content
+                            // data
+                            files.put("postData", postLine);
+                        }
+                    }
+                } else if (Method.PUT.equals(this.method)) {
+                    files.put("content", saveTmpFile(fbuf, 0, fbuf.limit(), null));
+                }
+            } finally {
+                safeClose(randomAccessFile);
+            }
+        }
+
+        /**
+         * Retrieves the content of a sent file and saves it to a temporary
+         * file. The full path to the saved file is returned.
+         */
+        private String saveTmpFile(ByteBuffer b, int offset, int len, String filename_hint) {
+            String path = "";
+            if (len > 0) {
+                FileOutputStream fileOutputStream = null;
+                try {
+                    TempFile tempFile = this.tempFileManager.createTempFile(filename_hint);
+                    ByteBuffer src = b.duplicate();
+                    fileOutputStream = new FileOutputStream(tempFile.getName());
+                    FileChannel dest = fileOutputStream.getChannel();
+                    src.position(offset).limit(offset + len);
+                    dest.write(src.slice());
+                    path = tempFile.getName();
+                } catch (Exception e) { // Catch exception if any
+                    throw new Error(e); // we won't recover, so throw an error
+                } finally {
+                    safeClose(fileOutputStream);
+                }
+            }
+            return path;
+        }
+
+        @Override
+        public String getRemoteIpAddress() {
+            return this.remoteIp;
+        }
+
+        @Override
+        public String getRemoteHostName() {
+            return this.remoteHostname;
+        }
+    }
+
+    /**
+     * Handles one session, i.e. parses the HTTP request and returns the
+     * response.
+     */
+    public interface IHTTPSession {
+
+        void execute() throws IOException;
+
+        CookieHandler getCookies();
+
+        Map<String, String> getHeaders();
+
+        InputStream getInputStream();
+
+        Method getMethod();
+
+        /**
+         * This method will only return the first value for a given parameter.
+         * You will want to use getParameters if you expect multiple values for
+         * a given key.
+         * 
+         * @deprecated use {@link #getParameters()} instead.
+         */
+        @Deprecated
+        Map<String, String> getParms();
+
+        Map<String, List<String>> getParameters();
+
+        String getQueryParameterString();
+
+        /**
+         * @return the path part of the URL.
+         */
+        String getUri();
+
+        /**
+         * Adds the files in the request body to the files map.
+         * 
+         * @param files
+         *            map to modify
+         */
+        void parseBody(Map<String, String> files) throws IOException, ResponseException;
+
+        /**
+         * Get the remote ip address of the requester.
+         * 
+         * @return the IP address.
+         */
+        String getRemoteIpAddress();
+
+        /**
+         * Get the remote hostname of the requester.
+         * 
+         * @return the hostname.
+         */
+        String getRemoteHostName();
+    }
+
+    /**
+     * HTTP Request methods, with the ability to decode a <code>String</code>
+     * back to its enum value.
+     */
+    public enum Method {
+        GET,
+        PUT,
+        POST,
+        DELETE,
+        HEAD,
+        OPTIONS,
+        TRACE,
+        CONNECT,
+        PATCH,
+        PROPFIND,
+        PROPPATCH,
+        MKCOL,
+        MOVE,
+        COPY,
+        LOCK,
+        UNLOCK;
+
+        static Method lookup(String method) {
+            if (method == null)
+                return null;
+
+            try {
+                return valueOf(method);
+            } catch (IllegalArgumentException e) {
+                // TODO: Log it?
+                return null;
+            }
+        }
+    }
+
+    /**
+     * HTTP response. Return one of these from serve().
+     */
+    public static class Response implements Closeable {
+
+        public interface IStatus {
+
+            String getDescription();
+
+            int getRequestStatus();
+        }
+
+        /**
+         * Some HTTP response status codes
+         */
+        public enum Status implements IStatus {
+            SWITCH_PROTOCOL(101, "Switching Protocols"),
+
+            OK(200, "OK"),
+            CREATED(201, "Created"),
+            ACCEPTED(202, "Accepted"),
+            NO_CONTENT(204, "No Content"),
+            PARTIAL_CONTENT(206, "Partial Content"),
+            MULTI_STATUS(207, "Multi-Status"),
+
+            REDIRECT(301, "Moved Permanently"),
+            /**
+             * Many user agents mishandle 302 in ways that violate the RFC1945
+             * spec (i.e., redirect a POST to a GET). 303 and 307 were added in
+             * RFC2616 to address this. You should prefer 303 and 307 unless the
+             * calling user agent does not support 303 and 307 functionality
+             */
+            @Deprecated
+            FOUND(302, "Found"),
+            REDIRECT_SEE_OTHER(303, "See Other"),
+            NOT_MODIFIED(304, "Not Modified"),
+            TEMPORARY_REDIRECT(307, "Temporary Redirect"),
+
+            BAD_REQUEST(400, "Bad Request"),
+            UNAUTHORIZED(401, "Unauthorized"),
+            FORBIDDEN(403, "Forbidden"),
+            NOT_FOUND(404, "Not Found"),
+            METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
+            NOT_ACCEPTABLE(406, "Not Acceptable"),
+            REQUEST_TIMEOUT(408, "Request Timeout"),
+            CONFLICT(409, "Conflict"),
+            GONE(410, "Gone"),
+            LENGTH_REQUIRED(411, "Length Required"),
+            PRECONDITION_FAILED(412, "Precondition Failed"),
+            PAYLOAD_TOO_LARGE(413, "Payload Too Large"),
+            UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"),
+            RANGE_NOT_SATISFIABLE(416, "Requested Range Not Satisfiable"),
+            EXPECTATION_FAILED(417, "Expectation Failed"),
+            TOO_MANY_REQUESTS(429, "Too Many Requests"),
+
+            INTERNAL_ERROR(500, "Internal Server Error"),
+            NOT_IMPLEMENTED(501, "Not Implemented"),
+            SERVICE_UNAVAILABLE(503, "Service Unavailable"),
+            UNSUPPORTED_HTTP_VERSION(505, "HTTP Version Not Supported");
+
+            private final int requestStatus;
+
+            private final String description;
+
+            Status(int requestStatus, String description) {
+                this.requestStatus = requestStatus;
+                this.description = description;
+            }
+
+            public static Status lookup(int requestStatus) {
+                for (Status status : Status.values()) {
+                    if (status.getRequestStatus() == requestStatus) {
+                        return status;
+                    }
+                }
+                return null;
+            }
+
+            @Override
+            public String getDescription() {
+                return "" + this.requestStatus + " " + this.description;
+            }
+
+            @Override
+            public int getRequestStatus() {
+                return this.requestStatus;
+            }
+
+        }
+
+        /**
+         * Output stream that will automatically send every write to the wrapped
+         * OutputStream according to chunked transfer:
+         * http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
+         */
+        private static class ChunkedOutputStream extends FilterOutputStream {
+
+            public ChunkedOutputStream(OutputStream out) {
+                super(out);
+            }
+
+            @Override
+            public void write(int b) throws IOException {
+                byte[] data = {
+                    (byte) b
+                };
+                write(data, 0, 1);
+            }
+
+            @Override
+            public void write(byte[] b) throws IOException {
+                write(b, 0, b.length);
+            }
+
+            @Override
+            public void write(byte[] b, int off, int len) throws IOException {
+                if (len == 0)
+                    return;
+                out.write(String.format("%x\r\n", len).getBytes());
+                out.write(b, off, len);
+                out.write("\r\n".getBytes());
+            }
+
+            public void finish() throws IOException {
+                out.write("0\r\n\r\n".getBytes());
+            }
+
+        }
+
+        /**
+         * HTTP status code after processing, e.g. "200 OK", Status.OK
+         */
+        private IStatus status;
+
+        /**
+         * MIME type of content, e.g. "text/html"
+         */
+        private String mimeType;
+
+        /**
+         * Data of the response, may be null.
+         */
+        private InputStream data;
+
+        private long contentLength;
+
+        /**
+         * Headers for the HTTP response. Use addHeader() to add lines. the
+         * lowercase map is automatically kept up to date.
+         */
+        @SuppressWarnings("serial")
+        private final Map<String, String> header = new HashMap<String, String>() {
+
+            public String put(String key, String value) {
+                lowerCaseHeader.put(key == null ? key : key.toLowerCase(), value);
+                return super.put(key, value);
+            };
+        };
+
+        /**
+         * copy of the header map with all the keys lowercase for faster
+         * searching.
+         */
+        private final Map<String, String> lowerCaseHeader = new HashMap<String, String>();
+
+        /**
+         * The request method that spawned this response.
+         */
+        private Method requestMethod;
+
+        /**
+         * Use chunkedTransfer
+         */
+        private boolean chunkedTransfer;
+
+        private boolean encodeAsGzip;
+
+        private boolean keepAlive;
+
+        /**
+         * Creates a fixed length response if totalBytes>=0, otherwise chunked.
+         */
+        protected Response(IStatus status, String mimeType, InputStream data, long totalBytes) {
+            this.status = status;
+            this.mimeType = mimeType;
+            if (data == null) {
+                this.data = new ByteArrayInputStream(new byte[0]);
+                this.contentLength = 0L;
+            } else {
+                this.data = data;
+                this.contentLength = totalBytes;
+            }
+            this.chunkedTransfer = this.contentLength < 0;
+            keepAlive = true;
+        }
+
+        @Override
+        public void close() throws IOException {
+            if (this.data != null) {
+                this.data.close();
+            }
+        }
+
+        /**
+         * Adds given line to the header.
+         */
+        public void addHeader(String name, String value) {
+            this.header.put(name, value);
+        }
+
+        /**
+         * Indicate to close the connection after the Response has been sent.
+         * 
+         * @param close
+         *            {@code true} to hint connection closing, {@code false} to
+         *            let connection be closed by client.
+         */
+        public void closeConnection(boolean close) {
+            if (close)
+                this.header.put("connection", "close");
+            else
+                this.header.remove("connection");
+        }
+
+        /**
+         * @return {@code true} if connection is to be closed after this
+         *         Response has been sent.
+         */
+        public boolean isCloseConnection() {
+            return "close".equals(getHeader("connection"));
+        }
+
+        public InputStream getData() {
+            return this.data;
+        }
+
+        public String getHeader(String name) {
+            return this.lowerCaseHeader.get(name.toLowerCase());
+        }
+
+        public String getMimeType() {
+            return this.mimeType;
+        }
+
+        public Method getRequestMethod() {
+            return this.requestMethod;
+        }
+
+        public IStatus getStatus() {
+            return this.status;
+        }
+
+        public void setGzipEncoding(boolean encodeAsGzip) {
+            this.encodeAsGzip = encodeAsGzip;
+        }
+
+        public void setKeepAlive(boolean useKeepAlive) {
+            this.keepAlive = useKeepAlive;
+        }
+
+        /**
+         * Sends given response to the socket.
+         */
+        protected void send(OutputStream outputStream) {
+            SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
+            gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT"));
+
+            try {
+                if (this.status == null) {
+                    throw new Error("sendResponse(): Status can't be null.");
+                }
+                PrintWriter pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(outputStream, new ContentType(this.mimeType).getEncoding())), false);
+                pw.append("HTTP/1.1 ").append(this.status.getDescription()).append(" \r\n");
+                if (this.mimeType != null) {
+                    printHeader(pw, "Content-Type", this.mimeType);
+                }
+                if (getHeader("date") == null) {
+                    printHeader(pw, "Date", gmtFrmt.format(new Date()));
+                }
+                for (Entry<String, String> entry : this.header.entrySet()) {
+                    printHeader(pw, entry.getKey(), entry.getValue());
+                }
+                if (getHeader("connection") == null) {
+                    printHeader(pw, "Connection", (this.keepAlive ? "keep-alive" : "close"));
+                }
+                if (getHeader("content-length") != null) {
+                    encodeAsGzip = false;
+                }
+                if (encodeAsGzip) {
+                    printHeader(pw, "Content-Encoding", "gzip");
+                    setChunkedTransfer(true);
+                }
+                long pending = this.data != null ? this.contentLength : 0;
+                if (this.requestMethod != Method.HEAD && this.chunkedTransfer) {
+                    printHeader(pw, "Transfer-Encoding", "chunked");
+                } else if (!encodeAsGzip) {
+                    pending = sendContentLengthHeaderIfNotAlreadyPresent(pw, pending);
+                }
+                pw.append("\r\n");
+                pw.flush();
+                sendBodyWithCorrectTransferAndEncoding(outputStream, pending);
+                outputStream.flush();
+                safeClose(this.data);
+            } catch (IOException ioe) {
+                NanoHTTPD.LOG.log(Level.SEVERE, "Could not send response to the client", ioe);
+            }
+        }
+
+        @SuppressWarnings("static-method")
+        protected void printHeader(PrintWriter pw, String key, String value) {
+            pw.append(key).append(": ").append(value).append("\r\n");
+        }
+
+        protected long sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, long defaultSize) {
+            String contentLengthString = getHeader("content-length");
+            long size = defaultSize;
+            if (contentLengthString != null) {
+                try {
+                    size = Long.parseLong(contentLengthString);
+                } catch (NumberFormatException ex) {
+                    LOG.severe("content-length was no number " + contentLengthString);
+                }
+            }
+            pw.print("Content-Length: " + size + "\r\n");
+            return size;
+        }
+
+        private void sendBodyWithCorrectTransferAndEncoding(OutputStream outputStream, long pending) throws IOException {
+            if (this.requestMethod != Method.HEAD && this.chunkedTransfer) {
+                ChunkedOutputStream chunkedOutputStream = new ChunkedOutputStream(outputStream);
+                sendBodyWithCorrectEncoding(chunkedOutputStream, -1);
+                chunkedOutputStream.finish();
+            } else {
+                sendBodyWithCorrectEncoding(outputStream, pending);
+            }
+        }
+
+        private void sendBodyWithCorrectEncoding(OutputStream outputStream, long pending) throws IOException {
+            if (encodeAsGzip) {
+                GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream);
+                sendBody(gzipOutputStream, -1);
+                gzipOutputStream.finish();
+            } else {
+                sendBody(outputStream, pending);
+            }
+        }
+
+        /**
+         * Sends the body to the specified OutputStream. The pending parameter
+         * limits the maximum amounts of bytes sent unless it is -1, in which
+         * case everything is sent.
+         * 
+         * @param outputStream
+         *            the OutputStream to send data to
+         * @param pending
+         *            -1 to send everything, otherwise sets a max limit to the
+         *            number of bytes sent
+         * @throws IOException
+         *             if something goes wrong while sending the data.
+         */
+        private void sendBody(OutputStream outputStream, long pending) throws IOException {
+            long BUFFER_SIZE = 16 * 1024;
+            byte[] buff = new byte[(int) BUFFER_SIZE];
+            boolean sendEverything = pending == -1;
+            while (pending > 0 || sendEverything) {
+                long bytesToRead = sendEverything ? BUFFER_SIZE : Math.min(pending, BUFFER_SIZE);
+                int read = this.data.read(buff, 0, (int) bytesToRead);
+                if (read <= 0) {
+                    break;
+                }
+                outputStream.write(buff, 0, read);
+                if (!sendEverything) {
+                    pending -= read;
+                }
+            }
+        }
+
+        public void setChunkedTransfer(boolean chunkedTransfer) {
+            this.chunkedTransfer = chunkedTransfer;
+        }
+
+        public void setData(InputStream data) {
+            this.data = data;
+        }
+
+        public void setMimeType(String mimeType) {
+            this.mimeType = mimeType;
+        }
+
+        public void setRequestMethod(Method requestMethod) {
+            this.requestMethod = requestMethod;
+        }
+
+        public void setStatus(IStatus status) {
+            this.status = status;
+        }
+    }
+
+    public static final class ResponseException extends Exception {
+
+        private static final long serialVersionUID = 6569838532917408380L;
+
+        private final Response.Status status;
+
+        public ResponseException(Response.Status status, String message) {
+            super(message);
+            this.status = status;
+        }
+
+        public ResponseException(Response.Status status, String message, Exception e) {
+            super(message, e);
+            this.status = status;
+        }
+
+        public Response.Status getStatus() {
+            return this.status;
+        }
+    }
+
+    /**
+     * The runnable that will be used for the main listening thread.
+     */
+    public class ServerRunnable implements Runnable {
+
+        private final int timeout;
+
+        private IOException bindException;
+
+        private boolean hasBinded = false;
+
+        public ServerRunnable(int timeout) {
+            this.timeout = timeout;
+        }
+
+        @Override
+        public void run() {
+            try {
+                myServerSocket.bind(hostname != null ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort));
+                hasBinded = true;
+            } catch (IOException e) {
+                this.bindException = e;
+                return;
+            }
+            do {
+                try {
+                    final Socket finalAccept = NanoHTTPD.this.myServerSocket.accept();
+                    if (this.timeout > 0) {
+                        finalAccept.setSoTimeout(this.timeout);
+                    }
+                    final InputStream inputStream = finalAccept.getInputStream();
+                    NanoHTTPD.this.asyncRunner.exec(createClientHandler(finalAccept, inputStream));
+                } catch (IOException e) {
+                    NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e);
+                }
+            } while (!NanoHTTPD.this.myServerSocket.isClosed());
+        }
+    }
+
+    /**
+     * A temp file.
+     * <p/>
+     * <p>
+     * Temp files are responsible for managing the actual temporary storage and
+     * cleaning themselves up when no longer needed.
+     * </p>
+     */
+    public interface TempFile {
+
+        public void delete() throws Exception;
+
+        public String getName();
+
+        public OutputStream open() throws Exception;
+    }
+
+    /**
+     * Temp file manager.
+     * <p/>
+     * <p>
+     * Temp file managers are created 1-to-1 with incoming requests, to create
+     * and cleanup temporary files created as a result of handling the request.
+     * </p>
+     */
+    public interface TempFileManager {
+
+        void clear();
+
+        public TempFile createTempFile(String filename_hint) throws Exception;
+    }
+
+    /**
+     * Factory to create temp file managers.
+     */
+    public interface TempFileManagerFactory {
+
+        public TempFileManager create();
+    }
+
+    /**
+     * Factory to create ServerSocketFactories.
+     */
+    public interface ServerSocketFactory {
+
+        public ServerSocket create() throws IOException;
+
+    }
+
+    /**
+     * Maximum time to wait on Socket.getInputStream().read() (in milliseconds)
+     * This is required as the Keep-Alive HTTP connections would otherwise block
+     * the socket reading thread forever (or as long the browser is open).
+     */
+    public static final int SOCKET_READ_TIMEOUT = 5000;
+
+    /**
+     * Common MIME type for dynamic content: plain text
+     */
+    public static final String MIME_PLAINTEXT = "text/plain";
+
+    /**
+     * Common MIME type for dynamic content: html
+     */
+    public static final String MIME_HTML = "text/html";
+
+    /**
+     * Pseudo-Parameter to use to store the actual query string in the
+     * parameters map for later re-processing.
+     */
+    private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING";
+
+    /**
+     * logger to log to.
+     */
+    private static final Logger LOG = Logger.getLogger(NanoHTTPD.class.getName());
+
+    /**
+     * Hashtable mapping (String)FILENAME_EXTENSION -> (String)MIME_TYPE
+     */
+    protected static Map<String, String> MIME_TYPES;
+
+    public static Map<String, String> mimeTypes() {
+        if (MIME_TYPES == null) {
+            MIME_TYPES = new HashMap<String, String>();
+            loadMimeTypes(MIME_TYPES, "META-INF/nanohttpd/default-mimetypes.properties");
+            loadMimeTypes(MIME_TYPES, "META-INF/nanohttpd/mimetypes.properties");
+            if (MIME_TYPES.isEmpty()) {
+                LOG.log(Level.WARNING, "no mime types found in the classpath! please provide mimetypes.properties");
+            }
+        }
+        return MIME_TYPES;
+    }
+
+    @SuppressWarnings({
+        "unchecked",
+        "rawtypes"
+    })
+    private static void loadMimeTypes(Map<String, String> result, String resourceName) {
+        try {
+            Enumeration<URL> resources = NanoHTTPD.class.getClassLoader().getResources(resourceName);
+            while (resources.hasMoreElements()) {
+                URL url = (URL) resources.nextElement();
+                Properties properties = new Properties();
+                InputStream stream = null;
+                try {
+                    stream = url.openStream();
+                    properties.load(stream);
+                } catch (IOException e) {
+                    LOG.log(Level.SEVERE, "could not load mimetypes from " + url, e);
+                } finally {
+                    safeClose(stream);
+                }
+                result.putAll((Map) properties);
+            }
+        } catch (IOException e) {
+            LOG.log(Level.INFO, "no mime types available at " + resourceName);
+        }
+    };
+
+    /**
+     * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and an
+     * array of loaded KeyManagers. These objects must properly
+     * loaded/initialized by the caller.
+     */
+    public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManager[] keyManagers) throws IOException {
+        SSLServerSocketFactory res = null;
+        try {
+            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+            trustManagerFactory.init(loadedKeyStore);
+            SSLContext ctx = SSLContext.getInstance("TLS");
+            ctx.init(keyManagers, trustManagerFactory.getTrustManagers(), null);
+            res = ctx.getServerSocketFactory();
+        } catch (Exception e) {
+            throw new IOException(e.getMessage());
+        }
+        return res;
+    }
+
+    /**
+     * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and a
+     * loaded KeyManagerFactory. These objects must properly loaded/initialized
+     * by the caller.
+     */
+    public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManagerFactory loadedKeyFactory) throws IOException {
+        try {
+            return makeSSLSocketFactory(loadedKeyStore, loadedKeyFactory.getKeyManagers());
+        } catch (Exception e) {
+            throw new IOException(e.getMessage());
+        }
+    }
+
+    /**
+     * Creates an SSLSocketFactory for HTTPS. Pass a KeyStore resource with your
+     * certificate and passphrase
+     */
+    public static SSLServerSocketFactory makeSSLSocketFactory(String keyAndTrustStoreClasspathPath, char[] passphrase) throws IOException {
+        try {
+            KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
+            InputStream keystoreStream = NanoHTTPD.class.getResourceAsStream(keyAndTrustStoreClasspathPath);
+
+            if (keystoreStream == null) {
+                throw new IOException("Unable to load keystore from classpath: " + keyAndTrustStoreClasspathPath);
+            }
+
+            keystore.load(keystoreStream, passphrase);
+            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+            keyManagerFactory.init(keystore, passphrase);
+            return makeSSLSocketFactory(keystore, keyManagerFactory);
+        } catch (Exception e) {
+            throw new IOException(e.getMessage());
+        }
+    }
+
+    /**
+     * Get MIME type from file name extension, if possible
+     * 
+     * @param uri
+     *            the string representing a file
+     * @return the connected mime/type
+     */
+    public static String getMimeTypeForFile(String uri) {
+        int dot = uri.lastIndexOf('.');
+        String mime = null;
+        if (dot >= 0) {
+            mime = mimeTypes().get(uri.substring(dot + 1).toLowerCase());
+        }
+        return mime == null ? "application/octet-stream" : mime;
+    }
+
+    private static final void safeClose(Object closeable) {
+        try {
+            if (closeable != null) {
+                if (closeable instanceof Closeable) {
+                    ((Closeable) closeable).close();
+                } else if (closeable instanceof Socket) {
+                    ((Socket) closeable).close();
+                } else if (closeable instanceof ServerSocket) {
+                    ((ServerSocket) closeable).close();
+                } else {
+                    throw new IllegalArgumentException("Unknown object to close");
+                }
+            }
+        } catch (IOException e) {
+            NanoHTTPD.LOG.log(Level.SEVERE, "Could not close", e);
+        }
+    }
+
+    private final String hostname;
+
+    private final int myPort;
+
+    private volatile ServerSocket myServerSocket;
+
+    private ServerSocketFactory serverSocketFactory = new DefaultServerSocketFactory();
+
+    private Thread myThread;
+
+    /**
+     * Pluggable strategy for asynchronously executing requests.
+     */
+    protected AsyncRunner asyncRunner;
+
+    /**
+     * Pluggable strategy for creating and cleaning up temporary files.
+     */
+    private TempFileManagerFactory tempFileManagerFactory;
+
+    /**
+     * Constructs an HTTP server on given port.
+     */
+    public NanoHTTPD(int port) {
+        this(null, port);
+    }
+
+    // -------------------------------------------------------------------------------
+    // //
+    //
+    // Threading Strategy.
+    //
+    // -------------------------------------------------------------------------------
+    // //
+
+    /**
+     * Constructs an HTTP server on given hostname and port.
+     */
+    public NanoHTTPD(String hostname, int port) {
+        this.hostname = hostname;
+        this.myPort = port;
+        setTempFileManagerFactory(new DefaultTempFileManagerFactory());
+        setAsyncRunner(new DefaultAsyncRunner());
+    }
+
+    /**
+     * Forcibly closes all connections that are open.
+     */
+    public synchronized void closeAllConnections() {
+        stop();
+    }
+
+    /**
+     * create a instance of the client handler, subclasses can return a subclass
+     * of the ClientHandler.
+     * 
+     * @param finalAccept
+     *            the socket the cleint is connected to
+     * @param inputStream
+     *            the input stream
+     * @return the client handler
+     */
+    protected ClientHandler createClientHandler(final Socket finalAccept, final InputStream inputStream) {
+        return new ClientHandler(inputStream, finalAccept);
+    }
+
+    /**
+     * Instantiate the server runnable, can be overwritten by subclasses to
+     * provide a subclass of the ServerRunnable.
+     * 
+     * @param timeout
+     *            the socet timeout to use.
+     * @return the server runnable.
+     */
+    protected ServerRunnable createServerRunnable(final int timeout) {
+        return new ServerRunnable(timeout);
+    }
+
+    /**
+     * Decode parameters from a URL, handing the case where a single parameter
+     * name might have been supplied several times, by return lists of values.
+     * In general these lists will contain a single element.
+     * 
+     * @param parms
+     *            original <b>NanoHTTPD</b> parameters values, as passed to the
+     *            <code>serve()</code> method.
+     * @return a map of <code>String</code> (parameter name) to
+     *         <code>List&lt;String&gt;</code> (a list of the values supplied).
+     */
+    protected static Map<String, List<String>> decodeParameters(Map<String, String> parms) {
+        return decodeParameters(parms.get(NanoHTTPD.QUERY_STRING_PARAMETER));
+    }
+
+    // -------------------------------------------------------------------------------
+    // //
+
+    /**
+     * Decode parameters from a URL, handing the case where a single parameter
+     * name might have been supplied several times, by return lists of values.
+     * In general these lists will contain a single element.
+     * 
+     * @param queryString
+     *            a query string pulled from the URL.
+     * @return a map of <code>String</code> (parameter name) to
+     *         <code>List&lt;String&gt;</code> (a list of the values supplied).
+     */
+    protected static Map<String, List<String>> decodeParameters(String queryString) {
+        Map<String, List<String>> parms = new HashMap<String, List<String>>();
+        if (queryString != null) {
+            StringTokenizer st = new StringTokenizer(queryString, "&");
+            while (st.hasMoreTokens()) {
+                String e = st.nextToken();
+                int sep = e.indexOf('=');
+                String propertyName = sep >= 0 ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim();
+                if (!parms.containsKey(propertyName)) {
+                    parms.put(propertyName, new ArrayList<String>());
+                }
+                String propertyValue = sep >= 0 ? decodePercent(e.substring(sep + 1)) : null;
+                if (propertyValue != null) {
+                    parms.get(propertyName).add(propertyValue);
+                }
+            }
+        }
+        return parms;
+    }
+
+    /**
+     * Decode percent encoded <code>String</code> values.
+     * 
+     * @param str
+     *            the percent encoded <code>String</code>
+     * @return expanded form of the input, for example "foo%20bar" becomes
+     *         "foo bar"
+     */
+    protected static String decodePercent(String str) {
+        String decoded = null;
+        try {
+            decoded = URLDecoder.decode(str, "UTF8");
+        } catch (UnsupportedEncodingException ignored) {
+            NanoHTTPD.LOG.log(Level.WARNING, "Encoding not supported, ignored", ignored);
+        }
+        return decoded;
+    }
+
+    /**
+     * @return true if the gzip compression should be used if the client
+     *         accespts it. Default this option is on for text content and off
+     *         for everything. Override this for custom semantics.
+     */
+    @SuppressWarnings("static-method")
+    protected boolean useGzipWhenAccepted(Response r) {
+        return r.getMimeType() != null && (r.getMimeType().toLowerCase().contains("text/") || r.getMimeType().toLowerCase().contains("/json"));
+    }
+
+    public final int getListeningPort() {
+        return this.myServerSocket == null ? -1 : this.myServerSocket.getLocalPort();
+    }
+
+    public final boolean isAlive() {
+        return wasStarted() && !this.myServerSocket.isClosed() && this.myThread.isAlive();
+    }
+
+    public ServerSocketFactory getServerSocketFactory() {
+        return serverSocketFactory;
+    }
+
+    public void setServerSocketFactory(ServerSocketFactory serverSocketFactory) {
+        this.serverSocketFactory = serverSocketFactory;
+    }
+
+    public String getHostname() {
+        return hostname;
+    }
+
+    public TempFileManagerFactory getTempFileManagerFactory() {
+        return tempFileManagerFactory;
+    }
+
+    /**
+     * Call before start() to serve over HTTPS instead of HTTP
+     */
+    public void makeSecure(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) {
+        this.serverSocketFactory = new SecureServerSocketFactory(sslServerSocketFactory, sslProtocols);
+    }
+
+    /**
+     * Create a response with unknown length (using HTTP 1.1 chunking).
+     */
+    public static Response newChunkedResponse(IStatus status, String mimeType, InputStream data) {
+        return new Response(status, mimeType, data, -1);
+    }
+
+    /**
+     * Create a response with known length.
+     */
+    public static Response newFixedLengthResponse(IStatus status, String mimeType, InputStream data, long totalBytes) {
+        return new Response(status, mimeType, data, totalBytes);
+    }
+
+    /**
+     * Create a text response with known length.
+     */
+    public static Response newFixedLengthResponse(IStatus status, String mimeType, String txt) {
+        ContentType contentType = new ContentType(mimeType);
+        if (txt == null) {
+            return newFixedLengthResponse(status, mimeType, new ByteArrayInputStream(new byte[0]), 0);
+        } else {
+            byte[] bytes;
+            try {
+                CharsetEncoder newEncoder = Charset.forName(contentType.getEncoding()).newEncoder();
+                if (!newEncoder.canEncode(txt)) {
+                    contentType = contentType.tryUTF8();
+                }
+                bytes = txt.getBytes(contentType.getEncoding());
+            } catch (UnsupportedEncodingException e) {
+                NanoHTTPD.LOG.log(Level.SEVERE, "encoding problem, responding nothing", e);
+                bytes = new byte[0];
+            }
+            return newFixedLengthResponse(status, contentType.getContentTypeHeader(), new ByteArrayInputStream(bytes), bytes.length);
+        }
+    }
+
+    /**
+     * Create a text response with known length.
+     */
+    public static Response newFixedLengthResponse(String msg) {
+        return newFixedLengthResponse(Status.OK, NanoHTTPD.MIME_HTML, msg);
+    }
+
+    /**
+     * Override this to customize the server.
+     * <p/>
+     * <p/>
+     * (By default, this returns a 404 "Not Found" plain text error response.)
+     * 
+     * @param session
+     *            The HTTP session
+     * @return HTTP response, see class Response for details
+     */
+    public Response serve(IHTTPSession session) {
+        Map<String, String> files = new HashMap<String, String>();
+        Method method = session.getMethod();
+        if (Method.PUT.equals(method) || Method.POST.equals(method)) {
+            try {
+                session.parseBody(files);
+            } catch (IOException ioe) {
+                return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
+            } catch (ResponseException re) {
+                return newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage());
+            }
+        }
+
+        Map<String, String> parms = session.getParms();
+        parms.put(NanoHTTPD.QUERY_STRING_PARAMETER, session.getQueryParameterString());
+        return serve(session.getUri(), method, session.getHeaders(), parms, files);
+    }
+
+    /**
+     * Override this to customize the server.
+     * <p/>
+     * <p/>
+     * (By default, this returns a 404 "Not Found" plain text error response.)
+     * 
+     * @param uri
+     *            Percent-decoded URI without parameters, for example
+     *            "/index.cgi"
+     * @param method
+     *            "GET", "POST" etc.
+     * @param parms
+     *            Parsed, percent decoded parameters from URI and, in case of
+     *            POST, data.
+     * @param headers
+     *            Header entries, percent decoded
+     * @return HTTP response, see class Response for details
+     */
+    @Deprecated
+    public Response serve(String uri, Method method, Map<String, String> headers, Map<String, String> parms, Map<String, String> files) {
+        return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not Found");
+    }
+
+    /**
+     * Pluggable strategy for asynchronously executing requests.
+     * 
+     * @param asyncRunner
+     *            new strategy for handling threads.
+     */
+    public void setAsyncRunner(AsyncRunner asyncRunner) {
+        this.asyncRunner = asyncRunner;
+    }
+
+    /**
+     * Pluggable strategy for creating and cleaning up temporary files.
+     * 
+     * @param tempFileManagerFactory
+     *            new strategy for handling temp files.
+     */
+    public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) {
+        this.tempFileManagerFactory = tempFileManagerFactory;
+    }
+
+    /**
+     * Start the server.
+     * 
+     * @throws IOException
+     *             if the socket is in use.
+     */
+    public void start() throws IOException {
+        start(NanoHTTPD.SOCKET_READ_TIMEOUT);
+    }
+
+    /**
+     * Starts the server (in setDaemon(true) mode).
+     */
+    public void start(final int timeout) throws IOException {
+        start(timeout, true);
+    }
+
+    /**
+     * Start the server.
+     * 
+     * @param timeout
+     *            timeout to use for socket connections.
+     * @param daemon
+     *            start the thread daemon or not.
+     * @throws IOException
+     *             if the socket is in use.
+     */
+    public void start(final int timeout, boolean daemon) throws IOException {
+        this.myServerSocket = this.getServerSocketFactory().create();
+        this.myServerSocket.setReuseAddress(true);
+
+        ServerRunnable serverRunnable = createServerRunnable(timeout);
+        this.myThread = new Thread(serverRunnable);
+        this.myThread.setDaemon(daemon);
+        this.myThread.setName("NanoHttpd Main Listener");
+        this.myThread.start();
+        while (!serverRunnable.hasBinded && serverRunnable.bindException == null) {
+            try {
+                Thread.sleep(10L);
+            } catch (Throwable e) {
+                // on android this may not be allowed, that's why we
+                // catch throwable the wait should be very short because we are
+                // just waiting for the bind of the socket
+            }
+        }
+        if (serverRunnable.bindException != null) {
+            throw serverRunnable.bindException;
+        }
+    }
+
+    /**
+     * Stop the server.
+     */
+    public void stop() {
+        try {
+            safeClose(this.myServerSocket);
+            this.asyncRunner.closeAll();
+            if (this.myThread != null) {
+                this.myThread.join();
+            }
+        } catch (Exception e) {
+            NanoHTTPD.LOG.log(Level.SEVERE, "Could not stop all connections", e);
+        }
+    }
+
+    public final boolean wasStarted() {
+        return this.myServerSocket != null && this.myThread != null;
+    }
+}
diff --git a/Progress.java b/Progress.java
new file mode 100644 (file)
index 0000000..748d4a6
--- /dev/null
@@ -0,0 +1,495 @@
+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.
+ * <p>
+ * A {@link Progress} can have a name, and that name will be reported through
+ * the event system (it will report the first non-null name in the stack from
+ * the {@link Progress} from which the event originated to the parent the event
+ * is listened on).
+ * <p>
+ * The {@link Progress} also has a table of keys/values shared amongst all the
+ * hierarchy (note that when adding a {@link Progress} to others, its values
+ * will be prioritized if some with the same keys were already present in the
+ * hierarchy).
+ * 
+ * @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 Map<Object, Object> map = new HashMap<Object, Object>();
+       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) {
+                       // Should not happen but just in case
+                       if (this.map != progress.map) {
+                               this.map.putAll(progress.map);
+                       }
+                       progress.map = this.map;
+                       progress.parent = this;
+                       this.children.put(progress, weight);
+                       progress.addProgressListener(progressListener);
+               }
+       }
+
+       /**
+        * Set the given value for the given key on this {@link Progress} and it's
+        * children.
+        * 
+        * @param key
+        *            the key
+        * @param value
+        *            the value
+        */
+       public void put(Object key, Object value) {
+               map.put(key, value);
+       }
+
+       /**
+        * Return the value associated with this key as a {@link String} if any,
+        * NULL if not.
+        * <p>
+        * If the value is not NULL but not a {@link String}, it will be converted
+        * via {@link Object#toString()}.
+        * 
+        * @param key
+        *            the key to check
+        * 
+        * @return the value or NULL
+        */
+       public String getString(Object key) {
+               Object value = map.get(key);
+               if (value == null) {
+                       return null;
+               }
+
+               return value.toString();
+       }
+
+       /**
+        * Return the value associated with this key if any, NULL if not.
+        * 
+        * @param key
+        *            the key to check
+        * 
+        * @return the value or NULL
+        */
+       public Object get(Object key) {
+               return map.get(key);
+       }
+}
diff --git a/Proxy.java b/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/StringJustifier.java b/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/StringUtils.java b/StringUtils.java
new file mode 100644 (file)
index 0000000..be1c654
--- /dev/null
@@ -0,0 +1,1165 @@
+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
+        * 
+        * @deprecated please use {@link HashUtils}
+        */
+       @Deprecated
+       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/TempFiles.java b/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/TraceHandler.java b/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/VERSION b/VERSION
deleted file mode 100644 (file)
index 47ae1f7..0000000
--- a/VERSION
+++ /dev/null
@@ -1 +0,0 @@
-3.1.2-dev
diff --git a/Version.java b/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/VersionCheck.java b/VersionCheck.java
new file mode 100644 (file)
index 0000000..0e16c2b
--- /dev/null
@@ -0,0 +1,172 @@
+package be.nikiroo.utils;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Version checker: can check the current version of the program against a
+ * remote changelog, and list the missed updates and their description.
+ * 
+ * @author niki
+ */
+public class VersionCheck {
+       private static final String base = "https://github.com/${PROJECT}/raw/master/changelog${LANG}.md";
+       private static Downloader downloader = new Downloader(null);
+
+       private Version current;
+       private List<Version> newer;
+       private Map<Version, List<String>> changes;
+
+       /**
+        * Create a new {@link VersionCheck}.
+        * 
+        * @param current
+        *            the current version of the program
+        * @param newer
+        *            the list of available {@link Version}s newer the current one
+        * @param changes
+        *            the list of changes
+        */
+       private VersionCheck(Version current, List<Version> newer,
+                       Map<Version, List<String>> changes) {
+               this.current = current;
+               this.newer = newer;
+               this.changes = changes;
+       }
+
+       /**
+        * Check if there are more recent {@link Version}s of this program
+        * available.
+        * 
+        * @return TRUE if there is at least one
+        */
+       public boolean isNewVersionAvailable() {
+               return !newer.isEmpty();
+       }
+
+       /**
+        * The current {@link Version} of the program.
+        * 
+        * @return the current {@link Version}
+        */
+       public Version getCurrentVersion() {
+               return current;
+       }
+
+       /**
+        * The list of available {@link Version}s newer than the current one.
+        * 
+        * @return the newer {@link Version}s
+        */
+       public List<Version> getNewer() {
+               return newer;
+       }
+
+       /**
+        * The list of changes for each available {@link Version} newer than the
+        * current one.
+        * 
+        * @return the list of changes
+        */
+       public Map<Version, List<String>> getChanges() {
+               return changes;
+       }
+
+       /**
+        * Check if there are available {@link Version}s of this program more recent
+        * than the current one.
+        * 
+        * @param githubProject
+        *            the GitHub project to check on, for instance "nikiroo/fanfix"
+        * @param lang
+        *            the current locale, so we can try to get the changelog in the
+        *            correct language (can be NULL, will fetch the default
+        *            changelog)
+        * 
+        * @return a {@link VersionCheck}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static VersionCheck check(String githubProject, Locale lang)
+                       throws IOException {
+               Version current = Version.getCurrentVersion();
+               List<Version> newer = new ArrayList<Version>();
+               Map<Version, List<String>> changes = new HashMap<Version, List<String>>();
+
+               // Use the right project:
+               String base = VersionCheck.base.replace("${PROJECT}", githubProject);
+
+               // Prepare the URLs according to the user's language (we take here
+               // "-fr_BE" as an example):
+               String fr = lang == null ? "" : "-" + lang.getLanguage();
+               String BE = lang == null ? ""
+                               : "_" + lang.getCountry().replace(".UTF8", "");
+               String urlFrBE = base.replace("${LANG}", fr + BE);
+               String urlFr = base.replace("${LANG}", "-" + fr);
+               String urlDefault = base.replace("${LANG}", "");
+
+               InputStream in = null;
+               for (String url : new String[] { urlFrBE, urlFr, urlDefault }) {
+                       try {
+                               in = downloader.open(new URL(url), false);
+                               break;
+                       } catch (IOException e) {
+                       }
+               }
+
+               if (in == null) {
+                       throw new IOException("No changelog found");
+               }
+
+               BufferedReader reader = new BufferedReader(
+                               new InputStreamReader(in, "UTF-8"));
+               try {
+                       Version version = new Version();
+                       for (String line = reader.readLine(); line != null; line = reader
+                                       .readLine()) {
+                               if (line.startsWith("## Version ")) {
+                                       version = new Version(
+                                                       line.substring("## Version ".length()));
+                                       if (version.isNewerThan(current)) {
+                                               newer.add(version);
+                                               changes.put(version, new ArrayList<String>());
+                                       } else {
+                                               version = new Version();
+                                       }
+                               } else if (!version.isEmpty() && !newer.isEmpty()
+                                               && !line.isEmpty()) {
+                                       List<String> ch = changes.get(newer.get(newer.size() - 1));
+                                       if (!ch.isEmpty() && !line.startsWith("- ")) {
+                                               int i = ch.size() - 1;
+                                               ch.set(i, ch.get(i) + " " + line.trim());
+                                       } else {
+                                               ch.add(line.substring("- ".length()).trim());
+                                       }
+                               }
+                       }
+               } finally {
+                       reader.close();
+               }
+
+               return new VersionCheck(current, newer, changes);
+       }
+       
+       @Override
+       public String toString() {
+               return String.format(
+                               "Version checker: version [%s], %d releases behind latest version [%s]", //
+                               current, //
+                               newer.size(), //
+                               newer.isEmpty() ? current : newer.get(newer.size() - 1)//
+               );
+       }
+}
diff --git a/android/ImageUtilsAndroid.class b/android/ImageUtilsAndroid.class
new file mode 100644 (file)
index 0000000..844712a
Binary files /dev/null and b/android/ImageUtilsAndroid.class differ
diff --git a/android/ImageUtilsAndroid.java b/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/android/test/TestAndroid.class b/android/test/TestAndroid.class
new file mode 100644 (file)
index 0000000..216aa20
Binary files /dev/null and b/android/test/TestAndroid.class differ
diff --git a/android/test/TestAndroid.java b/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/bundles/Config.java b/bundles/Config.java
deleted file mode 100644 (file)
index 86744b4..0000000
+++ /dev/null
@@ -1,188 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import be.nikiroo.utils.resources.Meta;
-import be.nikiroo.utils.resources.Meta.Format;
-
-/**
- * The configuration options.
- * 
- * @author niki
- */
-@SuppressWarnings("javadoc")
-public enum Config {
-       
-       // Note: all hidden values are subject to be removed in a later version
-       
-       @Meta(description = "The language to use for in the program (example: en-GB, fr-BE...) or nothing for default system language (can be overwritten with the variable $LANG)",//
-       format = Format.LOCALE, list = { "en-GB", "fr-BE" })
-       LANG, //
-       @Meta(description = "File format options",//
-       group = true)
-       FILE_FORMAT, //
-       @Meta(description = "How to save non-images documents in the library",//
-       format = Format.FIXED_LIST, list = { "INFO_TEXT", "EPUB", "HTML", "TEXT" }, def = "INFO_TEXT")
-       FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE, //
-       @Meta(description = "How to save images documents in the library",//
-       format = Format.FIXED_LIST, list = { "CBZ", "HTML" }, def = "CBZ")
-       FILE_FORMAT_IMAGES_DOCUMENT_TYPE, //
-       @Meta(description = "How to save cover images",//
-       format = Format.FIXED_LIST, list = { "PNG", "JPG", "BMP" }, def = "PNG")
-       FILE_FORMAT_IMAGE_FORMAT_COVER, //
-       @Meta(description = "How to save content images",//
-       format = Format.FIXED_LIST, list = { "PNG", "JPG", "BMP" }, def = "JPG")
-       FILE_FORMAT_IMAGE_FORMAT_CONTENT, //
-
-       @Meta(description = "Cache management",//
-       group = true)
-       CACHE, //
-       @Meta(description = "The directory where to store temporary files; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
-       format = Format.DIRECTORY, def = "tmp/")
-       CACHE_DIR, //
-       @Meta(description = "The delay in hours after which a cached resource that is thought to change ~often is considered too old and triggers a refresh delay (or 0 for no cache, or -1 for infinite time)", //
-       format = Format.INT, def = "24")
-       CACHE_MAX_TIME_CHANGING, //
-       @Meta(description = "The delay in hours after which a cached resource that is thought to change rarely is considered too old and triggers a refresh delay (or 0 for no cache, or -1 for infinite time)", //
-       format = Format.INT, def = "720")
-       CACHE_MAX_TIME_STABLE, //
-
-       @Meta(description = "The directory where to get the default story covers; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
-       format = Format.DIRECTORY, def = "covers/")
-       DEFAULT_COVERS_DIR, //
-       @Meta(description = "The directory where to store the library (can be overriden by the environment variable \"BOOKS_DIR\"; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
-       format = Format.DIRECTORY, def = "$HOME/Books/")
-       LIBRARY_DIR, //
-
-       @Meta(description = "Remote library\nA remote library can be configured to fetch the stories from a remote Fanfix server",//
-       group = true)
-       REMOTE_LIBRARY, //
-       @Meta(description = "Use the remote Fanfix server configured here instead of the local library (if FALSE, the local library will be used instead)",//
-       format = Format.BOOLEAN, def = "false")
-       REMOTE_LIBRARY_ENABLED, //
-       @Meta(description = "The remote Fanfix server to connect to (fanfix://, http://, https:// -- if not specified, fanfix:// is assumed)",//
-       format = Format.STRING)
-       REMOTE_LIBRARY_HOST, //
-       @Meta(description = "The port to use for the remote Fanfix server",//
-       format = Format.INT, def = "58365")
-       REMOTE_LIBRARY_PORT, //
-       @Meta(description = "The key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
-       format = Format.PASSWORD)
-       REMOTE_LIBRARY_KEY, //
-
-       @Meta(description = "Network configuration",//
-       group = true)
-       NETWORK, //
-       @Meta(description = "The user-agent to use to download files",//
-       def = "Mozilla/5.0 (X11; Linux x86_64; rv:44.0) Gecko/20100101 Firefox/44.0 -- ELinks/0.9.3 (Linux 2.6.11 i686; 80x24) -- Fanfix (https://github.com/nikiroo/fanfix/)")
-       NETWORK_USER_AGENT, //
-       @Meta(description = "The proxy server to use under the format 'user:pass@proxy:port', 'user@proxy:port', 'proxy:port' or ':' alone (system proxy); an empty String means no proxy",//
-       format = Format.STRING, def = "")
-       NETWORK_PROXY, //
-       @Meta(description = "If the last update check was done at least that many days ago, check for updates at startup (-1 for 'no checks')", //
-       format = Format.INT, def = "1")
-       NETWORK_UPDATE_INTERVAL, //
-
-       @Meta(description = "Remote Server configuration\nNote that the key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
-       group = true)
-       SERVER, //
-       @Meta(description = "Remote Server mode: you can use the fanfix protocol (which is encrypted), http (which is not) or https (which requires a keystore.jks file)",//
-       format = Format.FIXED_LIST, list = { "fanfix", "http", "https" }, def = "fanfix")
-       SERVER_MODE,
-       @Meta(description = "The port on which we can start the server (must be a valid port, from 1 to 65535)", //
-       format = Format.INT, def = "58365")
-       SERVER_PORT, //
-       @Meta(description = "A keystore.jks file, required to use HTTPS (the server will refuse to start in HTTPS mode without this file)", //
-       format = Format.STRING, def = "")
-       SERVER_SSL_KEYSTORE,
-       @Meta(description = "The pass phrase required to open the keystore.jks file (required for HTTPS mode)", //
-       format = Format.PASSWORD, def = "")
-       SERVER_SSL_KEYSTORE_PASS,
-       @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty -- is used to encrypt the traffic in fanfix mode (even if empty, traffic will be encrypted in fanfix mode), and used as a password for HTTP (clear text protocol) and HTTPS modes",//
-       format = Format.PASSWORD, def = "")
-       SERVER_KEY, //
-       @Meta(description = "Allow write access to the clients (download story, move story...) without RW subkeys", //
-       format = Format.BOOLEAN, def = "true")
-       SERVER_RW, //
-       @Meta(description = "If not empty, only the EXACT listed sources will be available for clients without a WL subkey",//
-       array = true, format = Format.STRING, def = "")
-       SERVER_WHITELIST, //
-       @Meta(description = "Those sources will not be available for clients without a BL subkey",//
-       array = true, format = Format.STRING, def = "")
-       SERVER_BLACKLIST, //
-       @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey is used as a login for HTTP (clear text protocol) and HTTPS modes", //
-       array = true, format = Format.STRING, def = "")
-       SERVER_ALLOWED_SUBKEYS, //
-       @Meta(description = "The maximum size of the cache, in MegaBytes, for HTTP and HTTPS servers", //
-       format = Format.INT, def = "100")
-       SERVER_MAX_CACHE_MB,
-
-       @Meta(description = "DEBUG options",//
-       group = true)
-       DEBUG, //
-       @Meta(description = "Show debug information on errors",//
-       format = Format.BOOLEAN, def = "false")
-       DEBUG_ERR, //
-       @Meta(description = "Show debug trace information",//
-       format = Format.BOOLEAN, def = "false")
-       DEBUG_TRACE, //
-
-       @Meta(description = "Internal configuration\nThose options are internal to the program and should probably not be changed",//
-       hidden = true, group = true)
-       CONF, //
-       @Meta(description = "LaTeX configuration",//
-       hidden = true, group = true)
-       CONF_LATEX_LANG, //
-       @Meta(description = "LaTeX output language (full name) for \"English\"",//
-       hidden = true, format = Format.STRING, def = "english")
-       CONF_LATEX_LANG_EN, //
-       @Meta(description = "LaTeX output language (full name) for \"French\"",//
-       hidden = true, format = Format.STRING, def = "french")
-       CONF_LATEX_LANG_FR, //
-       @Meta(description = "other 'by' prefixes before author name, used to identify the author",//
-       hidden = true, array = true, format = Format.STRING, def = "\"by\",\"par\",\"de\",\"©\",\"(c)\"")
-       CONF_BYS, //
-       @Meta(description = "List of languages codes used for chapter identification (should not be changed)", //
-       hidden = true, array = true, format = Format.STRING, def = "\"EN\",\"FR\"")
-       CONF_CHAPTER, //
-       @Meta(description = "Chapter identification string in English, used to identify a starting chapter in text mode",//
-       hidden = true, format = Format.STRING, def = "Chapter")
-       CONF_CHAPTER_EN, //
-       @Meta(description = "Chapter identification string in French, used to identify a starting chapter in text mode",//
-       hidden = true, format = Format.STRING, def = "Chapitre")
-       CONF_CHAPTER_FR, //
-
-       @Meta(description = "YiffStar/SoFurry credentials\nYou can give your YiffStar credentials here to have access to all the stories, though it should not be necessary anymore (some stories used to beblocked for anonymous viewers)",//
-       group = true)
-       LOGIN_YIFFSTAR, //
-       @Meta(description = "Your YiffStar/SoFurry login",//
-       format = Format.STRING)
-       LOGIN_YIFFSTAR_USER, //
-       @Meta(description = "Your YiffStar/SoFurry password",//
-       format = Format.PASSWORD)
-       LOGIN_YIFFSTAR_PASS, //
-
-       @Meta(description = "FimFiction APIKEY credentials\nFimFiction can be queried via an API, but requires an API key to do that. One has been created for this program, but if you have another API key you can set it here. You can also set a login and password instead, in that case, a new API key will be generated (and stored) if you still haven't set one.",//
-       group = true)
-       LOGIN_FIMFICTION_APIKEY, //
-       @Meta(description = "The login of the API key used to create a new token from FimFiction", //
-       format = Format.STRING)
-       LOGIN_FIMFICTION_APIKEY_CLIENT_ID, //
-       @Meta(description = "The password of the API key used to create a new token from FimFiction", //
-       format = Format.PASSWORD)
-       LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET, //
-       @Meta(description = "Do not use the new API, even if we have a token, and force HTML scraping",//
-       format = Format.BOOLEAN, def = "false")
-       LOGIN_FIMFICTION_APIKEY_FORCE_HTML, //
-       @Meta(description = "The token required to use the beta APIv2 from FimFiction (see APIKEY_CLIENT_* if you want to generate a new one from your own API key)", //
-       format = Format.PASSWORD, def = "Bearer WnZ5oHlzQoDocv1GcgHfcoqctHkSwL-D")
-       LOGIN_FIMFICTION_APIKEY_TOKEN, //
-       
-       @Meta(description = "e621.net credentials\nYou can give your e621.net credentials here to have access to all the comics and ignore the default blacklist",//
-       group = true)
-       LOGIN_E621, //
-       @Meta(description = "Your e621.net login",//
-       format = Format.STRING)
-       LOGIN_E621_LOGIN, //
-       @Meta(description = "Your e621.net API KEY",//
-       format = Format.PASSWORD)
-       LOGIN_E621_APIKEY, //
-}
diff --git a/bundles/ConfigBundle.java b/bundles/ConfigBundle.java
deleted file mode 100644 (file)
index ce72b3d..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.File;
-import java.io.IOException;
-
-import be.nikiroo.utils.resources.Bundle;
-
-/**
- * This class manages the configuration of the application.
- * 
- * @author niki
- */
-public class ConfigBundle extends Bundle<Config> {
-       /**
-        * Create a new {@link ConfigBundle}.
-        */
-       public ConfigBundle() {
-               super(Config.class, Target.config5, null);
-       }
-
-       /**
-        * Update resource file.
-        * 
-        * @param args
-        *            not used
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public static void main(String[] args) throws IOException {
-               String path = new File(".").getAbsolutePath()
-                               + "/src/be/nikiroo/fanfix/bundles/";
-               new ConfigBundle().updateFile(path);
-               System.out.println("Path updated: " + path);
-       }
-
-       @Override
-       protected String getBundleDisplayName() {
-               return "Configuration options";
-       }
-}
diff --git a/bundles/StringId.java b/bundles/StringId.java
deleted file mode 100644 (file)
index 9772248..0000000
+++ /dev/null
@@ -1,151 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.IOException;
-import java.io.Writer;
-
-import be.nikiroo.utils.resources.Bundle;
-import be.nikiroo.utils.resources.Meta;
-
-/**
- * The {@link Enum} representing textual information to be translated to the
- * user as a key.
- * 
- * Note that each key that should be translated <b>must</b> be annotated with a
- * {@link Meta} annotation.
- * 
- * @author niki
- */
-@SuppressWarnings("javadoc")
-public enum StringId {
-       /**
-        * A special key used for technical reasons only, without annotations so it
-        * is not visible in <tt>.properties</tt> files.
-        * <p>
-        * Use it when you need NO translation.
-        */
-       NULL, //
-       /**
-        * A special key used for technical reasons only, without annotations so it
-        * is not visible in <tt>.properties</tt> files.
-        * <p>
-        * Use it when you need a real translation but still don't have a key.
-        */
-       DUMMY, //
-       @Meta(info = "%s = supported input, %s = supported output", description = "help message for the syntax")
-       HELP_SYNTAX, //
-       @Meta(description = "syntax error message")
-       ERR_SYNTAX, //
-       @Meta(info = "%s = support name, %s = support desc", description = "an input or output support type description")
-       ERR_SYNTAX_TYPE, //
-       @Meta(info = "%s = input string", description = "Error when retrieving data")
-       ERR_LOADING, //
-       @Meta(info = "%s = save target", description = "Error when saving to given target")
-       ERR_SAVING, //
-       @Meta(info = "%s = bad output format", description = "Error when unknown output format")
-       ERR_BAD_OUTPUT_TYPE, //
-       @Meta(info = "%s = input string", description = "Error when converting input to URL/File")
-       ERR_BAD_URL, //
-       @Meta(info = "%s = input url", description = "URL/File not supported")
-       ERR_NOT_SUPPORTED, //
-       @Meta(info = "%s = cover URL", description = "Failed to download cover : %s")
-       ERR_BS_NO_COVER, //
-       @Meta(def = "`", info = "single char", description = "Canonical OPEN SINGLE QUOTE char (for instance: ‘)")
-       OPEN_SINGLE_QUOTE, //
-       @Meta(def = "‘", info = "single char", description = "Canonical CLOSE SINGLE QUOTE char (for instance: ’)")
-       CLOSE_SINGLE_QUOTE, //
-       @Meta(def = "“", info = "single char", description = "Canonical OPEN DOUBLE QUOTE char (for instance: “)")
-       OPEN_DOUBLE_QUOTE, //
-       @Meta(def = "”", info = "single char", description = "Canonical CLOSE DOUBLE QUOTE char (for instance: ”)")
-       CLOSE_DOUBLE_QUOTE, //
-       @Meta(def = "Description", description = "Name of the description fake chapter")
-       DESCRIPTION, //
-       @Meta(def = "Chapter %d: %s", info = "%d = number, %s = name", description = "Name of a chapter with a name")
-       CHAPTER_NAMED, //
-       @Meta(def = "Chapter %d", info = "%d = number, %s = name", description = "Name of a chapter without name")
-       CHAPTER_UNNAMED, //
-       @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
-       INPUT_DESC, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_EPUB, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_TEXT, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_INFO_TEXT, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_FANFICTION, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_FIMFICTION, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_MANGAFOX, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_E621, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_E_HENTAI, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_YIFFSTAR, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_CBZ, //
-       @Meta(description = "Description of this input type")
-       INPUT_DESC_HTML, //
-       @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
-       OUTPUT_DESC, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_EPUB, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_TEXT, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_INFO_TEXT, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_CBZ, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_HTML, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_LATEX, //
-       @Meta(description = "Description of this output type")
-       OUTPUT_DESC_SYSOUT, //
-       @Meta(group = true, info = "%s = type", description = "Default description when the type is not known by i18n")
-       OUTPUT_DESC_SHORT, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_EPUB, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_TEXT, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_INFO_TEXT, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_CBZ, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_LATEX, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_SYSOUT, //
-       @Meta(description = "Short description of this output type")
-       OUTPUT_DESC_SHORT_HTML, //
-       @Meta(info = "%s = the unknown 2-code language", description = "Error message for unknown 2-letter LaTeX language code")
-       LATEX_LANG_UNKNOWN, //
-       @Meta(def = "by", description = "'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection")
-       BY, //
-
-       ;
-
-       /**
-        * Write the header found in the configuration <tt>.properties</tt> file of
-        * this {@link Bundle}.
-        * 
-        * @param writer
-        *            the {@link Writer} to write the header in
-        * @param name
-        *            the file name
-        * 
-        * @throws IOException
-        *             in case of IO error
-        */
-       static public void writeHeader(Writer writer, String name)
-                       throws IOException {
-               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");
-       }
-}
diff --git a/bundles/StringIdBundle.java b/bundles/StringIdBundle.java
deleted file mode 100644 (file)
index b9a0d79..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.File;
-import java.io.IOException;
-
-import be.nikiroo.utils.resources.TransBundle;
-
-/**
- * This class manages the translation resources of the application (Core).
- * 
- * @author niki
- */
-public class StringIdBundle extends TransBundle<StringId> {
-       /**
-        * Create a translation service for the given language (will fall back to
-        * the default one if not found).
-        * 
-        * @param lang
-        *            the language to use
-        */
-       public StringIdBundle(String lang) {
-               super(StringId.class, Target.resources_core, lang);
-       }
-
-       /**
-        * Update resource file.
-        * 
-        * @param args
-        *            not used
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public static void main(String[] args) throws IOException {
-               String path = new File(".").getAbsolutePath()
-                               + "/src/be/nikiroo/fanfix/bundles/";
-               new StringIdBundle(null).updateFile(path);
-               System.out.println("Path updated: " + path);
-       }
-}
diff --git a/bundles/StringIdGui.java b/bundles/StringIdGui.java
deleted file mode 100644 (file)
index c109f42..0000000
+++ /dev/null
@@ -1,199 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.IOException;
-import java.io.Writer;
-
-import be.nikiroo.utils.resources.Bundle;
-import be.nikiroo.utils.resources.Meta;
-import be.nikiroo.utils.resources.Meta.Format;
-
-/**
- * The {@link Enum} representing textual information to be translated to the
- * user as a key.
- * 
- * Note that each key that should be translated <b>must</b> be annotated with a
- * {@link Meta} annotation.
- * 
- * @author niki
- */
-@SuppressWarnings("javadoc")
-public enum StringIdGui {
-       /**
-        * A special key used for technical reasons only, without annotations so it
-        * is not visible in <tt>.properties</tt> files.
-        * <p>
-        * Use it when you need NO translation.
-        */
-       NULL, //
-       /**
-        * A special key used for technical reasons only, without annotations so it
-        * is not visible in <tt>.properties</tt> files.
-        * <p>
-        * Use it when you need a real translation but still don't have a key.
-        */
-       DUMMY, //
-       @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library", info = "%s = current Fanfix version")
-       // The titles/subtitles:
-       TITLE_LIBRARY, //
-       @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)", info = "%s = current Fanfix version, %s = library name")
-       TITLE_LIBRARY_WITH_NAME, //
-       @Meta(def = "Fanfix Configuration", format = Format.STRING, description = "the title of the configuration window of Fanfix, also the name of the menu button")
-       TITLE_CONFIG, //
-       @Meta(def = "This is where you configure the options of the program.", format = Format.STRING, description = "the subtitle of the configuration window of Fanfix")
-       SUBTITLE_CONFIG, //
-       @Meta(def = "UI Configuration", format = Format.STRING, description = "the title of the UI configuration window of Fanfix, also the name of the menu button")
-       TITLE_CONFIG_UI, //
-       @Meta(def = "This is where you configure the graphical appearence of the program.", format = Format.STRING, description = "the subtitle of the UI configuration window of Fanfix")
-       SUBTITLE_CONFIG_UI, //
-       @Meta(def = "Save", format = Format.STRING, description = "the title of the 'save to/export to' window of Fanfix")
-       TITLE_SAVE, //
-       @Meta(def = "Moving story", format = Format.STRING, description = "the title of the 'move to' window of Fanfix")
-       TITLE_MOVE_TO, //
-       @Meta(def = "Move to:", format = Format.STRING, description = "the subtitle of the 'move to' window of Fanfix")
-       SUBTITLE_MOVE_TO, //
-       @Meta(def = "Delete story", format = Format.STRING, description = "the title of the 'delete' window of Fanfix")
-       TITLE_DELETE, //
-       @Meta(def = "Delete %s: %s", format = Format.STRING, description = "the subtitle of the 'delete' window of Fanfix", info = "%s = LUID of the story, %s = title of the story")
-       SUBTITLE_DELETE, //
-       @Meta(def = "Library error", format = Format.STRING, description = "the title of the 'library error' dialogue")
-       TITLE_ERROR_LIBRARY, //
-       @Meta(def = "Importing from URL", format = Format.STRING, description = "the title of the 'import URL' dialogue")
-       TITLE_IMPORT_URL, //
-       @Meta(def = "URL of the story to import:", format = Format.STRING, description = "the subtitle of the 'import URL' dialogue")
-       SUBTITLE_IMPORT_URL, //
-       @Meta(def = "Error", format = Format.STRING, description = "the title of general error dialogues")
-       TITLE_ERROR, //
-       @Meta(def = "%s: %s", format = Format.STRING, description = "the title of a story for the properties dialogue, the viewers...", info = "%s = LUID of the story, %s = title of the story")
-       TITLE_STORY, //
-
-       //
-
-       @Meta(def = "A new version of the program is available at %s", format = Format.STRING, description = "HTML text used to notify of a new version", info = "%s = url link in HTML")
-       NEW_VERSION_AVAILABLE, //
-       @Meta(def = "Updates available", format = Format.STRING, description = "text used as title for the update dialogue")
-       NEW_VERSION_TITLE, //
-       @Meta(def = "Version %s", format = Format.STRING, description = "HTML text used to specify a newer version title and number, used for each version newer than the current one", info = "%s = the newer version number")
-       NEW_VERSION_VERSION, //
-       @Meta(def = "%s words", format = Format.STRING, description = "show the number of words of a book", info = "%s = the number")
-       BOOK_COUNT_WORDS, //
-       @Meta(def = "%s images", format = Format.STRING, description = "show the number of images of a book", info = "%s = the number")
-       BOOK_COUNT_IMAGES, //
-       @Meta(def = "%s stories", format = Format.STRING, description = "show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)", info = "%s = the number")
-       BOOK_COUNT_STORIES, //
-
-       // Menu (and popup) items:
-
-       @Meta(def = "File", format = Format.STRING, description = "the file menu")
-       MENU_FILE, //
-       @Meta(def = "Exit", format = Format.STRING, description = "the file/exit menu button")
-       MENU_FILE_EXIT, //
-       @Meta(def = "Import File...", format = Format.STRING, description = "the file/import_file menu button")
-       MENU_FILE_IMPORT_FILE, //
-       @Meta(def = "Import URL...", format = Format.STRING, description = "the file/import_url menu button")
-       MENU_FILE_IMPORT_URL, //
-       @Meta(def = "Save as...", format = Format.STRING, description = "the file/export menu button")
-       MENU_FILE_EXPORT, //
-       @Meta(def = "Move to", format = Format.STRING, description = "the file/move to menu button")
-       MENU_FILE_MOVE_TO, //
-       @Meta(def = "Set author", format = Format.STRING, description = "the file/set author menu button")
-       MENU_FILE_SET_AUTHOR, //
-       @Meta(def = "New source...", format = Format.STRING, description = "the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source")
-       MENU_FILE_MOVE_TO_NEW_TYPE, //
-       @Meta(def = "New author...", format = Format.STRING, description = "the file/move to/new author menu button, that will trigger a dialogue to create a new author")
-       MENU_FILE_MOVE_TO_NEW_AUTHOR, //
-       @Meta(def = "Rename...", format = Format.STRING, description = "the file/rename menu item, that will trigger a dialogue to ask for a new title for the story")
-       MENU_FILE_RENAME, //
-       @Meta(def = "Properties", format = Format.STRING, description = "the file/Properties menu item, that will trigger a dialogue to show the properties of the story")
-       MENU_FILE_PROPERTIES, //
-       @Meta(def = "Open", format = Format.STRING, description = "the file/open menu item, that will open the story or fake-story (an author or a source/type)")
-       MENU_FILE_OPEN, //
-       @Meta(def = "Edit", format = Format.STRING, description = "the edit menu")
-       MENU_EDIT, //
-       @Meta(def = "Prefetch to cache", format = Format.STRING, description = "the edit/send to cache menu button, to download the story into the cache if not already done")
-       MENU_EDIT_DOWNLOAD_TO_CACHE, //
-       @Meta(def = "Clear cache", format = Format.STRING, description = "the clear cache menu button, to clear the cache for a single book")
-       MENU_EDIT_CLEAR_CACHE, //
-       @Meta(def = "Redownload", format = Format.STRING, description = "the edit/redownload menu button, to download the latest version of the book")
-       MENU_EDIT_REDOWNLOAD, //
-       @Meta(def = "Delete", format = Format.STRING, description = "the edit/delete menu button")
-       MENU_EDIT_DELETE, //
-       @Meta(def = "Set as cover for source", format = Format.STRING, description = "the edit/Set as cover for source menu button")
-       MENU_EDIT_SET_COVER_FOR_SOURCE, //
-       @Meta(def = "Set as cover for author", format = Format.STRING, description = "the edit/Set as cover for author menu button")
-       MENU_EDIT_SET_COVER_FOR_AUTHOR, //
-       @Meta(def = "Search", format = Format.STRING, description = "the search menu to open the earch stories on one of the searchable websites")
-       MENU_SEARCH,
-       @Meta(def = "View", format = Format.STRING, description = "the view menu")
-       MENU_VIEW, //
-       @Meta(def = "Word count", format = Format.STRING, description = "the view/word_count menu button, to show the word/image/story count as secondary info")
-       MENU_VIEW_WCOUNT, //
-       @Meta(def = "Author", format = Format.STRING, description = "the view/author menu button, to show the author as secondary info")
-       MENU_VIEW_AUTHOR, //
-       @Meta(def = "Sources", format = Format.STRING, description = "the sources menu, to select the books from a specific source; also used as a title for the source books")
-       MENU_SOURCES, //
-       @Meta(def = "Authors", format = Format.STRING, description = "the authors menu, to select the books of a specific author; also used as a title for the author books")
-       MENU_AUTHORS, //
-       @Meta(def = "Options", format = Format.STRING, description = "the options menu, to configure Fanfix from the GUI")
-       MENU_OPTIONS, //
-       @Meta(def = "All", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, by group (one book = one group)")
-       MENU_XXX_ALL_GROUPED, //
-       @Meta(def = "Listing", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)")
-       MENU_XXX_ALL_LISTING, //
-       @Meta(def = "[unknown]", format = Format.STRING, description = "a special menu button to select the books without author")
-       MENU_AUTHORS_UNKNOWN, //
-
-       // Progress names
-       @Meta(def = "Reload books", format = Format.STRING, description = "progress bar caption for the 'reload books' step of all outOfUi operations")
-       PROGRESS_OUT_OF_UI_RELOAD_BOOKS, //
-       @Meta(def = "Change the source of the book to %s", format = Format.STRING, description = "progress bar caption for the 'change source' step of the ReDownload operation", info = "%s = new source name")
-       PROGRESS_CHANGE_SOURCE, //
-
-       // Error messages
-       @Meta(def = "An error occured when contacting the library", format = Format.STRING, description = "default description if the error is not known")
-       ERROR_LIB_STATUS, //
-       @Meta(def = "You are not allowed to access this library", format = Format.STRING, description = "library access not allowed")
-       ERROR_LIB_STATUS_UNAUTHORIZED, //
-       @Meta(def = "Library not valid", format = Format.STRING, description = "the library is invalid (not correctly set up)")
-       ERROR_LIB_STATUS_INVALID, //
-       @Meta(def = "Library currently unavailable", format = Format.STRING, description = "the library is out of commission")
-       ERROR_LIB_STATUS_UNAVAILABLE, //
-       @Meta(def = "Cannot open the selected book", format = Format.STRING, description = "cannot open the book, internal or external viewer")
-       ERROR_CANNOT_OPEN, //
-       @Meta(def = "URL not supported: %s", format = Format.STRING, description = "URL is not supported by Fanfix", info = "%s = URL")
-       ERROR_URL_NOT_SUPPORTED, //
-       @Meta(def = "Failed to import %s:\n%s", format = Format.STRING, description = "cannot import the URL", info = "%s = URL, %s = reasons")
-       ERROR_URL_IMPORT_FAILED,
-
-       // Others
-       @Meta(def = "&nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters")
-       CHAPTER_HTML_UNNAMED, //
-       @Meta(def = "&nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>: %s", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters, %s = chapter name")
-       CHAPTER_HTML_NAMED, //
-       @Meta(def = "Image %d / %d", format = Format.STRING, description = "(NO html) the chapter progression value used on the viewers", info = "%d = current image number, %d = total images")
-       IMAGE_PROGRESSION, //
-       
-       ;
-
-       /**
-        * Write the header found in the configuration <tt>.properties</tt> file of
-        * this {@link Bundle}.
-        * 
-        * @param writer
-        *            the {@link Writer} to write the header in
-        * @param name
-        *            the file name
-        * 
-        * @throws IOException
-        *             in case of IO error
-        */
-       static public void writeHeader(Writer writer, String name)
-                       throws IOException {
-               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");
-       }
-}
diff --git a/bundles/StringIdGuiBundle.java b/bundles/StringIdGuiBundle.java
deleted file mode 100644 (file)
index c036381..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.File;
-import java.io.IOException;
-
-import be.nikiroo.utils.resources.TransBundle;
-
-/**
- * This class manages the translation resources of the application (GUI).
- * 
- * @author niki
- */
-public class StringIdGuiBundle extends TransBundle<StringIdGui> {
-       /**
-        * Create a translation service for the given language (will fall back to
-        * the default one if not found).
-        * 
-        * @param lang
-        *            the language to use
-        */
-       public StringIdGuiBundle(String lang) {
-               super(StringIdGui.class, Target.resources_gui, lang);
-       }
-
-       /**
-        * Update resource file.
-        * 
-        * @param args
-        *            not used
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public static void main(String[] args) throws IOException {
-               String path = new File(".").getAbsolutePath()
-                               + "/src/be/nikiroo/fanfix/bundles/";
-               new StringIdGuiBundle(null).updateFile(path);
-               System.out.println("Path updated: " + path);
-       }
-}
diff --git a/bundles/Target.java b/bundles/Target.java
deleted file mode 100644 (file)
index 64284c6..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import be.nikiroo.utils.resources.Bundle;
-
-/**
- * The type of configuration information the associated {@link Bundle} will
- * convey.
- * <p>
- * Those values can change when the file is not compatible anymore.
- * 
- * @author niki
- */
-public enum Target {
-       /**
-        * Configuration options that the user can change in the
-        * <tt>.properties</tt> file
-        */
-       config5,
-       /** Translation resources (Core) */
-       resources_core,
-       /** Translation resources (GUI) */
-       resources_gui,
-       /** UI resources (from colours to behaviour) */
-       ui,
-       /** Description of UI resources. */
-       ui_description,
-}
diff --git a/bundles/UiConfig.java b/bundles/UiConfig.java
deleted file mode 100644 (file)
index 2122ccf..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import be.nikiroo.utils.resources.Meta;
-import be.nikiroo.utils.resources.Meta.Format;
-
-/**
- * The configuration options.
- * 
- * @author niki
- */
-@SuppressWarnings("javadoc")
-public enum UiConfig {
-       @Meta(description = "The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
-       format = Format.DIRECTORY, def = "tmp-reader/")
-       CACHE_DIR_LOCAL_READER, //
-       @Meta(description = "How to save the cached stories for the GUI Reader (non-images documents) -- those files will be sent to the reader",//
-       format = Format.COMBO_LIST, list = { "INFO_TEXT", "EPUB", "HTML", "TEXT" }, def = "EPUB")
-       GUI_NON_IMAGES_DOCUMENT_TYPE, //
-       @Meta(description = "How to save the cached stories for the GUI Reader (images documents) -- those files will be sent to the reader",//
-       format = Format.COMBO_LIST, list = { "CBZ", "HTML" }, def = "CBZ")
-       GUI_IMAGES_DOCUMENT_TYPE, //
-       @Meta(description = "Use the internal reader for images documents",//
-       format = Format.BOOLEAN, def = "true")
-       IMAGES_DOCUMENT_USE_INTERNAL_READER, //
-       @Meta(description = "The external viewer for images documents (or empty to use the system default program for the given file type)",//
-       format = Format.STRING)
-       IMAGES_DOCUMENT_READER, //
-       @Meta(description = "Use the internal reader for non-images documents",//
-       format = Format.BOOLEAN, def = "true")
-       NON_IMAGES_DOCUMENT_USE_INTERNAL_READER, //
-       @Meta(description = "The external viewer for non-images documents (or empty to use the system default program for the given file type)",//
-       format = Format.STRING)
-       NON_IMAGES_DOCUMENT_READER, //
-       @Meta(description = "The icon to use for the program",//
-       format = Format.FIXED_LIST, def = "default", list = { "default", "alternative", "magic-book", "pony-book", "pony-library" })
-       PROGRAM_ICON, //
-       //
-       // GUI settings (hidden in config)
-       //
-       @Meta(description = "Show the side panel by default",//
-       hidden = true, format = Format.BOOLEAN, def = "true")
-       SHOW_SIDE_PANEL, //
-       @Meta(description = "Show the details panel by default",//
-       hidden = true, format = Format.BOOLEAN, def = "true")
-       SHOW_DETAILS_PANEL, //
-       @Meta(description = "Show thumbnails by default in the books view",//
-       hidden = true, format = Format.BOOLEAN, def = "false")
-       SHOW_THUMBNAILS, //
-       @Meta(description = "Show a words/images count instead of the author by default in the books view",//
-       hidden = true, format = Format.BOOLEAN, def = "false")
-       SHOW_WORDCOUNT, //
-       //
-       // Deprecated
-       //
-       @Meta(description = "The background colour of the library if you don't like the default system one",//
-       hidden = true, format = Format.COLOR)
-       @Deprecated
-       BACKGROUND_COLOR, //
-}
diff --git a/bundles/UiConfigBundle.java b/bundles/UiConfigBundle.java
deleted file mode 100644 (file)
index 8b2c008..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.File;
-import java.io.IOException;
-
-import be.nikiroo.utils.resources.Bundle;
-
-/**
- * This class manages the configuration of UI of the application (colours and
- * behaviour)
- * 
- * @author niki
- */
-public class UiConfigBundle extends Bundle<UiConfig> {
-       public UiConfigBundle() {
-               super(UiConfig.class, Target.ui, new UiConfigBundleDesc());
-       }
-
-       /**
-        * Update resource file.
-        * 
-        * @param args
-        *            not used
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public static void main(String[] args) throws IOException {
-               String path = new File(".").getAbsolutePath()
-                               + "/src/be/nikiroo/fanfix/bundles/";
-               new UiConfigBundle().updateFile(path);
-               System.out.println("Path updated: " + path);
-       }
-
-       @Override
-       protected String getBundleDisplayName() {
-               return "UI configuration options";
-       }
-}
diff --git a/bundles/UiConfigBundleDesc.java b/bundles/UiConfigBundleDesc.java
deleted file mode 100644 (file)
index da42950..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-package be.nikiroo.fanfix.bundles;
-
-import java.io.File;
-import java.io.IOException;
-
-import be.nikiroo.utils.resources.TransBundle;
-
-/**
- * This class manages the configuration of UI of the application (colours and
- * behaviour)
- * 
- * @author niki
- */
-public class UiConfigBundleDesc extends TransBundle<UiConfig> {
-       public UiConfigBundleDesc() {
-               super(UiConfig.class, Target.ui_description);
-       }
-
-       /**
-        * Update resource file.
-        * 
-        * @param args
-        *            not used
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public static void main(String[] args) throws IOException {
-               String path = new File(".").getAbsolutePath()
-                               + "/src/be/nikiroo/fanfix/bundles/";
-               new UiConfigBundleDesc().updateFile(path);
-               System.out.println("Path updated: " + path);
-       }
-
-       @Override
-       protected String getBundleDisplayName() {
-               return "UI configuration options description";
-       }
-}
diff --git a/bundles/package-info.java b/bundles/package-info.java
deleted file mode 100644 (file)
index 80cdd15..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * This package encloses the different 
- * {@link be.nikiroo.utils.resources.Bundle} and their associated 
- * {@link java.lang.Enum}s used by the application.
- * 
- * @author niki
- */
-package be.nikiroo.fanfix.bundles;
\ No newline at end of file
diff --git a/bundles/resources_core.properties b/bundles/resources_core.properties
deleted file mode 100644 (file)
index dc7881a..0000000
+++ /dev/null
@@ -1,207 +0,0 @@
-# United Kingdom (en_GB) resources_core translation file (UTF-8)
-# 
-# Note that any key can be doubled with a _NOUTF suffix
-# to use when the NOUTF env variable is set to 1
-# 
-# Also, the comments always refer to the key below them.
-# 
-
-
-# help message for the syntax
-# (FORMAT: STRING) 
-HELP_SYNTAX = Valid options:\n\
-\t--import [URL]: import into library\n\
-\t--export [id] [output_type] [target]: export story to target\n\
-\t--convert [URL] [output_type] [target] (+info): convert URL into target\n\
-\t--read [id] ([chapter number]): read the given story from the library\n\
-\t--read-url [URL] ([chapter number]): convert on the fly and read the \n\
-\t\tstory, without saving it\n\
-\t--search WEBSITE [free text] ([page] ([item])): search for the given terms, show the\n\
-\t\tgiven page (page 0 means "how many page do we have", starts at page 1)\n\
-\t--search-tag WEBSITE ([tag 1] [tag2...] ([page] ([item]))): list the known tags or \n\
-\t\tsearch the stories for the given tag(s), show the given page of results\n\
-\t--search: list the supported websites (where)\n\
-\t--search [where] [keywords] (page [page]) (item [item]): search on the supported \n\
-\t\twebsite and display the given results page of stories it found, or the story \n\
-\t\tdetails if asked\n\
-\t--search-tag [where]: list all the tags supported by this website\n\
-\t--search-tag [index 1]... (page [page]) (item [item]): search for the given stories or \n\
-\t\tsubtags, tag by tag, and display information about a specific page of results or \n\
-\t\tabout a specific item if requested\n\
-\t--list ([type]) : list the stories present in the library\n\
-\t--set-source [id] [new source]: change the source of the given story\n\
-\t--set-title [id] [new title]: change the title of the given story\n\
-\t--set-author [id] [new author]: change the author of the given story\n\
-\t--set-reader [reader type]: set the reader type to CLI, TUI or GUI for \n\
-\t\tthis command\n\
-\t--server: start the server mode (see config file for parameters)\n\
-\t--stop-server: stop the remote server running on this port\n\
-\t\tif any (key must be set to the same value)\n\
-\t--remote [key] [host] [port]: select this remote server to get \n\
-\t\t(or update or...) the stories from (key must be set to the \n\
-\t\tsame value)\n\
-\t--help: this help message\n\
-\t--version: return the version of the program\n\
-\n\
-Supported input types:\n\
-%s\n\
-\n\
-Supported output types:\n\
-%s
-# syntax error message
-# (FORMAT: STRING) 
-ERR_SYNTAX = Syntax error (try "--help")
-# an input or output support type description
-# (FORMAT: STRING) 
-ERR_SYNTAX_TYPE = > %s: %s
-# Error when retrieving data
-# (FORMAT: STRING) 
-ERR_LOADING = Error when retrieving data from: %s
-# Error when saving to given target
-# (FORMAT: STRING) 
-ERR_SAVING = Error when saving to target: %s
-# Error when unknown output format
-# (FORMAT: STRING) 
-ERR_BAD_OUTPUT_TYPE = Unknown output type: %s
-# Error when converting input to URL/File
-# (FORMAT: STRING) 
-ERR_BAD_URL = Cannot understand file or protocol: %s
-# URL/File not supported
-# (FORMAT: STRING) 
-ERR_NOT_SUPPORTED = URL not supported: %s
-# Failed to download cover : %s
-# (FORMAT: STRING) 
-ERR_BS_NO_COVER = Failed to download cover: %s
-# Canonical OPEN SINGLE QUOTE char (for instance: ‘)
-# (FORMAT: STRING) 
-OPEN_SINGLE_QUOTE = ‘
-# Canonical CLOSE SINGLE QUOTE char (for instance: ’)
-# (FORMAT: STRING) 
-CLOSE_SINGLE_QUOTE = ’
-# Canonical OPEN DOUBLE QUOTE char (for instance: “)
-# (FORMAT: STRING) 
-OPEN_DOUBLE_QUOTE = “
-# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
-# (FORMAT: STRING) 
-CLOSE_DOUBLE_QUOTE = ”
-# Name of the description fake chapter
-# (FORMAT: STRING) 
-DESCRIPTION = Description
-# Name of a chapter with a name
-# (FORMAT: STRING) 
-CHAPTER_NAMED = Chapter %d: %s
-# Name of a chapter without name
-# (FORMAT: STRING) 
-CHAPTER_UNNAMED = Chapter %d
-# Default description when the type is not known by i18n
-# (FORMAT: STRING) 
-INPUT_DESC = Unknown type: %s
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_EPUB = EPUB files created by this program (we do not support "all" EPUB files)
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_TEXT = Stories encoded in textual format, with a few rules :\n\
-\tthe title must be on the first line, \n\
-\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
-\t\tline, possibly with the publication date in parenthesis\n\
-\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
-\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
-\t\tCHAPTER", where "x" is the chapter number,\n\
-\ta description of the story must be given as chapter number 0,\n\
-\ta cover image may be present with the same filename but a PNG, \n\
-\t\tJPEG or JPG extension.
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
-\tcompanion ".info" file to store some metadata
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_FANFICTION = Fanfictions of many, many different universes, from TV shows to \n\
-\tnovels to games.
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_FIMFICTION = Fanfictions devoted to the My Little Pony show
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_MANGAHUB = A well filled repository of mangas, in English
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_E621 = Furry website supporting comics, including MLP
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_E_HENTAI = Website offering many comics/mangas, mostly but not always NSFW
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_YIFFSTAR = A Furry website, story-oriented
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_CBZ = CBZ files coming from this very program
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_HTML = HTML files coming from this very program
-# Default description when the type is not known by i18n
-# (FORMAT: STRING) 
-OUTPUT_DESC = Unknown type: %s
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
-\tthe title must be on the first line, \n\
-\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
-\t\tline, possibly with the publication date in parenthesis \n\
-\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
-\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
-\t\tCHAPTER", where "x" is the chapter number,\n\
-\ta description of the story must be given as chapter number 0,\n\
-\ta cover image may be present with the same filename but a PNG, JPEG \n\
-\t\tor JPG extension.
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
-\tcompanion ".info" file to store some metadata
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images \n\
-\tin PNG format by default)
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_HTML = HTML files (a directory containing the resources and "index.html")
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SYSOUT = A simple DEBUG console output
-# Default description when the type is not known by i18n
-# This item is used as a group, its content is not expected to be used.
-OUTPUT_DESC_SHORT = %s
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_EPUB = Electronic book (.epub)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_TEXT = Plain text (.txt)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_INFO_TEXT = Plain text and metadata
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_CBZ = Comic book (.cbz)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_LATEX = LaTeX (.tex)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_SYSOUT = Console output
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_HTML = HTML files with resources (directory, .html)
-# Error message for unknown 2-letter LaTeX language code
-# (FORMAT: STRING) 
-LATEX_LANG_UNKNOWN = Unknown language: %s
-# 'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection
-# (FORMAT: STRING) 
-BY = by
diff --git a/bundles/resources_core_fr.properties b/bundles/resources_core_fr.properties
deleted file mode 100644 (file)
index a64a5a0..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-# français (fr) resources_core translation file (UTF-8)
-# 
-# Note that any key can be doubled with a _NOUTF suffix
-# to use when the NOUTF env variable is set to 1
-# 
-# Also, the comments always refer to the key below them.
-# 
-
-
-# help message for the syntax
-# (FORMAT: STRING) 
-HELP_SYNTAX = Options reconnues :\n\
-\t--import [URL]: importer une histoire dans la librairie\n\
-\t--export [id] [output_type] [target]: exporter l'histoire "id" vers le fichier donné\n\
-\t--convert [URL] [output_type] [target] (+info): convertir l'histoire vers le fichier donné, et forcer l'ajout d'un fichier .info si +info est utilisé\n\
-\t--read [id] ([chapter number]): afficher l'histoire "id"\n\
-\t--read-url [URL] ([chapter number]): convertir l'histoire et la lire à la volée, sans la sauver\n\
-\t--search: liste les sites supportés (where)\n\
-\t--search [where] [keywords] (page [page]) (item [item]): lance une recherche et \n\
-\t\taffiche les résultats de la page page (page 1 par défaut), et de l'item item \n\
-\t\tspécifique si demandé\n\
-\t--search-tag [where]: liste tous les tags supportés par ce site web\n\
-\t--search-tag [index 1]... (page [page]) (item [item]): affine la recherche, tag par tag,\n\
-\t\tet affiche si besoin les sous-tags, les histoires ou les infos précises de \n\
-\t\tl'histoire demandée\n\
-\t--list ([type]): lister les histoires presentes dans la librairie et leurs IDs\n\
-\t--set-source [id] [nouvelle source]: change la source de l'histoire\n\
-\t--set-title [id] [nouveau titre]: change le titre de l'histoire\n\
-\t--set-author [id] [nouvel auteur]: change l'auteur de l'histoire\n\
-\t--set-reader [reader type]: changer le type de lecteur pour la commande en cours sur CLI, TUI ou GUI\n\
-\t--server: démarre le mode serveur (les paramètres sont dans le fichier de config)\n\
-\t--stop-server: arrêter le serveur distant sur ce port (key doit avoir la même valeur) \n\
-\t--remote [key] [host] [port]: contacter ce server au lieu de la librairie habituelle (key doit avoir la même valeur)\n\
-\t--help: afficher la liste des options disponibles\n\
-\t--version: retourne la version du programme\n\
-\n\
-Types supportés en entrée :\n\
-%s\n\
-\n\
-Types supportés en sortie :\n\
-%s
-# syntax error message
-# (FORMAT: STRING) 
-ERR_SYNTAX = Erreur de syntaxe (essayez "--help")
-# an input or output support type description
-# (FORMAT: STRING) 
-ERR_SYNTAX_TYPE = > %s : %s
-# Error when retrieving data
-# (FORMAT: STRING) 
-ERR_LOADING = Erreur de récupération des données depuis : %s
-# Error when saving to given target
-# (FORMAT: STRING) 
-ERR_SAVING = Erreur lors de la sauvegarde sur : %s
-# Error when unknown output format
-# (FORMAT: STRING) 
-ERR_BAD_OUTPUT_TYPE = Type de sortie inconnu : %s
-# Error when converting input to URL/File
-# (FORMAT: STRING) 
-ERR_BAD_URL = Protocole ou type de fichier inconnu : %s
-# URL/File not supported
-# (FORMAT: STRING) 
-ERR_NOT_SUPPORTED = Site web non supporté : %s
-# Failed to download cover : %s
-# (FORMAT: STRING) 
-ERR_BS_NO_COVER = Échec de la récupération de la page de couverture : %s
-# Canonical OPEN SINGLE QUOTE char (for instance: ‘)
-# (FORMAT: STRING) 
-OPEN_SINGLE_QUOTE = ‘
-# Canonical CLOSE SINGLE QUOTE char (for instance: ’)
-# (FORMAT: STRING) 
-CLOSE_SINGLE_QUOTE = ’
-# Canonical OPEN DOUBLE QUOTE char (for instance: “)
-# (FORMAT: STRING) 
-OPEN_DOUBLE_QUOTE = “
-# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
-# (FORMAT: STRING) 
-CLOSE_DOUBLE_QUOTE = ”
-# Name of the description fake chapter
-# (FORMAT: STRING) 
-DESCRIPTION = Description
-# Name of a chapter with a name
-# (FORMAT: STRING) 
-CHAPTER_NAMED = Chapitre %d : %s
-# Name of a chapter without name
-# (FORMAT: STRING) 
-CHAPTER_UNNAMED = Chapitre %d
-# Default description when the type is not known by i18n
-# (FORMAT: STRING) 
-INPUT_DESC = Type d'entrée inconnu : %s
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_EPUB = Les fichiers .epub créés avec Fanfix (nous ne supportons pas les autres fichiers .epub, du moins pour le moment)
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_TEXT = Les histoires enregistrées en texte (.txt), avec quelques règles spécifiques : \n\
-\tle titre doit être sur la première ligne\n\
-\tl'auteur (précédé de rien, "Par ", "De " ou "©") doit être sur la deuxième ligne, optionnellement suivi de la date de publication entre parenthèses (i.e., "Par Quelqu'un (3 octobre 1998)")\n\
-\tles chapitres doivent être déclarés avec "Chapitre x" ou "Chapitre x: NOM DU CHAPTITRE", où "x" est le numéro du chapitre\n\
-\tune description de l'histoire doit être donnée en tant que chaptire 0\n\
-\tune image de couverture peut être présente avec le même nom de fichier que l'histoire, mais une extension .png, .jpeg ou .jpg
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_INFO_TEXT = Fort proche du format texte, mais avec un fichier .info accompagnant l'histoire pour y enregistrer quelques metadata (le fichier de metadata est supposé être créé par Fanfix, ou être compatible avec)
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_FANFICTION = Fanfictions venant d'une multitude d'univers différents, depuis les shows télévisés aux livres en passant par les jeux-vidéos
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_FIMFICTION = Fanfictions dévouées à la série My Little Pony
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_MANGAHUB = Un site répertoriant une quantité non négligeable de mangas, en anglais
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_E621 = Un site Furry proposant des comics, y compris de MLP
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_E_HENTAI = Un site web proposant beaucoup de comics/mangas, souvent mais pas toujours NSFW
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_YIFFSTAR = Un site web Furry, orienté sur les histoires plutôt que les images
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_CBZ = Les fichiers .cbz (une collection d'images zipées), de préférence créés avec Fanfix (même si les autres .cbz sont aussi supportés, mais sans la majorité des metadata de Fanfix dans ce cas)
-# Description of this input type
-# (FORMAT: STRING) 
-INPUT_DESC_HTML = Les fichiers HTML que vous pouvez ouvrir avec n'importe quel navigateur ; remarquez que Fanfix créera un répertoire pour y mettre les fichiers nécessaires, dont un fichier "index.html" pour afficher le tout -- nous ne supportons en entrée que les fichiers HTML créés par Fanfix
-# Default description when the type is not known by i18n
-# (FORMAT: STRING) 
-OUTPUT_DESC = Type de sortie inconnu : %s
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
-\tthe title must be on the first line, \n\
-\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
-\t\tline, possibly with the publication date in parenthesis \n\
-\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
-\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
-\t\tCHAPTER", where "x" is the chapter number,\n\
-\ta description of the story must be given as chapter number 0,\n\
-\ta cover image may be present with the same filename but a PNG, JPEG \n\
-\t\tor JPG extension.
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
-\tcompanion ".info" file to store some metadata
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images \n\
-\tin PNG format by default)
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_HTML = HTML files (a directory containing the resources and "index.html")
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
-# Description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SYSOUT = A simple DEBUG console output
-# Default description when the type is not known by i18n
-# This item is used as a group, its content is not expected to be used.
-OUTPUT_DESC_SHORT = %s
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_EPUB = Electronic book (.epub)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_TEXT = Plain text (.txt)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_INFO_TEXT = Plain text and metadata
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_CBZ = Comic book (.cbz)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_LATEX = LaTeX (.tex)
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_SYSOUT = Console output
-# Short description of this output type
-# (FORMAT: STRING) 
-OUTPUT_DESC_SHORT_HTML = HTML files with resources (directory, .html)
-# Error message for unknown 2-letter LaTeX language code
-# (FORMAT: STRING) 
-LATEX_LANG_UNKNOWN = Unknown language: %s
-# 'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection
-# (FORMAT: STRING) 
-BY = by
diff --git a/bundles/resources_gui.properties b/bundles/resources_gui.properties
deleted file mode 100644 (file)
index 40be5eb..0000000
+++ /dev/null
@@ -1,199 +0,0 @@
-# United Kingdom (en_GB) resources_gui translation file (UTF-8)
-# 
-# Note that any key can be doubled with a _NOUTF suffix
-# to use when the NOUTF env variable is set to 1
-# 
-# Also, the comments always refer to the key below them.
-# 
-
-
-# the title of the main window of Fanfix, the library
-# (FORMAT: STRING) 
-TITLE_LIBRARY = Fanfix %s
-# the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)
-# (FORMAT: STRING) 
-TITLE_LIBRARY_WITH_NAME = Fanfix %s
-# the title of the configuration window of Fanfix, also the name of the menu button
-# (FORMAT: STRING) 
-TITLE_CONFIG = Fanfix Configuration
-# the subtitle of the configuration window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_CONFIG = This is where you configure the options of the program.
-# the title of the UI configuration window of Fanfix, also the name of the menu button
-# (FORMAT: STRING) 
-TITLE_CONFIG_UI = UI Configuration
-# the subtitle of the UI configuration window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_CONFIG_UI = This is where you configure the graphical appearence of the program.
-# the title of the 'save to/export to' window of Fanfix
-# (FORMAT: STRING) 
-TITLE_SAVE = Save
-# the title of the 'move to' window of Fanfix
-# (FORMAT: STRING) 
-TITLE_MOVE_TO = Moving story
-# the subtitle of the 'move to' window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_MOVE_TO = Move to:
-# the title of the 'delete' window of Fanfix
-# (FORMAT: STRING) 
-TITLE_DELETE = Delete story
-# the subtitle of the 'delete' window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_DELETE = Delete %s: %s
-# the title of the 'library error' dialogue
-# (FORMAT: STRING) 
-TITLE_ERROR_LIBRARY = Library error
-# the title of the 'import URL' dialogue
-# (FORMAT: STRING) 
-TITLE_IMPORT_URL = Importing from URL
-# the subtitle of the 'import URL' dialogue
-# (FORMAT: STRING) 
-SUBTITLE_IMPORT_URL = URL of the story to import:
-# the title of general error dialogues
-# (FORMAT: STRING) 
-TITLE_ERROR = Error
-# the title of a story for the properties dialogue, the viewers...
-# (FORMAT: STRING) 
-TITLE_STORY = %s: %s
-# HTML text used to notify of a new version
-# (FORMAT: STRING) 
-NEW_VERSION_AVAILABLE = A new version of the program is available at %s
-# text used as title for the update dialogue
-# (FORMAT: STRING) 
-NEW_VERSION_TITLE = Updates available
-# HTML text used to specify a newer version title and number, used for each version newer than the current one
-# (FORMAT: STRING) 
-NEW_VERSION_VERSION = Version %s
-# show the number of words of a book
-# (FORMAT: STRING) 
-BOOK_COUNT_WORDS = %s words
-# show the number of images of a book
-# (FORMAT: STRING) 
-BOOK_COUNT_IMAGES = %s images
-# show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)
-# (FORMAT: STRING) 
-BOOK_COUNT_STORIES = %s stories
-# the file menu
-# (FORMAT: STRING) 
-MENU_FILE = File
-# the file/exit menu button
-# (FORMAT: STRING) 
-MENU_FILE_EXIT = Exit
-# the file/import_file menu button
-# (FORMAT: STRING) 
-MENU_FILE_IMPORT_FILE = Import File...
-# the file/import_url menu button
-# (FORMAT: STRING) 
-MENU_FILE_IMPORT_URL = Import URL...
-# the file/export menu button
-# (FORMAT: STRING) 
-MENU_FILE_EXPORT = Save as...
-# the file/move to menu button
-# (FORMAT: STRING) 
-MENU_FILE_MOVE_TO = Move to
-# the file/set author menu button
-# (FORMAT: STRING) 
-MENU_FILE_SET_AUTHOR = Set author
-# the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source
-# (FORMAT: STRING) 
-MENU_FILE_MOVE_TO_NEW_TYPE = New source...
-# the file/move to/new author menu button, that will trigger a dialogue to create a new author
-# (FORMAT: STRING) 
-MENU_FILE_MOVE_TO_NEW_AUTHOR = New author...
-# the file/rename menu item, that will trigger a dialogue to ask for a new title for the story
-# (FORMAT: STRING) 
-MENU_FILE_RENAME = Rename...
-# the file/Properties menu item, that will trigger a dialogue to show the properties of the story
-# (FORMAT: STRING) 
-MENU_FILE_PROPERTIES = Properties
-# the file/open menu item, that will open the story or fake-story (an author or a source/type)
-# (FORMAT: STRING) 
-MENU_FILE_OPEN = Open
-# the edit menu
-# (FORMAT: STRING) 
-MENU_EDIT = Edit
-# the edit/send to cache menu button, to download the story into the cache if not already done
-# (FORMAT: STRING) 
-MENU_EDIT_DOWNLOAD_TO_CACHE = Prefetch to cache 
-# the clear cache menu button, to clear the cache for a single book
-# (FORMAT: STRING) 
-MENU_EDIT_CLEAR_CACHE = Clear cache
-# the edit/redownload menu button, to download the latest version of the book
-# (FORMAT: STRING) 
-MENU_EDIT_REDOWNLOAD = Redownload
-# the edit/delete menu button
-# (FORMAT: STRING) 
-MENU_EDIT_DELETE = Delete
-# the edit/Set as cover for source menu button
-# (FORMAT: STRING) 
-MENU_EDIT_SET_COVER_FOR_SOURCE = Set as cover for source
-# the edit/Set as cover for author menu button
-# (FORMAT: STRING) 
-MENU_EDIT_SET_COVER_FOR_AUTHOR = Set as cover for author
-# the search menu to open the earch stories on one of the searchable websites
-# (FORMAT: STRING) 
-MENU_SEARCH = Search
-# the view menu
-# (FORMAT: STRING) 
-MENU_VIEW = View
-# the view/word_count menu button, to show the word/image/story count as secondary info
-# (FORMAT: STRING) 
-MENU_VIEW_WCOUNT = Word count
-# the view/author menu button, to show the author as secondary info
-# (FORMAT: STRING) 
-MENU_VIEW_AUTHOR = Author
-# the sources menu, to select the books from a specific source; also used as a title for the source books
-# (FORMAT: STRING) 
-MENU_SOURCES = Sources
-# the authors menu, to select the books of a specific author; also used as a title for the author books
-# (FORMAT: STRING) 
-MENU_AUTHORS = Authors
-# the options menu, to configure Fanfix from the GUI
-# (FORMAT: STRING) 
-MENU_OPTIONS = Options
-# a special menu button to select all the sources/types or authors, by group (one book = one group)
-# (FORMAT: STRING) 
-MENU_XXX_ALL_GROUPED = All
-# a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)
-# (FORMAT: STRING) 
-MENU_XXX_ALL_LISTING = Listing
-# a special menu button to select the books without author
-# (FORMAT: STRING) 
-MENU_AUTHORS_UNKNOWN = [unknown]
-# progress bar caption for the 'reload books' step of all outOfUi operations
-# (FORMAT: STRING) 
-PROGRESS_OUT_OF_UI_RELOAD_BOOKS = Reload books
-# progress bar caption for the 'change source' step of the ReDownload operation
-# (FORMAT: STRING) 
-PROGRESS_CHANGE_SOURCE = Change the source of the book to %s
-# default description if the error is not known
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS = An error occured when contacting the library
-# library access not allowed
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS_UNAUTHORIZED = You are not allowed to access this library
-# the library is invalid (not correctly set up)
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS_INVALID = Library not valid
-# the library is out of commission
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS_UNAVAILABLE = Library currently unavailable
-# cannot open the book, internal or external viewer
-# (FORMAT: STRING) 
-ERROR_CANNOT_OPEN = Cannot open the selected book
-# URL is not supported by Fanfix
-# (FORMAT: STRING) 
-ERROR_URL_NOT_SUPPORTED = URL not supported: %s
-# cannot import the URL
-# (FORMAT: STRING) 
-ERROR_URL_IMPORT_FAILED = Failed to import %s:\n\
-%s
-# (html) the chapter progression value used on the viewers
-# (FORMAT: STRING) 
-CHAPTER_HTML_UNNAMED = &nbsp;&nbsp;<B>Chapter <SPAN COLOR='#7777DD'>%d</SPAN>/%d</B>
-# (html) the chapter progression value used on the viewers
-# (FORMAT: STRING) 
-CHAPTER_HTML_NAMED = &nbsp;&nbsp;<B>Chapter <SPAN COLOR='#7777DD'>%d</SPAN>/%d</B>: %s
-# (NO html) the chapter progression value used on the viewers
-# (FORMAT: STRING) 
-IMAGE_PROGRESSION = Image %d / %d
diff --git a/bundles/resources_gui_fr.properties b/bundles/resources_gui_fr.properties
deleted file mode 100644 (file)
index 25ff542..0000000
+++ /dev/null
@@ -1,199 +0,0 @@
-# français (fr) resources_gui translation file (UTF-8)
-# 
-# Note that any key can be doubled with a _NOUTF suffix
-# to use when the NOUTF env variable is set to 1
-# 
-# Also, the comments always refer to the key below them.
-# 
-
-
-# the title of the main window of Fanfix, the library
-# (FORMAT: STRING) 
-TITLE_LIBRARY = Fanfix %s
-# the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)
-# (FORMAT: STRING) 
-TITLE_LIBRARY_WITH_NAME = Fanfix %s
-# the title of the configuration window of Fanfix, also the name of the menu button
-# (FORMAT: STRING) 
-TITLE_CONFIG = Configuration de Fanfix
-# the subtitle of the configuration window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_CONFIG = C'est ici que vous pouvez configurer les options du programme.
-# the title of the UI configuration window of Fanfix, also the name of the menu button
-# (FORMAT: STRING) 
-TITLE_CONFIG_UI = Configuration de l'interface
-# the subtitle of the UI configuration window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_CONFIG_UI = C'est ici que vous pouvez configurer les options de l'apparence de l'application.
-# the title of the 'save to/export to' window of Fanfix
-# (FORMAT: STRING) 
-TITLE_SAVE = Sauver
-# the title of the 'move to' window of Fanfix
-# (FORMAT: STRING) 
-TITLE_MOVE_TO = Déplacer le livre
-# the subtitle of the 'move to' window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_MOVE_TO = Déplacer vers :
-# the title of the 'delete' window of Fanfix
-# (FORMAT: STRING) 
-TITLE_DELETE = Supprimer le livre
-# the subtitle of the 'delete' window of Fanfix
-# (FORMAT: STRING) 
-SUBTITLE_DELETE = Supprimer %s : %s
-# the title of the 'library error' dialogue
-# (FORMAT: STRING) 
-TITLE_ERROR_LIBRARY = Erreur avec la librairie
-# the title of the 'import URL' dialogue
-# (FORMAT: STRING) 
-TITLE_IMPORT_URL = Importer depuis une URL
-# the subtitle of the 'import URL' dialogue
-# (FORMAT: STRING) 
-SUBTITLE_IMPORT_URL = L'URL du livre à importer
-# the title of general error dialogues
-# (FORMAT: STRING) 
-TITLE_ERROR = Error
-# the title of a story for the properties dialogue, the viewers...
-# (FORMAT: STRING) 
-TITLE_STORY = %s: %s
-# HTML text used to notify of a new version
-# (FORMAT: STRING) 
-NEW_VERSION_AVAILABLE = Une nouvelle version du programme est disponible sur %s
-# text used as title for the update dialogue
-# (FORMAT: STRING) 
-NEW_VERSION_TITLE = Mise-à-jour disponible
-# HTML text used to specify a newer version title and number, used for each version newer than the current one
-# (FORMAT: STRING) 
-NEW_VERSION_VERSION = Version %s
-# show the number of words of a book
-# (FORMAT: STRING) 
-BOOK_COUNT_WORDS = %s mots
-# show the number of images of a book
-# (FORMAT: STRING) 
-BOOK_COUNT_IMAGES = %s images
-# show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)
-# (FORMAT: STRING) 
-BOOK_COUNT_STORIES = %s livres
-# the file menu
-# (FORMAT: STRING) 
-MENU_FILE = Fichier
-# the file/exit menu button
-# (FORMAT: STRING) 
-MENU_FILE_EXIT = Quiter
-# the file/import_file menu button
-# (FORMAT: STRING) 
-MENU_FILE_IMPORT_FILE = Importer un fichier...
-# the file/import_url menu button
-# (FORMAT: STRING) 
-MENU_FILE_IMPORT_URL = Importer une URL...
-# the file/export menu button
-# (FORMAT: STRING) 
-MENU_FILE_EXPORT = Sauver sous...
-# the file/move to menu button
-# (FORMAT: STRING) 
-MENU_FILE_MOVE_TO = Déplacer vers
-# the file/set author menu button
-# (FORMAT: STRING) 
-MENU_FILE_SET_AUTHOR = Changer l'auteur
-# the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source
-# (FORMAT: STRING) 
-MENU_FILE_MOVE_TO_NEW_TYPE = Nouvelle source...
-# the file/move to/new author menu button, that will trigger a dialogue to create a new author
-# (FORMAT: STRING) 
-MENU_FILE_MOVE_TO_NEW_AUTHOR = Nouvel auteur...
-# the file/rename menu item, that will trigger a dialogue to ask for a new title for the story
-# (FORMAT: STRING) 
-MENU_FILE_RENAME = Renommer...
-# the file/Properties menu item, that will trigger a dialogue to show the properties of the story
-# (FORMAT: STRING) 
-MENU_FILE_PROPERTIES = Propriétés
-# the file/open menu item, that will open the story or fake-story (an author or a source/type)
-# (FORMAT: STRING) 
-MENU_FILE_OPEN = Ouvrir
-# the edit menu
-# (FORMAT: STRING) 
-MENU_EDIT = Edition
-# the edit/send to cache menu button, to download the story into the cache if not already done
-# (FORMAT: STRING) 
-MENU_EDIT_DOWNLOAD_TO_CACHE = Précharger en cache 
-# the clear cache menu button, to clear the cache for a single book
-# (FORMAT: STRING) 
-MENU_EDIT_CLEAR_CACHE = Nettoyer le cache
-# the edit/redownload menu button, to download the latest version of the book
-# (FORMAT: STRING) 
-MENU_EDIT_REDOWNLOAD = Re-downloader
-# the edit/delete menu button
-# (FORMAT: STRING) 
-MENU_EDIT_DELETE = Supprimer
-# the edit/Set as cover for source menu button
-# (FORMAT: STRING) 
-MENU_EDIT_SET_COVER_FOR_SOURCE = Utiliser comme cover pour la source
-# the edit/Set as cover for author menu button
-# (FORMAT: STRING) 
-MENU_EDIT_SET_COVER_FOR_AUTHOR = Utiliser comme cover pour l'auteur
-# the search menu to open the earch stories on one of the searchable websites
-# (FORMAT: STRING) 
-MENU_SEARCH = Recherche
-# the view menu
-# (FORMAT: STRING) 
-MENU_VIEW = Affichage
-# the view/word_count menu button, to show the word/image/story count as secondary info
-# (FORMAT: STRING) 
-MENU_VIEW_WCOUNT = Nombre de mots
-# the view/author menu button, to show the author as secondary info
-# (FORMAT: STRING) 
-MENU_VIEW_AUTHOR = Auteur
-# the sources menu, to select the books from a specific source; also used as a title for the source books
-# (FORMAT: STRING) 
-MENU_SOURCES = Sources
-# the authors menu, to select the books of a specific author; also used as a title for the author books
-# (FORMAT: STRING) 
-MENU_AUTHORS = Auteurs
-# the options menu, to configure Fanfix from the GUI
-# (FORMAT: STRING) 
-MENU_OPTIONS = Options
-# a special menu button to select all the sources/types or authors, by group (one book = one group)
-# (FORMAT: STRING) 
-MENU_XXX_ALL_GROUPED = Tout
-# a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)
-# (FORMAT: STRING) 
-MENU_XXX_ALL_LISTING = Listing
-# a special menu button to select the books without author
-# (FORMAT: STRING) 
-MENU_AUTHORS_UNKNOWN = [inconnu]
-# progress bar caption for the 'reload books' step of all outOfUi operations
-# (FORMAT: STRING) 
-PROGRESS_OUT_OF_UI_RELOAD_BOOKS = Recharger les livres
-# progress bar caption for the 'change source' step of the ReDownload operation
-# (FORMAT: STRING) 
-PROGRESS_CHANGE_SOURCE = Change la source du livre en %s
-# default description if the error is not known
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS = Une erreur est survenue en contactant la librairie
-# library access not allowed
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS_UNAUTHORIZED = Vous n'êtes pas autorisé à accéder à cette librairie
-# the library is invalid (not correctly set up)
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS_INVALID = Librairie invalide
-# the library is out of commission
-# (FORMAT: STRING) 
-ERROR_LIB_STATUS_UNAVAILABLE = Librairie indisponible
-# cannot open the book, internal or external viewer
-# (FORMAT: STRING) 
-ERROR_CANNOT_OPEN = Impossible d'ouvrir le livre sélectionné
-# URL is not supported by Fanfix
-# (FORMAT: STRING) 
-ERROR_URL_NOT_SUPPORTED = URL non supportée : %s
-# cannot import the URL
-# (FORMAT: STRING) 
-ERROR_URL_IMPORT_FAILED = Erreur lors de l'import de %s:\n\
-%s
-# (html) the chapter progression value used on the viewers
-# (FORMAT: STRING) 
-CHAPTER_HTML_UNNAMED = &nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>
-# (html) the chapter progression value used on the viewers
-# (FORMAT: STRING) 
-CHAPTER_HTML_NAMED = &nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>: %s
-# (NO html) the chapter progression value used on the viewers
-# (FORMAT: STRING) 
-IMAGE_PROGRESSION = Image %d / %d
diff --git a/bundles/ui_description.properties b/bundles/ui_description.properties
deleted file mode 100644 (file)
index c8def83..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-# United Kingdom (en_GB) UI configuration options description translation file (UTF-8)
-# 
-# Note that any key can be doubled with a _NOUTF suffix
-# to use when the NOUTF env variable is set to 1
-# 
-# Also, the comments always refer to the key below them.
-# 
-
-
-# The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator
-# (FORMAT: DIRECTORY) absolute path, $HOME variable supported, / is always accepted as dir separator
-CACHE_DIR_LOCAL_READER = The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator
-# The type of output for the GUI Reader for non-images documents
-# (FORMAT: COMBO_LIST) One of the known output type
-# ALLOWED VALUES: "INFO_TEXT" "EPUB" "HTML" "TEXT"
-GUI_NON_IMAGES_DOCUMENT_TYPE = 
-# The type of output for the GUI Reader for images documents
-# (FORMAT: COMBO_LIST) 
-# ALLOWED VALUES: "CBZ" "HTML"
-GUI_IMAGES_DOCUMENT_TYPE = 
-# Use the internal reader for images documents -- this is TRUE by default
-# (FORMAT: BOOLEAN) 
-IMAGES_DOCUMENT_USE_INTERNAL_READER = 
-# The command launched for images documents -- default to the system default for the current file type
-# (FORMAT: STRING) A command to start
-IMAGES_DOCUMENT_READER = 
-# Use the internal reader for non images documents -- this is TRUE by default
-# (FORMAT: BOOLEAN) 
-NON_IMAGES_DOCUMENT_USE_INTERNAL_READER = 
-# The command launched for non images documents -- default to the system default for the current file type
-# (FORMAT: STRING) A command to start
-NON_IMAGES_DOCUMENT_READER = 
-# The background colour if you don't want the default system one
-# (FORMAT: COLOR) 
-BACKGROUND_COLOR = 
diff --git a/data/Chapter.java b/data/Chapter.java
deleted file mode 100644 (file)
index d490058..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-package be.nikiroo.fanfix.data;
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-/**
- * A chapter in the story (or the resume/description).
- * 
- * @author niki
- */
-public class Chapter implements Iterable<Paragraph>, Cloneable, Serializable {
-       private static final long serialVersionUID = 1L;
-       
-       private String name;
-       private int number;
-       private List<Paragraph> paragraphs = new ArrayList<Paragraph>();
-       private List<Paragraph> empty = new ArrayList<Paragraph>();
-       private long words;
-
-       /**
-        * Empty constructor, not to use.
-        */
-       @SuppressWarnings("unused")
-       private Chapter() {
-               // for serialisation purposes
-       }
-
-       /**
-        * Create a new {@link Chapter} with the given information.
-        * 
-        * @param number
-        *            the chapter number, or 0 for the description/resume.
-        * @param name
-        *            the chapter name
-        */
-       public Chapter(int number, String name) {
-               this.number = number;
-               this.name = name;
-       }
-
-       /**
-        * The chapter name.
-        * 
-        * @return the name
-        */
-       public String getName() {
-               return name;
-       }
-
-       /**
-        * The chapter name.
-        * 
-        * @param name
-        *            the name to set
-        */
-       public void setName(String name) {
-               this.name = name;
-       }
-
-       /**
-        * The chapter number, or 0 for the description/resume.
-        * 
-        * @return the number
-        */
-       public int getNumber() {
-               return number;
-       }
-
-       /**
-        * The chapter number, or 0 for the description/resume.
-        * 
-        * @param number
-        *            the number to set
-        */
-       public void setNumber(int number) {
-               this.number = number;
-       }
-
-       /**
-        * The included paragraphs.
-        * 
-        * @return the paragraphs
-        */
-       public List<Paragraph> getParagraphs() {
-               return paragraphs;
-       }
-
-       /**
-        * The included paragraphs.
-        * 
-        * @param paragraphs
-        *            the paragraphs to set
-        */
-       public void setParagraphs(List<Paragraph> paragraphs) {
-               this.paragraphs = paragraphs;
-       }
-
-       /**
-        * Get an iterator on the {@link Paragraph}s.
-        */
-       @Override
-       public Iterator<Paragraph> iterator() {
-               return paragraphs == null ? empty.iterator() : paragraphs.iterator();
-       }
-
-       /**
-        * The number of words (or images) in this {@link Chapter}.
-        * 
-        * @return the number of words
-        */
-       public long getWords() {
-               return words;
-       }
-
-       /**
-        * The number of words (or images) in this {@link Chapter}.
-        * 
-        * @param words
-        *            the number of words to set
-        */
-       public void setWords(long words) {
-               this.words = words;
-       }
-
-       /**
-        * Display a DEBUG {@link String} representation of this object.
-        */
-       @Override
-       public String toString() {
-               return "Chapter " + number + ": " + name;
-       }
-
-       @Override
-       public Chapter clone() {
-               Chapter chap = null;
-               try {
-                       chap = (Chapter) super.clone();
-               } catch (CloneNotSupportedException e) {
-                       // Did the clones rebel?
-                       System.err.println(e);
-               }
-
-               if (paragraphs != null) {
-                       chap.paragraphs = new ArrayList<Paragraph>();
-                       for (Paragraph para : paragraphs) {
-                               chap.paragraphs.add(para.clone());
-                       }
-               }
-
-               return chap;
-       }
-}
diff --git a/data/JsonIO.java b/data/JsonIO.java
deleted file mode 100644 (file)
index 524f99a..0000000
+++ /dev/null
@@ -1,355 +0,0 @@
-package be.nikiroo.fanfix.data;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.utils.Progress;
-
-public class JsonIO {
-       static public JSONObject toJson(MetaData meta) {
-               if (meta == null) {
-                       return null;
-               }
-
-               JSONObject json = new JSONObject();
-
-               put(json, "", MetaData.class.getName());
-               put(json, "luid", meta.getLuid());
-               put(json, "title", meta.getTitle());
-               put(json, "author", meta.getAuthor());
-               put(json, "source", meta.getSource());
-               put(json, "url", meta.getUrl());
-               put(json, "words", meta.getWords());
-               put(json, "creation_date", meta.getCreationDate());
-               put(json, "date", meta.getDate());
-               put(json, "lang", meta.getLang());
-               put(json, "publisher", meta.getPublisher());
-               put(json, "subject", meta.getSubject());
-               put(json, "type", meta.getType());
-               put(json, "uuid", meta.getUuid());
-               put(json, "fake_cover", meta.isFakeCover());
-               put(json, "image_document", meta.isImageDocument());
-
-               put(json, "resume", toJson(meta.getResume()));
-               put(json, "tags", new JSONArray(meta.getTags()));
-
-               return json;
-       }
-
-       /**
-        * // no image
-        * 
-        * @param json
-        * 
-        * @return
-        * 
-        * @throws JSONException
-        *             when it cannot be converted
-        */
-       static public MetaData toMetaData(JSONObject json) {
-               if (json == null) {
-                       return null;
-               }
-
-               MetaData meta = new MetaData();
-
-               meta.setLuid(getString(json, "luid"));
-               meta.setTitle(getString(json, "title"));
-               meta.setAuthor(getString(json, "author"));
-               meta.setSource(getString(json, "source"));
-               meta.setUrl(getString(json, "url"));
-               meta.setWords(getLong(json, "words", 0));
-               meta.setCreationDate(getString(json, "creation_date"));
-               meta.setDate(getString(json, "date"));
-               meta.setLang(getString(json, "lang"));
-               meta.setPublisher(getString(json, "publisher"));
-               meta.setSubject(getString(json, "subject"));
-               meta.setType(getString(json, "type"));
-               meta.setUuid(getString(json, "uuid"));
-               meta.setFakeCover(getBoolean(json, "fake_cover", false));
-               meta.setImageDocument(getBoolean(json, "image_document", false));
-
-               meta.setResume(toChapter(getJson(json, "resume")));
-               meta.setTags(toListString(getJsonArr(json, "tags")));
-
-               return meta;
-       }
-
-       static public JSONObject toJson(Story story) {
-               if (story == null) {
-                       return null;
-               }
-
-               JSONObject json = new JSONObject();
-               put(json, "", Story.class.getName());
-               put(json, "meta", toJson(story.getMeta()));
-
-               List<JSONObject> chapters = new ArrayList<JSONObject>();
-               for (Chapter chap : story) {
-                       chapters.add(toJson(chap));
-               }
-               put(json, "chapters", new JSONArray(chapters));
-
-               return json;
-       }
-
-       /**
-        * 
-        * @param json
-        * 
-        * @return
-        * 
-        * @throws JSONException
-        *             when it cannot be converted
-        */
-       static public Story toStory(JSONObject json) {
-               if (json == null) {
-                       return null;
-               }
-
-               Story story = new Story();
-               story.setMeta(toMetaData(getJson(json, "meta")));
-               story.setChapters(toListChapter(getJsonArr(json, "chapters")));
-
-               return story;
-       }
-
-       static public JSONObject toJson(Chapter chap) {
-               if (chap == null) {
-                       return null;
-               }
-
-               JSONObject json = new JSONObject();
-               put(json, "", Chapter.class.getName());
-               put(json, "name", chap.getName());
-               put(json, "number", chap.getNumber());
-               put(json, "words", chap.getWords());
-
-               List<JSONObject> paragraphs = new ArrayList<JSONObject>();
-               for (Paragraph para : chap) {
-                       paragraphs.add(toJson(para));
-               }
-               put(json, "paragraphs", new JSONArray(paragraphs));
-
-               return json;
-       }
-
-       /**
-        * 
-        * @param json
-        * 
-        * @return
-        * 
-        * @throws JSONException
-        *             when it cannot be converted
-        */
-       static public Chapter toChapter(JSONObject json) {
-               if (json == null) {
-                       return null;
-               }
-
-               Chapter chap = new Chapter(getInt(json, "number", 0),
-                               getString(json, "name"));
-               chap.setWords(getLong(json, "words", 0));
-
-               chap.setParagraphs(toListParagraph(getJsonArr(json, "paragraphs")));
-
-               return chap;
-       }
-
-       // no images
-       static public JSONObject toJson(Paragraph para) {
-               if (para == null) {
-                       return null;
-               }
-
-               JSONObject json = new JSONObject();
-
-               put(json, "", Paragraph.class.getName());
-               put(json, "content", para.getContent());
-               put(json, "words", para.getWords());
-
-               put(json, "type", para.getType().toString());
-
-               return json;
-       }
-
-       /**
-        * // no images
-        * 
-        * @param json
-        * 
-        * @return
-        * 
-        * @throws JSONException
-        *             when it cannot be converted
-        */
-       static public Paragraph toParagraph(JSONObject json) {
-               if (json == null) {
-                       return null;
-               }
-
-               Paragraph para = new Paragraph(
-                               ParagraphType.valueOf(getString(json, "type")),
-                               getString(json, "content"), getLong(json, "words", 0));
-
-               return para;
-       }
-
-       // no children included
-       static public JSONObject toJson(Progress pg) {
-               if (pg == null) {
-                       return null;
-               }
-
-               JSONObject json = new JSONObject();
-
-               put(json, "", Progress.class.getName());
-               put(json, "name", pg.getName());
-               put(json, "min", pg.getMin());
-               put(json, "max", pg.getMax());
-               put(json, "progress", pg.getProgress());
-
-               return json;
-       }
-
-       // no children included
-       static public Progress toProgress(JSONObject json) {
-               if (json == null) {
-                       return null;
-               }
-
-               Progress pg = new Progress(getString(json, "name"),
-                               getInt(json, "min", 0), getInt(json, "max", 100));
-               pg.setProgress(getInt(json, "progress", 0));
-
-               return pg;
-       }
-
-       static public List<String> toListString(JSONArray array) {
-               if (array != null) {
-                       List<String> values = new ArrayList<String>();
-                       for (int i = 0; i < array.length(); i++) {
-                               values.add(array.getString(i));
-                       }
-                       return values;
-               }
-
-               return null;
-       }
-
-       static public List<Paragraph> toListParagraph(JSONArray array) {
-               if (array != null) {
-                       List<Paragraph> values = new ArrayList<Paragraph>();
-                       for (int i = 0; i < array.length(); i++) {
-                               JSONObject value = array.getJSONObject(i);
-                               values.add(toParagraph(value));
-                       }
-                       return values;
-               }
-
-               return null;
-       }
-
-       private static List<Chapter> toListChapter(JSONArray array) {
-               if (array != null) {
-                       List<Chapter> values = new ArrayList<Chapter>();
-                       for (int i = 0; i < array.length(); i++) {
-                               JSONObject value = array.getJSONObject(i);
-                               values.add(toChapter(value));
-                       }
-                       return values;
-               }
-
-               return null;
-       }
-
-       static private void put(JSONObject json, String key, Object o) {
-               json.put(key, o == null ? JSONObject.NULL : o);
-       }
-
-       static String getString(JSONObject json, String key) {
-               if (json.has(key)) {
-                       Object o = json.get(key);
-                       if (o instanceof String) {
-                               return (String) o;
-                       }
-               }
-
-               return null;
-       }
-
-       static long getLong(JSONObject json, String key, long def) {
-               if (json.has(key)) {
-                       Object o = json.get(key);
-                       if (o instanceof Byte)
-                               return (Byte) o;
-                       if (o instanceof Short)
-                               return (Short) o;
-                       if (o instanceof Integer)
-                               return (Integer) o;
-                       if (o instanceof Long)
-                               return (Long) o;
-               }
-
-               return def;
-       }
-
-       static boolean getBoolean(JSONObject json, String key, boolean def) {
-               if (json.has(key)) {
-                       Object o = json.get(key);
-                       if (o instanceof Boolean) {
-                               return (Boolean) o;
-                       }
-               }
-
-               return def;
-       }
-
-       static int getInt(JSONObject json, String key, int def) {
-               if (json.has(key)) {
-                       Object o = json.get(key);
-                       if (o instanceof Byte)
-                               return (Byte) o;
-                       if (o instanceof Short)
-                               return (Short) o;
-                       if (o instanceof Integer)
-                               return (Integer) o;
-                       if (o instanceof Long) {
-                               try {
-                                       return (int) (long) ((Long) o);
-                               } catch (Exception e) {
-                               }
-                       }
-               }
-
-               return def;
-       }
-
-       static JSONObject getJson(JSONObject json, String key) {
-               if (json.has(key)) {
-                       Object o = json.get(key);
-                       if (o instanceof JSONObject) {
-                               return (JSONObject) o;
-                       }
-               }
-
-               return null;
-       }
-
-       static JSONArray getJsonArr(JSONObject json, String key) {
-               if (json.has(key)) {
-                       Object o = json.get(key);
-                       if (o instanceof JSONArray) {
-                               return (JSONArray) o;
-                       }
-               }
-
-               return null;
-       }
-}
diff --git a/data/MetaData.java b/data/MetaData.java
deleted file mode 100644 (file)
index bcebf75..0000000
+++ /dev/null
@@ -1,493 +0,0 @@
-package be.nikiroo.fanfix.data;
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.List;
-
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * The meta data associated to a {@link Story} object.
- * 
- * @author niki
- */
-public class MetaData implements Cloneable, Comparable<MetaData>, Serializable {
-       private static final long serialVersionUID = 1L;
-
-       private String title;
-       private String author;
-       private String date;
-       private Chapter resume;
-       private List<String> tags;
-       private Image cover;
-       private String subject;
-       private String source;
-       private String url;
-       private String uuid;
-       private String luid;
-       private String lang;
-       private String publisher;
-       private String type;
-       private boolean imageDocument;
-       private long words;
-       private String creationDate;
-       private boolean fakeCover;
-
-       /**
-        * Create an empty {@link MetaData}.
-        */
-       public MetaData() {
-       }
-       
-       /**
-        * The title of the story.
-        * 
-        * @return the title
-        */
-       public String getTitle() {
-               return title;
-       }
-
-       /**
-        * The title of the story.
-        * 
-        * @param title
-        *            the title to set
-        */
-       public void setTitle(String title) {
-               this.title = title;
-       }
-
-       /**
-        * The author of the story.
-        * 
-        * @return the author
-        */
-       public String getAuthor() {
-               return author;
-       }
-
-       /**
-        * The author of the story.
-        * 
-        * @param author
-        *            the author to set
-        */
-       public void setAuthor(String author) {
-               this.author = author;
-       }
-
-       /**
-        * The story publication date.
-        * 
-        * @return the date
-        */
-       public String getDate() {
-               return date;
-       }
-
-       /**
-        * The story publication date.
-        * 
-        * @param date
-        *            the date to set
-        */
-       public void setDate(String date) {
-               this.date = date;
-       }
-
-       /**
-        * The tags associated with this story.
-        * 
-        * @return the tags
-        */
-       public List<String> getTags() {
-               return tags;
-       }
-
-       /**
-        * The tags associated with this story.
-        * 
-        * @param tags
-        *            the tags to set
-        */
-       public void setTags(List<String> tags) {
-               this.tags = tags;
-       }
-
-       /**
-        * The story resume (a.k.a. description).
-        * <p>
-        * This can be NULL if we don't have a resume for this {@link Story}.
-        * 
-        * @return the resume
-        */
-       public Chapter getResume() {
-               return resume;
-       }
-
-       /**
-        * The story resume (a.k.a. description).
-        * 
-        * @param resume
-        *            the resume to set
-        */
-       public void setResume(Chapter resume) {
-               this.resume = resume;
-       }
-
-       /**
-        * The cover image of the story if any (can be NULL).
-        * 
-        * @return the cover
-        */
-       public Image getCover() {
-               return cover;
-       }
-
-       /**
-        * The cover image of the story if any (can be NULL).
-        * 
-        * @param cover
-        *            the cover to set
-        */
-       public void setCover(Image cover) {
-               this.cover = cover;
-       }
-
-       /**
-        * The subject of the story (or instance, if it is a fanfiction, what is the
-        * original work; if it is a technical text, what is the technical
-        * subject...).
-        * 
-        * @return the subject
-        */
-       public String getSubject() {
-               return subject;
-       }
-
-       /**
-        * The subject of the story (for instance, if it is a fanfiction, what is
-        * the original work; if it is a technical text, what is the technical
-        * subject...).
-        * 
-        * @param subject
-        *            the subject to set
-        */
-       public void setSubject(String subject) {
-               this.subject = subject;
-       }
-
-       /**
-        * The source of this story (which online library it was downloaded from).
-        * 
-        * @return the source
-        */
-       public String getSource() {
-               return source;
-       }
-
-       /**
-        * The source of this story (which online library it was downloaded from).
-        * 
-        * @param source
-        *            the source to set
-        */
-       public void setSource(String source) {
-               this.source = source;
-       }
-
-       /**
-        * The original URL from which this {@link Story} was imported.
-        * 
-        * @return the url
-        */
-       public String getUrl() {
-               return url;
-       }
-
-       /**
-        * The original URL from which this {@link Story} was imported.
-        * 
-        * @param url
-        *            the new url to set
-        */
-       public void setUrl(String url) {
-               this.url = url;
-       }
-
-       /**
-        * A unique value representing the story (it is often a URL).
-        * 
-        * @return the uuid
-        */
-       public String getUuid() {
-               return uuid;
-       }
-
-       /**
-        * A unique value representing the story (it is often a URL).
-        * 
-        * @param uuid
-        *            the uuid to set
-        */
-       public void setUuid(String uuid) {
-               this.uuid = uuid;
-       }
-
-       /**
-        * A unique value representing the story in the local library.
-        * 
-        * @return the luid
-        */
-       public String getLuid() {
-               return luid;
-       }
-
-       /**
-        * A unique value representing the story in the local library.
-        * 
-        * @param luid
-        *            the luid to set
-        */
-       public void setLuid(String luid) {
-               this.luid = luid;
-       }
-
-       /**
-        * The 2-letter code language of this story.
-        * 
-        * @return the lang
-        */
-       public String getLang() {
-               return lang;
-       }
-
-       /**
-        * The 2-letter code language of this story.
-        * 
-        * @param lang
-        *            the lang to set
-        */
-       public void setLang(String lang) {
-               this.lang = lang;
-       }
-
-       /**
-        * The story publisher (other the same as the source).
-        * 
-        * @return the publisher
-        */
-       public String getPublisher() {
-               return publisher;
-       }
-
-       /**
-        * The story publisher (other the same as the source).
-        * 
-        * @param publisher
-        *            the publisher to set
-        */
-       public void setPublisher(String publisher) {
-               this.publisher = publisher;
-       }
-
-       /**
-        * The output type this {@link Story} is in.
-        * 
-        * @return the type the type
-        */
-       public String getType() {
-               return type;
-       }
-
-       /**
-        * The output type this {@link Story} is in.
-        * 
-        * @param type
-        *            the new type to set
-        */
-       public void setType(String type) {
-               this.type = type;
-       }
-
-       /**
-        * Document catering mostly to image files.
-        * 
-        * @return the imageDocument state
-        */
-       public boolean isImageDocument() {
-               return imageDocument;
-       }
-
-       /**
-        * Document catering mostly to image files.
-        * 
-        * @param imageDocument
-        *            the imageDocument state to set
-        */
-       public void setImageDocument(boolean imageDocument) {
-               this.imageDocument = imageDocument;
-       }
-
-       /**
-        * The number of words in the related {@link Story}.
-        * 
-        * @return the number of words
-        */
-       public long getWords() {
-               return words;
-       }
-
-       /**
-        * The number of words in the related {@link Story}.
-        * 
-        * @param words
-        *            the number of words to set
-        */
-       public void setWords(long words) {
-               this.words = words;
-       }
-
-       /**
-        * The (Fanfix) {@link Story} creation date.
-        * 
-        * @return the creationDate
-        */
-       public String getCreationDate() {
-               return creationDate;
-       }
-
-       /**
-        * The (Fanfix) {@link Story} creation date.
-        * 
-        * @param creationDate
-        *            the creationDate to set
-        */
-       public void setCreationDate(String creationDate) {
-               this.creationDate = creationDate;
-       }
-
-       /**
-        * The cover in this {@link MetaData} object is "fake", in the sens that it
-        * comes from the actual content images.
-        * 
-        * @return TRUE for a fake cover
-        */
-       public boolean isFakeCover() {
-               return fakeCover;
-       }
-
-       /**
-        * The cover in this {@link MetaData} object is "fake", in the sens that it
-        * comes from the actual content images
-        * 
-        * @param fakeCover
-        *            TRUE for a fake cover
-        */
-       public void setFakeCover(boolean fakeCover) {
-               this.fakeCover = fakeCover;
-       }
-
-       @Override
-       public int compareTo(MetaData o) {
-               if (o == null) {
-                       return 1;
-               }
-
-               String id = (getTitle() == null ? "" : getTitle())
-                               + (getUuid() == null ? "" : getUuid())
-                               + (getLuid() == null ? "" : getLuid());
-               String oId = (getTitle() == null ? "" : o.getTitle())
-                               + (getUuid() == null ? "" : o.getUuid())
-                               + (o.getLuid() == null ? "" : o.getLuid());
-
-               return id.compareToIgnoreCase(oId);
-       }
-
-       @Override
-       public boolean equals(Object obj) {
-               if (!(obj instanceof MetaData)) {
-                       return false;
-               }
-
-               return compareTo((MetaData) obj) == 0;
-       }
-
-       @Override
-       public int hashCode() {
-               String uuid = getUuid();
-               if (uuid == null) {
-                       uuid = "" + title + author + source;
-               }
-
-               return uuid.hashCode();
-       }
-
-       @Override
-       public MetaData clone() {
-               MetaData meta = null;
-               try {
-                       meta = (MetaData) super.clone();
-               } catch (CloneNotSupportedException e) {
-                       // Did the clones rebel?
-                       System.err.println(e);
-               }
-
-               if (tags != null) {
-                       meta.tags = new ArrayList<String>(tags);
-               }
-
-               if (resume != null) {
-                       meta.resume = resume.clone();
-               }
-
-               return meta;
-       }
-
-       /**
-        * Display a DEBUG {@link String} representation of this object.
-        * <p>
-        * This is not efficient, nor intended to be.
-        */
-       @Override
-       public String toString() {
-               String title = "";
-               if (getTitle() != null) {
-                       title = getTitle();
-               }
-
-               StringBuilder tags = new StringBuilder();
-               if (getTags() != null) {
-                       for (String tag : getTags()) {
-                               if (tags.length() > 0) {
-                                       tags.append(", ");
-                               }
-                               tags.append(tag);
-                       }
-               }
-
-               String resume = "";
-               if (getResume() != null) {
-                       for (Paragraph para : getResume()) {
-                               resume += "\n\t";
-                               resume += para.toString().substring(0,
-                                               Math.min(para.toString().length(), 120));
-                       }
-                       resume += "\n";
-               }
-
-               String cover = "none";
-               if (getCover() != null) {
-                       cover = StringUtils.formatNumber(getCover().getSize())
-                                       + "bytes";
-               }
-
-               return String.format(
-                               "Meta %s:\n\tTitle: [%s]\n\tAuthor: [%s]\n\tDate: [%s]\n\tTags: [%s]\n\tWord count: [%s]"
-                                               + "\n\tResume: [%s]\n\tCover: [%s]",
-                               luid, title, getAuthor(), getDate(), tags.toString(),
-                               "" + words, resume, cover);
-       }
-}
diff --git a/data/Paragraph.java b/data/Paragraph.java
deleted file mode 100644 (file)
index d5a0f1c..0000000
+++ /dev/null
@@ -1,182 +0,0 @@
-package be.nikiroo.fanfix.data;
-
-import java.io.Serializable;
-
-import be.nikiroo.utils.Image;
-
-/**
- * A paragraph in a chapter of the story.
- * 
- * @author niki
- */
-public class Paragraph implements Cloneable, Serializable {
-       private static final long serialVersionUID = 1L;
-
-       /**
-        * A paragraph type, that will dictate how the paragraph will be handled.
-        * 
-        * @author niki
-        */
-       public enum ParagraphType {
-               /** Normal paragraph (text) */
-               NORMAL,
-               /** Blank line */
-               BLANK,
-               /** A Break paragraph, i.e.: HR (Horizontal Line) or '* * *' or whatever */
-               BREAK,
-               /** Quotation (dialogue) */
-               QUOTE,
-               /** An image (no text) */
-               IMAGE, ;
-
-               /**
-                * This paragraph type is of a text kind (quote or not).
-                * 
-                * @param allowEmpty
-                *            allow empty text as text, too (blanks, breaks...)
-                * @return TRUE if it is
-                */
-               public boolean isText(boolean allowEmpty) {
-                       return (this == NORMAL || this == QUOTE)
-                                       || (allowEmpty && (this == BLANK || this == BREAK));
-               }
-       }
-
-       private ParagraphType type;
-       private String content;
-       private Image contentImage;
-       private long words;
-
-       /**
-        * Empty constructor, not to use.
-        */
-       @SuppressWarnings("unused")
-       private Paragraph() {
-               // for serialisation purposes
-       }
-
-       /**
-        * Create a new {@link Paragraph} with the given image.
-        * 
-        * @param contentImage
-        *            the image
-        */
-       public Paragraph(Image contentImage) {
-               this(ParagraphType.IMAGE, null, 1);
-               this.contentImage = contentImage;
-       }
-
-       /**
-        * Create a new {@link Paragraph} with the given values.
-        * 
-        * @param type
-        *            the {@link ParagraphType}
-        * @param content
-        *            the content of this paragraph
-        * @param words
-        *            the number of words (or images)
-        */
-       public Paragraph(ParagraphType type, String content, long words) {
-               this.type = type;
-               this.content = content;
-               this.words = words;
-       }
-
-       /**
-        * The {@link ParagraphType}.
-        * 
-        * @return the type
-        */
-       public ParagraphType getType() {
-               return type;
-       }
-
-       /**
-        * The {@link ParagraphType}.
-        * 
-        * @param type
-        *            the type to set
-        */
-       public void setType(ParagraphType type) {
-               this.type = type;
-       }
-
-       /**
-        * The content of this {@link Paragraph} if it is not an image.
-        * 
-        * @return the content
-        */
-       public String getContent() {
-               return content;
-       }
-
-       /**
-        * The content of this {@link Paragraph}.
-        * 
-        * @param content
-        *            the content to set
-        */
-       public void setContent(String content) {
-               this.content = content;
-       }
-
-       /**
-        * The content of this {@link Paragraph} if it is an image.
-        * 
-        * @return the content
-        */
-       public Image getContentImage() {
-               return contentImage;
-       }
-
-       /**
-        * The content of this {@link Paragraph} if it is an image.
-        * 
-        * @param contentImage
-        *            the content
-        */
-       public void setContentImage(Image contentImage) {
-               this.contentImage = contentImage;
-       }
-       
-       /**
-        * The number of words (or images) in this {@link Paragraph}.
-        * 
-        * @return the number of words
-        */
-       public long getWords() {
-               return words;
-       }
-
-       /**
-        * The number of words (or images) in this {@link Paragraph}.
-        * 
-        * @param words
-        *            the number of words to set
-        */
-       public void setWords(long words) {
-               this.words = words;
-       }
-
-       /**
-        * Display a DEBUG {@link String} representation of this object.
-        */
-       @Override
-       public String toString() {
-               return String.format("%s: [%s]", "" + type, content == null ? "N/A"
-                               : content);
-       }
-
-       @Override
-       public Paragraph clone() {
-               Paragraph para = null;
-               try {
-                       para = (Paragraph) super.clone();
-               } catch (CloneNotSupportedException e) {
-                       // Did the clones rebel?
-                       System.err.println(e);
-               }
-
-               return para;
-       }
-}
diff --git a/data/Story.java b/data/Story.java
deleted file mode 100644 (file)
index fc3f909..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-package be.nikiroo.fanfix.data;
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-/**
- * The main data class, where the whole story resides.
- * 
- * @author niki
- */
-public class Story implements Iterable<Chapter>, Cloneable, Serializable {
-       private static final long serialVersionUID = 1L;
-       
-       private MetaData meta;
-       private List<Chapter> chapters = new ArrayList<Chapter>();
-       private List<Chapter> empty = new ArrayList<Chapter>();
-
-       /**
-        * The metadata about this {@link Story}.
-        * 
-        * @return the meta
-        */
-       public MetaData getMeta() {
-               return meta;
-       }
-
-       /**
-        * The metadata about this {@link Story}.
-        * 
-        * @param meta
-        *            the meta to set
-        */
-       public void setMeta(MetaData meta) {
-               this.meta = meta;
-       }
-
-       /**
-        * The chapters of the story.
-        * 
-        * @return the chapters
-        */
-       public List<Chapter> getChapters() {
-               return chapters;
-       }
-
-       /**
-        * The chapters of the story.
-        * 
-        * @param chapters
-        *            the chapters to set
-        */
-       public void setChapters(List<Chapter> chapters) {
-               this.chapters = chapters;
-       }
-
-       /**
-        * Get an iterator on the {@link Chapter}s.
-        */
-       @Override
-       public Iterator<Chapter> iterator() {
-               return chapters == null ? empty.iterator() : chapters.iterator();
-       }
-
-       /**
-        * Display a DEBUG {@link String} representation of this object.
-        * <p>
-        * This is not efficient, nor intended to be.
-        */
-       @Override
-       public String toString() {
-               if (getMeta() != null)
-                       return "Story: [\n" + getMeta().toString() + "\n]";
-               return "Story: [ no metadata found ]";
-       }
-
-       @Override
-       public Story clone() {
-               Story story = null;
-               try {
-                       story = (Story) super.clone();
-               } catch (CloneNotSupportedException e) {
-                       // Did the clones rebel?
-                       System.err.println(e);
-               }
-
-               if (meta != null) {
-                       story.meta = meta.clone();
-               }
-
-               if (chapters != null) {
-                       story.chapters = new ArrayList<Chapter>();
-                       for (Chapter chap : chapters) {
-                               story.chapters.add(chap.clone());
-                       }
-               }
-
-               return story;
-       }
-}
diff --git a/data/package-info.java b/data/package-info.java
deleted file mode 100644 (file)
index 57db36b..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * This package contains the data structure used by the program, without the 
- * logic behind them.
- * <p>
- * All the classes inside are serializable.
- * 
- * @author niki
- */
-package be.nikiroo.fanfix.data;
\ No newline at end of file
diff --git a/derename.sh b/derename.sh
deleted file mode 100755 (executable)
index 6c8cbff..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/sh
-
-git status | grep renamed: | sed 's/[^:]*: *\([^>]*\) -> \(.*\)/\1>\2/g' | while read -r ln; do
-       old="`echo "$ln" | cut -f1 -d'>'`"
-       new="`echo "$ln" | cut -f2 -d'>'`"
-       mkdir -p "`dirname "$old"`"
-       git mv "$new" "$old"
-       rmdir "`dirname "$new"`" 2>/dev/null
-       true
-done
-
diff --git a/library/BasicLibrary.java b/library/BasicLibrary.java
deleted file mode 100644 (file)
index 8b72f19..0000000
+++ /dev/null
@@ -1,994 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.URL;
-import java.net.UnknownHostException;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.output.BasicOutput;
-import be.nikiroo.fanfix.output.BasicOutput.OutputType;
-import be.nikiroo.fanfix.supported.BasicSupport;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Manage a library of Stories: import, export, list, modify.
- * <p>
- * Each {@link Story} object will be associated with a (local to the library)
- * unique ID, the LUID, which will be used to identify the {@link Story}.
- * <p>
- * Most of the {@link BasicLibrary} functions work on a partial (cover
- * <b>MAY</b> not be included) {@link MetaData} object.
- * 
- * @author niki
- */
-abstract public class BasicLibrary {
-       /**
-        * A {@link BasicLibrary} status.
-        * 
-        * @author niki
-        */
-       public enum Status {
-               /** The library is ready and r/w. */
-               READ_WRITE,
-               /** The library is ready, but read-only. */
-               READ_ONLY,
-               /** You are not allowed to access this library. */
-               UNAUTHORIZED,
-               /** The library is invalid, and will never work as is. */
-               INVALID,
-               /** The library is currently out of commission, but may work later. */
-               UNAVAILABLE;
-
-               /**
-                * The library is available (you can query it).
-                * <p>
-                * It does <b>not</b> specify if it is read-only or not.
-                * 
-                * @return TRUE if it is
-                */
-               public boolean isReady() {
-                       return (this == READ_WRITE || this == READ_ONLY);
-               }
-
-               /**
-                * This library can be modified (= you are allowed to modify it).
-                * 
-                * @return TRUE if it is
-                */
-               public boolean isWritable() {
-                       return (this == READ_WRITE);
-               }
-       }
-
-       /**
-        * Return a name for this library (the UI may display this).
-        * <p>
-        * Must not be NULL.
-        * 
-        * @return the name, or an empty {@link String} if none
-        */
-       public String getLibraryName() {
-               return "";
-       }
-
-       /**
-        * The library status.
-        * 
-        * @return the current status
-        */
-       public Status getStatus() {
-               return Status.READ_WRITE;
-       }
-
-       /**
-        * Retrieve the main {@link File} corresponding to the given {@link Story},
-        * which can be passed to an external reader or instance.
-        * <p>
-        * Do <b>NOT</b> alter this file.
-        * 
-        * @param luid
-        *            the Library UID of the story, can be NULL
-        * @param pg
-        *            the optional {@link Progress}
-        * 
-        * @return the corresponding {@link Story}
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public abstract File getFile(String luid, Progress pg) throws IOException;
-
-       /**
-        * Return the cover image associated to this story.
-        * 
-        * @param luid
-        *            the Library UID of the story
-        * 
-        * @return the cover image
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public abstract Image getCover(String luid) throws IOException;
-
-       /**
-        * Retrieve the list of {@link MetaData} known by this {@link BasicLibrary}
-        * in a easy-to-filter version.
-        * 
-        * @param pg
-        *            the optional {@link Progress}
-        * @return the list of {@link MetaData} as a {@link MetaResultList} you can
-        *         query
-        * @throws IOException
-        *             in case of I/O eror
-        */
-       public MetaResultList getList(Progress pg) throws IOException {
-               // TODO: ensure it is the main used interface
-
-               return new MetaResultList(getMetas(pg));
-       }
-
-       // TODO: make something for (normal and custom) non-story covers
-
-       /**
-        * Return the cover image associated to this source.
-        * <p>
-        * By default, return the custom cover if any, and if not, return the cover
-        * of the first story with this source.
-        * 
-        * @param source
-        *            the source
-        * 
-        * @return the cover image or NULL
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public Image getSourceCover(String source) throws IOException {
-               Image custom = getCustomSourceCover(source);
-               if (custom != null) {
-                       return custom;
-               }
-
-               List<MetaData> metas = getList().filter(source, null, null);
-               if (metas.size() > 0) {
-                       return getCover(metas.get(0).getLuid());
-               }
-
-               return null;
-       }
-
-       /**
-        * Return the cover image associated to this author.
-        * <p>
-        * By default, return the custom cover if any, and if not, return the cover
-        * of the first story with this author.
-        * 
-        * @param author
-        *            the author
-        * 
-        * @return the cover image or NULL
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public Image getAuthorCover(String author) throws IOException {
-               Image custom = getCustomAuthorCover(author);
-               if (custom != null) {
-                       return custom;
-               }
-
-               List<MetaData> metas = getList().filter(null, author, null);
-               if (metas.size() > 0) {
-                       return getCover(metas.get(0).getLuid());
-               }
-
-               return null;
-       }
-
-       /**
-        * Return the custom cover image associated to this source.
-        * <p>
-        * By default, return NULL.
-        * 
-        * @param source
-        *            the source to look for
-        * 
-        * @return the custom cover or NULL if none
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       @SuppressWarnings("unused")
-       public Image getCustomSourceCover(String source) throws IOException {
-               return null;
-       }
-
-       /**
-        * Return the custom cover image associated to this author.
-        * <p>
-        * By default, return NULL.
-        * 
-        * @param author
-        *            the author to look for
-        * 
-        * @return the custom cover or NULL if none
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       @SuppressWarnings("unused")
-       public Image getCustomAuthorCover(String author) throws IOException {
-               return null;
-       }
-
-       /**
-        * Set the source cover to the given story cover.
-        * 
-        * @param source
-        *            the source to change
-        * @param luid
-        *            the story LUID
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public abstract void setSourceCover(String source, String luid)
-                       throws IOException;
-
-       /**
-        * Set the author cover to the given story cover.
-        * 
-        * @param author
-        *            the author to change
-        * @param luid
-        *            the story LUID
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public abstract void setAuthorCover(String author, String luid)
-                       throws IOException;
-
-       /**
-        * Return the list of stories (represented by their {@link MetaData}, which
-        * <b>MAY</b> not have the cover included).
-        * <p>
-        * The returned list <b>MUST</b> be a copy, not the original one.
-        * 
-        * @param pg
-        *            the optional {@link Progress}
-        * 
-        * @return the list (can be empty but not NULL)
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       protected abstract List<MetaData> getMetas(Progress pg) throws IOException;
-
-       /**
-        * Invalidate the {@link Story} cache (when the content should be re-read
-        * because it was changed).
-        */
-       protected void invalidateInfo() {
-               invalidateInfo(null);
-       }
-
-       /**
-        * Invalidate the {@link Story} cache (when the content is removed).
-        * <p>
-        * All the cache can be deleted if NULL is passed as meta.
-        * 
-        * @param luid
-        *            the LUID of the {@link Story} to clear from the cache, or NULL
-        *            for all stories
-        */
-       protected abstract void invalidateInfo(String luid);
-
-       /**
-        * Invalidate the {@link Story} cache (when the content has changed, but we
-        * already have it) with the new given meta.
-        * 
-        * @param meta
-        *            the {@link Story} to clear from the cache
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       protected abstract void updateInfo(MetaData meta) throws IOException;
-
-       /**
-        * Return the next LUID that can be used.
-        * 
-        * @return the next luid
-        */
-       protected abstract String getNextId();
-
-       /**
-        * Delete the target {@link Story}.
-        * 
-        * @param luid
-        *            the LUID of the {@link Story}
-        * 
-        * @throws IOException
-        *             in case of I/O error or if the {@link Story} wa not found
-        */
-       protected abstract void doDelete(String luid) throws IOException;
-
-       /**
-        * Actually save the story to the back-end.
-        * 
-        * @param story
-        *            the {@link Story} to save
-        * @param pg
-        *            the optional {@link Progress}
-        * 
-        * @return the saved {@link Story} (which may have changed, especially
-        *         regarding the {@link MetaData})
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract Story doSave(Story story, Progress pg)
-                       throws IOException;
-
-       /**
-        * Refresh the {@link BasicLibrary}, that is, make sure all metas are
-        * loaded.
-        * 
-        * @param pg
-        *            the optional progress reporter
-        */
-       public void refresh(Progress pg) {
-               try {
-                       getMetas(pg);
-               } catch (IOException e) {
-                       // We will let it fail later
-               }
-       }
-
-       /**
-        * Check if the {@link Story} denoted by this Library UID is present in the
-        * cache (if we have no cache, we default to </tt>true</tt>).
-        * 
-        * @param luid
-        *            the Library UID
-        * 
-        * @return TRUE if it is
-        */
-       public boolean isCached(@SuppressWarnings("unused") String luid) {
-               // By default, everything is cached
-               return true;
-       }
-
-       /**
-        * Clear the {@link Story} from the cache, if needed.
-        * <p>
-        * The next time we try to retrieve the {@link Story}, it may be required to
-        * cache it again.
-        * 
-        * @param luid
-        *            the story to clear
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       @SuppressWarnings("unused")
-       public void clearFromCache(String luid) throws IOException {
-               // By default, this is a noop.
-       }
-
-       /**
-        * @return the same as getList()
-        * @throws IOException
-        *             in case of I/O error
-        * @deprecated please use {@link BasicLibrary#getList()} and
-        *             {@link MetaResultList#getSources()} instead.
-        */
-       @Deprecated
-       public List<String> getSources() throws IOException {
-               return getList().getSources();
-       }
-
-       /**
-        * @return the same as getList()
-        * @throws IOException
-        *             in case of I/O error
-        * @deprecated please use {@link BasicLibrary#getList()} and
-        *             {@link MetaResultList#getSourcesGrouped()} instead.
-        */
-       @Deprecated
-       public Map<String, List<String>> getSourcesGrouped() throws IOException {
-               return getList().getSourcesGrouped();
-       }
-
-       /**
-        * @return the same as getList()
-        * @throws IOException
-        *             in case of I/O error
-        * @deprecated please use {@link BasicLibrary#getList()} and
-        *             {@link MetaResultList#getAuthors()} instead.
-        */
-       @Deprecated
-       public List<String> getAuthors() throws IOException {
-               return getList().getAuthors();
-       }
-
-       /**
-        * @return the same as getList()
-        * @throws IOException
-        *             in case of I/O error
-        * @deprecated please use {@link BasicLibrary#getList()} and
-        *             {@link MetaResultList#getAuthorsGrouped()} instead.
-        */
-       @Deprecated
-       public Map<String, List<String>> getAuthorsGrouped() throws IOException {
-               return getList().getAuthorsGrouped();
-       }
-
-       /**
-        * List all the stories in the {@link BasicLibrary}.
-        * <p>
-        * Cover images <b>MAYBE</b> not included.
-        * 
-        * @return the stories
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public MetaResultList getList() throws IOException {
-               return getList(null);
-       }
-
-       /**
-        * Retrieve a {@link MetaData} corresponding to the given {@link Story},
-        * cover image <b>MAY</b> not be included.
-        * 
-        * @param luid
-        *            the Library UID of the story, can be NULL
-        * 
-        * @return the corresponding {@link Story} or NULL if not found
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public MetaData getInfo(String luid) throws IOException {
-               if (luid != null) {
-                       for (MetaData meta : getMetas(null)) {
-                               if (luid.equals(meta.getLuid())) {
-                                       return meta;
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Retrieve a specific {@link Story}.
-        * 
-        * @param luid
-        *            the Library UID of the story
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the corresponding {@link Story} or NULL if not found
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public Story getStory(String luid, Progress pg) throws IOException {
-               Progress pgMetas = new Progress();
-               Progress pgStory = new Progress();
-               if (pg != null) {
-                       pg.setMinMax(0, 100);
-                       pg.addProgress(pgMetas, 10);
-                       pg.addProgress(pgStory, 90);
-               }
-
-               MetaData meta = null;
-               for (MetaData oneMeta : getMetas(pgMetas)) {
-                       if (oneMeta.getLuid().equals(luid)) {
-                               meta = oneMeta;
-                               break;
-                       }
-               }
-
-               pgMetas.done();
-
-               Story story = getStory(luid, meta, pgStory);
-               pgStory.done();
-
-               return story;
-       }
-
-       /**
-        * Retrieve a specific {@link Story}.
-        * 
-        * @param luid
-        *            the LUID of the story
-        * @param meta
-        *            the meta of the story
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the corresponding {@link Story} or NULL if not found
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
-                       throws IOException {
-
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Progress pgGet = new Progress();
-               Progress pgProcess = new Progress();
-
-               pg.setMinMax(0, 2);
-               pg.addProgress(pgGet, 1);
-               pg.addProgress(pgProcess, 1);
-
-               Story story = null;
-               File file = null;
-
-               if (luid != null && meta != null) {
-                       file = getFile(luid, pgGet);
-               }
-
-               pgGet.done();
-               try {
-                       if (file != null) {
-                               SupportType type = SupportType.valueOfAllOkUC(meta.getType());
-                               if (type == null) {
-                                       throw new IOException("Unknown type: " + meta.getType());
-                               }
-
-                               URL url = file.toURI().toURL();
-                               story = BasicSupport.getSupport(type, url) //
-                                               .process(pgProcess);
-
-                               // Because we do not want to clear the meta cache:
-                               meta.setCover(story.getMeta().getCover());
-                               meta.setResume(story.getMeta().getResume());
-                               story.setMeta(meta);
-                       }
-               } catch (IOException e) {
-                       // We should not have not-supported files in the library
-                       Instance.getInstance().getTraceHandler()
-                                       .error(new IOException(String.format(
-                                                       "Cannot load file of type '%s' from library: %s",
-                                                       meta.getType(), file), e));
-               } finally {
-                       pgProcess.done();
-                       pg.done();
-               }
-
-               return story;
-       }
-
-       /**
-        * Import the {@link Story} at the given {@link URL} into the
-        * {@link BasicLibrary}.
-        * 
-        * @param url
-        *            the {@link URL} to import
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the imported Story {@link MetaData}
-        * 
-        * @throws UnknownHostException
-        *             if the host is not supported
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public MetaData imprt(URL url, Progress pg) throws IOException {
-               return imprt(url, null, pg);
-       }
-
-       /**
-        * Import the {@link Story} at the given {@link URL} into the
-        * {@link BasicLibrary}.
-        * 
-        * @param url
-        *            the {@link URL} to import
-        * @param luid
-        *            the LUID to use
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the imported Story {@link MetaData}
-        * 
-        * @throws UnknownHostException
-        *             if the host is not supported
-        * @throws IOException
-        *             in case of I/O error
-        */
-       MetaData imprt(URL url, String luid, Progress pg) throws IOException {
-               if (pg == null)
-                       pg = new Progress();
-
-               pg.setMinMax(0, 1000);
-               Progress pgProcess = new Progress();
-               Progress pgSave = new Progress();
-               pg.addProgress(pgProcess, 800);
-               pg.addProgress(pgSave, 200);
-
-               BasicSupport support = BasicSupport.getSupport(url);
-               if (support == null) {
-                       throw new UnknownHostException("" + url);
-               }
-
-               Story story = save(support.process(pgProcess), luid, pgSave);
-               pg.done();
-
-               return story.getMeta();
-       }
-
-       /**
-        * Import the story from one library to another, and keep the same LUID.
-        * 
-        * @param other
-        *            the other library to import from
-        * @param luid
-        *            the Library UID
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public void imprt(BasicLibrary other, String luid, Progress pg)
-                       throws IOException {
-               Progress pgGetStory = new Progress();
-               Progress pgSave = new Progress();
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               pg.setMinMax(0, 2);
-               pg.addProgress(pgGetStory, 1);
-               pg.addProgress(pgSave, 1);
-
-               Story story = other.getStory(luid, pgGetStory);
-               if (story != null) {
-                       story = this.save(story, luid, pgSave);
-                       pg.done();
-               } else {
-                       pg.done();
-                       throw new IOException("Cannot find story in Library: " + luid);
-               }
-       }
-
-       /**
-        * Export the {@link Story} to the given target in the given format.
-        * 
-        * @param luid
-        *            the {@link Story} ID
-        * @param type
-        *            the {@link OutputType} to transform it to
-        * @param target
-        *            the target to save to
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the saved resource (the main saved {@link File})
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public File export(String luid, OutputType type, String target, Progress pg)
-                       throws IOException {
-               Progress pgGetStory = new Progress();
-               Progress pgOut = new Progress();
-               if (pg != null) {
-                       pg.setMax(2);
-                       pg.addProgress(pgGetStory, 1);
-                       pg.addProgress(pgOut, 1);
-               }
-
-               BasicOutput out = BasicOutput.getOutput(type, false, false);
-               if (out == null) {
-                       throw new IOException("Output type not supported: " + type);
-               }
-
-               Story story = getStory(luid, pgGetStory);
-               if (story == null) {
-                       throw new IOException("Cannot find story to export: " + luid);
-               }
-
-               return out.process(story, target, pgOut);
-       }
-
-       /**
-        * Save a {@link Story} to the {@link BasicLibrary}.
-        * 
-        * @param story
-        *            the {@link Story} to save
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the same {@link Story}, whose LUID may have changed
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public Story save(Story story, Progress pg) throws IOException {
-               return save(story, null, pg);
-       }
-
-       /**
-        * Save a {@link Story} to the {@link BasicLibrary} -- the LUID <b>must</b>
-        * be correct, or NULL to get the next free one.
-        * <p>
-        * Will override any previous {@link Story} with the same LUID.
-        * 
-        * @param story
-        *            the {@link Story} to save
-        * @param luid
-        *            the <b>correct</b> LUID or NULL to get the next free one
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the same {@link Story}, whose LUID may have changed
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public synchronized Story save(Story story, String luid, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Instance.getInstance().getTraceHandler().trace(
-                               this.getClass().getSimpleName() + ": saving story " + luid);
-
-               // Do not change the original metadata, but change the original story
-               MetaData meta = story.getMeta().clone();
-               story.setMeta(meta);
-
-               pg.setName("Saving story");
-
-               if (luid == null || luid.isEmpty()) {
-                       meta.setLuid(getNextId());
-               } else {
-                       meta.setLuid(luid);
-               }
-
-               if (luid != null && getInfo(luid) != null) {
-                       delete(luid);
-               }
-
-               story = doSave(story, pg);
-
-               updateInfo(story.getMeta());
-
-               Instance.getInstance().getTraceHandler()
-                               .trace(this.getClass().getSimpleName() + ": story saved ("
-                                               + luid + ")");
-
-               pg.setName(meta.getTitle());
-               pg.done();
-               return story;
-       }
-
-       /**
-        * Delete the given {@link Story} from this {@link BasicLibrary}.
-        * 
-        * @param luid
-        *            the LUID of the target {@link Story}
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public synchronized void delete(String luid) throws IOException {
-               Instance.getInstance().getTraceHandler().trace(
-                               this.getClass().getSimpleName() + ": deleting story " + luid);
-
-               doDelete(luid);
-               invalidateInfo(luid);
-
-               Instance.getInstance().getTraceHandler()
-                               .trace(this.getClass().getSimpleName() + ": story deleted ("
-                                               + luid + ")");
-       }
-
-       /**
-        * Change the type (source) of the given {@link Story}.
-        * 
-        * @param luid
-        *            the {@link Story} LUID
-        * @param newSource
-        *            the new source
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @throws IOException
-        *             in case of I/O error or if the {@link Story} was not found
-        */
-       public synchronized void changeSource(String luid, String newSource,
-                       Progress pg) throws IOException {
-               MetaData meta = getInfo(luid);
-               if (meta == null) {
-                       throw new IOException("Story not found: " + luid);
-               }
-
-               changeSTA(luid, newSource, meta.getTitle(), meta.getAuthor(), pg);
-       }
-
-       /**
-        * Change the title (name) of the given {@link Story}.
-        * 
-        * @param luid
-        *            the {@link Story} LUID
-        * @param newTitle
-        *            the new title
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @throws IOException
-        *             in case of I/O error or if the {@link Story} was not found
-        */
-       public synchronized void changeTitle(String luid, String newTitle,
-                       Progress pg) throws IOException {
-               MetaData meta = getInfo(luid);
-               if (meta == null) {
-                       throw new IOException("Story not found: " + luid);
-               }
-
-               changeSTA(luid, meta.getSource(), newTitle, meta.getAuthor(), pg);
-       }
-
-       /**
-        * Change the author of the given {@link Story}.
-        * 
-        * @param luid
-        *            the {@link Story} LUID
-        * @param newAuthor
-        *            the new author
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @throws IOException
-        *             in case of I/O error or if the {@link Story} was not found
-        */
-       public synchronized void changeAuthor(String luid, String newAuthor,
-                       Progress pg) throws IOException {
-               MetaData meta = getInfo(luid);
-               if (meta == null) {
-                       throw new IOException("Story not found: " + luid);
-               }
-
-               changeSTA(luid, meta.getSource(), meta.getTitle(), newAuthor, pg);
-       }
-
-       /**
-        * Change the Source, Title and Author of the {@link Story} in one single
-        * go.
-        * 
-        * @param luid
-        *            the {@link Story} LUID
-        * @param newSource
-        *            the new source
-        * @param newTitle
-        *            the new title
-        * @param newAuthor
-        *            the new author
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @throws IOException
-        *             in case of I/O error or if the {@link Story} was not found
-        */
-       protected synchronized void changeSTA(String luid, String newSource,
-                       String newTitle, String newAuthor, Progress pg) throws IOException {
-               MetaData meta = getInfo(luid);
-               if (meta == null) {
-                       throw new IOException("Story not found: " + luid);
-               }
-
-               meta.setSource(newSource);
-               meta.setTitle(newTitle);
-               meta.setAuthor(newAuthor);
-               saveMeta(meta, pg);
-       }
-
-       /**
-        * Save back the current state of the {@link MetaData} (LUID <b>MUST NOT</b>
-        * change) for this {@link Story}.
-        * <p>
-        * By default, delete the old {@link Story} then recreate a new
-        * {@link Story}.
-        * <p>
-        * Note that this behaviour can lead to data loss in case of problems!
-        * 
-        * @param meta
-        *            the new {@link MetaData} (LUID <b>MUST NOT</b> change)
-        * @param pg
-        *            the optional {@link Progress}
-        * 
-        * @throws IOException
-        *             in case of I/O error or if the {@link Story} was not found
-        */
-       protected synchronized void saveMeta(MetaData meta, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Progress pgGet = new Progress();
-               Progress pgSet = new Progress();
-               pg.addProgress(pgGet, 50);
-               pg.addProgress(pgSet, 50);
-
-               Story story = getStory(meta.getLuid(), pgGet);
-               if (story == null) {
-                       throw new IOException("Story not found: " + meta.getLuid());
-               }
-
-               // TODO: this is not safe!
-               delete(meta.getLuid());
-               story.setMeta(meta);
-               save(story, meta.getLuid(), pgSet);
-
-               pg.done();
-       }
-
-       /**
-        * Describe a {@link Story} from its {@link MetaData} and return a list of
-        * title/value that represent this {@link Story}.
-        * 
-        * @param meta
-        *            the {@link MetaData} to represent
-        * 
-        * @return the information, translated and sorted
-        */
-       static public Map<String, String> getMetaDesc(MetaData meta) {
-               Map<String, String> metaDesc = new LinkedHashMap<String, String>();
-
-               // TODO: i18n
-
-               StringBuilder tags = new StringBuilder();
-               for (String tag : meta.getTags()) {
-                       if (tags.length() > 0) {
-                               tags.append(", ");
-                       }
-                       tags.append(tag);
-               }
-
-               // TODO: i18n
-               metaDesc.put("Author", meta.getAuthor());
-               metaDesc.put("Published on", meta.getPublisher());
-               metaDesc.put("Publication date", meta.getDate());
-               metaDesc.put("Creation date", meta.getCreationDate());
-               String count = "";
-               if (meta.getWords() > 0) {
-                       count = StringUtils.formatNumber(meta.getWords());
-               }
-               if (meta.isImageDocument()) {
-                       metaDesc.put("Number of images", count);
-               } else {
-                       metaDesc.put("Number of words", count);
-               }
-               metaDesc.put("Source", meta.getSource());
-               metaDesc.put("Subject", meta.getSubject());
-               metaDesc.put("Language", meta.getLang());
-               metaDesc.put("Tags", tags.toString());
-               metaDesc.put("URL", meta.getUrl());
-
-               return metaDesc;
-       }
-}
diff --git a/library/CacheLibrary.java b/library/CacheLibrary.java
deleted file mode 100644 (file)
index e184c1b..0000000
+++ /dev/null
@@ -1,435 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.TreeSet;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.UiConfig;
-import be.nikiroo.fanfix.bundles.UiConfigBundle;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.output.BasicOutput.OutputType;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-
-/**
- * This library will cache another pre-existing {@link BasicLibrary}.
- * 
- * @author niki
- */
-public class CacheLibrary extends BasicLibrary {
-       private List<MetaData> metasReal;
-       private List<MetaData> metasMixed;
-       private Object metasLock = new Object();
-
-       private BasicLibrary lib;
-       private LocalLibrary cacheLib;
-
-       /**
-        * Create a cache library around the given one.
-        * <p>
-        * It will return the same result, but those will be saved to disk at the
-        * same time to be fetched quicker the next time.
-        * 
-        * @param cacheDir
-        *            the cache directory where to save the files to disk
-        * @param lib
-        *            the original library to wrap
-        * @param config
-        *            the configuration used to know which kind of default
-        *            {@link OutputType} to use for images and non-images stories
-        */
-       public CacheLibrary(File cacheDir, BasicLibrary lib,
-                       UiConfigBundle config) {
-               this.cacheLib = new LocalLibrary(cacheDir, //
-                               config.getString(UiConfig.GUI_NON_IMAGES_DOCUMENT_TYPE),
-                               config.getString(UiConfig.GUI_IMAGES_DOCUMENT_TYPE), true);
-               this.lib = lib;
-       }
-
-       @Override
-       public String getLibraryName() {
-               return lib.getLibraryName();
-       }
-
-       @Override
-       public Status getStatus() {
-               return lib.getStatus();
-       }
-
-       @Override
-       protected List<MetaData> getMetas(Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               List<MetaData> copy;
-               synchronized (metasLock) {
-                       // We make sure that cached metas have precedence
-                       if (metasMixed == null) {
-                               if (metasReal == null) {
-                                       metasReal = lib.getMetas(pg);
-                               }
-
-                               metasMixed = new ArrayList<MetaData>();
-                               TreeSet<String> cachedLuids = new TreeSet<String>();
-                               for (MetaData cachedMeta : cacheLib.getMetas(null)) {
-                                       metasMixed.add(cachedMeta);
-                                       cachedLuids.add(cachedMeta.getLuid());
-                               }
-                               for (MetaData realMeta : metasReal) {
-                                       if (!cachedLuids.contains(realMeta.getLuid())) {
-                                               metasMixed.add(realMeta);
-                                       }
-                               }
-                       }
-
-                       copy = new ArrayList<MetaData>(metasMixed);
-               }
-
-               pg.done();
-               return copy;
-       }
-
-       @Override
-       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Progress pgImport = new Progress();
-               Progress pgGet = new Progress();
-
-               pg.setMinMax(0, 4);
-               pg.addProgress(pgImport, 3);
-               pg.addProgress(pgGet, 1);
-
-               if (!isCached(luid)) {
-                       try {
-                               cacheLib.imprt(lib, luid, pgImport);
-                               updateMetaCache(metasMixed, cacheLib.getInfo(luid));
-                               pgImport.done();
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(e);
-                       }
-
-                       pgImport.done();
-                       pgGet.done();
-               }
-
-               String type = cacheLib.getOutputType(meta.isImageDocument());
-               MetaData cachedMeta = meta.clone();
-               cachedMeta.setType(type);
-
-               return cacheLib.getStory(luid, cachedMeta, pg);
-       }
-
-       @Override
-       public synchronized File getFile(final String luid, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Progress pgGet = new Progress();
-               Progress pgRecall = new Progress();
-
-               pg.setMinMax(0, 5);
-               pg.addProgress(pgGet, 4);
-               pg.addProgress(pgRecall, 1);
-
-               if (!isCached(luid)) {
-                       getStory(luid, pgGet);
-                       pgGet.done();
-               }
-
-               File file = cacheLib.getFile(luid, pgRecall);
-               pgRecall.done();
-
-               pg.done();
-               return file;
-       }
-
-       @Override
-       public Image getCover(final String luid) throws IOException {
-               if (isCached(luid)) {
-                       return cacheLib.getCover(luid);
-               }
-
-               // We could update the cache here, but it's not easy
-               return lib.getCover(luid);
-       }
-
-       @Override
-       public Image getSourceCover(String source) throws IOException {
-               Image custom = getCustomSourceCover(source);
-               if (custom != null) {
-                       return custom;
-               }
-
-               Image cached = cacheLib.getSourceCover(source);
-               if (cached != null) {
-                       return cached;
-               }
-
-               return lib.getSourceCover(source);
-       }
-
-       @Override
-       public Image getAuthorCover(String author) throws IOException {
-               Image custom = getCustomAuthorCover(author);
-               if (custom != null) {
-                       return custom;
-               }
-
-               Image cached = cacheLib.getAuthorCover(author);
-               if (cached != null) {
-                       return cached;
-               }
-
-               return lib.getAuthorCover(author);
-       }
-
-       @Override
-       public Image getCustomSourceCover(String source) throws IOException {
-               Image custom = cacheLib.getCustomSourceCover(source);
-               if (custom == null) {
-                       custom = lib.getCustomSourceCover(source);
-                       if (custom != null) {
-                               cacheLib.setSourceCover(source, custom);
-                       }
-               }
-
-               return custom;
-       }
-
-       @Override
-       public Image getCustomAuthorCover(String author) throws IOException {
-               Image custom = cacheLib.getCustomAuthorCover(author);
-               if (custom == null) {
-                       custom = lib.getCustomAuthorCover(author);
-                       if (custom != null) {
-                               cacheLib.setAuthorCover(author, custom);
-                       }
-               }
-
-               return custom;
-       }
-
-       @Override
-       public void setSourceCover(String source, String luid) throws IOException {
-               lib.setSourceCover(source, luid);
-               cacheLib.setSourceCover(source, getCover(luid));
-       }
-
-       @Override
-       public void setAuthorCover(String author, String luid) throws IOException {
-               lib.setAuthorCover(author, luid);
-               cacheLib.setAuthorCover(author, getCover(luid));
-       }
-
-       /**
-        * Invalidate the {@link Story} cache (when the content has changed, but we
-        * already have it) with the new given meta.
-        * <p>
-        * <b>Make sure to always use {@link MetaData} from the cached library in
-        * priority, here.</b>
-        * 
-        * @param meta
-        *            the {@link Story} to clear from the cache
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       @Override
-       @Deprecated
-       protected void updateInfo(MetaData meta) throws IOException {
-               throw new IOException(
-                               "This method is not supported in a CacheLibrary, please use updateMetaCache");
-       }
-
-       // relplace the meta in Metas by Meta, add it if needed
-       // return TRUE = added
-       private boolean updateMetaCache(List<MetaData> metas, MetaData meta) {
-               if (meta != null && metas != null) {
-                       synchronized (metasLock) {
-                               boolean changed = false;
-                               for (int i = 0; i < metas.size(); i++) {
-                                       if (metas.get(i).getLuid().equals(meta.getLuid())) {
-                                               metas.set(i, meta);
-                                               changed = true;
-                                       }
-                               }
-
-                               if (!changed) {
-                                       metas.add(meta);
-                                       return true;
-                               }
-                       }
-               }
-
-               return false;
-       }
-
-       @Override
-       protected void invalidateInfo(String luid) {
-               if (luid == null) {
-                       synchronized (metasLock) {
-                               metasReal = null;
-                               metasMixed = null;
-                       }
-               } else {
-                       invalidateInfo(metasReal, luid);
-                       invalidateInfo(metasMixed, luid);
-               }
-
-               cacheLib.invalidateInfo(luid);
-               lib.invalidateInfo(luid);
-       }
-
-       // luid cannot be null
-       private void invalidateInfo(List<MetaData> metas, String luid) {
-               if (metas != null) {
-                       synchronized (metasLock) {
-                               for (int i = 0; i < metas.size(); i++) {
-                                       if (metas.get(i).getLuid().equals(luid)) {
-                                               metas.remove(i--);
-                                       }
-                               }
-                       }
-               }
-       }
-
-       @Override
-       public synchronized Story save(Story story, String luid, Progress pg)
-                       throws IOException {
-               Progress pgLib = new Progress();
-               Progress pgCacheLib = new Progress();
-
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               pg.setMinMax(0, 2);
-               pg.addProgress(pgLib, 1);
-               pg.addProgress(pgCacheLib, 1);
-
-               story = lib.save(story, luid, pgLib);
-               updateMetaCache(metasReal, story.getMeta());
-
-               story = cacheLib.save(story, story.getMeta().getLuid(), pgCacheLib);
-               updateMetaCache(metasMixed, story.getMeta());
-
-               return story;
-       }
-
-       @Override
-       public synchronized void delete(String luid) throws IOException {
-               if (isCached(luid)) {
-                       cacheLib.delete(luid);
-               }
-               lib.delete(luid);
-
-               invalidateInfo(luid);
-       }
-
-       @Override
-       protected synchronized void changeSTA(String luid, String newSource,
-                       String newTitle, String newAuthor, Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Progress pgCache = new Progress();
-               Progress pgOrig = new Progress();
-               pg.setMinMax(0, 2);
-               pg.addProgress(pgCache, 1);
-               pg.addProgress(pgOrig, 1);
-
-               MetaData meta = getInfo(luid);
-               if (meta == null) {
-                       throw new IOException("Story not found: " + luid);
-               }
-
-               if (isCached(luid)) {
-                       cacheLib.changeSTA(luid, newSource, newTitle, newAuthor, pgCache);
-               }
-               pgCache.done();
-
-               lib.changeSTA(luid, newSource, newTitle, newAuthor, pgOrig);
-               pgOrig.done();
-
-               meta.setSource(newSource);
-               meta.setTitle(newTitle);
-               meta.setAuthor(newAuthor);
-               pg.done();
-
-               if (isCached(luid)) {
-                       updateMetaCache(metasMixed, meta);
-                       updateMetaCache(metasReal, lib.getInfo(luid));
-               } else {
-                       updateMetaCache(metasReal, meta);
-               }
-       }
-
-       @Override
-       public boolean isCached(String luid) {
-               try {
-                       return cacheLib.getInfo(luid) != null;
-               } catch (IOException e) {
-                       return false;
-               }
-       }
-
-       @Override
-       public void clearFromCache(String luid) throws IOException {
-               if (isCached(luid)) {
-                       cacheLib.delete(luid);
-               }
-       }
-
-       @Override
-       public MetaData imprt(URL url, Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Progress pgImprt = new Progress();
-               Progress pgCache = new Progress();
-               pg.setMinMax(0, 10);
-               pg.addProgress(pgImprt, 7);
-               pg.addProgress(pgCache, 3);
-
-               MetaData meta = lib.imprt(url, pgImprt);
-               updateMetaCache(metasReal, meta);
-               metasMixed = null;
-
-               clearFromCache(meta.getLuid());
-
-               pg.done();
-               return meta;
-       }
-
-       // All the following methods are only used by Save and Delete in
-       // BasicLibrary:
-
-       @Override
-       protected String getNextId() {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       @Override
-       protected void doDelete(String luid) throws IOException {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       @Override
-       protected Story doSave(Story story, Progress pg) throws IOException {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-}
diff --git a/library/LocalLibrary.java b/library/LocalLibrary.java
deleted file mode 100644 (file)
index 7220a39..0000000
+++ /dev/null
@@ -1,754 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.File;
-import java.io.FileFilter;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.ConfigBundle;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.output.BasicOutput;
-import be.nikiroo.fanfix.output.BasicOutput.OutputType;
-import be.nikiroo.fanfix.output.InfoCover;
-import be.nikiroo.fanfix.supported.InfoReader;
-import be.nikiroo.utils.HashUtils;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-
-/**
- * This {@link BasicLibrary} will store the stories locally on disk.
- * 
- * @author niki
- */
-public class LocalLibrary extends BasicLibrary {
-       private int lastId;
-       private Object lock = new Object();
-       private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
-       private Map<String, Image> sourceCovers;
-       private Map<String, Image> authorCovers;
-
-       private File baseDir;
-       private OutputType text;
-       private OutputType image;
-
-       /**
-        * Create a new {@link LocalLibrary} with the given back-end directory.
-        * 
-        * @param baseDir
-        *            the directory where to find the {@link Story} objects
-        * @param config
-        *            the configuration used to know which kind of default
-        *            {@link OutputType} to use for images and non-images stories
-        */
-       public LocalLibrary(File baseDir, ConfigBundle config) {
-               this(baseDir, //
-                               config.getString(Config.FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE),
-                               config.getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE),
-                               false);
-       }
-
-       /**
-        * Create a new {@link LocalLibrary} with the given back-end directory.
-        * 
-        * @param baseDir
-        *            the directory where to find the {@link Story} objects
-        * @param text
-        *            the {@link OutputType} to use for non-image documents
-        * @param image
-        *            the {@link OutputType} to use for image documents
-        * @param defaultIsHtml
-        *            if the given text or image is invalid, use HTML by default (if
-        *            not, it will be INFO_TEXT/CBZ by default)
-        */
-       public LocalLibrary(File baseDir, String text, String image,
-                       boolean defaultIsHtml) {
-               this(baseDir,
-                               OutputType.valueOfAllOkUC(text,
-                                               defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
-                               OutputType.valueOfAllOkUC(image,
-                                               defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
-       }
-
-       /**
-        * Create a new {@link LocalLibrary} with the given back-end directory.
-        * 
-        * @param baseDir
-        *            the directory where to find the {@link Story} objects
-        * @param text
-        *            the {@link OutputType} to use for non-image documents
-        * @param image
-        *            the {@link OutputType} to use for image documents
-        */
-       public LocalLibrary(File baseDir, OutputType text, OutputType image) {
-               this.baseDir = baseDir;
-               this.text = text;
-               this.image = image;
-
-               this.lastId = 0;
-               this.stories = null;
-               this.sourceCovers = null;
-
-               baseDir.mkdirs();
-       }
-
-       @Override
-       protected List<MetaData> getMetas(Progress pg) {
-               return new ArrayList<MetaData>(getStories(pg).keySet());
-       }
-
-       @Override
-       public File getFile(String luid, Progress pg) throws IOException {
-               Instance.getInstance().getTraceHandler().trace(
-                               this.getClass().getSimpleName() + ": get file for " + luid);
-
-               File file = null;
-               String mess = "no file found for ";
-
-               MetaData meta = getInfo(luid);
-               if (meta != null) {
-                       File[] files = getStories(pg).get(meta);
-                       if (files != null) {
-                               mess = "file retrieved for ";
-                               file = files[1];
-                       }
-               }
-
-               Instance.getInstance().getTraceHandler()
-                               .trace(this.getClass().getSimpleName() + ": " + mess + luid
-                                               + " (" + meta.getTitle() + ")");
-
-               return file;
-       }
-
-       @Override
-       public Image getCover(String luid) throws IOException {
-               MetaData meta = getInfo(luid);
-               if (meta != null) {
-                       if (meta.getCover() != null) {
-                               return meta.getCover();
-                       }
-
-                       File[] files = getStories(null).get(meta);
-                       if (files != null) {
-                               File infoFile = files[0];
-
-                               try {
-                                       meta = InfoReader.readMeta(infoFile, true);
-                                       return meta.getCover();
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected void updateInfo(MetaData meta) {
-               invalidateInfo();
-       }
-
-       @Override
-       protected void invalidateInfo(String luid) {
-               synchronized (lock) {
-                       stories = null;
-                       sourceCovers = null;
-               }
-       }
-
-       @Override
-       protected String getNextId() {
-               getStories(null); // make sure lastId is set
-
-               synchronized (lock) {
-                       return String.format("%03d", ++lastId);
-               }
-       }
-
-       @Override
-       protected void doDelete(String luid) throws IOException {
-               for (File file : getRelatedFiles(luid)) {
-                       // TODO: throw an IOException if we cannot delete the files?
-                       IOUtils.deltree(file);
-                       file.getParentFile().delete();
-               }
-       }
-
-       @Override
-       protected Story doSave(Story story, Progress pg) throws IOException {
-               MetaData meta = story.getMeta();
-
-               File expectedTarget = getExpectedFile(meta);
-               expectedTarget.getParentFile().mkdirs();
-
-               BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
-               it.process(story, expectedTarget.getPath(), pg);
-
-               return story;
-       }
-
-       @Override
-       protected synchronized void saveMeta(MetaData meta, Progress pg)
-                       throws IOException {
-               File newDir = getExpectedDir(meta.getSource());
-               if (!newDir.exists()) {
-                       newDir.mkdirs();
-               }
-
-               List<File> relatedFiles = getRelatedFiles(meta.getLuid());
-               for (File relatedFile : relatedFiles) {
-                       // TODO: this is not safe at all.
-                       // We should copy all the files THEN delete them
-                       // Maybe also adding some rollback cleanup if possible
-                       if (relatedFile.getName().endsWith(".info")) {
-                               try {
-                                       String name = relatedFile.getName().replaceFirst("\\.info$",
-                                                       "");
-                                       relatedFile.delete();
-                                       InfoCover.writeInfo(newDir, name, meta);
-                                       relatedFile.getParentFile().delete();
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                               }
-                       } else {
-                               relatedFile.renameTo(new File(newDir, relatedFile.getName()));
-                               relatedFile.getParentFile().delete();
-                       }
-               }
-
-               updateInfo(meta);
-       }
-
-       @Override
-       public Image getCustomSourceCover(String source) {
-               synchronized (lock) {
-                       if (sourceCovers == null) {
-                               sourceCovers = new HashMap<String, Image>();
-                       }
-               }
-
-               synchronized (lock) {
-                       Image img = sourceCovers.get(source);
-                       if (img != null) {
-                               return img;
-                       }
-               }
-
-               File coverDir = getExpectedDir(source);
-               if (coverDir.isDirectory()) {
-                       File cover = new File(coverDir, ".cover.png");
-                       if (cover.exists()) {
-                               InputStream in;
-                               try {
-                                       in = new FileInputStream(cover);
-                                       try {
-                                               synchronized (lock) {
-                                                       sourceCovers.put(source, new Image(in));
-                                               }
-                                       } finally {
-                                               in.close();
-                                       }
-                               } catch (FileNotFoundException e) {
-                                       e.printStackTrace();
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException(
-                                                                       "Cannot load the existing custom source cover: "
-                                                                                       + cover,
-                                                                       e));
-                               }
-                       }
-               }
-
-               synchronized (lock) {
-                       return sourceCovers.get(source);
-               }
-       }
-
-       @Override
-       public Image getCustomAuthorCover(String author) {
-               synchronized (lock) {
-                       if (authorCovers == null) {
-                               authorCovers = new HashMap<String, Image>();
-                       }
-               }
-
-               synchronized (lock) {
-                       Image img = authorCovers.get(author);
-                       if (img != null) {
-                               return img;
-                       }
-               }
-
-               File cover = getAuthorCoverFile(author);
-               if (cover.exists()) {
-                       InputStream in;
-                       try {
-                               in = new FileInputStream(cover);
-                               try {
-                                       synchronized (lock) {
-                                               authorCovers.put(author, new Image(in));
-                                       }
-                               } finally {
-                                       in.close();
-                               }
-                       } catch (FileNotFoundException e) {
-                               e.printStackTrace();
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler()
-                                               .error(new IOException(
-                                                               "Cannot load the existing custom author cover: "
-                                                                               + cover,
-                                                               e));
-                       }
-               }
-
-               synchronized (lock) {
-                       return authorCovers.get(author);
-               }
-       }
-
-       @Override
-       public void setSourceCover(String source, String luid) throws IOException {
-               setSourceCover(source, getCover(luid));
-       }
-
-       @Override
-       public void setAuthorCover(String author, String luid) throws IOException {
-               setAuthorCover(author, getCover(luid));
-       }
-
-       /**
-        * Set the source cover to the given story cover.
-        * 
-        * @param source
-        *            the source to change
-        * @param coverImage
-        *            the cover image
-        */
-       void setSourceCover(String source, Image coverImage) {
-               File dir = getExpectedDir(source);
-               dir.mkdirs();
-               File cover = new File(dir, ".cover");
-               try {
-                       Instance.getInstance().getCache().saveAsImage(coverImage, cover,
-                                       true);
-                       synchronized (lock) {
-                               if (sourceCovers != null) {
-                                       sourceCovers.put(source, coverImage);
-                               }
-                       }
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-               }
-       }
-
-       /**
-        * Set the author cover to the given story cover.
-        * 
-        * @param author
-        *            the author to change
-        * @param coverImage
-        *            the cover image
-        */
-       void setAuthorCover(String author, Image coverImage) {
-               File cover = getAuthorCoverFile(author);
-               cover.getParentFile().mkdirs();
-               try {
-                       Instance.getInstance().getCache().saveAsImage(coverImage, cover,
-                                       true);
-                       synchronized (lock) {
-                               if (authorCovers != null) {
-                                       authorCovers.put(author, coverImage);
-                               }
-                       }
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-               }
-       }
-
-       @Override
-       public void imprt(BasicLibrary other, String luid, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               // Check if we can simply copy the files instead of the whole process
-               if (other instanceof LocalLibrary) {
-                       LocalLibrary otherLocalLibrary = (LocalLibrary) other;
-
-                       MetaData meta = otherLocalLibrary.getInfo(luid);
-                       String expectedType = ""
-                                       + (meta != null && meta.isImageDocument() ? image : text);
-                       if (meta != null && meta.getType().equals(expectedType)) {
-                               File from = otherLocalLibrary.getExpectedDir(meta.getSource());
-                               File to = this.getExpectedDir(meta.getSource());
-                               List<File> relatedFiles = otherLocalLibrary
-                                               .getRelatedFiles(luid);
-                               if (!relatedFiles.isEmpty()) {
-                                       pg.setMinMax(0, relatedFiles.size());
-                               }
-
-                               for (File relatedFile : relatedFiles) {
-                                       File target = new File(relatedFile.getAbsolutePath()
-                                                       .replace(from.getAbsolutePath(),
-                                                                       to.getAbsolutePath()));
-                                       if (!relatedFile.equals(target)) {
-                                               target.getParentFile().mkdirs();
-                                               InputStream in = null;
-                                               try {
-                                                       in = new FileInputStream(relatedFile);
-                                                       IOUtils.write(in, target);
-                                               } catch (IOException e) {
-                                                       if (in != null) {
-                                                               try {
-                                                                       in.close();
-                                                               } catch (Exception ee) {
-                                                               }
-                                                       }
-
-                                                       pg.done();
-                                                       throw e;
-                                               }
-                                       }
-
-                                       pg.add(1);
-                               }
-
-                               invalidateInfo();
-                               pg.done();
-                               return;
-                       }
-               }
-
-               super.imprt(other, luid, pg);
-       }
-
-       /**
-        * Return the {@link OutputType} for this {@link Story}.
-        * 
-        * @param meta
-        *            the {@link Story} {@link MetaData}
-        * 
-        * @return the type
-        */
-       private OutputType getOutputType(MetaData meta) {
-               if (meta != null && meta.isImageDocument()) {
-                       return image;
-               }
-
-               return text;
-       }
-
-       /**
-        * Return the default {@link OutputType} for this kind of {@link Story}.
-        * 
-        * @param imageDocument
-        *            TRUE for images document, FALSE for text documents
-        * 
-        * @return the type
-        */
-       public String getOutputType(boolean imageDocument) {
-               if (imageDocument) {
-                       return image.toString();
-               }
-
-               return text.toString();
-       }
-
-       /**
-        * Get the target {@link File} related to the given <tt>.info</tt>
-        * {@link File} and {@link MetaData}.
-        * 
-        * @param meta
-        *            the meta
-        * @param infoFile
-        *            the <tt>.info</tt> {@link File}
-        * 
-        * @return the target {@link File}
-        */
-       private File getTargetFile(MetaData meta, File infoFile) {
-               // Replace .info with whatever is needed:
-               String path = infoFile.getPath();
-               path = path.substring(0, path.length() - ".info".length());
-               String newExt = getOutputType(meta).getDefaultExtension(true);
-
-               return new File(path + newExt);
-       }
-
-       /**
-        * The target (full path) where the {@link Story} related to this
-        * {@link MetaData} should be located on disk for a new {@link Story}.
-        * 
-        * @param key
-        *            the {@link Story} {@link MetaData}
-        * 
-        * @return the target
-        */
-       private File getExpectedFile(MetaData key) {
-               String title = key.getTitle();
-               if (title == null) {
-                       title = "";
-               }
-               title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
-               if (title.length() > 40) {
-                       title = title.substring(0, 40);
-               }
-               return new File(getExpectedDir(key.getSource()),
-                               key.getLuid() + "_" + title);
-       }
-
-       /**
-        * The directory (full path) where the new {@link Story} related to this
-        * {@link MetaData} should be located on disk.
-        * 
-        * @param source
-        *            the type (source)
-        * 
-        * @return the target directory
-        */
-       private File getExpectedDir(String source) {
-               String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
-
-               while (sanitizedSource.startsWith("/")
-                               || sanitizedSource.startsWith("_")) {
-                       if (sanitizedSource.length() > 1) {
-                               sanitizedSource = sanitizedSource.substring(1);
-                       } else {
-                               sanitizedSource = "";
-                       }
-               }
-
-               sanitizedSource = sanitizedSource.replace("/", File.separator);
-
-               if (sanitizedSource.isEmpty()) {
-                       sanitizedSource = "_EMPTY";
-               }
-
-               return new File(baseDir, sanitizedSource);
-       }
-
-       /**
-        * Return the full path to the file to use for the custom cover of this
-        * author.
-        * <p>
-        * One or more of the parent directories <b>MAY</b> not exist.
-        * 
-        * @param author
-        *            the author
-        * 
-        * @return the custom cover file
-        */
-       private File getAuthorCoverFile(String author) {
-               File aDir = new File(baseDir, "_AUTHORS");
-               String hash = HashUtils.md5(author);
-               String ext = Instance.getInstance().getConfig()
-                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
-               return new File(aDir, hash + "." + ext.toLowerCase());
-       }
-
-       /**
-        * Return the list of files/directories on disk for this {@link Story}.
-        * <p>
-        * If the {@link Story} is not found, and empty list is returned.
-        * 
-        * @param luid
-        *            the {@link Story} LUID
-        * 
-        * @return the list of {@link File}s
-        * 
-        * @throws IOException
-        *             if the {@link Story} was not found
-        */
-       private List<File> getRelatedFiles(String luid) throws IOException {
-               List<File> files = new ArrayList<File>();
-
-               MetaData meta = getInfo(luid);
-               if (meta == null) {
-                       throw new IOException("Story not found: " + luid);
-               }
-
-               File infoFile = getStories(null).get(meta)[0];
-               File targetFile = getStories(null).get(meta)[1];
-
-               files.add(infoFile);
-               files.add(targetFile);
-
-               String readerExt = getOutputType(meta).getDefaultExtension(true);
-               String fileExt = getOutputType(meta).getDefaultExtension(false);
-
-               String path = targetFile.getAbsolutePath();
-               if (readerExt != null && !readerExt.equals(fileExt)) {
-                       path = path.substring(0, path.length() - readerExt.length())
-                                       + fileExt;
-                       File relatedFile = new File(path);
-
-                       if (relatedFile.exists()) {
-                               files.add(relatedFile);
-                       }
-               }
-
-               String coverExt = "." + Instance.getInstance().getConfig()
-                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-               File coverFile = new File(path + coverExt);
-               if (!coverFile.exists()) {
-                       coverFile = new File(
-                                       path.substring(0, path.length() - fileExt.length())
-                                                       + coverExt);
-               }
-
-               if (coverFile.exists()) {
-                       files.add(coverFile);
-               }
-
-               return files;
-       }
-
-       /**
-        * Fill the list of stories by reading the content of the local directory
-        * {@link LocalLibrary#baseDir}.
-        * <p>
-        * Will use a cached list when possible (see
-        * {@link BasicLibrary#invalidateInfo()}).
-        * 
-        * @param pg
-        *            the optional {@link Progress}
-        * 
-        * @return the list of stories (for each item, the first {@link File} is the
-        *         info file, the second file is the target {@link File})
-        */
-       private Map<MetaData, File[]> getStories(Progress pg) {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-
-               Map<MetaData, File[]> stories = this.stories;
-               if (stories == null) {
-                       stories = getStoriesDo(pg);
-                       synchronized (lock) {
-                               if (this.stories == null)
-                                       this.stories = stories;
-                               else
-                                       stories = this.stories;
-                       }
-               }
-
-               pg.done();
-               return stories;
-
-       }
-
-       /**
-        * Actually do the work of {@link LocalLibrary#getStories(Progress)} (i.e.,
-        * do not retrieve the cache).
-        * 
-        * @param pg
-        *            the optional {@link Progress}
-        * 
-        * @return the list of stories (for each item, the first {@link File} is the
-        *         info file, the second file is the target {@link File})
-        */
-       private synchronized Map<MetaData, File[]> getStoriesDo(Progress pg) {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-
-               Map<MetaData, File[]> stories = new HashMap<MetaData, File[]>();
-
-               File[] dirs = baseDir.listFiles(new FileFilter() {
-                       @Override
-                       public boolean accept(File file) {
-                               return file != null && file.isDirectory();
-                       }
-               });
-
-               if (dirs != null) {
-                       Progress pgDirs = new Progress(0, 100 * dirs.length);
-                       pg.addProgress(pgDirs, 100);
-
-                       for (File dir : dirs) {
-                               Progress pgFiles = new Progress();
-                               pgDirs.addProgress(pgFiles, 100);
-                               pgDirs.setName("Loading from: " + dir.getName());
-
-                               addToStories(stories, pgFiles, dir);
-
-                               pgFiles.setName(null);
-                       }
-
-                       pgDirs.setName("Loading directories");
-               }
-
-               pg.done();
-
-               return stories;
-       }
-
-       private void addToStories(Map<MetaData, File[]> stories, Progress pgFiles,
-                       File dir) {
-               File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
-                       @Override
-                       public boolean accept(File file) {
-                               boolean info = file != null && file.isFile()
-                                               && file.getPath().toLowerCase().endsWith(".info");
-                               boolean dir = file != null && file.isDirectory();
-                               boolean isExpandedHtml = new File(file, "index.html").isFile();
-                               return info || (dir && !isExpandedHtml);
-                       }
-               });
-
-               if (pgFiles != null) {
-                       pgFiles.setMinMax(0, infoFilesAndSubdirs.length);
-               }
-
-               for (File infoFileOrSubdir : infoFilesAndSubdirs) {
-                       if (infoFileOrSubdir.isDirectory()) {
-                               addToStories(stories, null, infoFileOrSubdir);
-                       } else {
-                               try {
-                                       MetaData meta = InfoReader.readMeta(infoFileOrSubdir,
-                                                       false);
-                                       try {
-                                               int id = Integer.parseInt(meta.getLuid());
-                                               if (id > lastId) {
-                                                       lastId = id;
-                                               }
-
-                                               stories.put(meta, new File[] { infoFileOrSubdir,
-                                                               getTargetFile(meta, infoFileOrSubdir) });
-                                       } catch (Exception e) {
-                                               // not normal!!
-                                               throw new IOException("Cannot understand the LUID of "
-                                                               + infoFileOrSubdir + ": " + meta.getLuid(), e);
-                                       }
-                               } catch (IOException e) {
-                                       // We should not have not-supported files in the
-                                       // library
-                                       Instance.getInstance().getTraceHandler().error(
-                                                       new IOException("Cannot load file from library: "
-                                                                       + infoFileOrSubdir, e));
-                               }
-                       }
-
-                       if (pgFiles != null) {
-                               pgFiles.add(1);
-                       }
-               }
-       }
-}
diff --git a/library/MetaResultList.java b/library/MetaResultList.java
deleted file mode 100644 (file)
index 8b8a167..0000000
+++ /dev/null
@@ -1,419 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.StringUtils;
-
-public class MetaResultList {
-       /** Max number of items before splitting in [A-B] etc. for eligible items */
-       static private final int MAX = 20;
-
-       private List<MetaData> metas;
-
-       // Lazy lists:
-       // TODO: sync-protect them?
-       private List<String> sources;
-       private List<String> authors;
-       private List<String> tags;
-
-       // can be null (will consider it empty)
-       public MetaResultList(List<MetaData> metas) {
-               if (metas == null) {
-                       metas = new ArrayList<MetaData>();
-               }
-
-               Collections.sort(metas);
-               this.metas = metas;
-       }
-
-       // not NULL
-       // sorted
-       public List<MetaData> getMetas() {
-               return metas;
-       }
-
-       public List<String> getSources() {
-               if (sources == null) {
-                       sources = new ArrayList<String>();
-                       for (MetaData meta : metas) {
-                               if (!sources.contains(meta.getSource()))
-                                       sources.add(meta.getSource());
-                       }
-                       sort(sources);
-               }
-
-               return sources;
-       }
-
-       // A -> (A), A/ -> (A, A/*) if we can find something for "*"
-       public List<String> getSources(String source) {
-               List<String> linked = new ArrayList<String>();
-               if (source != null && !source.isEmpty()) {
-                       if (!source.endsWith("/")) {
-                               linked.add(source);
-                       } else {
-                               linked.add(source.substring(0, source.length() - 1));
-                               for (String src : getSources()) {
-                                       if (src.startsWith(source)) {
-                                               linked.add(src);
-                                       }
-                               }
-                       }
-               }
-
-               sort(linked);
-               return linked;
-       }
-
-       /**
-        * List all the known types (sources) of stories, grouped by directory
-        * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
-        * <p>
-        * Note that an empty item in the list means a non-grouped source (type) --
-        * e.g., you could have for Source_1:
-        * <ul>
-        * <li><tt></tt>: empty, so source is "Source_1"</li>
-        * <li><tt>a</tt>: empty, so source is "Source_1/a"</li>
-        * <li><tt>b</tt>: empty, so source is "Source_1/b"</li>
-        * </ul>
-        * 
-        * @return the grouped list
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public Map<String, List<String>> getSourcesGrouped() throws IOException {
-               Map<String, List<String>> map = new TreeMap<String, List<String>>();
-               for (String source : getSources()) {
-                       String name;
-                       String subname;
-
-                       int pos = source.indexOf('/');
-                       if (pos > 0 && pos < source.length() - 1) {
-                               name = source.substring(0, pos);
-                               subname = source.substring(pos + 1);
-
-                       } else {
-                               name = source;
-                               subname = "";
-                       }
-
-                       List<String> list = map.get(name);
-                       if (list == null) {
-                               list = new ArrayList<String>();
-                               map.put(name, list);
-                       }
-                       list.add(subname);
-               }
-
-               return map;
-       }
-
-       public List<String> getAuthors() {
-               if (authors == null) {
-                       authors = new ArrayList<String>();
-                       for (MetaData meta : metas) {
-                               if (!authors.contains(meta.getAuthor()))
-                                       authors.add(meta.getAuthor());
-                       }
-                       sort(authors);
-               }
-
-               return authors;
-       }
-
-       /**
-        * Return the list of authors, grouped by starting letter(s) if needed.
-        * <p>
-        * If the number of authors is not too high, only one group with an empty
-        * name and all the authors will be returned.
-        * <p>
-        * If not, the authors will be separated into groups:
-        * <ul>
-        * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
-        * </li>
-        * <li><tt>0-9</tt>: any author whose name starts with a number</li>
-        * <li><tt>A-C</tt> (for instance): any author whose name starts with
-        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
-        * </ul>
-        * Note that the letters used in the groups can vary (except <tt>*</tt> and
-        * <tt>0-9</tt>, which may only be present or not).
-        * 
-        * @return the authors' names, grouped by letter(s)
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public Map<String, List<String>> getAuthorsGrouped() throws IOException {
-               return group(getAuthors());
-       }
-
-       public List<String> getTags() {
-               if (tags == null) {
-                       tags = new ArrayList<String>();
-                       for (MetaData meta : metas) {
-                               for (String tag : meta.getTags()) {
-                                       if (!tags.contains(tag))
-                                               tags.add(tag);
-                               }
-                       }
-                       sort(tags);
-               }
-
-               return tags;
-       }
-
-       /**
-        * Return the list of tags, grouped by starting letter(s) if needed.
-        * <p>
-        * If the number of tags is not too high, only one group with an empty name
-        * and all the tags will be returned.
-        * <p>
-        * If not, the tags will be separated into groups:
-        * <ul>
-        * <li><tt>*</tt>: any tag which name doesn't contain letters nor numbers
-        * </li>
-        * <li><tt>0-9</tt>: any tag which name starts with a number</li>
-        * <li><tt>A-C</tt> (for instance): any tag which name starts with
-        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
-        * </ul>
-        * Note that the letters used in the groups can vary (except <tt>*</tt> and
-        * <tt>0-9</tt>, which may only be present or not).
-        * 
-        * @return the tags' names, grouped by letter(s)
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       public Map<String, List<String>> getTagsGrouped() throws IOException {
-               return group(getTags());
-       }
-
-       // helper
-       public List<MetaData> filter(String source, String author, String tag) {
-               List<String> sources = source == null ? null : Arrays.asList(source);
-               List<String> authors = author == null ? null : Arrays.asList(author);
-               List<String> tags = tag == null ? null : Arrays.asList(tag);
-
-               return filter(sources, authors, tags);
-       }
-
-       // null or empty -> no check, rest = must be included
-       // source: a source ending in "/" means "this or any source starting with
-       // this",
-       // i;e., to enable source hierarchy
-       // + sorted
-       public List<MetaData> filter(List<String> sources, List<String> authors,
-                       List<String> tags) {
-               if (sources != null && sources.isEmpty())
-                       sources = null;
-               if (authors != null && authors.isEmpty())
-                       authors = null;
-               if (tags != null && tags.isEmpty())
-                       tags = null;
-
-               // Quick check
-               if (sources == null && authors == null && tags == null) {
-                       return metas;
-               }
-               
-               // allow "sources/" hierarchy
-               if (sources != null) {
-                       List<String> folders = new ArrayList<String>();
-                       List<String> leaves = new ArrayList<String>();
-                       for (String source : sources) {
-                               if (source.endsWith("/")) {
-                                       if (!folders.contains(source))
-                                               folders.add(source);
-                               } else {
-                                       if (!leaves.contains(source))
-                                               leaves.add(source);
-                               }
-                       }
-
-                       sources = leaves;
-                       for (String folder : folders) {
-                               for (String otherLeaf : getSources(folder)) {
-                                       if (!sources.contains(otherLeaf)) {
-                                               sources.add(otherLeaf);
-                                       }
-                               }
-                       }
-               }
-
-               List<MetaData> result = new ArrayList<MetaData>();
-               for (MetaData meta : metas) {
-                       if (sources != null && !sources.contains(meta.getSource())) {
-                               continue;
-                       }
-                       if (authors != null && !authors.contains(meta.getAuthor())) {
-                               continue;
-                       }
-
-                       if (tags != null) {
-                               boolean keep = false;
-                               for (String thisTag : meta.getTags()) {
-                                       if (tags.contains(thisTag))
-                                               keep = true;
-                               }
-
-                               if (!keep)
-                                       continue;
-                       }
-
-                       result.add(meta);
-               }
-
-               Collections.sort(result);
-               return result;
-       }
-
-       /**
-        * Return the list of values, grouped by starting letter(s) if needed.
-        * <p>
-        * If the number of values is not too high, only one group with an empty
-        * name and all the values will be returned (see
-        * {@link MetaResultList#MAX}).
-        * <p>
-        * If not, the values will be separated into groups:
-        * <ul>
-        * <li><tt>*</tt>: any value which name doesn't contain letters nor numbers
-        * </li>
-        * <li><tt>0-9</tt>: any value which name starts with a number</li>
-        * <li><tt>A-C</tt> (for instance): any value which name starts with
-        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
-        * </ul>
-        * Note that the letters used in the groups can vary (except <tt>*</tt> and
-        * <tt>0-9</tt>, which may only be present or not).
-        * 
-        * @param values
-        *            the values to group
-        * 
-        * @return the values, grouped by letter(s)
-        * 
-        * @throws IOException
-        *             in case of IOException
-        */
-       private Map<String, List<String>> group(List<String> values)
-                       throws IOException {
-               Map<String, List<String>> groups = new TreeMap<String, List<String>>();
-
-               // If all authors fit the max, just report them as is
-               if (values.size() <= MAX) {
-                       groups.put("", values);
-                       return groups;
-               }
-
-               // Create groups A to Z, which can be empty here
-               for (char car = 'A'; car <= 'Z'; car++) {
-                       groups.put(Character.toString(car), find(values, car));
-               }
-
-               // Collapse them
-               List<String> keys = new ArrayList<String>(groups.keySet());
-               for (int i = 0; i + 1 < keys.size(); i++) {
-                       String keyNow = keys.get(i);
-                       String keyNext = keys.get(i + 1);
-
-                       List<String> now = groups.get(keyNow);
-                       List<String> next = groups.get(keyNext);
-
-                       int currentTotal = now.size() + next.size();
-                       if (currentTotal <= MAX) {
-                               String key = keyNow.charAt(0) + "-"
-                                               + keyNext.charAt(keyNext.length() - 1);
-
-                               List<String> all = new ArrayList<String>();
-                               all.addAll(now);
-                               all.addAll(next);
-
-                               groups.remove(keyNow);
-                               groups.remove(keyNext);
-                               groups.put(key, all);
-
-                               keys.set(i, key); // set the new key instead of key(i)
-                               keys.remove(i + 1); // remove the next, consumed key
-                               i--; // restart at key(i)
-                       }
-               }
-
-               // Add "special" groups
-               groups.put("*", find(values, '*'));
-               groups.put("0-9", find(values, '0'));
-
-               // Prune empty groups
-               keys = new ArrayList<String>(groups.keySet());
-               for (String key : keys) {
-                       if (groups.get(key).isEmpty()) {
-                               groups.remove(key);
-                       }
-               }
-
-               return groups;
-       }
-
-       /**
-        * Get all the authors that start with the given character:
-        * <ul>
-        * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
-        * </li>
-        * <li><tt>0</tt>: any authors whose name starts with a number</li>
-        * <li><tt>A</tt> (any capital latin letter): any author whose name starts
-        * with <tt>A</tt></li>
-        * </ul>
-        * 
-        * @param values
-        *            the full list of authors
-        * @param car
-        *            the starting character, <tt>*</tt>, <tt>0</tt> or a capital
-        *            letter
-        * 
-        * @return the authors that fulfil the starting letter
-        */
-       private List<String> find(List<String> values, char car) {
-               List<String> accepted = new ArrayList<String>();
-               for (String value : values) {
-                       char first = '*';
-                       for (int i = 0; first == '*' && i < value.length(); i++) {
-                               String san = StringUtils.sanitize(value, true, true);
-                               char c = san.charAt(i);
-                               if (c >= '0' && c <= '9') {
-                                       first = '0';
-                               } else if (c >= 'a' && c <= 'z') {
-                                       first = (char) (c - 'a' + 'A');
-                               } else if (c >= 'A' && c <= 'Z') {
-                                       first = c;
-                               }
-                       }
-
-                       if (first == car) {
-                               accepted.add(value);
-                       }
-               }
-
-               return accepted;
-       }
-
-       /**
-        * Sort the given {@link String} values, ignoring case.
-        * 
-        * @param values
-        *            the values to sort
-        */
-       private void sort(List<String> values) {
-               Collections.sort(values, new Comparator<String>() {
-                       @Override
-                       public int compare(String o1, String o2) {
-                               return ("" + o1).compareToIgnoreCase("" + o2);
-                       }
-               });
-       }
-}
diff --git a/library/RemoteLibrary.java b/library/RemoteLibrary.java
deleted file mode 100644 (file)
index ad92800..0000000
+++ /dev/null
@@ -1,589 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.URL;
-import java.net.UnknownHostException;
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.net.ssl.SSLException;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.Version;
-import be.nikiroo.utils.serial.server.ConnectActionClientObject;
-
-/**
- * This {@link BasicLibrary} will access a remote server to list the available
- * stories, and download the ones you try to load to the local directory
- * specified in the configuration.
- * <p>
- * This remote library uses a custom fanfix:// protocol.
- * 
- * @author niki
- */
-public class RemoteLibrary extends BasicLibrary {
-       interface RemoteAction {
-               public void action(ConnectActionClientObject action) throws Exception;
-       }
-
-       class RemoteConnectAction extends ConnectActionClientObject {
-               public RemoteConnectAction() throws IOException {
-                       super(host, port, key);
-               }
-
-               @Override
-               public Object send(Object data)
-                               throws IOException, NoSuchFieldException, NoSuchMethodException,
-                               ClassNotFoundException {
-                       Object rep = super.send(data);
-                       if (rep instanceof RemoteLibraryException) {
-                               RemoteLibraryException remoteEx = (RemoteLibraryException) rep;
-                               throw remoteEx.unwrapException();
-                       }
-
-                       return rep;
-               }
-       }
-
-       private String host;
-       private int port;
-       private final String key;
-       private final String subkey;
-
-       // informative only (server will make the actual checks)
-       private boolean rw;
-
-       /**
-        * Create a {@link RemoteLibrary} linked to the given server.
-        * <p>
-        * Note that the key is structured:
-        * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>(|<b>wl</b>)(|<b>bl</b>)(|<b>rw</b>)</tt>
-        * <p>
-        * Note that anything before the first pipe (<tt>|</tt>) character is
-        * considered to be the encryption key, anything after that character is
-        * called the subkey (including the other pipe characters and flags!).
-        * <p>
-        * This is important because the subkey (including the pipe characters and
-        * flags) must be present as-is in the server configuration file to be
-        * allowed.
-        * <ul>
-        * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
-        * server</li>
-        * <li><b><i>yyy</i></b>: the secondary key</li>
-        * <li><b>rw</b>: flag to allow read and write access if it is not the
-        * default on this server</li>
-        * <li><b>bl</b>: flag to bypass the blacklist (if it exists)</li>
-        * <li><b>wl</b>: flag to bypass the whitelist if it exists</li>
-        * </ul>
-        * <p>
-        * Some examples:
-        * <ul>
-        * <li><b>my_key</b>: normal connection, will take the default server
-        * options</li>
-        * <li><b>my_key|agzyzz|wl|bl</b>: will ask to bypass the black list and the
-        * white list (if it exists)</li>
-        * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
-        * is read-only)</li>
-        * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
-        * list bypass</li>
-        * </ul>
-        * 
-        * @param key
-        *            the key that will allow us to exchange information with the
-        *            server
-        * @param host
-        *            the host to contact or NULL for localhost
-        * @param port
-        *            the port to contact it on
-        */
-       public RemoteLibrary(String key, String host, int port) {
-               int index = -1;
-               if (key != null) {
-                       index = key.indexOf('|');
-               }
-
-               if (index >= 0) {
-                       this.key = key.substring(0, index);
-                       this.subkey = key.substring(index + 1);
-               } else {
-                       this.key = key;
-                       this.subkey = "";
-               }
-
-               if (host.startsWith("fanfix://")) {
-                       host = host.substring("fanfix://".length());
-               }
-
-               this.host = host;
-               this.port = port;
-       }
-
-       @Override
-       public String getLibraryName() {
-               return (rw ? "[READ-ONLY] " : "") + "fanfix://" + host + ":" + port;
-       }
-
-       @Override
-       public Status getStatus() {
-               Instance.getInstance().getTraceHandler()
-                               .trace("Getting remote lib status...");
-               Status status = getStatusDo();
-               Instance.getInstance().getTraceHandler()
-                               .trace("Remote lib status: " + status);
-               return status;
-       }
-
-       private Status getStatusDo() {
-               final Status[] result = new Status[1];
-
-               result[0] = null;
-               try {
-                       new RemoteConnectAction() {
-                               @Override
-                               public void action(Version serverVersion) throws Exception {
-                                       Object rep = send(new Object[] { subkey, "PING" });
-
-                                       if ("r/w".equals(rep)) {
-                                               rw = true;
-                                               result[0] = Status.READ_WRITE;
-                                       } else if ("r/o".equals(rep)) {
-                                               rw = false;
-                                               result[0] = Status.READ_ONLY;
-                                       } else {
-                                               result[0] = Status.UNAUTHORIZED;
-                                       }
-                               }
-
-                               @Override
-                               protected void onError(Exception e) {
-                                       if (e instanceof SSLException) {
-                                               result[0] = Status.UNAUTHORIZED;
-                                       } else {
-                                               result[0] = Status.UNAVAILABLE;
-                                       }
-                               }
-                       }.connect();
-               } catch (UnknownHostException e) {
-                       result[0] = Status.INVALID;
-               } catch (IllegalArgumentException e) {
-                       result[0] = Status.INVALID;
-               } catch (Exception e) {
-                       result[0] = Status.UNAVAILABLE;
-               }
-
-               return result[0];
-       }
-
-       @Override
-       public Image getCover(final String luid) throws IOException {
-               final Image[] result = new Image[1];
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Object rep = action
-                                               .send(new Object[] { subkey, "GET_COVER", luid });
-                               result[0] = (Image) rep;
-                       }
-               });
-
-               return result[0];
-       }
-
-       @Override
-       public Image getCustomSourceCover(final String source) throws IOException {
-               return getCustomCover(source, "SOURCE");
-       }
-
-       @Override
-       public Image getCustomAuthorCover(final String author) throws IOException {
-               return getCustomCover(author, "AUTHOR");
-       }
-
-       // type: "SOURCE" or "AUTHOR"
-       private Image getCustomCover(final String source, final String type)
-                       throws IOException {
-               final Image[] result = new Image[1];
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Object rep = action.send(new Object[] { subkey,
-                                               "GET_CUSTOM_COVER", type, source });
-                               result[0] = (Image) rep;
-                       }
-               });
-
-               return result[0];
-       }
-
-       @Override
-       public synchronized Story getStory(final String luid, Progress pg)
-                       throws IOException {
-               final Progress pgF = pg;
-               final Story[] result = new Story[1];
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Progress pg = pgF;
-                               if (pg == null) {
-                                       pg = new Progress();
-                               }
-
-                               Object rep = action
-                                               .send(new Object[] { subkey, "GET_STORY", luid });
-
-                               MetaData meta = null;
-                               if (rep instanceof MetaData) {
-                                       meta = (MetaData) rep;
-                                       if (meta.getWords() <= Integer.MAX_VALUE) {
-                                               pg.setMinMax(0, (int) meta.getWords());
-                                       }
-                               }
-
-                               List<Object> list = new ArrayList<Object>();
-                               for (Object obj = action.send(null); obj != null; obj = action
-                                               .send(null)) {
-                                       list.add(obj);
-                                       pg.add(1);
-                               }
-
-                               result[0] = RemoteLibraryServer.rebuildStory(list);
-                               pg.done();
-                       }
-               });
-
-               return result[0];
-       }
-
-       @Override
-       public synchronized Story save(final Story story, final String luid,
-                       Progress pg) throws IOException {
-
-               final String[] luidSaved = new String[1];
-               Progress pgSave = new Progress();
-               Progress pgRefresh = new Progress();
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               pg.setMinMax(0, 10);
-               pg.addProgress(pgSave, 9);
-               pg.addProgress(pgRefresh, 1);
-
-               final Progress pgF = pgSave;
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Progress pg = pgF;
-                               if (story.getMeta().getWords() <= Integer.MAX_VALUE) {
-                                       pg.setMinMax(0, (int) story.getMeta().getWords());
-                               }
-
-                               action.send(new Object[] { subkey, "SAVE_STORY", luid });
-
-                               List<Object> list = RemoteLibraryServer.breakStory(story);
-                               for (Object obj : list) {
-                                       action.send(obj);
-                                       pg.add(1);
-                               }
-
-                               luidSaved[0] = (String) action.send(null);
-
-                               pg.done();
-                       }
-               });
-
-               // because the meta changed:
-               MetaData meta = getInfo(luidSaved[0]);
-               if (story.getMeta().getClass() != null) {
-                       // If already available locally:
-                       meta.setCover(story.getMeta().getCover());
-               } else {
-                       // If required:
-                       meta.setCover(getCover(meta.getLuid()));
-               }
-               story.setMeta(meta);
-
-               pg.done();
-
-               return story;
-       }
-
-       @Override
-       public synchronized void delete(final String luid) throws IOException {
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               action.send(new Object[] { subkey, "DELETE_STORY", luid });
-                       }
-               });
-       }
-
-       @Override
-       public void setSourceCover(final String source, final String luid)
-                       throws IOException {
-               setCover(source, luid, "SOURCE");
-       }
-
-       @Override
-       public void setAuthorCover(final String author, final String luid)
-                       throws IOException {
-               setCover(author, luid, "AUTHOR");
-       }
-
-       // type = "SOURCE" | "AUTHOR"
-       private void setCover(final String value, final String luid,
-                       final String type) throws IOException {
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               action.send(new Object[] { subkey, "SET_COVER", type, value,
-                                               luid });
-                       }
-               });
-       }
-
-       @Override
-       // Could work (more slowly) without it
-       public MetaData imprt(final URL url, Progress pg) throws IOException {
-               // Import the file locally if it is actually a file
-
-               if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
-                       return super.imprt(url, pg);
-               }
-
-               // Import it remotely if it is an URL
-
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               final Progress pgF = pg;
-               final String[] luid = new String[1];
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Progress pg = pgF;
-
-                               Object rep = action.send(
-                                               new Object[] { subkey, "IMPORT", url.toString() });
-
-                               while (true) {
-                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
-                                               break;
-                                       }
-
-                                       rep = action.send(null);
-                               }
-
-                               pg.done();
-                               luid[0] = (String) rep;
-                       }
-               });
-
-               if (luid[0] == null) {
-                       throw new IOException("Remote failure");
-               }
-
-               pg.done();
-               return getInfo(luid[0]);
-       }
-
-       @Override
-       // Could work (more slowly) without it
-       protected synchronized void changeSTA(final String luid,
-                       final String newSource, final String newTitle,
-                       final String newAuthor, Progress pg) throws IOException {
-
-               final Progress pgF = pg == null ? new Progress() : pg;
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Progress pg = pgF;
-
-                               Object rep = action.send(new Object[] { subkey, "CHANGE_STA",
-                                               luid, newSource, newTitle, newAuthor });
-                               while (true) {
-                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
-                                               break;
-                                       }
-
-                                       rep = action.send(null);
-                               }
-                       }
-               });
-       }
-
-       @Override
-       public File getFile(final String luid, Progress pg) {
-               throw new java.lang.InternalError(
-                               "Operation not supportorted on remote Libraries");
-       }
-
-       /**
-        * Stop the server.
-        * 
-        * @throws IOException
-        *             in case of I/O errors
-        * @throws SSLException
-        *             when the key was not accepted
-        */
-       public void exit() throws IOException, SSLException {
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               action.send(new Object[] { subkey, "EXIT" });
-                               Thread.sleep(100);
-                       }
-               });
-       }
-
-       @Override
-       public MetaData getInfo(String luid) throws IOException {
-               List<MetaData> metas = getMetasList(luid, null);
-               if (!metas.isEmpty()) {
-                       return metas.get(0);
-               }
-
-               return null;
-       }
-
-       @Override
-       protected List<MetaData> getMetas(Progress pg) throws IOException {
-               return getMetasList("*", pg);
-       }
-
-       @Override
-       protected void updateInfo(MetaData meta) {
-               // Will be taken care of directly server side
-       }
-
-       @Override
-       protected void invalidateInfo(String luid) {
-               // Will be taken care of directly server side
-       }
-
-       // The following methods are only used by Save and Delete in BasicLibrary:
-
-       @Override
-       protected String getNextId() {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       @Override
-       protected void doDelete(String luid) throws IOException {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       @Override
-       protected Story doSave(Story story, Progress pg) throws IOException {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       //
-
-       /**
-        * Return the meta of the given story or a list of all known metas if the
-        * luid is "*".
-        * <p>
-        * Will not get the covers.
-        * 
-        * @param luid
-        *            the luid of the story or *
-        * @param pg
-        *            the optional progress
-        * 
-        * @return the metas
-        * 
-        * @throws IOException
-        *             in case of I/O error or bad key (SSLException)
-        */
-       private List<MetaData> getMetasList(final String luid, Progress pg)
-                       throws IOException {
-               final Progress pgF = pg;
-               final List<MetaData> metas = new ArrayList<MetaData>();
-
-               connectRemoteAction(new RemoteAction() {
-                       @Override
-                       public void action(ConnectActionClientObject action)
-                                       throws Exception {
-                               Progress pg = pgF;
-                               if (pg == null) {
-                                       pg = new Progress();
-                               }
-
-                               Object rep = action
-                                               .send(new Object[] { subkey, "GET_METADATA", luid });
-
-                               while (true) {
-                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
-                                               break;
-                                       }
-
-                                       rep = action.send(null);
-                               }
-
-                               if (rep instanceof MetaData[]) {
-                                       for (MetaData meta : (MetaData[]) rep) {
-                                               metas.add(meta);
-                                       }
-                               } else if (rep != null) {
-                                       metas.add((MetaData) rep);
-                               }
-                       }
-               });
-
-               return metas;
-       }
-
-       private void connectRemoteAction(final RemoteAction runAction)
-                       throws IOException {
-               final IOException[] err = new IOException[1];
-               try {
-                       final RemoteConnectAction[] array = new RemoteConnectAction[1];
-                       RemoteConnectAction ra = new RemoteConnectAction() {
-                               @Override
-                               public void action(Version serverVersion) throws Exception {
-                                       runAction.action(array[0]);
-                               }
-
-                               @Override
-                               protected void onError(Exception e) {
-                                       if (!(e instanceof IOException)) {
-                                               Instance.getInstance().getTraceHandler().error(e);
-                                               return;
-                                       }
-
-                                       err[0] = (IOException) e;
-                               }
-                       };
-                       array[0] = ra;
-                       ra.connect();
-               } catch (Exception e) {
-                       err[0] = (IOException) e;
-               }
-
-               if (err[0] != null) {
-                       throw err[0];
-               }
-       }
-}
diff --git a/library/RemoteLibraryException.java b/library/RemoteLibraryException.java
deleted file mode 100644 (file)
index 4cbb631..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.IOException;
-
-/**
- * Exceptions sent from remote to local.
- * 
- * @author niki
- */
-public class RemoteLibraryException extends IOException {
-       private static final long serialVersionUID = 1L;
-
-       private boolean wrapped;
-
-       @SuppressWarnings("unused")
-       private RemoteLibraryException() {
-               // for serialization purposes
-       }
-
-       /**
-        * Wrap an {@link IOException} to allow it to pass across the network.
-        * 
-        * @param cause
-        *            the exception to wrap
-        * @param remote
-        *            this exception is used to send the contained
-        *            {@link IOException} to the other end of the network
-        */
-       public RemoteLibraryException(IOException cause, boolean remote) {
-               this(null, cause, remote);
-       }
-
-       /**
-        * Wrap an {@link IOException} to allow it to pass across the network.
-        * 
-        * @param message
-        *            the error message
-        * @param wrapped
-        *            this exception is used to send the contained
-        *            {@link IOException} to the other end of the network
-        */
-       public RemoteLibraryException(String message, boolean wrapped) {
-               this(message, null, wrapped);
-       }
-
-       /**
-        * Wrap an {@link IOException} to allow it to pass across the network.
-        * 
-        * @param message
-        *            the error message
-        * @param cause
-        *            the exception to wrap
-        * @param wrapped
-        *            this exception is used to send the contained
-        *            {@link IOException} to the other end of the network
-        */
-       public RemoteLibraryException(String message, IOException cause,
-                       boolean wrapped) {
-               super(message, cause);
-               this.wrapped = wrapped;
-       }
-
-       /**
-        * Return the actual exception we should return to the client code. It can
-        * be:
-        * <ul>
-        * <li>the <tt>cause</tt> if {@link RemoteLibraryException#isWrapped()} is
-        * TRUE</li>
-        * <li><tt>this</tt> if {@link RemoteLibraryException#isWrapped()} is FALSE
-        * (</li>
-        * <li><tt>this</tt> if the <tt>cause</tt> is NULL (so we never return NULL)
-        * </li>
-        * </ul>
-        * It is never NULL.
-        * 
-        * @return the unwrapped exception or <tt>this</tt>, never NULL
-        */
-       public synchronized IOException unwrapException() {
-               Throwable ex = super.getCause();
-               if (!isWrapped() || !(ex instanceof IOException)) {
-                       ex = this;
-               }
-
-               return (IOException) ex;
-       }
-
-       /**
-        * This exception is used to send the contained {@link IOException} to the
-        * other end of the network.
-        * <p>
-        * In other words, do not use <tt>this</tt> exception in client code when it
-        * has reached the other end of the network, but use its cause instead (see
-        * {@link RemoteLibraryException#unwrapException()}).
-        * 
-        * @return TRUE if it is
-        */
-       public boolean isWrapped() {
-               return wrapped;
-       }
-}
diff --git a/library/RemoteLibraryServer.java b/library/RemoteLibraryServer.java
deleted file mode 100644 (file)
index 59819bb..0000000
+++ /dev/null
@@ -1,596 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.IOException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.net.ssl.SSLException;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.Progress.ProgressListener;
-import be.nikiroo.utils.StringUtils;
-import be.nikiroo.utils.Version;
-import be.nikiroo.utils.serial.server.ConnectActionServerObject;
-import be.nikiroo.utils.serial.server.ServerObject;
-
-/**
- * Create a new remote server that will listen for orders on the given port.
- * <p>
- * The available commands are given as arrays of objects (first item is the
- * command, the rest are the arguments).
- * <p>
- * All the commands are always prefixed by the subkey (which can be EMPTY if
- * none).
- * <p>
- * <ul>
- * <li>PING: will return the mode if the key is accepted (mode can be: "r/o" or
- * "r/w")</li>
- * <li>GET_METADATA *: will return the metadata of all the stories in the
- * library (array)</li> *
- * <li>GET_METADATA [luid]: will return the metadata of the story of LUID
- * luid</li>
- * <li>GET_STORY [luid]: will return the given story if it exists (or NULL if
- * not)</li>
- * <li>SAVE_STORY [luid]: save the story (that must be sent just after the
- * command) with the given LUID, then return the LUID</li>
- * <li>IMPORT [url]: save the story found at the given URL, then return the LUID
- * </li>
- * <li>DELETE_STORY [luid]: delete the story of LUID luid</li>
- * <li>GET_COVER [luid]: return the cover of the story</li>
- * <li>GET_CUSTOM_COVER ["SOURCE"|"AUTHOR"] [source]: return the cover for this
- * source/author</li>
- * <li>SET_COVER ["SOURCE"|"AUTHOR"] [value] [luid]: set the default cover for
- * the given source/author to the cover of the story denoted by luid</li>
- * <li>CHANGE_SOURCE [luid] [new source]: change the source of the story of LUID
- * luid</li>
- * <li>EXIT: stop the server</li>
- * </ul>
- * 
- * @author niki
- */
-public class RemoteLibraryServer extends ServerObject {
-       private Map<Long, String> commands = new HashMap<Long, String>();
-       private Map<Long, Long> times = new HashMap<Long, Long>();
-       private Map<Long, Boolean> wls = new HashMap<Long, Boolean>();
-       private Map<Long, Boolean> bls = new HashMap<Long, Boolean>();
-       private Map<Long, Boolean> rws = new HashMap<Long, Boolean>();
-
-       /**
-        * Create a new remote server (will not be active until
-        * {@link RemoteLibraryServer#start()} is called).
-        * <p>
-        * Note: the key we use here is the encryption key (it must not contain a
-        * subkey).
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public RemoteLibraryServer() throws IOException {
-               super("Fanfix remote library",
-                               Instance.getInstance().getConfig()
-                                               .getInteger(Config.SERVER_PORT),
-                               Instance.getInstance().getConfig()
-                                               .getString(Config.SERVER_KEY));
-
-               setTraceHandler(Instance.getInstance().getTraceHandler());
-       }
-
-       @Override
-       protected Object onRequest(ConnectActionServerObject action,
-                       Version clientVersion, Object data, long id) throws Exception {
-               long start = new Date().getTime();
-
-               // defaults are positive (as previous versions without the feature)
-               boolean rw = true;
-               boolean wl = true;
-               boolean bl = true;
-
-               String subkey = "";
-               String command = "";
-               Object[] args = new Object[0];
-               if (data instanceof Object[]) {
-                       Object[] dataArray = (Object[]) data;
-                       if (dataArray.length > 0) {
-                               subkey = "" + dataArray[0];
-                       }
-                       if (dataArray.length > 1) {
-                               command = "" + dataArray[1];
-
-                               args = new Object[dataArray.length - 2];
-                               for (int i = 2; i < dataArray.length; i++) {
-                                       args[i - 2] = dataArray[i];
-                               }
-                       }
-               }
-
-               List<String> whitelist = Instance.getInstance().getConfig()
-                               .getList(Config.SERVER_WHITELIST);
-               if (whitelist == null) {
-                       whitelist = new ArrayList<String>();
-               }
-               List<String> blacklist = Instance.getInstance().getConfig()
-                               .getList(Config.SERVER_BLACKLIST);
-               if (blacklist == null) {
-                       blacklist = new ArrayList<String>();
-               }
-
-               if (whitelist.isEmpty()) {
-                       wl = false;
-               }
-
-               rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
-                               rw);
-               if (!subkey.isEmpty()) {
-                       List<String> allowed = Instance.getInstance().getConfig()
-                                       .getList(Config.SERVER_ALLOWED_SUBKEYS);
-                       if (allowed.contains(subkey)) {
-                               if ((subkey + "|").contains("|rw|")) {
-                                       rw = true;
-                               }
-                               if ((subkey + "|").contains("|wl|")) {
-                                       wl = false; // |wl| = bypass whitelist
-                                       whitelist = new ArrayList<String>();
-                               }
-                               if ((subkey + "|").contains("|bl|")) {
-                                       bl = false; // |bl| = bypass blacklist
-                                       blacklist = new ArrayList<String>();
-                               }
-                       }
-               }
-
-               String mode = display(wl, bl, rw);
-
-               String trace = mode + "[ " + command + "] ";
-               for (Object arg : args) {
-                       trace += arg + " ";
-               }
-               long now = System.currentTimeMillis();
-               System.out.println(StringUtils.fromTime(now) + ": " + trace);
-
-               Object rep = null;
-               try {
-                       rep = doRequest(action, command, args, rw, whitelist, blacklist);
-               } catch (IOException e) {
-                       rep = new RemoteLibraryException(e, true);
-               }
-
-               commands.put(id, command);
-               wls.put(id, wl);
-               bls.put(id, bl);
-               rws.put(id, rw);
-               times.put(id, (new Date().getTime() - start));
-
-               return rep;
-       }
-
-       private String display(boolean whitelist, boolean blacklist, boolean rw) {
-               String mode = "";
-               if (!rw) {
-                       mode += "RO: ";
-               }
-               if (whitelist) {
-                       mode += "WL: ";
-               }
-               if (blacklist) {
-                       mode += "BL: ";
-               }
-
-               return mode;
-       }
-
-       @Override
-       protected void onRequestDone(long id, long bytesReceived, long bytesSent) {
-               boolean whitelist = wls.get(id);
-               boolean blacklist = bls.get(id);
-               boolean rw = rws.get(id);
-               wls.remove(id);
-               bls.remove(id);
-               rws.remove(id);
-
-               String rec = StringUtils.formatNumber(bytesReceived) + "b";
-               String sent = StringUtils.formatNumber(bytesSent) + "b";
-               long now = System.currentTimeMillis();
-               System.out.println(StringUtils.fromTime(now) + ": "
-                               + String.format("%s[>%s]: (%s sent, %s rec) in %d ms",
-                                               display(whitelist, blacklist, rw), commands.get(id),
-                                               sent, rec, times.get(id)));
-
-               commands.remove(id);
-               times.remove(id);
-       }
-
-       private Object doRequest(ConnectActionServerObject action, String command,
-                       Object[] args, boolean rw, List<String> whitelist,
-                       List<String> blacklist) throws NoSuchFieldException,
-                       NoSuchMethodException, ClassNotFoundException, IOException {
-               if ("PING".equals(command)) {
-                       return rw ? "r/w" : "r/o";
-               } else if ("GET_METADATA".equals(command)) {
-                       List<MetaData> metas = new ArrayList<MetaData>();
-
-                       if ("*".equals(args[0])) {
-                               Progress pg = createPgForwarder(action);
-
-                               for (MetaData meta : Instance.getInstance().getLibrary()
-                                               .getMetas(pg)) {
-                                       metas.add(removeCover(meta));
-                               }
-
-                               forcePgDoneSent(pg);
-                       } else {
-                               MetaData meta = Instance.getInstance().getLibrary()
-                                               .getInfo((String) args[0]);
-                               MetaData light;
-                               if (meta.getCover() == null) {
-                                       light = meta;
-                               } else {
-                                       light = meta.clone();
-                                       light.setCover(null);
-                               }
-
-                               metas.add(light);
-                       }
-
-                       for (int i = 0; i < metas.size(); i++) {
-                               if (!isAllowed(metas.get(i), whitelist, blacklist)) {
-                                       metas.remove(i);
-                                       i--;
-                               }
-                       }
-
-                       return metas.toArray(new MetaData[0]);
-
-               } else if ("GET_STORY".equals(command)) {
-                       MetaData meta = Instance.getInstance().getLibrary()
-                                       .getInfo((String) args[0]);
-                       if (meta == null || !isAllowed(meta, whitelist, blacklist)) {
-                               return null;
-                       }
-
-                       meta = meta.clone();
-                       meta.setCover(null);
-
-                       action.send(meta);
-                       action.rec();
-
-                       Story story = Instance.getInstance().getLibrary()
-                                       .getStory((String) args[0], null);
-                       for (Object obj : breakStory(story)) {
-                               action.send(obj);
-                               action.rec();
-                       }
-               } else if ("SAVE_STORY".equals(command)) {
-                       if (!rw) {
-                               throw new RemoteLibraryException(
-                                               "Read-Only remote library: " + args[0], false);
-                       }
-
-                       List<Object> list = new ArrayList<Object>();
-
-                       action.send(null);
-                       Object obj = action.rec();
-                       while (obj != null) {
-                               list.add(obj);
-                               action.send(null);
-                               obj = action.rec();
-                       }
-
-                       Story story = rebuildStory(list);
-                       Instance.getInstance().getLibrary().save(story, (String) args[0],
-                                       null);
-                       return story.getMeta().getLuid();
-               } else if ("IMPORT".equals(command)) {
-                       if (!rw) {
-                               throw new RemoteLibraryException(
-                                               "Read-Only remote library: " + args[0], false);
-                       }
-
-                       Progress pg = createPgForwarder(action);
-                       MetaData meta = Instance.getInstance().getLibrary()
-                                       .imprt(new URL((String) args[0]), pg);
-                       forcePgDoneSent(pg);
-                       return meta.getLuid();
-               } else if ("DELETE_STORY".equals(command)) {
-                       if (!rw) {
-                               throw new RemoteLibraryException(
-                                               "Read-Only remote library: " + args[0], false);
-                       }
-
-                       Instance.getInstance().getLibrary().delete((String) args[0]);
-               } else if ("GET_COVER".equals(command)) {
-                       return Instance.getInstance().getLibrary()
-                                       .getCover((String) args[0]);
-               } else if ("GET_CUSTOM_COVER".equals(command)) {
-                       if ("SOURCE".equals(args[0])) {
-                               return Instance.getInstance().getLibrary()
-                                               .getCustomSourceCover((String) args[1]);
-                       } else if ("AUTHOR".equals(args[0])) {
-                               return Instance.getInstance().getLibrary()
-                                               .getCustomAuthorCover((String) args[1]);
-                       } else {
-                               return null;
-                       }
-               } else if ("SET_COVER".equals(command)) {
-                       if (!rw) {
-                               throw new RemoteLibraryException(
-                                               "Read-Only remote library: " + args[0] + ", " + args[1],
-                                               false);
-                       }
-
-                       if ("SOURCE".equals(args[0])) {
-                               Instance.getInstance().getLibrary()
-                                               .setSourceCover((String) args[1], (String) args[2]);
-                       } else if ("AUTHOR".equals(args[0])) {
-                               Instance.getInstance().getLibrary()
-                                               .setAuthorCover((String) args[1], (String) args[2]);
-                       }
-               } else if ("CHANGE_STA".equals(command)) {
-                       if (!rw) {
-                               throw new RemoteLibraryException(
-                                               "Read-Only remote library: " + args[0] + ", " + args[1],
-                                               false);
-                       }
-
-                       Progress pg = createPgForwarder(action);
-                       Instance.getInstance().getLibrary().changeSTA((String) args[0],
-                                       (String) args[1], (String) args[2], (String) args[3], pg);
-                       forcePgDoneSent(pg);
-               } else if ("EXIT".equals(command)) {
-                       if (!rw) {
-                               throw new RemoteLibraryException(
-                                               "Read-Only remote library: EXIT", false);
-                       }
-
-                       stop(10000, false);
-               }
-
-               return null;
-       }
-
-       @Override
-       protected void onError(Exception e) {
-               if (e instanceof SSLException) {
-                       long now = System.currentTimeMillis();
-                       System.out.println(StringUtils.fromTime(now) + ": "
-                                       + "[Client connection refused (bad key)]");
-               } else {
-                       getTraceHandler().error(e);
-               }
-       }
-
-       /**
-        * Break a story in multiple {@link Object}s for easier serialisation.
-        * 
-        * @param story
-        *            the {@link Story} to break
-        * 
-        * @return the list of {@link Object}s
-        */
-       static List<Object> breakStory(Story story) {
-               List<Object> list = new ArrayList<Object>();
-
-               story = story.clone();
-               list.add(story);
-
-               if (story.getMeta().isImageDocument()) {
-                       for (Chapter chap : story) {
-                               list.add(chap);
-                               list.addAll(chap.getParagraphs());
-                               chap.setParagraphs(new ArrayList<Paragraph>());
-                       }
-                       story.setChapters(new ArrayList<Chapter>());
-               }
-
-               return list;
-       }
-
-       /**
-        * Rebuild a story from a list of broke up {@link Story} parts.
-        * 
-        * @param list
-        *            the list of {@link Story} parts
-        * 
-        * @return the reconstructed {@link Story}
-        */
-       static Story rebuildStory(List<Object> list) {
-               Story story = null;
-               Chapter chap = null;
-
-               for (Object obj : list) {
-                       if (obj instanceof Story) {
-                               story = (Story) obj;
-                       } else if (obj instanceof Chapter) {
-                               chap = (Chapter) obj;
-                               story.getChapters().add(chap);
-                       } else if (obj instanceof Paragraph) {
-                               chap.getParagraphs().add((Paragraph) obj);
-                       }
-               }
-
-               return story;
-       }
-
-       /**
-        * Update the {@link Progress} with the adequate {@link Object} received
-        * from the network via {@link RemoteLibraryServer}.
-        * 
-        * @param pg
-        *            the {@link Progress} to update
-        * @param rep
-        *            the object received from the network
-        * 
-        * @return TRUE if it was a progress event, FALSE if not
-        */
-       static boolean updateProgress(Progress pg, Object rep) {
-               boolean updateProgress = false;
-               if (rep instanceof Integer[] && ((Integer[]) rep).length == 3)
-                       updateProgress = true;
-               if (rep instanceof Object[] && ((Object[]) rep).length >= 5
-                               && "UPDATE".equals(((Object[]) rep)[0]))
-                       updateProgress = true;
-
-               if (updateProgress) {
-                       Object[] a = (Object[]) rep;
-
-                       int offset = 0;
-                       if (a[0] instanceof String) {
-                               offset = 1;
-                       }
-
-                       int min = (Integer) a[0 + offset];
-                       int max = (Integer) a[1 + offset];
-                       int progress = (Integer) a[2 + offset];
-
-                       Object meta = null;
-                       if (a.length > (3 + offset)) {
-                               meta = a[3 + offset];
-                       }
-
-                       String name = null;
-                       if (a.length > (4 + offset)) {
-                               name = a[4 + offset] == null ? "" : a[4 + offset].toString();
-                       }
-
-                       if (min >= 0 && min <= max) {
-                               pg.setName(name);
-                               pg.setMinMax(min, max);
-                               pg.setProgress(progress);
-                               if (meta != null) {
-                                       pg.put("meta", meta);
-                               }
-
-                               return true;
-                       }
-               }
-
-               return false;
-       }
-
-       /**
-        * Create a {@link Progress} that will forward its progress over the
-        * network.
-        * 
-        * @param action
-        *            the {@link ConnectActionServerObject} to use to forward it
-        * 
-        * @return the {@link Progress}
-        */
-       private Progress createPgForwarder(final ConnectActionServerObject action) {
-               final Boolean[] isDoneForwarded = new Boolean[] { false };
-               final Progress pg = new Progress() {
-                       @Override
-                       public boolean isDone() {
-                               return isDoneForwarded[0];
-                       }
-               };
-
-               final Integer[] p = new Integer[] { -1, -1, -1 };
-               final Object[] pMeta = new MetaData[1];
-               final String[] pName = new String[1];
-               final Long[] lastTime = new Long[] { new Date().getTime() };
-               pg.addProgressListener(new ProgressListener() {
-                       @Override
-                       public void progress(Progress progress, String name) {
-                               Object meta = pg.get("meta");
-                               if (meta instanceof MetaData) {
-                                       meta = removeCover((MetaData) meta);
-                               }
-
-                               int min = pg.getMin();
-                               int max = pg.getMax();
-                               int rel = min + (int) Math
-                                               .round(pg.getRelativeProgress() * (max - min));
-
-                               boolean samePg = p[0] == min && p[1] == max && p[2] == rel;
-
-                               // Do not re-send the same value twice over the wire,
-                               // unless more than 2 seconds have elapsed (to maintain the
-                               // connection)
-                               if (!samePg || !same(pMeta[0], meta) || !same(pName[0], name) //
-                                               || (new Date().getTime() - lastTime[0] > 2000)) {
-                                       p[0] = min;
-                                       p[1] = max;
-                                       p[2] = rel;
-                                       pMeta[0] = meta;
-                                       pName[0] = name;
-
-                                       try {
-                                               action.send(new Object[] { "UPDATE", min, max, rel,
-                                                               meta, name });
-                                               action.rec();
-                                       } catch (Exception e) {
-                                               getTraceHandler().error(e);
-                                       }
-
-                                       lastTime[0] = new Date().getTime();
-                               }
-
-                               isDoneForwarded[0] = (pg.getProgress() >= pg.getMax());
-                       }
-               });
-
-               return pg;
-       }
-
-       private boolean same(Object obj1, Object obj2) {
-               if (obj1 == null || obj2 == null)
-                       return obj1 == null && obj2 == null;
-
-               return obj1.equals(obj2);
-       }
-
-       // with 30 seconds timeout
-       private void forcePgDoneSent(Progress pg) {
-               long start = new Date().getTime();
-               pg.done();
-               while (!pg.isDone() && new Date().getTime() - start < 30000) {
-                       try {
-                               Thread.sleep(100);
-                       } catch (InterruptedException e) {
-                               getTraceHandler().error(e);
-                       }
-               }
-       }
-
-       private MetaData removeCover(MetaData meta) {
-               MetaData light = null;
-               if (meta != null) {
-                       if (meta.getCover() == null) {
-                               light = meta;
-                       } else {
-                               light = meta.clone();
-                               light.setCover(null);
-                       }
-               }
-
-               return light;
-       }
-
-       private boolean isAllowed(MetaData meta, List<String> whitelist,
-                       List<String> blacklist) {
-               MetaResultList one = new MetaResultList(Arrays.asList(meta));
-               if (!whitelist.isEmpty()) {
-                       if (one.filter(whitelist, null, null).isEmpty()) {
-                               return false;
-                       }
-               }
-               if (!blacklist.isEmpty()) {
-                       if (!one.filter(blacklist, null, null).isEmpty()) {
-                               return false;
-                       }
-               }
-
-               return true;
-       }
-}
diff --git a/library/WebLibrary.java b/library/WebLibrary.java
deleted file mode 100644 (file)
index 42f7ea5..0000000
+++ /dev/null
@@ -1,418 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.json.JSONArray;
-import org.json.JSONObject;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.JsonIO;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.Version;
-
-/**
- * This {@link BasicLibrary} will access a remote server to list the available
- * stories, and download the ones you try to load to the local directory
- * specified in the configuration.
- * <p>
- * This remote library uses http:// or https://.
- * 
- * @author niki
- */
-public class WebLibrary extends BasicLibrary {
-       private String host;
-       private int port;
-       private final String key;
-       private final String subkey;
-
-       // informative only (server will make the actual checks)
-       private boolean rw;
-
-       /**
-        * Create a {@link RemoteLibrary} linked to the given server.
-        * <p>
-        * Note that the key is structured:
-        * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>|<b>wl</b>)(|<b>rw</b>)</tt>
-        * <p>
-        * Note that anything before the first pipe (<tt>|</tt>) character is
-        * considered to be the encryption key, anything after that character is
-        * called the subkey (including the other pipe characters and flags!).
-        * <p>
-        * This is important because the subkey (including the pipe characters and
-        * flags) must be present as-is in the server configuration file to be
-        * allowed.
-        * <ul>
-        * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
-        * server</li>
-        * <li><b><i>yyy</i></b>: the secondary key</li>
-        * <li><b>rw</b>: flag to allow read and write access if it is not the
-        * default on this server</li>
-        * <li><b>wl</b>: flag to allow access to all the stories (bypassing the
-        * whitelist if it exists)</li>
-        * </ul>
-        * <p>
-        * Some examples:
-        * <ul>
-        * <li><b>my_key</b>: normal connection, will take the default server
-        * options</li>
-        * <li><b>my_key|agzyzz|wl</b>: will ask to bypass the white list (if it
-        * exists)</li>
-        * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
-        * is read-only)</li>
-        * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
-        * list bypass</li>
-        * </ul>
-        * 
-        * @param key
-        *            the key that will allow us to exchange information with the
-        *            server
-        * @param host
-        *            the host to contact or NULL for localhost
-        * @param port
-        *            the port to contact it on
-        */
-       public WebLibrary(String key, String host, int port) {
-               int index = -1;
-               if (key != null) {
-                       index = key.indexOf('|');
-               }
-
-               if (index >= 0) {
-                       this.key = key.substring(0, index);
-                       this.subkey = key.substring(index + 1);
-               } else {
-                       this.key = key;
-                       this.subkey = "";
-               }
-
-               this.rw = subkey.contains("|rw");
-
-               this.host = host;
-               this.port = port;
-       }
-
-       /**
-        * Return the version of the program running server-side.
-        * <p>
-        * Never returns NULL.
-        * 
-        * @return the version or an empty {@link Version} if not known
-        */
-       public Version getVersion() {
-               try {
-                       InputStream in = post(WebLibraryUrls.VERSION_URL);
-                       try {
-                               return new Version(IOUtils.readSmallStream(in));
-                       } finally {
-                               in.close();
-                       }
-               } catch (IOException e) {
-               }
-
-               return new Version();
-       }
-
-       @Override
-       public Status getStatus() {
-               try {
-                       post(WebLibraryUrls.INDEX_URL).close();
-               } catch (IOException e) {
-                       try {
-                               post("/style.css").close();
-                               return Status.UNAUTHORIZED;
-                       } catch (IOException ioe) {
-                               return Status.UNAVAILABLE;
-                       }
-               }
-
-               return rw ? Status.READ_WRITE : Status.READ_ONLY;
-       }
-
-       @Override
-       public String getLibraryName() {
-               return (rw ? "[READ-ONLY] " : "") + host + ":" + port + " ("
-                               + getVersion() + ")";
-       }
-
-       @Override
-       public Image getCover(String luid) throws IOException {
-               InputStream in = post(WebLibraryUrls.getStoryUrlCover(luid));
-               try {
-                       Image img = new Image(in);
-                       if (img.getSize() > 0) {
-                               return img;
-                       }
-
-                       return null;
-               } finally {
-                       in.close();
-               }
-       }
-
-       @Override
-       public Image getCustomSourceCover(String source) throws IOException {
-               InputStream in = post(WebLibraryUrls.getCoverUrlSource(source));
-               try {
-                       Image img = new Image(in);
-                       if (img.getSize() > 0) {
-                               return img;
-                       }
-
-                       return null;
-               } finally {
-                       in.close();
-               }
-       }
-
-       @Override
-       public Image getCustomAuthorCover(String author) throws IOException {
-               InputStream in = post(WebLibraryUrls.getCoverUrlAuthor(author));
-               try {
-                       Image img = new Image(in);
-                       if (img.getSize() > 0) {
-                               return img;
-                       }
-
-                       return null;
-               } finally {
-                       in.close();
-               }
-       }
-
-       @Override
-       public void setSourceCover(String source, String luid) throws IOException {
-               Map<String, String> post = new HashMap<String, String>();
-               post.put("luid", luid);
-               post(WebLibraryUrls.getCoverUrlSource(source), post).close();
-       }
-
-       @Override
-       public void setAuthorCover(String author, String luid) throws IOException {
-               Map<String, String> post = new HashMap<String, String>();
-               post.put("luid", luid);
-               post(WebLibraryUrls.getCoverUrlAuthor(author), post).close();
-       }
-
-       @Override
-       public synchronized Story getStory(final String luid, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               Story story;
-               InputStream in = post(WebLibraryUrls.getStoryUrlJson(luid));
-               try {
-                       JSONObject json = new JSONObject(IOUtils.readSmallStream(in));
-                       story = JsonIO.toStory(json);
-               } finally {
-                       in.close();
-               }
-
-               int max = 0;
-               for (Chapter chap : story) {
-                       max += chap.getParagraphs().size();
-               }
-               pg.setMinMax(0, max);
-
-               story.getMeta().setCover(getCover(luid));
-               int chapNum = 1;
-               for (Chapter chap : story) {
-                       int number = 1;
-                       for (Paragraph para : chap) {
-                               if (para.getType() == ParagraphType.IMAGE) {
-                                       InputStream subin = post(
-                                                       WebLibraryUrls.getStoryUrl(luid, chapNum, number));
-                                       try {
-                                               Image img = new Image(subin);
-                                               if (img.getSize() > 0) {
-                                                       para.setContentImage(img);
-                                               }
-                                       } finally {
-                                               subin.close();
-                                       }
-                               }
-
-                               pg.add(1);
-                               number++;
-                       }
-
-                       chapNum++;
-               }
-
-               pg.done();
-               return story;
-       }
-
-       @Override
-       protected List<MetaData> getMetas(Progress pg) throws IOException {
-               List<MetaData> metas = new ArrayList<MetaData>();
-               InputStream in = post(WebLibraryUrls.LIST_URL_METADATA);
-               JSONArray jsonArr = new JSONArray(IOUtils.readSmallStream(in));
-               for (int i = 0; i < jsonArr.length(); i++) {
-                       JSONObject json = jsonArr.getJSONObject(i);
-                       metas.add(JsonIO.toMetaData(json));
-               }
-
-               return metas;
-       }
-
-       @Override
-       // Could work (more slowly) without it
-       public MetaData imprt(final URL url, Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               // Import the file locally if it is actually a file
-
-               if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
-                       return super.imprt(url, pg);
-               }
-
-               // Import it remotely if it is an URL
-
-               try {
-                       String luid = null;
-
-                       Map<String, String> post = new HashMap<String, String>();
-                       post.put("url", url.toString());
-                       InputStream in = post(WebLibraryUrls.IMPRT_URL_IMPORT, post);
-                       try {
-                               luid = IOUtils.readSmallStream(in);
-                       } finally {
-                               in.close();
-                       }
-
-                       Progress subPg = null;
-                       do {
-                               try {
-                                       Thread.sleep(2000);
-                               } catch (InterruptedException e) {
-                               }
-
-                               in = post(WebLibraryUrls.getImprtProgressUrl(luid));
-                               try {
-                                       subPg = JsonIO.toProgress(
-                                                       new JSONObject(IOUtils.readSmallStream(in)));
-                                       pg.setName(subPg.getName());
-                                       pg.setMinMax(subPg.getMin(), subPg.getMax());
-                                       pg.setProgress(subPg.getProgress());
-                               } catch (Exception e) {
-                                       subPg = null;
-                               } finally {
-                                       in.close();
-                               }
-                       } while (subPg != null);
-
-                       in = post(WebLibraryUrls.getStoryUrlMetadata(luid));
-                       try {
-                               return JsonIO.toMetaData(
-                                               new JSONObject(IOUtils.readSmallStream(in)));
-                       } finally {
-                               in.close();
-                       }
-               } finally {
-                       pg.done();
-               }
-       }
-
-       @Override
-       // Could work (more slowly) without it
-       protected synchronized void changeSTA(final String luid,
-                       final String newSource, final String newTitle,
-                       final String newAuthor, Progress pg) throws IOException {
-               MetaData meta = getInfo(luid);
-               if (meta != null) {
-                       if (!meta.getSource().equals(newSource)) {
-                               Map<String, String> post = new HashMap<String, String>();
-                               post.put("value", newSource);
-                               post(WebLibraryUrls.getStoryUrlSource(luid), post).close();
-                       }
-                       if (!meta.getTitle().equals(newTitle)) {
-                               Map<String, String> post = new HashMap<String, String>();
-                               post.put("value", newTitle);
-                               post(WebLibraryUrls.getStoryUrlTitle(luid), post).close();
-                       }
-                       if (!meta.getAuthor().equals(newAuthor)) {
-                               Map<String, String> post = new HashMap<String, String>();
-                               post.put("value", newAuthor);
-                               post(WebLibraryUrls.getStoryUrlAuthor(luid), post).close();
-                       }
-               }
-       }
-
-       @Override
-       public synchronized void delete(String luid) throws IOException {
-               post(WebLibraryUrls.getDeleteUrlStory(luid), null).close();
-       }
-
-       @Override
-       protected void updateInfo(MetaData meta) {
-               // Will be taken care of directly server side
-       }
-
-       @Override
-       protected void invalidateInfo(String luid) {
-               // Will be taken care of directly server side
-       }
-
-       // The following methods are only used by Save and Delete in BasicLibrary:
-
-       @Override
-       protected String getNextId() {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       @Override
-       protected void doDelete(String luid) throws IOException {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       @Override
-       protected Story doSave(Story story, Progress pg) throws IOException {
-               throw new java.lang.InternalError("Should not have been called");
-       }
-
-       //
-
-       @Override
-       public File getFile(final String luid, Progress pg) {
-               throw new java.lang.InternalError(
-                               "Operation not supportorted on remote Libraries");
-       }
-
-       // starts with "/", never NULL
-       private InputStream post(String path) throws IOException {
-               return post(path, null);
-       }
-
-       // starts with "/", never NULL
-       private InputStream post(String path, Map<String, String> post)
-                       throws IOException {
-               URL url = new URL(host + ":" + port + path);
-
-               if (post == null) {
-                       post = new HashMap<String, String>();
-               }
-               post.put("login", subkey);
-               post.put("password", key);
-
-               return Instance.getInstance().getCache().openNoCache(url, null, post,
-                               null, null);
-       }
-}
diff --git a/library/WebLibraryServer.java b/library/WebLibraryServer.java
deleted file mode 100644 (file)
index b3d897d..0000000
+++ /dev/null
@@ -1,662 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-
-import org.json.JSONArray;
-import org.json.JSONObject;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.JsonIO;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.LoginResult;
-import be.nikiroo.utils.NanoHTTPD;
-import be.nikiroo.utils.NanoHTTPD.Response;
-import be.nikiroo.utils.NanoHTTPD.Response.Status;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.Progress.ProgressListener;
-
-public class WebLibraryServer extends WebLibraryServerHtml {
-       class WLoginResult extends LoginResult {
-               public WLoginResult(boolean badLogin, boolean badCookie) {
-                       super(badLogin, badCookie);
-               }
-
-               public WLoginResult(String who, String key, String subkey, boolean rw,
-                               boolean wl, boolean bl) {
-                       super(who, key, subkey, (rw ? "|rw" : "") + (wl ? "|wl" : "")
-                                       + (bl ? "|bl" : "") + "|");
-               }
-
-               public WLoginResult(String cookie, String who, String key,
-                               List<String> subkeys) {
-                       super(cookie, who, key, subkeys,
-                                       subkeys == null || subkeys.isEmpty());
-               }
-
-               public boolean isRw() {
-                       return getOption().contains("|rw|");
-               }
-
-               public boolean isWl() {
-                       return getOption().contains("|wl|");
-               }
-
-               public boolean isBl() {
-                       return getOption().contains("|bl|");
-               }
-       }
-
-       private Map<String, Story> storyCache = new HashMap<String, Story>();
-       private LinkedList<String> storyCacheOrder = new LinkedList<String>();
-       private long storyCacheSize = 0;
-       private long maxStoryCacheSize;
-
-       private List<String> whitelist;
-       private List<String> blacklist;
-
-       private Map<String, Progress> imprts = new HashMap<String, Progress>();
-
-       public WebLibraryServer(boolean secure) throws IOException {
-               super(secure);
-
-               int cacheMb = Instance.getInstance().getConfig()
-                               .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
-               maxStoryCacheSize = cacheMb * 1024 * 1024;
-
-               setTraceHandler(Instance.getInstance().getTraceHandler());
-
-               whitelist = Instance.getInstance().getConfig()
-                               .getList(Config.SERVER_WHITELIST, new ArrayList<String>());
-               blacklist = Instance.getInstance().getConfig()
-                               .getList(Config.SERVER_BLACKLIST, new ArrayList<String>());
-       }
-
-       /**
-        * 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 WebLibraryServer#run()}).
-        */
-       public void start() {
-               new Thread(this).start();
-       }
-
-       @Override
-       protected WLoginResult login(boolean badLogin, boolean badCookie) {
-               return new WLoginResult(false, false);
-       }
-
-       @Override
-       protected WLoginResult login(String who, String cookie) {
-               List<String> subkeys = Instance.getInstance().getConfig()
-                               .getList(Config.SERVER_ALLOWED_SUBKEYS);
-               String realKey = Instance.getInstance().getConfig()
-                               .getString(Config.SERVER_KEY);
-
-               return new WLoginResult(cookie, who, realKey, subkeys);
-       }
-
-       // allow rw/wl
-       @Override
-       protected WLoginResult login(String who, String key, String subkey) {
-               String realKey = Instance.getInstance().getConfig()
-                               .getString(Config.SERVER_KEY, "");
-
-               // I don't like NULLs...
-               key = key == null ? "" : key;
-               subkey = subkey == null ? "" : subkey;
-
-               if (!realKey.equals(key)) {
-                       return new WLoginResult(true, false);
-               }
-
-               // defaults are true (as previous versions without the feature)
-               boolean rw = true;
-               boolean wl = true;
-               boolean bl = true;
-
-               rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
-                               rw);
-
-               List<String> allowed = Instance.getInstance().getConfig().getList(
-                               Config.SERVER_ALLOWED_SUBKEYS, new ArrayList<String>());
-
-               if (!allowed.isEmpty()) {
-                       if (!allowed.contains(subkey)) {
-                               return new WLoginResult(true, false);
-                       }
-
-                       if ((subkey + "|").contains("|rw|")) {
-                               rw = true;
-                       }
-                       if ((subkey + "|").contains("|wl|")) {
-                               wl = false; // |wl| = bypass whitelist
-                       }
-                       if ((subkey + "|").contains("|bl|")) {
-                               bl = false; // |bl| = bypass blacklist
-                       }
-               }
-
-               return new WLoginResult(who, key, subkey, rw, wl, bl);
-       }
-
-       @Override
-       protected Response getList(String uri, WLoginResult login)
-                       throws IOException {
-               if (WebLibraryUrls.LIST_URL_METADATA.equals(uri)) {
-                       List<JSONObject> jsons = new ArrayList<JSONObject>();
-                       for (MetaData meta : metas(login)) {
-                               jsons.add(JsonIO.toJson(meta));
-                       }
-
-                       return newInputStreamResponse("application/json",
-                                       new ByteArrayInputStream(
-                                                       new JSONArray(jsons).toString().getBytes()));
-               }
-
-               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                               NanoHTTPD.MIME_PLAINTEXT, null);
-       }
-
-       // /story/luid/chapter/para <-- text/image
-       // /story/luid/cover <-- image
-       // /story/luid/metadata <-- json
-       // /story/luid/json <-- json, whole chapter (no images)
-       @Override
-       protected Response getStoryPart(String uri, WLoginResult login) {
-               String[] uriParts = uri.split("/");
-               int off = 2;
-
-               if (uriParts.length < off + 2) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, null);
-               }
-
-               String luid = uriParts[off + 0];
-               String chapterStr = uriParts[off + 1];
-               String imageStr = uriParts.length < off + 3 ? null : uriParts[off + 2];
-
-               // 1-based (0 = desc)
-               int chapter = 0;
-               if (chapterStr != null && !"cover".equals(chapterStr)
-                               && !"metadata".equals(chapterStr)
-                               && !"json".equals(chapterStr)) {
-                       try {
-                               chapter = Integer.parseInt(chapterStr);
-                               if (chapter < 0) {
-                                       throw new NumberFormatException();
-                               }
-                       } catch (NumberFormatException e) {
-                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                               NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
-                       }
-               }
-
-               // 1-based
-               int paragraph = 1;
-               if (imageStr != null) {
-                       try {
-                               paragraph = Integer.parseInt(imageStr);
-                               if (paragraph < 0) {
-                                       throw new NumberFormatException();
-                               }
-                       } catch (NumberFormatException e) {
-                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                               NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
-                       }
-               }
-
-               String mimeType = NanoHTTPD.MIME_PLAINTEXT;
-               InputStream in = null;
-               try {
-                       if ("cover".equals(chapterStr)) {
-                               Image img = storyCover(luid, login);
-                               if (img != null) {
-                                       in = img.newInputStream();
-                               }
-                               // TODO: get correct image type
-                               mimeType = "image/png";
-                       } else if ("metadata".equals(chapterStr)) {
-                               MetaData meta = meta(luid, login);
-                               JSONObject json = JsonIO.toJson(meta);
-                               mimeType = "application/json";
-                               in = new ByteArrayInputStream(json.toString().getBytes());
-                       } else if ("json".equals(chapterStr)) {
-                               Story story = story(luid, login);
-                               JSONObject json = JsonIO.toJson(story);
-                               mimeType = "application/json";
-                               in = new ByteArrayInputStream(json.toString().getBytes());
-                       } else {
-                               Story story = story(luid, login);
-                               if (story != null) {
-                                       if (chapter == 0) {
-                                               StringBuilder builder = new StringBuilder();
-                                               for (Paragraph p : story.getMeta().getResume()) {
-                                                       if (builder.length() == 0) {
-                                                               builder.append("\n");
-                                                       }
-                                                       builder.append(p.getContent());
-                                               }
-
-                                               in = new ByteArrayInputStream(
-                                                               builder.toString().getBytes("utf-8"));
-                                       } else {
-                                               Paragraph para = story.getChapters().get(chapter - 1)
-                                                               .getParagraphs().get(paragraph - 1);
-                                               Image img = para.getContentImage();
-                                               if (para.getType() == ParagraphType.IMAGE) {
-                                                       // TODO: get correct image type
-                                                       mimeType = "image/png";
-                                                       in = img.newInputStream();
-                                               } else {
-                                                       in = new ByteArrayInputStream(
-                                                                       para.getContent().getBytes("utf-8"));
-                                               }
-                                       }
-                               }
-                       }
-               } catch (IndexOutOfBoundsException e) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
-                                       NanoHTTPD.MIME_PLAINTEXT,
-                                       "Chapter or paragraph does not exist");
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler()
-                                       .error(new IOException("Cannot get image: " + uri, e));
-                       return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
-               }
-
-               return newInputStreamResponse(mimeType, in);
-       }
-
-       // /story/luid/source
-       // /story/luid/title
-       // /story/luid/author
-       @Override
-       protected Response setStoryPart(String uri, String value,
-                       WLoginResult login) throws IOException {
-               String[] uriParts = uri.split("/");
-               int off = 2; // "" and "story"
-
-               if (uriParts.length < off + 2) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Invalid story part request");
-               }
-
-               if (!login.isRw()) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
-                                       NanoHTTPD.MIME_PLAINTEXT, "SET story part not allowed");
-               }
-
-               String luid = uriParts[off + 0];
-               String type = uriParts[off + 1];
-
-               if (!Arrays.asList("source", "title", "author").contains(type)) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT,
-                                       "Invalid SET story part: " + type);
-               }
-
-               if (meta(luid, login) != null) {
-                       BasicLibrary lib = Instance.getInstance().getLibrary();
-                       if ("source".equals(type)) {
-                               lib.changeSource(luid, value, null);
-                       } else if ("title".equals(type)) {
-                               lib.changeTitle(luid, value, null);
-                       } else if ("author".equals(type)) {
-                               lib.changeAuthor(luid, value, null);
-                       }
-               }
-
-               return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
-       }
-
-       @Override
-       protected Response getCover(String uri, WLoginResult login)
-                       throws IOException {
-               String[] uriParts = uri.split("/");
-               int off = 2; // "" and "cover"
-
-               if (uriParts.length < off + 2) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
-               }
-
-               String type = uriParts[off + 0];
-               String id = uriParts[off + 1];
-
-               InputStream in = null;
-
-               if ("story".equals(type)) {
-                       Image img = storyCover(id, login);
-                       if (img != null) {
-                               in = img.newInputStream();
-                       }
-               } else if ("source".equals(type)) {
-                       Image img = sourceCover(id, login);
-                       if (img != null) {
-                               in = img.newInputStream();
-                       }
-               } else if ("author".equals(type)) {
-                       Image img = authorCover(id, login);
-                       if (img != null) {
-                               in = img.newInputStream();
-                       }
-               } else {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT,
-                                       "Invalid GET cover type: " + type);
-               }
-
-               // TODO: get correct image type
-               return newInputStreamResponse("image/png", in);
-       }
-
-       @Override
-       protected Response setCover(String uri, String luid, WLoginResult login)
-                       throws IOException {
-               String[] uriParts = uri.split("/");
-               int off = 2; // "" and "cover"
-
-               if (uriParts.length < off + 2) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
-               }
-
-               if (!login.isRw()) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Cover request not allowed");
-               }
-
-               String type = uriParts[off + 0];
-               String id = uriParts[off + 1];
-
-               if ("source".equals(type)) {
-                       sourceCover(id, login, luid);
-               } else if ("author".equals(type)) {
-                       authorCover(id, login, luid);
-               } else {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT,
-                                       "Invalid SET cover type: " + type);
-               }
-
-               return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
-       }
-
-       @Override
-       protected Response imprt(String uri, String urlStr, WLoginResult login)
-                       throws IOException {
-               final BasicLibrary lib = Instance.getInstance().getLibrary();
-
-               if (!login.isRw()) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Import not allowed");
-               }
-
-               final URL url = new URL(urlStr);
-               final Progress pg = new Progress();
-               final String luid = lib.getNextId();
-
-               // Keep the latest name
-               pg.addProgressListener(new ProgressListener() {
-                       @Override
-                       public void progress(Progress progress, String name) {
-                               pg.setName(name);
-                       }
-               });
-
-               synchronized (imprts) {
-                       imprts.put(luid, pg);
-               }
-
-               new Thread(new Runnable() {
-                       @Override
-                       public void run() {
-                               try {
-                                       lib.imprt(url, luid, pg);
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                               } finally {
-                                       synchronized (imprts) {
-                                               imprts.remove(luid);
-                                       }
-                               }
-                       }
-               }, "Import story: " + urlStr).start();
-
-               return NanoHTTPD.newFixedLengthResponse(Status.OK,
-                               NanoHTTPD.MIME_PLAINTEXT, luid);
-       }
-
-       @Override
-       protected Response imprtProgress(String uri, WLoginResult login) {
-               String[] uriParts = uri.split("/");
-               int off = 2; // "" and "import"
-
-               if (uriParts.length < off + 1) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
-               }
-
-               String luid = uriParts[off + 0];
-
-               Progress pg = null;
-               synchronized (imprts) {
-                       pg = imprts.get(luid);
-               }
-               if (pg != null) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.OK,
-                                       "application/json", JsonIO.toJson(pg).toString());
-               }
-
-               return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
-       }
-
-       @Override
-       protected Response delete(String uri, WLoginResult login)
-                       throws IOException {
-               String[] uriParts = uri.split("/");
-               int off = 2; // "" and "delete"
-
-               if (uriParts.length < off + 1) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Invalid delete request");
-               }
-
-               if (!login.isRw()) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Delete not allowed");
-               }
-
-               String luid = uriParts[off + 0];
-
-               BasicLibrary lib = Instance.getInstance().getLibrary();
-               lib.delete(luid);
-
-               return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
-       }
-
-       @Override
-       protected List<MetaData> metas(WLoginResult login) throws IOException {
-               BasicLibrary lib = Instance.getInstance().getLibrary();
-               List<MetaData> metas = new ArrayList<MetaData>();
-               for (MetaData meta : lib.getList().getMetas()) {
-                       if (isAllowed(meta, login)) {
-                               metas.add(meta);
-                       }
-               }
-
-               return metas;
-       }
-
-       // NULL if not whitelist OK or if not found
-       @Override
-       protected Story story(String luid, WLoginResult login) throws IOException {
-               synchronized (storyCache) {
-                       if (storyCache.containsKey(luid)) {
-                               Story story = storyCache.get(luid);
-                               if (!isAllowed(story.getMeta(), login))
-                                       return null;
-
-                               return story;
-                       }
-               }
-
-               Story story = null;
-               MetaData meta = meta(luid, login);
-               if (meta != null) {
-                       BasicLibrary lib = Instance.getInstance().getLibrary();
-                       story = lib.getStory(luid, null);
-                       long size = sizeOf(story);
-
-                       synchronized (storyCache) {
-                               // Could have been added by another request
-                               if (!storyCache.containsKey(luid)) {
-                                       while (!storyCacheOrder.isEmpty()
-                                                       && storyCacheSize + size > maxStoryCacheSize) {
-                                               String oldestLuid = storyCacheOrder.removeFirst();
-                                               Story oldestStory = storyCache.remove(oldestLuid);
-                                               maxStoryCacheSize -= sizeOf(oldestStory);
-                                       }
-
-                                       storyCacheOrder.add(luid);
-                                       storyCache.put(luid, story);
-                               }
-                       }
-               }
-
-               return story;
-       }
-
-       private MetaData meta(String luid, WLoginResult login) throws IOException {
-               BasicLibrary lib = Instance.getInstance().getLibrary();
-               MetaData meta = lib.getInfo(luid);
-               if (!isAllowed(meta, login))
-                       return null;
-
-               return meta;
-       }
-
-       private Image storyCover(String luid, WLoginResult login)
-                       throws IOException {
-               MetaData meta = meta(luid, login);
-               if (meta != null) {
-                       BasicLibrary lib = Instance.getInstance().getLibrary();
-                       return lib.getCover(meta.getLuid());
-               }
-
-               return null;
-       }
-
-       private Image authorCover(String author, WLoginResult login)
-                       throws IOException {
-               Image img = null;
-
-               List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
-                               author, null);
-               if (metas.size() > 0) {
-                       BasicLibrary lib = Instance.getInstance().getLibrary();
-                       img = lib.getCustomAuthorCover(author);
-                       if (img == null)
-                               img = lib.getCover(metas.get(0).getLuid());
-               }
-
-               return img;
-
-       }
-
-       private void authorCover(String author, WLoginResult login, String luid)
-                       throws IOException {
-               if (meta(luid, login) != null) {
-                       List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
-                                       author, null);
-                       if (metas.size() > 0) {
-                               BasicLibrary lib = Instance.getInstance().getLibrary();
-                               lib.setAuthorCover(author, luid);
-                       }
-               }
-       }
-
-       private Image sourceCover(String source, WLoginResult login)
-                       throws IOException {
-               Image img = null;
-
-               List<MetaData> metas = new MetaResultList(metas(login)).filter(source,
-                               null, null);
-               if (metas.size() > 0) {
-                       BasicLibrary lib = Instance.getInstance().getLibrary();
-                       img = lib.getCustomSourceCover(source);
-                       if (img == null)
-                               img = lib.getCover(metas.get(0).getLuid());
-               }
-
-               return img;
-       }
-
-       private void sourceCover(String source, WLoginResult login, String luid)
-                       throws IOException {
-               if (meta(luid, login) != null) {
-                       List<MetaData> metas = new MetaResultList(metas(login))
-                                       .filter(source, null, null);
-                       if (metas.size() > 0) {
-                               BasicLibrary lib = Instance.getInstance().getLibrary();
-                               lib.setSourceCover(source, luid);
-                       }
-               }
-       }
-
-       private boolean isAllowed(MetaData meta, WLoginResult login) {
-               MetaResultList one = new MetaResultList(Arrays.asList(meta));
-               if (login.isWl() && !whitelist.isEmpty()) {
-                       if (one.filter(whitelist, null, null).isEmpty()) {
-                               return false;
-                       }
-               }
-               if (login.isBl() && !blacklist.isEmpty()) {
-                       if (!one.filter(blacklist, null, null).isEmpty()) {
-                               return false;
-                       }
-               }
-
-               return true;
-       }
-
-       private long sizeOf(Story story) {
-               long size = 0;
-               for (Chapter chap : story) {
-                       for (Paragraph para : chap) {
-                               if (para.getType() == ParagraphType.IMAGE) {
-                                       size += para.getContentImage().getSize();
-                               } else {
-                                       size += para.getContent().length();
-                               }
-                       }
-               }
-
-               return size;
-       }
-
-       public static void main(String[] args) throws IOException {
-               Instance.init();
-               WebLibraryServer web = new WebLibraryServer(false);
-               web.run();
-       }
-}
diff --git a/library/WebLibraryServerHtml.java b/library/WebLibraryServerHtml.java
deleted file mode 100644 (file)
index ac0631d..0000000
+++ /dev/null
@@ -1,877 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.security.KeyStore;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.net.ssl.KeyManagerFactory;
-import javax.net.ssl.SSLServerSocketFactory;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.UiConfig;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.library.WebLibraryServer.WLoginResult;
-import be.nikiroo.fanfix.library.web.WebLibraryServerIndex;
-import be.nikiroo.fanfix.reader.TextOutput;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.NanoHTTPD;
-import be.nikiroo.utils.NanoHTTPD.IHTTPSession;
-import be.nikiroo.utils.NanoHTTPD.Response;
-import be.nikiroo.utils.NanoHTTPD.Response.Status;
-import be.nikiroo.utils.TraceHandler;
-import be.nikiroo.utils.Version;
-
-abstract class WebLibraryServerHtml implements Runnable {
-       private NanoHTTPD server;
-       protected TraceHandler tracer = new TraceHandler();
-
-       abstract protected WLoginResult login(String who, String cookie);
-
-       abstract protected WLoginResult login(String who, String key,
-                       String subkey);
-
-       abstract protected WLoginResult login(boolean badLogin, boolean badCookie);
-
-       abstract protected Response getList(String uri, WLoginResult login)
-                       throws IOException;
-
-       abstract protected Response getStoryPart(String uri, WLoginResult login);
-
-       abstract protected Response setStoryPart(String uri, String value,
-                       WLoginResult login) throws IOException;
-
-       abstract protected Response getCover(String uri, WLoginResult login)
-                       throws IOException;
-
-       abstract protected Response setCover(String uri, String luid,
-                       WLoginResult login) throws IOException;
-
-       abstract protected List<MetaData> metas(WLoginResult login)
-                       throws IOException;
-
-       abstract protected Story story(String luid, WLoginResult login)
-                       throws IOException;
-
-       protected abstract Response imprt(String uri, String url,
-                       WLoginResult login) throws IOException;
-
-       protected abstract Response imprtProgress(String uri, WLoginResult login);
-
-       protected abstract Response delete(String uri, WLoginResult login)
-                       throws IOException;
-
-       public WebLibraryServerHtml(boolean secure) throws IOException {
-               Integer port = Instance.getInstance().getConfig()
-                               .getInteger(Config.SERVER_PORT);
-               if (port == null) {
-                       throw new IOException(
-                                       "Cannot start web server: port not specified");
-               }
-
-               SSLServerSocketFactory ssf = null;
-               if (secure) {
-                       String keystorePath = Instance.getInstance().getConfig()
-                                       .getString(Config.SERVER_SSL_KEYSTORE, "");
-                       String keystorePass = Instance.getInstance().getConfig()
-                                       .getString(Config.SERVER_SSL_KEYSTORE_PASS);
-
-                       if (secure && keystorePath.isEmpty()) {
-                               throw new IOException(
-                                               "Cannot start a secure web server: no keystore.jks file povided");
-                       }
-
-                       if (!keystorePath.isEmpty()) {
-                               File keystoreFile = new File(keystorePath);
-                               try {
-                                       KeyStore keystore = KeyStore
-                                                       .getInstance(KeyStore.getDefaultType());
-                                       InputStream keystoreStream = new FileInputStream(
-                                                       keystoreFile);
-                                       try {
-                                               keystore.load(keystoreStream,
-                                                               keystorePass.toCharArray());
-                                               KeyManagerFactory keyManagerFactory = KeyManagerFactory
-                                                               .getInstance(KeyManagerFactory
-                                                                               .getDefaultAlgorithm());
-                                               keyManagerFactory.init(keystore,
-                                                               keystorePass.toCharArray());
-                                               ssf = NanoHTTPD.makeSSLSocketFactory(keystore,
-                                                               keyManagerFactory);
-                                       } finally {
-                                               keystoreStream.close();
-                                       }
-                               } catch (Exception e) {
-                                       throw new IOException(e.getMessage());
-                               }
-                       }
-               }
-
-               server = new NanoHTTPD(port) {
-                       @Override
-                       public Response serve(final IHTTPSession session) {
-                               super.serve(session);
-
-                               String query = session.getQueryParameterString(); // a=a%20b&dd=2
-                               Method method = session.getMethod(); // GET, POST..
-                               String uri = session.getUri(); // /home.html
-
-                               // need them in real time (not just those sent by the UA)
-                               Map<String, String> cookies = new HashMap<String, String>();
-                               for (String cookie : session.getCookies()) {
-                                       cookies.put(cookie, session.getCookies().read(cookie));
-                               }
-
-                               WLoginResult login = null;
-                               Map<String, String> params = session.getParms();
-                               String who = session.getRemoteHostName()
-                                               + session.getRemoteIpAddress();
-                               if (params.get("login") != null) {
-                                       login = login(who, params.get("password"),
-                                                       params.get("login"));
-                               } else {
-                                       String cookie = cookies.get("cookie");
-                                       login = login(who, cookie);
-                               }
-
-                               if (login.isSuccess()) {
-                                       // refresh cookie
-                                       session.getCookies().set(new Cookie("cookie",
-                                                       login.getCookie(), "30; path=/"));
-
-                                       // set options
-                                       String optionName = params.get("optionName");
-                                       if (optionName != null && !optionName.isEmpty()) {
-                                               String optionNo = params.get("optionNo");
-                                               String optionValue = params.get("optionValue");
-                                               if (optionNo != null || optionValue == null
-                                                               || optionValue.isEmpty()) {
-                                                       session.getCookies().delete(optionName);
-                                                       cookies.remove(optionName);
-                                               } else {
-                                                       session.getCookies().set(new Cookie(optionName,
-                                                                       optionValue, "; path=/"));
-                                                       cookies.put(optionName, optionValue);
-                                               }
-                                       }
-                               }
-
-                               Response rep = null;
-                               if (!login.isSuccess() && WebLibraryUrls.isSupportedUrl(uri)) {
-                                       rep = loginPage(login, uri);
-                               }
-
-                               if (rep == null) {
-                                       try {
-                                               if (WebLibraryUrls.isSupportedUrl(uri)) {
-                                                       if (WebLibraryUrls.INDEX_URL.equals(uri)) {
-                                                               rep = root(session, cookies, login);
-                                                       } else if (WebLibraryUrls.VERSION_URL.equals(uri)) {
-                                                               rep = newFixedLengthResponse(Status.OK,
-                                                                               MIME_PLAINTEXT,
-                                                                               Version.getCurrentVersion().toString());
-                                                       } else if (WebLibraryUrls.isCoverUrl(uri)) {
-                                                               String luid = params.get("luid");
-                                                               if (luid != null) {
-                                                                       rep = setCover(uri, luid, login);
-                                                               } else {
-                                                                       rep = getCover(uri, login);
-                                                               }
-                                                       } else if (WebLibraryUrls.isListUrl(uri)) {
-                                                               rep = getList(uri, login);
-                                                       } else if (WebLibraryUrls.isStoryUrl(uri)) {
-                                                               String value = params.get("value");
-                                                               if (value != null) {
-                                                                       rep = setStoryPart(uri, value, login);
-                                                               } else {
-                                                                       rep = getStoryPart(uri, login);
-                                                               }
-                                                       } else if (WebLibraryUrls.isViewUrl(uri)) {
-                                                               rep = getViewer(cookies, uri, login);
-                                                       } else if (WebLibraryUrls.LOGOUT_URL.equals(uri)) {
-                                                               session.getCookies().delete("cookie");
-                                                               cookies.remove("cookie");
-                                                               rep = loginPage(login(false, false), uri);
-                                                       } else if (WebLibraryUrls.isImprtUrl(uri)) {
-                                                               String url = params.get("url");
-                                                               if (url != null) {
-                                                                       rep = imprt(uri, url, login);
-                                                               } else {
-                                                                       rep = imprtProgress(uri, login);
-                                                               }
-                                                       } else if (WebLibraryUrls.isDeleteUrl(uri)) {
-                                                               rep = delete(uri, login);
-                                                       } else {
-                                                               getTraceHandler().error(
-                                                                               "Supported URL was not processed: "
-                                                                                               + uri);
-                                                               rep = newFixedLengthResponse(
-                                                                               Status.INTERNAL_ERROR,
-                                                                               NanoHTTPD.MIME_PLAINTEXT,
-                                                                               "An error happened");
-                                                       }
-                                               } else {
-                                                       if (uri.startsWith("/"))
-                                                               uri = uri.substring(1);
-                                                       InputStream in = IOUtils.openResource(
-                                                                       WebLibraryServerIndex.class, uri);
-                                                       if (in != null) {
-                                                               String mimeType = MIME_PLAINTEXT;
-                                                               if (uri.endsWith(".css")) {
-                                                                       mimeType = "text/css";
-                                                               } else if (uri.endsWith(".html")) {
-                                                                       mimeType = "text/html";
-                                                               } else if (uri.endsWith(".js")) {
-                                                                       mimeType = "text/javascript";
-                                                               }
-                                                               rep = newChunkedResponse(Status.OK, mimeType,
-                                                                               in);
-                                                       }
-
-                                                       if (rep == null) {
-                                                               getTraceHandler().trace("404: " + uri);
-                                                               rep = newFixedLengthResponse(Status.NOT_FOUND,
-                                                                               NanoHTTPD.MIME_PLAINTEXT, "Not Found");
-                                                       }
-                                               }
-                                       } catch (Exception e) {
-                                               Instance.getInstance().getTraceHandler().error(
-                                                               new IOException("Cannot process web request",
-                                                                               e));
-                                               rep = newFixedLengthResponse(Status.INTERNAL_ERROR,
-                                                               NanoHTTPD.MIME_PLAINTEXT, "An error occured");
-                                       }
-                               }
-
-                               return rep;
-                       }
-               };
-
-               if (ssf != null) {
-                       getTraceHandler().trace("Install SSL on the web server...");
-                       server.makeSecure(ssf, null);
-                       getTraceHandler().trace("Done.");
-               }
-       }
-
-       @Override
-       public void run() {
-               try {
-                       server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
-               } catch (IOException e) {
-                       tracer.error(new IOException("Cannot start the web server", e));
-               }
-       }
-
-       /**
-        * The traces handler for this {@link WebLibraryServerHtml}.
-        * 
-        * @return the traces handler
-        */
-       public TraceHandler getTraceHandler() {
-               return tracer;
-       }
-
-       /**
-        * The traces handler for this {@link WebLibraryServerHtml}.
-        * 
-        * @param tracer
-        *            the new traces handler
-        */
-       public void setTraceHandler(TraceHandler tracer) {
-               if (tracer == null) {
-                       tracer = new TraceHandler(false, false, false);
-               }
-
-               this.tracer = tracer;
-       }
-
-       private Response loginPage(WLoginResult login, String uri) {
-               StringBuilder builder = new StringBuilder();
-
-               appendPreHtml(builder, true);
-
-               if (login.isBadLogin()) {
-                       builder.append("<div class='error'>Bad login or password</div>");
-               } else if (login.isBadCookie()) {
-                       builder.append("<div class='error'>Your session timed out</div>");
-               }
-
-               if (WebLibraryUrls.LOGOUT_URL.equals(uri)) {
-                       uri = WebLibraryUrls.INDEX_URL;
-               }
-
-               builder.append(
-                               "<form method='POST' action='" + uri + "' class='login'>\n");
-               builder.append(
-                               "<p>You must be logged into the system to see the stories.</p>");
-               builder.append("\t<input type='text' name='login' />\n");
-               builder.append("\t<input type='password' name='password' />\n");
-               builder.append("\t<input type='submit' value='Login' />\n");
-               builder.append("</form>\n");
-
-               appendPostHtml(builder);
-
-               return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
-                               NanoHTTPD.MIME_HTML, builder.toString());
-       }
-
-       private Response root(IHTTPSession session, Map<String, String> cookies,
-                       WLoginResult login) throws IOException {
-               BasicLibrary lib = Instance.getInstance().getLibrary();
-               MetaResultList result = new MetaResultList(metas(login));
-               StringBuilder builder = new StringBuilder();
-
-               appendPreHtml(builder, true);
-
-               Map<String, String> params = session.getParms();
-
-               String filter = cookies.get("filter");
-               if (params.get("optionNo") != null)
-                       filter = null;
-               if (filter == null) {
-                       filter = "";
-               }
-
-               String browser = params.get("browser") == null ? ""
-                               : params.get("browser");
-               String browser2 = params.get("browser2") == null ? ""
-                               : params.get("browser2");
-               String browser3 = params.get("browser3") == null ? ""
-                               : params.get("browser3");
-
-               String filterSource = null;
-               String filterAuthor = null;
-               String filterTag = null;
-
-               // TODO: javascript in realtime, using visible=false + hide [submit]
-
-               builder.append("<form class='browser'>\n");
-               builder.append("<div class='breadcrumbs'>\n");
-
-               builder.append("\t<select name='browser'>");
-               appendOption(builder, 2, "", "", browser);
-               appendOption(builder, 2, "Sources", "sources", browser);
-               appendOption(builder, 2, "Authors", "authors", browser);
-               appendOption(builder, 2, "Tags", "tags", browser);
-               builder.append("\t</select>\n");
-
-               if (!browser.isEmpty()) {
-                       builder.append("\t<select name='browser2'>");
-                       if (browser.equals("sources")) {
-                               filterSource = browser2.isEmpty() ? filterSource : browser2;
-                               // TODO: if 1 group -> no group
-                               appendOption(builder, 2, "", "", browser2);
-                               Map<String, List<String>> sources = result.getSourcesGrouped();
-                               for (String source : sources.keySet()) {
-                                       appendOption(builder, 2, source, source, browser2);
-                               }
-                       } else if (browser.equals("authors")) {
-                               filterAuthor = browser2.isEmpty() ? filterAuthor : browser2;
-                               // TODO: if 1 group -> no group
-                               appendOption(builder, 2, "", "", browser2);
-                               Map<String, List<String>> authors = result.getAuthorsGrouped();
-                               for (String author : authors.keySet()) {
-                                       appendOption(builder, 2, author, author, browser2);
-                               }
-                       } else if (browser.equals("tags")) {
-                               filterTag = browser2.isEmpty() ? filterTag : browser2;
-                               appendOption(builder, 2, "", "", browser2);
-                               for (String tag : result.getTags()) {
-                                       appendOption(builder, 2, tag, tag, browser2);
-                               }
-                       }
-                       builder.append("\t</select>\n");
-               }
-
-               if (!browser2.isEmpty()) {
-                       if (browser.equals("sources")) {
-                               filterSource = browser3.isEmpty() ? filterSource : browser3;
-                               Map<String, List<String>> sourcesGrouped = result
-                                               .getSourcesGrouped();
-                               List<String> sources = sourcesGrouped.get(browser2);
-                               if (sources != null && !sources.isEmpty()) {
-                                       // TODO: single empty value
-                                       builder.append("\t<select name='browser3'>");
-                                       appendOption(builder, 2, "", "", browser3);
-                                       for (String source : sources) {
-                                               appendOption(builder, 2, source, source, browser3);
-                                       }
-                                       builder.append("\t</select>\n");
-                               }
-                       } else if (browser.equals("authors")) {
-                               filterAuthor = browser3.isEmpty() ? filterAuthor : browser3;
-                               Map<String, List<String>> authorsGrouped = result
-                                               .getAuthorsGrouped();
-                               List<String> authors = authorsGrouped.get(browser2);
-                               if (authors != null && !authors.isEmpty()) {
-                                       // TODO: single empty value
-                                       builder.append("\t<select name='browser3'>");
-                                       appendOption(builder, 2, "", "", browser3);
-                                       for (String author : authors) {
-                                               appendOption(builder, 2, author, author, browser3);
-                                       }
-                                       builder.append("\t</select>\n");
-                               }
-                       }
-               }
-
-               builder.append("\t<input type='submit' value='Select'/>\n");
-               builder.append("</div>\n");
-
-               // TODO: javascript in realtime, using visible=false + hide [submit]
-               builder.append("<div class='filter'>\n");
-               builder.append("\t<span class='label'>Filter: </span>\n");
-               builder.append(
-                               "\t<input name='optionName'  type='hidden' value='filter' />\n");
-               builder.append("\t<input name='optionValue' type='text'   value='"
-                               + filter + "' place-holder='...' />\n");
-               builder.append("\t<input name='optionNo'  type='submit' value='x' />");
-               builder.append(
-                               "\t<input name='submit' type='submit' value='Filter' />\n");
-               builder.append("</div>\n");
-               builder.append("</form>\n");
-
-               builder.append("\t<div class='books'>");
-               for (MetaData meta : result.getMetas()) {
-                       if (!filter.isEmpty() && !meta.getTitle().toLowerCase()
-                                       .contains(filter.toLowerCase())) {
-                               continue;
-                       }
-
-                       // TODO Sub sources
-                       if (filterSource != null
-                                       && !filterSource.equals(meta.getSource())) {
-                               continue;
-                       }
-
-                       // TODO: sub authors
-                       if (filterAuthor != null
-                                       && !filterAuthor.equals(meta.getAuthor())) {
-                               continue;
-                       }
-
-                       if (filterTag != null && !meta.getTags().contains(filterTag)) {
-                               continue;
-                       }
-
-                       builder.append("<div class='book_line'>");
-                       builder.append("<a href='");
-                       builder.append(
-                                       WebLibraryUrls.getViewUrl(meta.getLuid(), null, null));
-                       builder.append("'");
-                       builder.append(" class='link'>");
-
-                       if (lib.isCached(meta.getLuid())) {
-                               // ◉ = &#9673;
-                               builder.append(
-                                               "<span class='cache_icon cached'>&#9673;</span>");
-                       } else {
-                               // ○ = &#9675;
-                               builder.append(
-                                               "<span class='cache_icon uncached'>&#9675;</span>");
-                       }
-                       builder.append("<span class='luid'>");
-                       builder.append(meta.getLuid());
-                       builder.append("</span>");
-                       builder.append("<span class='title'>");
-                       builder.append(meta.getTitle());
-                       builder.append("</span>");
-                       builder.append("<span class='author'>");
-                       if (meta.getAuthor() != null && !meta.getAuthor().isEmpty()) {
-                               builder.append("(").append(meta.getAuthor()).append(")");
-                       }
-                       builder.append("</span>");
-                       builder.append("</a></div>\n");
-               }
-               builder.append("</div>");
-
-               appendPostHtml(builder);
-               return NanoHTTPD.newFixedLengthResponse(builder.toString());
-       }
-
-       private Response getViewer(Map<String, String> cookies, String uri,
-                       WLoginResult login) {
-               String[] cover = uri.split("/");
-               int off = 2;
-
-               if (cover.length < off + 2) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                       NanoHTTPD.MIME_PLAINTEXT, null);
-               }
-
-               String type = cover[off + 0];
-               String luid = cover[off + 1];
-               String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
-               String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
-
-               // 1-based (0 = desc)
-               int chapter = 0;
-               if (chapterStr != null) {
-                       try {
-                               chapter = Integer.parseInt(chapterStr);
-                               if (chapter < 0) {
-                                       throw new NumberFormatException();
-                               }
-                       } catch (NumberFormatException e) {
-                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                               NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
-                       }
-               }
-
-               // 1-based
-               int paragraph = 0;
-               if (paragraphStr != null) {
-                       try {
-                               paragraph = Integer.parseInt(paragraphStr);
-                               if (paragraph <= 0) {
-                                       throw new NumberFormatException();
-                               }
-                       } catch (NumberFormatException e) {
-                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
-                                               NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
-                       }
-               }
-
-               try {
-                       Story story = story(luid, login);
-                       if (story == null) {
-                               return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
-                                               NanoHTTPD.MIME_PLAINTEXT, "Story not found");
-                       }
-
-                       StringBuilder builder = new StringBuilder();
-                       appendPreHtml(builder, false);
-
-                       // For images documents, always go to the images if not chap 0 desc
-                       if (story.getMeta().isImageDocument()) {
-                               if (chapter > 0 && paragraph <= 0)
-                                       paragraph = 1;
-                       }
-
-                       Chapter chap = null;
-                       if (chapter <= 0) {
-                               chap = story.getMeta().getResume();
-                       } else {
-                               try {
-                                       chap = story.getChapters().get(chapter - 1);
-                               } catch (IndexOutOfBoundsException e) {
-                                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
-                                                       NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
-                               }
-                       }
-
-                       String first, previous, next, last;
-
-                       StringBuilder content = new StringBuilder();
-
-                       String disabledLeft = "";
-                       String disabledRight = "";
-                       String disabledZoomReal = "";
-                       String disabledZoomWidth = "";
-                       String disabledZoomHeight = "";
-
-                       if (paragraph <= 0) {
-                               first = WebLibraryUrls.getViewUrl(luid, 0, null);
-                               previous = WebLibraryUrls.getViewUrl(luid,
-                                               (Math.max(chapter - 1, 0)), null);
-                               next = WebLibraryUrls.getViewUrl(luid,
-                                               (Math.min(chapter + 1, story.getChapters().size())),
-                                               null);
-                               last = WebLibraryUrls.getViewUrl(luid,
-                                               story.getChapters().size(), null);
-
-                               StringBuilder desc = new StringBuilder();
-
-                               if (chapter <= 0) {
-                                       desc.append("<h1 class='title'>");
-                                       desc.append(story.getMeta().getTitle());
-                                       desc.append("</h1>\n");
-                                       desc.append("<div class='desc'>\n");
-                                       desc.append("\t<a href='" + next + "' class='cover'>\n");
-                                       desc.append("\t\t<img src='/story/" + luid + "/cover'/>\n");
-                                       desc.append("\t</a>\n");
-                                       desc.append("\t<table class='details'>\n");
-                                       Map<String, String> details = BasicLibrary
-                                                       .getMetaDesc(story.getMeta());
-                                       for (String key : details.keySet()) {
-                                               appendTableRow(desc, 2, key, details.get(key));
-                                       }
-                                       desc.append("\t</table>\n");
-                                       desc.append("</div>\n");
-                                       desc.append("<h1 class='title'>Description</h1>\n");
-                               }
-
-                               content.append("<div class='viewer text'>\n");
-                               content.append(desc);
-                               String description = new TextOutput(false).convert(chap,
-                                               chapter > 0);
-                               content.append(chap.getParagraphs().size() <= 0
-                                               ? "No content provided."
-                                               : description);
-                               content.append("</div>\n");
-
-                               if (chapter <= 0)
-                                       disabledLeft = " disabled='disbaled'";
-                               if (chapter >= story.getChapters().size())
-                                       disabledRight = " disabled='disbaled'";
-                       } else {
-                               first = WebLibraryUrls.getViewUrl(luid, chapter, 1);
-                               previous = WebLibraryUrls.getViewUrl(luid, chapter,
-                                               (Math.max(paragraph - 1, 1)));
-                               next = WebLibraryUrls.getViewUrl(luid, chapter,
-                                               (Math.min(paragraph + 1, chap.getParagraphs().size())));
-                               last = WebLibraryUrls.getViewUrl(luid, chapter,
-                                               chap.getParagraphs().size());
-
-                               if (paragraph <= 1)
-                                       disabledLeft = " disabled='disbaled'";
-                               if (paragraph >= chap.getParagraphs().size())
-                                       disabledRight = " disabled='disbaled'";
-
-                               // First -> previous *chapter*
-                               if (chapter > 0)
-                                       disabledLeft = "";
-                               first = WebLibraryUrls.getViewUrl(luid,
-                                               (Math.max(chapter - 1, 0)), null);
-                               if (paragraph <= 1) {
-                                       previous = first;
-                               }
-
-                               Paragraph para = null;
-                               try {
-                                       para = chap.getParagraphs().get(paragraph - 1);
-                               } catch (IndexOutOfBoundsException e) {
-                                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
-                                                       NanoHTTPD.MIME_PLAINTEXT,
-                                                       "Paragraph " + paragraph + " not found");
-                               }
-
-                               if (para.getType() == ParagraphType.IMAGE) {
-                                       String zoomStyle = "max-width: 100%;";
-                                       disabledZoomWidth = " disabled='disabled'";
-                                       String zoomOption = cookies.get("zoom");
-                                       if (zoomOption != null && !zoomOption.isEmpty()) {
-                                               if (zoomOption.equals("real")) {
-                                                       zoomStyle = "";
-                                                       disabledZoomWidth = "";
-                                                       disabledZoomReal = " disabled='disabled'";
-                                               } else if (zoomOption.equals("width")) {
-                                                       zoomStyle = "max-width: 100%;";
-                                               } else if (zoomOption.equals("height")) {
-                                                       // see height of navbar + optionbar
-                                                       zoomStyle = "max-height: calc(100% - 128px);";
-                                                       disabledZoomWidth = "";
-                                                       disabledZoomHeight = " disabled='disabled'";
-                                               }
-                                       }
-
-                                       String javascript = "document.getElementById(\"previous\").click(); return false;";
-                                       content.append(String.format("" //
-                                                       + "<a class='viewer link' oncontextmenu='%s' href='%s'>"
-                                                       + "<img class='viewer img' style='%s' src='%s'/>"
-                                                       + "</a>", //
-                                                       javascript, //
-                                                       next, //
-                                                       zoomStyle, //
-                                                       WebLibraryUrls.getStoryUrl(luid, chapter,
-                                                                       paragraph)));
-                               } else {
-                                       content.append(String.format("" //
-                                                       + "<div class='viewer text'>%s</div>", //
-                                                       para.getContent()));
-                               }
-                       }
-
-                       builder.append(String.format("" //
-                                       + "<div class='bar navbar'>\n" //
-                                       + "\t<a%s class='button first' href='%s'>&lt;&lt;</a>\n"//
-                                       + "\t<a%s id='previous' class='button previous' href='%s'>&lt;</a>\n" //
-                                       + "\t<div class='gotobox itemsbox'>\n" //
-                                       + "\t\t<div class='button goto'>%d</div>\n" //
-                                       + "\t\t<div class='items goto'>\n", //
-                                       disabledLeft, first, //
-                                       disabledLeft, previous, //
-                                       paragraph > 0 ? paragraph : chapter //
-                       ));
-
-                       // List of chap/para links
-
-                       appendItemA(builder, 3, WebLibraryUrls.getViewUrl(luid, 0, null),
-                                       "Description", paragraph == 0 && chapter == 0);
-                       if (paragraph > 0) {
-                               for (int i = 1; i <= chap.getParagraphs().size(); i++) {
-                                       appendItemA(builder, 3,
-                                                       WebLibraryUrls.getViewUrl(luid, chapter, i),
-                                                       "Image " + i, paragraph == i);
-                               }
-                       } else {
-                               int i = 1;
-                               for (Chapter c : story.getChapters()) {
-                                       String chapName = "Chapter " + c.getNumber();
-                                       if (c.getName() != null && !c.getName().isEmpty()) {
-                                               chapName += ": " + c.getName();
-                                       }
-
-                                       appendItemA(builder, 3,
-                                                       WebLibraryUrls.getViewUrl(luid, i, null), chapName,
-                                                       chapter == i);
-
-                                       i++;
-                               }
-                       }
-
-                       builder.append(String.format("" //
-                                       + "\t\t</div>\n" //
-                                       + "\t</div>\n" //
-                                       + "\t<a%s class='button next' href='%s'>&gt;</a>\n" //
-                                       + "\t<a%s class='button last' href='%s'>&gt;&gt;</a>\n"//
-                                       + "</div>\n", //
-                                       disabledRight, next, //
-                                       disabledRight, last //
-                       ));
-
-                       builder.append(content);
-
-                       builder.append("<div class='bar optionbar ");
-                       if (paragraph > 0) {
-                               builder.append("s4");
-                       } else {
-                               builder.append("s1");
-                       }
-                       builder.append("'>\n");
-                       builder.append("        <a class='button back' href='/'>BACK</a>\n");
-
-                       if (paragraph > 0) {
-                               builder.append(String.format("" //
-                                               + "\t<a%s class='button zoomreal'   href='%s'>REAL</a>\n"//
-                                               + "\t<a%s class='button zoomwidth'  href='%s'>WIDTH</a>\n"//
-                                               + "\t<a%s class='button zoomheight' href='%s'>HEIGHT</a>\n"//
-                                               + "</div>\n", //
-                                               disabledZoomReal,
-                                               uri + "?optionName=zoom&optionValue=real", //
-                                               disabledZoomWidth,
-                                               uri + "?optionName=zoom&optionValue=width", //
-                                               disabledZoomHeight,
-                                               uri + "?optionName=zoom&optionValue=height" //
-                               ));
-                       }
-
-                       appendPostHtml(builder);
-                       return NanoHTTPD.newFixedLengthResponse(Status.OK,
-                                       NanoHTTPD.MIME_HTML, builder.toString());
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler()
-                                       .error(new IOException("Cannot get image: " + uri, e));
-                       return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
-                                       NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
-               }
-       }
-
-       protected Response newInputStreamResponse(String mimeType, InputStream in) {
-               if (in == null) {
-                       return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
-                                       null);
-               }
-               return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
-       }
-
-       private String getContentOf(String file) {
-               InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
-                               file);
-               if (in != null) {
-                       try {
-                               return IOUtils.readSmallStream(in);
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(
-                                               new IOException("Cannot get file: index.pre.html", e));
-                       }
-               }
-
-               return "";
-       }
-
-       private void appendPreHtml(StringBuilder builder, boolean banner) {
-               String favicon = "favicon.ico";
-               String icon = Instance.getInstance().getUiConfig()
-                               .getString(UiConfig.PROGRAM_ICON);
-               if (icon != null) {
-                       favicon = "icon_" + icon.replace("-", "_") + ".png";
-               }
-
-               builder.append(
-                               getContentOf("index.pre.html").replace("favicon.ico", favicon));
-
-               if (banner) {
-                       builder.append("<div class='banner'>\n");
-                       builder.append("\t<img class='ico' src='/") //
-                                       .append(favicon) //
-                                       .append("'/>\n");
-                       builder.append("\t<h1>Fanfix</h1>\n");
-                       builder.append("\t<h2>") //
-                                       .append(Version.getCurrentVersion()) //
-                                       .append("</h2>\n");
-                       builder.append("</div>\n");
-               }
-       }
-
-       private void appendPostHtml(StringBuilder builder) {
-               builder.append(getContentOf("index.post.html"));
-       }
-
-       private void appendOption(StringBuilder builder, int depth, String name,
-                       String value, String selected) {
-               for (int i = 0; i < depth; i++) {
-                       builder.append("\t");
-               }
-               builder.append("<option value='").append(value).append("'");
-               if (value.equals(selected)) {
-                       builder.append(" selected='selected'");
-               }
-               builder.append(">").append(name).append("</option>\n");
-       }
-
-       private void appendTableRow(StringBuilder builder, int depth,
-                       String... tds) {
-               for (int i = 0; i < depth; i++) {
-                       builder.append("\t");
-               }
-
-               int col = 1;
-               builder.append("<tr>");
-               for (String td : tds) {
-                       builder.append("<td class='col");
-                       builder.append(col++);
-                       builder.append("'>");
-                       builder.append(td);
-                       builder.append("</td>");
-               }
-               builder.append("</tr>\n");
-       }
-
-       private void appendItemA(StringBuilder builder, int depth, String link,
-                       String name, boolean selected) {
-               for (int i = 0; i < depth; i++) {
-                       builder.append("\t");
-               }
-
-               builder.append("<a href='");
-               builder.append(link);
-               builder.append("' class='item goto");
-               if (selected) {
-                       builder.append(" selected");
-               }
-               builder.append("'>");
-               builder.append(name);
-               builder.append("</a>\n");
-       }
-}
diff --git a/library/WebLibraryUrls.java b/library/WebLibraryUrls.java
deleted file mode 100644 (file)
index 5ec669f..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-package be.nikiroo.fanfix.library;
-
-class WebLibraryUrls {
-       static public final String INDEX_URL = "/";
-
-       static public final String VERSION_URL = "/version";
-
-       static public final String LOGOUT_URL = "/logout";
-
-       static private final String VIEWER_URL_BASE = "/view/story/";
-       static private final String VIEWER_URL = VIEWER_URL_BASE
-                       + "{luid}/{chap}/{para}";
-
-       static private final String STORY_URL_BASE = "/story/";
-       static private final String STORY_URL = STORY_URL_BASE
-                       + "{luid}/{chap}/{para}";
-       static private final String STORY_URL_COVER = STORY_URL_BASE
-                       + "{luid}/cover";
-       static private final String STORY_URL_JSON = STORY_URL_BASE + "{luid}/json";
-       static private final String STORY_URL_METADATA = STORY_URL_BASE
-                       + "{luid}/metadata";
-
-       // GET/SET ("value" param -> set STA to this value)
-       static private final String STORY_URL_SOURCE = STORY_URL_BASE
-                       + "{luid}/source";
-       static private final String STORY_URL_TITLE = STORY_URL_BASE
-                       + "{luid}/title";
-       static private final String STORY_URL_AUTHOR = STORY_URL_BASE
-                       + "{luid}/author";
-
-       static private final String LIST_URL_BASE = "/list/";
-
-       static public final String LIST_URL_METADATA = LIST_URL_BASE + "metadata";
-
-       // "import" requires param "url" and return an luid, "/{luid}" return
-       // progress status as a JSON Progress or 404 if none (done or failed)
-       static private final String IMPRT_URL_BASE = "/import/";
-       static private final String IMPRT_URL_PROGRESS = IMPRT_URL_BASE + "{luid}";
-       static public final String IMPRT_URL_IMPORT = IMPRT_URL_BASE + "import";
-
-       static private final String DELETE_URL_BASE = "/delete/";
-       static private final String DELETE_URL_STORY = DELETE_URL_BASE + "{luid}";
-
-       // GET/SET ("luid" param -> set cover to the cover of this story -- not ok
-       // for /cover/story/)
-       static private final String COVER_URL_BASE = "/cover/";
-       static private final String COVER_URL_STORY = COVER_URL_BASE
-                       + "story/{luid}";
-       static private final String COVER_URL_AUTHOR = COVER_URL_BASE
-                       + "author/{author}";
-       static private final String COVER_URL_SOURCE = COVER_URL_BASE
-                       + "source/{source}";
-
-       static public String getViewUrl(String luid, Integer chap, Integer para) {
-               return VIEWER_URL //
-                               .replace("{luid}", luid) //
-                               .replace("/{chap}", chap == null ? "" : "/" + chap) //
-                               .replace("/{para}",
-                                               (chap == null || para == null) ? "" : "/" + para);
-       }
-
-       static public String getStoryUrl(String luid, int chap, Integer para) {
-               return STORY_URL //
-                               .replace("{luid}", luid) //
-                               .replace("{chap}", Integer.toString(chap)) //
-                               .replace("{para}", para == null ? "" : Integer.toString(para));
-       }
-
-       static public String getStoryUrlCover(String luid) {
-               return STORY_URL_COVER //
-                               .replace("{luid}", luid);
-       }
-
-       static public String getStoryUrlJson(String luid) {
-               return STORY_URL_JSON //
-                               .replace("{luid}", luid);
-       }
-
-       static public String getStoryUrlSource(String luid) {
-               return STORY_URL_SOURCE //
-                               .replace("{luid}", luid);
-       }
-
-       static public String getStoryUrlTitle(String luid) {
-               return STORY_URL_TITLE//
-                               .replace("{luid}", luid);
-       }
-
-       static public String getStoryUrlAuthor(String luid) {
-               return STORY_URL_AUTHOR //
-                               .replace("{luid}", luid);
-       }
-
-       static public String getStoryUrlMetadata(String luid) {
-               return STORY_URL_METADATA //
-                               .replace("{luid}", luid);
-       }
-
-       static public String getImprtProgressUrl(String luid) {
-               return IMPRT_URL_PROGRESS //
-                               .replace("{luid}", luid);
-       }
-
-       static public boolean isSupportedUrl(String url) {
-               return INDEX_URL.equals(url) || VERSION_URL.equals(url)
-                               || LOGOUT_URL.equals(url) || isViewUrl(url) || isStoryUrl(url)
-                               || isListUrl(url) || isCoverUrl(url) || isImprtUrl(url)
-                               || isDeleteUrl(url);
-       }
-
-       static public String getCoverUrlStory(String luid) {
-               return COVER_URL_STORY //
-                               .replace("{luid}", luid);
-       }
-
-       static public String getCoverUrlSource(String source) {
-               return COVER_URL_SOURCE //
-                               .replace("{source}", source);
-       }
-
-       static public String getCoverUrlAuthor(String author) {
-               return COVER_URL_AUTHOR //
-                               .replace("{author}", author);
-       }
-
-       static public String getDeleteUrlStory(String luid) {
-               return DELETE_URL_STORY //
-                               .replace("{luid}", luid);
-       }
-
-       static public boolean isViewUrl(String url) {
-               return url != null && url.startsWith(VIEWER_URL_BASE);
-       }
-
-       static public boolean isStoryUrl(String url) {
-               return url != null && url.startsWith(STORY_URL_BASE);
-       }
-
-       static public boolean isListUrl(String url) {
-               return url != null && url.startsWith(LIST_URL_BASE);
-       }
-
-       static public boolean isCoverUrl(String url) {
-               return url != null && url.startsWith(COVER_URL_BASE);
-       }
-
-       static public boolean isImprtUrl(String url) {
-               return url != null && url.startsWith(IMPRT_URL_BASE);
-       }
-
-       static public boolean isDeleteUrl(String url) {
-               return url != null && url.startsWith(DELETE_URL_BASE);
-       }
-}
diff --git a/library/package-info.java b/library/package-info.java
deleted file mode 100644 (file)
index 1bb63ea..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * This package offer a Libraries to store stories into.
- * <p>
- * It currently has a local library and a remote one, as well as the required 
- * remote server. 
- * 
- * @author niki
- */
-package be.nikiroo.fanfix.library;
\ No newline at end of file
diff --git a/library/web/WebLibraryServerIndex.java b/library/web/WebLibraryServerIndex.java
deleted file mode 100644 (file)
index 15c371b..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-package be.nikiroo.fanfix.library.web;
-
-public class WebLibraryServerIndex {
-}
diff --git a/library/web/actual_size-32x32.png b/library/web/actual_size-32x32.png
deleted file mode 100644 (file)
index 0d356b7..0000000
Binary files a/library/web/actual_size-32x32.png and /dev/null differ
diff --git a/library/web/actual_size-64x64.png b/library/web/actual_size-64x64.png
deleted file mode 100644 (file)
index 474afda..0000000
Binary files a/library/web/actual_size-64x64.png and /dev/null differ
diff --git a/library/web/arrow_double_left-32x32.png b/library/web/arrow_double_left-32x32.png
deleted file mode 100644 (file)
index e163b60..0000000
Binary files a/library/web/arrow_double_left-32x32.png and /dev/null differ
diff --git a/library/web/arrow_double_left-64x64.png b/library/web/arrow_double_left-64x64.png
deleted file mode 100644 (file)
index e66dd93..0000000
Binary files a/library/web/arrow_double_left-64x64.png and /dev/null differ
diff --git a/library/web/arrow_double_right-32x32.png b/library/web/arrow_double_right-32x32.png
deleted file mode 100644 (file)
index 297dcab..0000000
Binary files a/library/web/arrow_double_right-32x32.png and /dev/null differ
diff --git a/library/web/arrow_double_right-64x64.png b/library/web/arrow_double_right-64x64.png
deleted file mode 100644 (file)
index 52226f6..0000000
Binary files a/library/web/arrow_double_right-64x64.png and /dev/null differ
diff --git a/library/web/arrow_left-32x32.png b/library/web/arrow_left-32x32.png
deleted file mode 100644 (file)
index af83425..0000000
Binary files a/library/web/arrow_left-32x32.png and /dev/null differ
diff --git a/library/web/arrow_left-64x64.png b/library/web/arrow_left-64x64.png
deleted file mode 100644 (file)
index 44cba9c..0000000
Binary files a/library/web/arrow_left-64x64.png and /dev/null differ
diff --git a/library/web/arrow_right-32x32.png b/library/web/arrow_right-32x32.png
deleted file mode 100644 (file)
index 2540b62..0000000
Binary files a/library/web/arrow_right-32x32.png and /dev/null differ
diff --git a/library/web/arrow_right-64x64.png b/library/web/arrow_right-64x64.png
deleted file mode 100644 (file)
index ab960b3..0000000
Binary files a/library/web/arrow_right-64x64.png and /dev/null differ
diff --git a/library/web/avicon.ico b/library/web/avicon.ico
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/library/web/back-32x32.png b/library/web/back-32x32.png
deleted file mode 100644 (file)
index 46c19dc..0000000
Binary files a/library/web/back-32x32.png and /dev/null differ
diff --git a/library/web/back-64x64.png b/library/web/back-64x64.png
deleted file mode 100644 (file)
index d58d004..0000000
Binary files a/library/web/back-64x64.png and /dev/null differ
diff --git a/library/web/clear-32x32.png b/library/web/clear-32x32.png
deleted file mode 100644 (file)
index bdc51d0..0000000
Binary files a/library/web/clear-32x32.png and /dev/null differ
diff --git a/library/web/clear.png b/library/web/clear.png
deleted file mode 100644 (file)
index db0509f..0000000
Binary files a/library/web/clear.png and /dev/null differ
diff --git a/library/web/favicon.ico b/library/web/favicon.ico
deleted file mode 100644 (file)
index feedaf0..0000000
Binary files a/library/web/favicon.ico and /dev/null differ
diff --git a/library/web/fit_to_height-32x32.png b/library/web/fit_to_height-32x32.png
deleted file mode 100644 (file)
index 727dec2..0000000
Binary files a/library/web/fit_to_height-32x32.png and /dev/null differ
diff --git a/library/web/fit_to_height-64x64.png b/library/web/fit_to_height-64x64.png
deleted file mode 100644 (file)
index cec7da4..0000000
Binary files a/library/web/fit_to_height-64x64.png and /dev/null differ
diff --git a/library/web/fit_to_width-32x32.png b/library/web/fit_to_width-32x32.png
deleted file mode 100644 (file)
index ee90843..0000000
Binary files a/library/web/fit_to_width-32x32.png and /dev/null differ
diff --git a/library/web/fit_to_width-64x64.png b/library/web/fit_to_width-64x64.png
deleted file mode 100644 (file)
index 7b897d2..0000000
Binary files a/library/web/fit_to_width-64x64.png and /dev/null differ
diff --git a/library/web/icon_alternative.png b/library/web/icon_alternative.png
deleted file mode 100644 (file)
index 4ab0957..0000000
Binary files a/library/web/icon_alternative.png and /dev/null differ
diff --git a/library/web/icon_default.png b/library/web/icon_default.png
deleted file mode 100644 (file)
index 983b344..0000000
Binary files a/library/web/icon_default.png and /dev/null differ
diff --git a/library/web/icon_magic_book.png b/library/web/icon_magic_book.png
deleted file mode 100644 (file)
index 1798dd3..0000000
Binary files a/library/web/icon_magic_book.png and /dev/null differ
diff --git a/library/web/icon_pony_book.png b/library/web/icon_pony_book.png
deleted file mode 100644 (file)
index fb6fe0d..0000000
Binary files a/library/web/icon_pony_book.png and /dev/null differ
diff --git a/library/web/icon_pony_library.png b/library/web/icon_pony_library.png
deleted file mode 100644 (file)
index a56a4d2..0000000
Binary files a/library/web/icon_pony_library.png and /dev/null differ
diff --git a/library/web/index.post.html b/library/web/index.post.html
deleted file mode 100644 (file)
index d4e0905..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-</div>
-</body>
\ No newline at end of file
diff --git a/library/web/index.pre.html b/library/web/index.pre.html
deleted file mode 100644 (file)
index 18c1508..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-<!--
-       Copyright 2020 David ROULET
-       
-       This file is part of fanfix.
-       
-       fanfix is free software: you can redistribute it and/or modify
-       it under the terms of the GNU Affero General Public License as published by
-       the Free Software Foundation, either version 3 of the License, or
-       (at your option) any later version.
-       
-       fanfix is distributed in the hope that it will be useful,
-       but WITHOUT ANY WARRANTY; without even the implied warranty of
-       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-       GNU Affero General Public License for more details.
-       
-       You should have received a copy of the GNU Affero General Public License
-       along with fanfix.  If not, see <https://www.gnu.org/licenses/>.
-       ___________________________________________________________________________
-
-       This website was coded by:
-                       A kangaroo.
-                                                  _  _
-                                                 (\\( \
-                                                  `.\-.)
-                              _...._            _,-"   `-.
-\                           ,"      `-._.- -.,-"       .  \
- \`.                      ,"                               `.
-  \ `-...__              /                           .   .:  y
-   `._     ``-...__     /                           ,"```-._/
-      `-._         ```-"                      |    /_          //
-          `.._                   _            ;   <_ \        //
-              ``-.___             `.           `-._ \ \      //
-                     `- <           `.     (\ _/)/ `.\/     //
-                         \            \     `       ^^^^^^^^^
-       ___________________________________________________________________________
-       
-       -->
-       <meta http-equiv="content-type" content="text/html; charset=utf-8">
-       <meta name="viewport" content="width=device-width, initial-scale=1.0">
-       <title>Fanfix</title>
-       <link rel="stylesheet" type="text/css" href="/style.css" />
-       <link rel="icon" type="image/x-icon" href="/favicon.ico" />
-</head>
-<body>
-<div class='main'>
diff --git a/library/web/search-32x32.png b/library/web/search-32x32.png
deleted file mode 100644 (file)
index 92b716d..0000000
Binary files a/library/web/search-32x32.png and /dev/null differ
diff --git a/library/web/search-64x64.png b/library/web/search-64x64.png
deleted file mode 100644 (file)
index 93dbf6d..0000000
Binary files a/library/web/search-64x64.png and /dev/null differ
diff --git a/library/web/style.css b/library/web/style.css
deleted file mode 100644 (file)
index 4196aeb..0000000
+++ /dev/null
@@ -1,338 +0,0 @@
-/*
- * #AAA391: main gray
- * #555C7E: reverse main
- *
- * #bac2e1: button border
- * #DCE4F3: button hover
- *
- * #ddffdd: background pastel green
- * #88dd88: for border
- *
- * #ffdddd: pastel red
- * #dd8888: for border
- *
- */
-
-html, body, .main {
-       margin: 0;
-       padding: 0;
-       font-family: Verdana, "Bitstream Vera Sans", "DejaVu Sans", Tahoma, Geneva, Arial, Sans-serif;
-       font-size: 12px;
-       height: 100%;
-}
-
-.banner {
-}
-
-.banner .ico {
-       display: block;
-       height: 50px;
-       float: left;
-       padding: 10px;
-}
-
-.banner h1, .banner h2 {
-}
-
-.main {
-       margin: auto;
-}
-
-.message {
-       background-color: #ddffdd;
-       border: 1px solid #88dd88;
-       clear: left;
-       border-radius: 5px;
-       padding: 5px;
-       margin: 10px;
-}
-
-.error {
-       background-color: #ffdddd;
-       border: 1px solid #dd8888;
-       clear: left;
-       border-radius: 5px;
-       padding: 5px;
-       margin: 10px;
-}
-
-/* all links and clickable should show a pointer cursor */
-[onclick], h2[onclick]:before, h3[onclick]:before {
-       cursor: pointer;
-}
-
-a:not(.selected):hover {
-       background-color: rgb(225, 225, 225);
-}
-
-h2 {
-       border-bottom: 1px solid #AAA391;
-}
-
-h3 {
-       border-bottom: 1px solid #AAA391;
-       margin-left: 20px;
-}
-
-.itemsbox .items {
-       display: block;
-       position: absolute;
-       overflow-y: auto;
-       
-       transition: all 0.5s ease;
-       visibility: hidden;
-       opacity: 0;
-}
-
-.itemsbox:hover .items, .itemsbox .items:hover {
-       visibility: visible;
-       opacity: 1;
-}
-
-.itemsbox .items .item {
-       display: block;
-       padding: 5px;
-       color: black;
-       cursor: pointer;
-       text-decoration: none;
-}
-
-.itemsbox .items .item.selected {
-       font-weight: bold;
-       cursor: default;
-}
-
-.itemsbox .items .item:not(.selected):hover, .button:hover {
-       background-color: #DCE4F3;
-}
-
-.itemsbox .items .item[disabled], .button[disabled] {
-       pointer-events: none;
-       background-color: #aaa;
-       filter: grayscale(100%);
-       border-color: #AAA391;
-}
-
-.button {
-       display: inline-block;
-       text-align: center;
-       text-decoration: none;
-       background-position: center;
-       background-repeat: no-repeat;
-       border: 1px solid #bac2e1;
-       border-radius: 5px;
-}
-
-.login {
-       width: 250px;
-       display: flex;
-       margin: auto;
-       margin-top: 100px;
-       flex-direction: column;
-       border: 1px solid #AAA391;
-       padding: 20px;
-       border-radius: 10px;
-}
-
-.login input {
-       margin: 5px;
-       min-height: 22px;
-}
-
-.login input[type='submit'] {
-       margin-top: 15px;
-}
-
-.breadcrumbs {
-}
-
-.filter {
-       padding: 5px;
-       height: 36px;
-       border: 1px solid #AAA391;
-       border-radius: 5px;
-       margin-top: 5px;
-       margin-bottom: 5px;
-       display: flex;
-}
-
-.filter > * {
-       height: 36px;
-       display: inline-block;
-}
-
-.filter .label {
-       line-height: 36px;
-       padding-right: 5px;
-}
-
-.filter input[type='text'] {
-       height: 32px;
-       padding: 0;
-       margin: 0;
-       border-width: 2px;
-       min-width: 50px;
-       flex-grow: 100;
-       flex-shrink: 100;
-}
-
-.filter input[name='optionNo'] {
-       background-image: url('/clear-32x32.png');
-       color: transparent;
-       width: 36px;
-}
-
-.books {
-}
-
-.book_line {
-       width: 100%;
-       display: flex;
-}
-
-.book_line .link, .book_line .title {
-       flex-grow: 100;
-       padding: 5px;
-}
-
-.book_line .link {
-       text-decoration: none;
-}
-
-.book_line .cache_icon {
-       color: green;
-}
-
-.book_line .luid {
-       color: #AAA391;
-       padding-right: 10px;
-       padding-left: 10px;
-}
-
-.book_line .title {
-       color: initial;
-}
-
-.book_line .author {
-       float: right;
-       color: blue;
-}
-
-.bar {
-       height: 64px;
-       width: 100%;
-       display: block;
-       background: white;
-       position: fixed;
-       margin: auto;
-}
-
-.bar.navbar       { padding-left: calc(50% - (5 * (64px + 5px) / 2)); }
-
-.bar.optionbar.s1 { padding-left: calc(50% - (1 * (64px + 5px) / 2)); }
-.bar.optionbar.s4 { padding-left: calc(50% - (4 * (64px + 5px) / 2)); }
-
-.bar.optionbar {
-       bottom: 0;
-}
-
-.bar .button {
-       height: 54px;
-       width: 54px;
-       line-height: 54px;
-       margin: 5px;
-}
-
-.bar .gotobox {
-       display: inline-block;
-}
-
-.bar .gotobox .items {
-       background-color: white;
-       border: 1px solid gray;
-       border-radius: 5px;
-       width: 196px;
-       margin: -5px;
-       margin-left: -66px;
-       max-height: 400px;
-}
-
-.bar .button.first    { color: transparent; background-image: url('/arrow_double_left-32x32.png');  }
-.bar .button.previous { color: transparent; background-image: url('/arrow_left-32x32.png');         }
-.bar .button.next     { color: transparent; background-image: url('/arrow_right-32x32.png');        }
-.bar .button.last     { color: transparent; background-image: url('/arrow_double_right-32x32.png'); }
-
-.bar .button.back       { color: transparent; background-image: url('/back-32x32.png');          }
-.bar .button.zoomreal   { color: transparent; background-image: url('/actual_size-32x32.png');   }
-.bar .button.zoomwidth  { color: transparent; background-image: url('/fit_to_width-32x32.png');  }
-.bar .button.zoomheight { color: transparent; background-image: url('/fit_to_height-32x32.png'); }
-
-.viewer {
-       padding-top: 64px;
-       padding-bottom: 64px;
-}
-
-.viewer .title {
-       background-color: #AAA391;
-       text-align: center;
-       font-variant: small-caps;
-       color: white;
-}
-
-.viewer.img {
-       margin: auto;
-       display: block;
-}
-
-a.viewer.link:hover {
-       background-color: transparent;
-}
-
-.viewer.text {
-       padding-left: 10px;
-       padding-right: 10px;
-}
-
-.desc {
-       display: flex;
-       flex-direction: column;
-       padding-top: 10px;
-}
-
-.desc .cover {
-       max-height: 280px;
-       overflow: hidden;
-}
-
-.desc .cover img {
-       max-width: 80%;
-       margin: auto;
-       display: block;
-}
-
-.desc .details .col1 {
-       font-weight: bold;
-       padding-left: 5px;
-       padding-right: 5px; 
-}
-
-.desc .details .col2 {
-       color: #AAA391;
-}
-
-@media(min-width: 800px) {
-        .main {
-               max-width: 800px;
-        }
-        
-       .bar.navbar       { padding-left: calc(400px - (5 * (64px + 5px) / 2)); }
-       
-       .bar.optionbar.s1 { padding-left: calc(400px - (1 * (64px + 5px) / 2)); }
-       .bar.optionbar.s4 { padding-left: calc(400px - (4 * (64px + 5px) / 2)); }
-       
-       .desc { flex-direction: row; }
-       .desc .cover img {
-               width: 200px;
-               padding-right: 10px;
-       }
-}
diff --git a/library/web/unknown-32x32.png b/library/web/unknown-32x32.png
deleted file mode 100644 (file)
index d2315d1..0000000
Binary files a/library/web/unknown-32x32.png and /dev/null differ
diff --git a/library/web/unknown-64x64.png b/library/web/unknown-64x64.png
deleted file mode 100644 (file)
index 261889d..0000000
Binary files a/library/web/unknown-64x64.png and /dev/null differ
diff --git a/libs.txt b/libs.txt
deleted file mode 100644 (file)
index 091daf5..0000000
--- a/libs.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# Required librairies:
-
-They are available in the other branches if needed:
-- libs/jsoup-1.10.3-sources.jar
-- libs/unbescape-1.1.4-sources.jar
-
diff --git a/main/bridge.java b/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/main/img2aa.java b/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/main/justify.java b/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/output/BasicOutput.java b/output/BasicOutput.java
deleted file mode 100644 (file)
index 41634fa..0000000
+++ /dev/null
@@ -1,553 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.Version;
-
-/**
- * This class is the base class used by the other output classes. It can be used
- * outside of this package, and have static method that you can use to get
- * access to the correct support class.
- * 
- * @author niki
- */
-public abstract class BasicOutput {
-       /**
-        * The supported output types for which we can get a {@link BasicOutput}
-        * object.
-        * 
-        * @author niki
-        */
-       public enum OutputType {
-               /** EPUB files created with this program */
-               EPUB,
-               /** Pure text file with some rules */
-               TEXT,
-               /** TEXT but with associated .info file */
-               INFO_TEXT,
-               /** DEBUG output to console */
-               SYSOUT,
-               /** ZIP with (PNG) images */
-               CBZ,
-               /** LaTeX file with "book" template */
-               LATEX,
-               /** HTML files in a dedicated directory */
-               HTML,
-
-               ;
-
-               @Override
-               public String toString() {
-                       return super.toString().toLowerCase();
-               }
-
-               /**
-                * A description of this output type.
-                * 
-                * @param longDesc
-                *            TRUE for the long description, FALSE for the short one
-                * 
-                * @return the description
-                */
-               public String getDesc(boolean longDesc) {
-                       StringId id = longDesc ? StringId.OUTPUT_DESC
-                                       : StringId.OUTPUT_DESC_SHORT;
-
-                       String desc = Instance.getInstance().getTrans().getStringX(id, this.name());
-
-                       if (desc == null) {
-                               desc = Instance.getInstance().getTrans().getString(id, this.toString());
-                       }
-
-                       if (desc == null || desc.isEmpty()) {
-                               desc = this.toString();
-                       }
-
-                       return desc;
-               }
-
-               /**
-                * The default extension to add to the output files.
-                * 
-                * @param readerTarget
-                *            TRUE to point to the main {@link Story} entry point for a
-                *            reader (for instance, the main entry point if this
-                *            {@link Story} is in a directory bundle), FALSE to point to
-                *            the main file even if it is a directory for instance
-                * 
-                * @return the extension
-                */
-               public String getDefaultExtension(boolean readerTarget) {
-                       BasicOutput output = BasicOutput.getOutput(this, false, false);
-                       if (output != null) {
-                               return output.getDefaultExtension(readerTarget);
-                       }
-
-                       return null;
-               }
-
-               /**
-                * Call {@link OutputType#valueOf(String)} after conversion to upper
-                * case.
-                * 
-                * @param typeName
-                *            the possible type name
-                * 
-                * @return NULL or the type
-                */
-               public static OutputType valueOfUC(String typeName) {
-                       return OutputType.valueOf(typeName == null ? null : typeName
-                                       .toUpperCase());
-               }
-
-               /**
-                * Call {@link OutputType#valueOf(String)} after conversion to upper
-                * case but return def for NULL and empty instead of raising an
-                * exception.
-                * 
-                * @param typeName
-                *            the possible type name
-                * @param def
-                *            the default value
-                * 
-                * @return NULL or the type
-                */
-               public static OutputType valueOfNullOkUC(String typeName, OutputType def) {
-                       if (typeName == null || typeName.isEmpty()) {
-                               return def;
-                       }
-
-                       return OutputType.valueOfUC(typeName);
-               }
-
-               /**
-                * Call {@link OutputType#valueOf(String)} after conversion to upper
-                * case but return def in case of error instead of raising an exception.
-                * 
-                * @param typeName
-                *            the possible type name
-                * @param def
-                *            the default value
-                * 
-                * @return NULL or the type
-                */
-               public static OutputType valueOfAllOkUC(String typeName, OutputType def) {
-                       try {
-                               return OutputType.valueOfUC(typeName);
-                       } catch (Exception e) {
-                               return def;
-                       }
-               }
-       }
-
-       /** The creator name (this program, by me!) */
-       static protected final String EPUB_CREATOR = "Fanfix "
-                       + Version.getCurrentVersion() + " (by Niki)";
-
-       /** The current best name for an image */
-       private String imageName;
-       private File targetDir;
-       private String targetName;
-       private OutputType type;
-       private boolean writeCover;
-       private boolean writeInfo;
-       private Progress storyPg;
-       private Progress chapPg;
-
-       /**
-        * Process the {@link Story} into the given target.
-        * 
-        * @param story
-        *            the {@link Story} to export
-        * @param target
-        *            the target where to save to (will not necessary be taken as is
-        *            by the processor, for instance an extension can be added)
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the actual main target saved, which can be slightly different
-        *         that the input one
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public File process(Story story, String target, Progress pg)
-                       throws IOException {
-               storyPg = pg;
-
-               File targetDir = null;
-               String targetName = null;
-               if (target != null) {
-                       target = new File(target).getAbsolutePath();
-                       targetDir = new File(target).getParentFile();
-                       targetName = new File(target).getName();
-
-                       String ext = getDefaultExtension(false);
-                       if (ext != null && !ext.isEmpty()) {
-                               if (targetName.toLowerCase().endsWith(ext)) {
-                                       targetName = targetName.substring(0, targetName.length()
-                                                       - ext.length());
-                               }
-                       }
-               }
-
-               return process(story, targetDir, targetName);
-       }
-
-       /**
-        * Process the {@link Story} into the given target.
-        * <p>
-        * This method is expected to be overridden in most cases.
-        * 
-        * @param story
-        *            the {@link Story} to export
-        * @param targetDir
-        *            the target dir where to save to
-        * @param targetName
-        *            the target filename (will not necessary be taken as is by the
-        *            processor, for instance an extension can be added)
-        * 
-        * 
-        * @return the actual main target saved, which can be slightly different
-        *         that the input one
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected File process(Story story, File targetDir, String targetName)
-                       throws IOException {
-               this.targetDir = targetDir;
-               this.targetName = targetName;
-
-               writeStory(story);
-
-               return null;
-       }
-
-       /**
-        * The output type.
-        * 
-        * @return the type
-        */
-       public OutputType getType() {
-               return type;
-       }
-
-       /**
-        * Enable the creation of a .info file next to the resulting processed file.
-        * 
-        * @return TRUE to enable it
-        */
-       protected boolean isWriteInfo() {
-               return writeInfo;
-       }
-
-       /**
-        * Enable the creation of a cover file next to the resulting processed file
-        * if possible.
-        * 
-        * @return TRUE to enable it
-        */
-       protected boolean isWriteCover() {
-               return writeCover;
-       }
-
-       /**
-        * The output type.
-        * 
-        * @param type
-        *            the new type
-        * @param writeCover
-        *            TRUE to enable the creation of a cover if possible
-        * @param writeInfo
-        *            TRUE to enable the creation of a .info file
-        * 
-        * @return this
-        */
-       protected BasicOutput setType(OutputType type, boolean writeInfo,
-                       boolean writeCover) {
-               this.type = type;
-               this.writeInfo = writeInfo;
-               this.writeCover = writeCover;
-
-               return this;
-       }
-
-       /**
-        * The default extension to add to the output files.
-        * 
-        * @param readerTarget
-        *            TRUE to point to the main {@link Story} entry point for a
-        *            reader (for instance, the main entry point if this
-        *            {@link Story} is in a directory bundle), FALSE to point to the
-        *            main file even if it is a directory for instance
-        * 
-        * @return the extension
-        */
-       public String getDefaultExtension(
-                       @SuppressWarnings("unused") boolean readerTarget) {
-               return "";
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeStoryHeader(Story story) throws IOException {
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeChapterHeader(Chapter chap) throws IOException {
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeParagraphHeader(Paragraph para) throws IOException {
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeStoryFooter(Story story) throws IOException {
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeChapterFooter(Chapter chap) throws IOException {
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeParagraphFooter(Paragraph para) throws IOException {
-       }
-
-       protected void writeStory(Story story) throws IOException {
-               if (storyPg == null) {
-                       storyPg = new Progress(0, story.getChapters().size() + 2);
-               } else {
-                       storyPg.setMinMax(0, story.getChapters().size() + 2);
-               }
-
-               String chapterNameNum = String.format("%03d", 0);
-               String paragraphNumber = String.format("%04d", 0);
-               imageName = paragraphNumber + "_" + chapterNameNum;
-
-               if (story.getMeta() != null) {
-                       story.getMeta().setType("" + getType());
-               }
-
-               if (isWriteCover()) {
-                       InfoCover.writeCover(targetDir, targetName, story.getMeta());
-               }
-               if (isWriteInfo()) {
-                       InfoCover.writeInfo(targetDir, targetName, story.getMeta());
-               }
-
-               storyPg.setProgress(1);
-
-               List<Progress> chapPgs = new ArrayList<Progress>(story.getChapters()
-                               .size());
-               for (Chapter chap : story) {
-                       chapPg = new Progress(0, chap.getParagraphs().size());
-                       storyPg.addProgress(chapPg, 1);
-                       chapPgs.add(chapPg);
-                       chapPg = null;
-               }
-
-               writeStoryHeader(story);
-               for (int i = 0; i < story.getChapters().size(); i++) {
-                       chapPg = chapPgs.get(i);
-                       writeChapter(story.getChapters().get(i));
-                       chapPg.setProgress(chapPg.getMax());
-                       chapPg = null;
-               }
-               writeStoryFooter(story);
-
-               storyPg.setProgress(storyPg.getMax());
-               storyPg = null;
-       }
-
-       protected void writeChapter(Chapter chap) throws IOException {
-               String chapterNameNum;
-               if (chap.getName() == null || chap.getName().isEmpty()) {
-                       chapterNameNum = String.format("%03d", chap.getNumber());
-               } else {
-                       chapterNameNum = String.format("%03d", chap.getNumber()) + "_"
-                                       + chap.getName().replace(" ", "_");
-               }
-
-               int num = 0;
-               String paragraphNumber = String.format("%04d", num++);
-               imageName = chapterNameNum + "_" + paragraphNumber;
-
-               writeChapterHeader(chap);
-               int i = 1;
-               for (Paragraph para : chap) {
-                       paragraphNumber = String.format("%04d", num++);
-                       imageName = chapterNameNum + "_" + paragraphNumber;
-                       writeParagraph(para);
-                       if (chapPg != null) {
-                               chapPg.setProgress(i++);
-                       }
-               }
-               writeChapterFooter(chap);
-       }
-
-       protected void writeParagraph(Paragraph para) throws IOException {
-               writeParagraphHeader(para);
-               writeTextLine(para.getType(), para.getContent());
-               writeParagraphFooter(para);
-       }
-
-       @SuppressWarnings("unused")
-       protected void writeTextLine(ParagraphType type, String line)
-                       throws IOException {
-       }
-
-       /**
-        * Return the current best guess for an image name, based upon the current
-        * {@link Chapter} and {@link Paragraph}.
-        * 
-        * @param prefix
-        *            add the original target name as a prefix
-        * 
-        * @return the guessed name
-        */
-       protected String getCurrentImageBestName(boolean prefix) {
-               if (prefix) {
-                       return targetName + "_" + imageName;
-               }
-
-               return imageName;
-       }
-
-       /**
-        * Return the given word or sentence as <b>bold</b>.
-        * 
-        * @param word
-        *            the input
-        * 
-        * @return the bold output
-        */
-       protected String enbold(String word) {
-               return word;
-       }
-
-       /**
-        * Return the given word or sentence as <i>italic</i>.
-        * 
-        * @param word
-        *            the input
-        * 
-        * @return the italic output
-        */
-       protected String italize(String word) {
-               return word;
-       }
-
-       /**
-        * Decorate the given text with <b>bold</b> and <i>italic</i> words,
-        * according to {@link BasicOutput#enbold(String)} and
-        * {@link BasicOutput#italize(String)}.
-        * 
-        * @param text
-        *            the input
-        * 
-        * @return the decorated output
-        */
-       protected String decorateText(String text) {
-               StringBuilder builder = new StringBuilder();
-
-               int bold = -1;
-               int italic = -1;
-               char prev = '\0';
-               for (char car : text.toCharArray()) {
-                       switch (car) {
-                       case '*':
-                               if (bold >= 0 && prev != ' ') {
-                                       String data = builder.substring(bold);
-                                       builder.setLength(bold);
-                                       builder.append(enbold(data));
-                                       bold = -1;
-                               } else if (bold < 0
-                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
-                                       bold = builder.length();
-                               } else {
-                                       builder.append(car);
-                               }
-
-                               break;
-                       case '_':
-                               if (italic >= 0 && prev != ' ') {
-                                       String data = builder.substring(italic);
-                                       builder.setLength(italic);
-                                       builder.append(enbold(data));
-                                       italic = -1;
-                               } else if (italic < 0
-                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
-                                       italic = builder.length();
-                               } else {
-                                       builder.append(car);
-                               }
-
-                               break;
-                       default:
-                               builder.append(car);
-                               break;
-                       }
-
-                       prev = car;
-               }
-
-               if (bold >= 0) {
-                       builder.insert(bold, '*');
-               }
-
-               if (italic >= 0) {
-                       builder.insert(italic, '_');
-               }
-
-               return builder.toString();
-       }
-
-       /**
-        * Return a {@link BasicOutput} object compatible with the given
-        * {@link OutputType}.
-        * 
-        * @param type
-        *            the type
-        * @param writeCover
-        *            TRUE to enable the creation of a cover if possible to be saved
-        *            next to the main target file
-        * @param writeInfo
-        *            TRUE to enable the creation of a .info file to be saved next
-        *            to the main target file
-        * 
-        * @return the {@link BasicOutput}
-        */
-       public static BasicOutput getOutput(OutputType type, boolean writeInfo,
-                       boolean writeCover) {
-               if (type != null) {
-                       switch (type) {
-                       case EPUB:
-                               return new Epub().setType(type, writeInfo, writeCover);
-                       case TEXT:
-                               return new Text().setType(type, writeInfo, true);
-                       case INFO_TEXT:
-                               return new InfoText().setType(type, true, true);
-                       case SYSOUT:
-                               return new Sysout().setType(type, false, false);
-                       case CBZ:
-                               return new Cbz().setType(type, writeInfo, writeCover);
-                       case LATEX:
-                               return new LaTeX().setType(type, writeInfo, writeCover);
-                       case HTML:
-                               return new Html().setType(type, writeInfo, writeCover);
-                       }
-               }
-
-               return null;
-       }
-}
diff --git a/output/Cbz.java b/output/Cbz.java
deleted file mode 100644 (file)
index ee671e7..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.IOUtils;
-
-class Cbz extends BasicOutput {
-       private File dir;
-
-       @Override
-       public File process(Story story, File targetDir, String targetName)
-                       throws IOException {
-               String targetNameOrig = targetName;
-               targetName += getDefaultExtension(false);
-
-               File target = new File(targetDir, targetName);
-
-               dir = Instance.getInstance().getTempFiles().createTempDir("fanfic-reader-cbz-dir");
-               try {
-                       // will also save the images! (except the cover -> false)
-                       BasicOutput
-                                       .getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
-                                       // Force cover to FALSE:
-                                       .setType(OutputType.TEXT, isWriteInfo(), false)
-                                       .process(story, dir, targetNameOrig);
-
-                       try {
-                               super.process(story, targetDir, targetNameOrig);
-                       } finally {
-                       }
-
-                       InfoCover.writeInfo(dir, targetNameOrig, story.getMeta());
-                       if (story.getMeta() != null && !story.getMeta().isFakeCover()) {
-                               InfoCover.writeCover(dir, targetNameOrig, story.getMeta());
-                       }
-
-                       IOUtils.writeSmallFile(dir, "version", "3.0");
-
-                       IOUtils.zip(dir, target, true);
-               } finally {
-                       IOUtils.deltree(dir);
-               }
-
-               return target;
-       }
-
-       @Override
-       public String getDefaultExtension(boolean readerTarget) {
-               return ".cbz";
-       }
-
-       @Override
-       protected void writeStoryHeader(Story story) throws IOException {
-               MetaData meta = story.getMeta();
-
-               StringBuilder builder = new StringBuilder();
-               if (meta != null && meta.getResume() != null) {
-                       for (Paragraph para : story.getMeta().getResume()) {
-                               builder.append(para.getContent());
-                               builder.append("\n");
-                       }
-               }
-
-               BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
-                               new FileOutputStream(new File(dir, "URL")), "UTF-8"));
-               try {
-                       if (meta != null) {
-                               writer.write(meta.getUrl());
-                       }
-               } finally {
-                       writer.close();
-               }
-
-               writer = new BufferedWriter(new OutputStreamWriter(
-                               new FileOutputStream(new File(dir, "SUMMARY")), "UTF-8"));
-               try {
-                       String title = "";
-                       if (meta != null && meta.getTitle() != null) {
-                               title = meta.getTitle();
-                       }
-
-                       writer.write(title);
-                       if (meta != null && meta.getAuthor() != null) {
-                               writer.write("\n©");
-                               writer.write(meta.getAuthor());
-                       }
-                       writer.write("\n\n");
-                       writer.write(builder.toString());
-               } finally {
-                       writer.close();
-               }
-       }
-}
diff --git a/output/Epub.java b/output/Epub.java
deleted file mode 100644 (file)
index fc2dc8c..0000000
+++ /dev/null
@@ -1,514 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.BufferedWriter;
-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.io.OutputStreamWriter;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.StringUtils;
-
-class Epub extends BasicOutput {
-       private File tmpDir;
-       private BufferedWriter writer;
-       private boolean inDialogue = false;
-       private boolean inNormal = false;
-       private File images;
-       private boolean nextParaIsCover = true;
-
-       @Override
-       public File process(Story story, File targetDir, String targetName)
-                       throws IOException {
-               String targetNameOrig = targetName;
-               targetName += getDefaultExtension(false);
-
-               tmpDir = Instance.getInstance().getTempFiles().createTempDir("fanfic-reader-epub");
-               tmpDir.delete();
-
-               if (!tmpDir.mkdir()) {
-                       throw new IOException(
-                                       "Cannot create a temporary directory: no space left on device?");
-               }
-
-               super.process(story, targetDir, targetNameOrig);
-
-               File epub = null;
-               try {
-                       // "Originals"
-                       File data = new File(tmpDir, "DATA");
-                       data.mkdir();
-                       BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(),
-                                       isWriteCover()).process(story, data, targetNameOrig);
-                       InfoCover.writeInfo(data, targetNameOrig, story.getMeta());
-                       IOUtils.writeSmallFile(data, "version", "3.0");
-
-                       // zip/epub
-                       epub = new File(targetDir, targetName);
-                       IOUtils.zip(tmpDir, epub, true);
-
-                       OutputStream out = new FileOutputStream(epub);
-                       try {
-                               ZipOutputStream zip = new ZipOutputStream(out);
-                               try {
-                                       // "mimetype" MUST be the first element and not compressed
-                                       zip.setLevel(ZipOutputStream.STORED);
-                                       File mimetype = new File(tmpDir, "mimetype");
-                                       IOUtils.writeSmallFile(tmpDir, "mimetype",
-                                                       "application/epub+zip");
-                                       ZipEntry entry = new ZipEntry("mimetype");
-                                       entry.setExtra(new byte[] {});
-                                       zip.putNextEntry(entry);
-                                       FileInputStream in = new FileInputStream(mimetype);
-                                       try {
-                                               IOUtils.write(in, zip);
-                                       } finally {
-                                               in.close();
-                                       }
-                                       IOUtils.deltree(mimetype);
-                                       zip.setLevel(ZipOutputStream.DEFLATED);
-                                       //
-
-                                       IOUtils.zip(zip, "", tmpDir, true);
-                               } finally {
-                                       zip.close();
-                               }
-                       } finally {
-                               out.close();
-                       }
-               } finally {
-                       IOUtils.deltree(tmpDir);
-                       tmpDir = null;
-               }
-
-               return epub;
-       }
-
-       @Override
-       public String getDefaultExtension(boolean readerTarget) {
-               return ".epub";
-       }
-
-       @Override
-       protected void writeStoryHeader(Story story) throws IOException {
-               File ops = new File(tmpDir, "OPS");
-               ops.mkdirs();
-               File css = new File(ops, "css");
-               css.mkdirs();
-               images = new File(ops, "images");
-               images.mkdirs();
-               File metaInf = new File(tmpDir, "META-INF");
-               metaInf.mkdirs();
-
-               // META-INF
-               String containerContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
-                               + "<container xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\" version=\"1.0\">\n"
-                               + "\t<rootfiles>\n"
-                               + "\t\t<rootfile full-path=\"OPS/epb.opf\" media-type=\"application/oebps-package+xml\"/>\n"
-                               + "\t</rootfiles>\n" + "</container>\n";
-
-               IOUtils.writeSmallFile(metaInf, "container.xml", containerContent);
-
-               // OPS/css
-               InputStream inStyle = getClass().getResourceAsStream("epub.style.css");
-               if (inStyle == null) {
-                       throw new IOException("Cannot find style.css resource");
-               }
-               try {
-                       IOUtils.write(inStyle, new File(css, "style.css"));
-               } finally {
-                       inStyle.close();
-               }
-
-               // OPS/images
-               if (story.getMeta() != null && story.getMeta().getCover() != null) {
-                       File file = new File(images, "cover");
-                       try {
-                               Instance.getInstance().getCache().saveAsImage(story.getMeta().getCover(), file, true);
-                       } catch (Exception e) {
-                               Instance.getInstance().getTraceHandler().error(e);
-                       }
-               }
-
-               // OPS/* except chapters
-               IOUtils.writeSmallFile(ops, "epb.ncx", generateNcx(story));
-               IOUtils.writeSmallFile(ops, "epb.opf", generateOpf(story));
-               IOUtils.writeSmallFile(ops, "title.xhtml", generateTitleXml(story));
-
-               // Resume
-               if (story.getMeta() != null && story.getMeta().getResume() != null) {
-                       writeChapter(story.getMeta().getResume());
-               }
-       }
-
-       @Override
-       protected void writeChapterHeader(Chapter chap) throws IOException {
-               String filename = String.format("%s%03d%s", "chapter-",
-                               chap.getNumber(), ".xhtml");
-               writer = new BufferedWriter(new OutputStreamWriter(
-                               new FileOutputStream(new File(tmpDir + File.separator + "OPS",
-                                               filename)), "UTF-8"));
-               inDialogue = false;
-               inNormal = false;
-               try {
-                       String title = "Chapter " + chap.getNumber();
-                       String nameOrNum = Integer.toString(chap.getNumber());
-                       if (chap.getName() != null && !chap.getName().isEmpty()) {
-                               title += ": " + chap.getName();
-                               nameOrNum = chap.getName();
-                       }
-
-                       writer.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
-                       writer.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
-                       writer.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
-                       writer.write("\n<head>");
-                       writer.write("\n        <title>" + StringUtils.xmlEscape(title)
-                                       + "</title>");
-                       writer.write("\n        <link rel='stylesheet' href='css/style.css' type='text/css'/>");
-                       writer.write("\n</head>");
-                       writer.write("\n<body>");
-                       writer.write("\n        <h2>");
-                       writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
-                                       + chap.getNumber() + "</span>:</span> ");
-                       writer.write("\n                <span class='chaptitle'>"
-                                       + StringUtils.xmlEscape(nameOrNum) + "</span>");
-                       writer.write("\n        </h2>");
-                       writer.write("\n        ");
-                       writer.write("\n        <div class='chapter_content'>\n");
-               } catch (Exception e) {
-                       writer.close();
-                       throw new IOException(e);
-               }
-       }
-
-       @Override
-       protected void writeChapterFooter(Chapter chap) throws IOException {
-               try {
-                       if (inDialogue) {
-                               writer.write("          </div>\n");
-                               inDialogue = false;
-                       }
-                       if (inNormal) {
-                               writer.write("          </div>\n");
-                               inNormal = false;
-                       }
-                       writer.write("  </div>\n</body>\n</html>\n");
-               } finally {
-                       writer.close();
-                       writer = null;
-               }
-       }
-
-       @Override
-       protected void writeParagraphHeader(Paragraph para) throws IOException {
-               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
-                       writer.write("          <div class='dialogues'>\n");
-                       inDialogue = true;
-               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
-                       writer.write("          </div>\n");
-                       inDialogue = false;
-               }
-
-               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
-                       writer.write("          <div class='normals'>\n");
-                       inNormal = true;
-               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
-                       writer.write("          </div>\n");
-                       inNormal = false;
-               }
-
-               switch (para.getType()) {
-               case BLANK:
-                       writer.write("          <div class='blank'></div>");
-                       break;
-               case BREAK:
-                       writer.write("          <hr class='break'/>");
-                       break;
-               case NORMAL:
-                       writer.write("          <span class='normal'>");
-                       break;
-               case QUOTE:
-                       writer.write("                  <div class='dialogue'>&mdash; ");
-                       break;
-               case IMAGE:
-                       File file = new File(images, getCurrentImageBestName(false));
-                       Instance.getInstance().getCache().saveAsImage(para.getContentImage(), file, nextParaIsCover);
-                       writer.write("                  <img alt='page image' class='page-image' src='images/"
-                                       + getCurrentImageBestName(false) + "'/>");
-                       break;
-               }
-
-               nextParaIsCover = false;
-       }
-
-       @Override
-       protected void writeParagraphFooter(Paragraph para) throws IOException {
-               switch (para.getType()) {
-               case NORMAL:
-                       writer.write("</span>\n");
-                       break;
-               case QUOTE:
-                       writer.write("</div>\n");
-                       break;
-               default:
-                       writer.write("\n");
-                       break;
-               }
-       }
-
-       @Override
-       protected void writeTextLine(ParagraphType type, String line)
-                       throws IOException {
-               switch (type) {
-               case QUOTE:
-               case NORMAL:
-                       writer.write(decorateText(StringUtils.xmlEscape(line)));
-                       break;
-               default:
-                       break;
-               }
-       }
-
-       @Override
-       protected String enbold(String word) {
-               return "<strong>" + word + "</strong>";
-       }
-
-       @Override
-       protected String italize(String word) {
-               return "<emph>" + word + "</emph>";
-       }
-
-       private String generateNcx(Story story) {
-               StringBuilder builder = new StringBuilder();
-
-               String title = "";
-               String uuid = "";
-               String author = "";
-               if (story.getMeta() != null) {
-                       MetaData meta = story.getMeta();
-                       uuid = meta.getUuid();
-                       author = meta.getAuthor();
-                       title = meta.getTitle();
-               }
-
-               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
-               builder.append("\n<!DOCTYPE ncx");
-               builder.append("\nPUBLIC \"-//NISO//DTD ncx 2005-1//EN\" \"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd\">");
-               builder.append("\n<ncx xmlns=\"http://www.daisy.org/z3986/2005/ncx/\" version=\"2005-1\">");
-               builder.append("\n      <head>");
-               builder.append("\n              <!--The following four metadata items are required for all");
-               builder.append("\n                  NCX documents, including those conforming to the relaxed");
-               builder.append("\n                  constraints of OPS 2.0-->");
-               builder.append("\n              <meta name=\"dtb:uid\" content=\""
-                               + StringUtils.xmlEscapeQuote(uuid) + "\"/>");
-               builder.append("\n              <meta name=\"dtb:depth\" content=\"1\"/>");
-               builder.append("\n              <meta name=\"dtb:totalPageCount\" content=\"0\"/>");
-               builder.append("\n              <meta name=\"dtb:maxPageNumber\" content=\"0\"/>");
-               builder.append("\n              <meta name=\"epub-creator\" content=\""
-                               + StringUtils.xmlEscapeQuote(EPUB_CREATOR) + "\"/>");
-               builder.append("\n      </head>");
-               builder.append("\n      <docTitle>");
-               builder.append("\n              <text>" + StringUtils.xmlEscape(title) + "</text>");
-               builder.append("\n      </docTitle>");
-               builder.append("\n      <docAuthor>");
-
-               builder.append("\n              <text>" + StringUtils.xmlEscape(author) + "</text>");
-               builder.append("\n      </docAuthor>");
-               builder.append("\n      <navMap>");
-               builder.append("\n              <navPoint id=\"navpoint-1\" playOrder=\"1\">");
-               builder.append("\n                      <navLabel>");
-               builder.append("\n                              <text>Title Page</text>");
-               builder.append("\n                      </navLabel>");
-               builder.append("\n                      <content src=\"title.xhtml\"/>");
-               builder.append("\n              </navPoint>");
-
-               int navPoint = 2; // 1 is above
-
-               if (story.getMeta() != null & story.getMeta().getResume() != null) {
-                       Chapter chap = story.getMeta().getResume();
-                       generateNcx(chap, builder, navPoint++);
-               }
-
-               for (Chapter chap : story) {
-                       generateNcx(chap, builder, navPoint++);
-               }
-
-               builder.append("\n      </navMap>");
-               builder.append("\n</ncx>\n");
-
-               return builder.toString();
-       }
-
-       private void generateNcx(Chapter chap, StringBuilder builder, int navPoint) {
-               String name;
-               if (chap.getName() != null && !chap.getName().isEmpty()) {
-                       name = Instance.getInstance().getTrans().getString(StringId.CHAPTER_NAMED, chap.getNumber(),
-                                       chap.getName());
-               } else {
-                       name = Instance.getInstance().getTrans().getString(StringId.CHAPTER_UNNAMED, chap.getNumber());
-               }
-
-               String nnn = String.format("%03d", (navPoint - 2));
-
-               builder.append("\n              <navPoint id=\"navpoint-" + navPoint
-                               + "\" playOrder=\"" + navPoint + "\">");
-               builder.append("\n                      <navLabel>");
-               builder.append("\n                              <text>" + name + "</text>");
-               builder.append("\n                      </navLabel>");
-               builder.append("\n                      <content src=\"chapter-" + nnn + ".xhtml\"/>");
-               builder.append("\n              </navPoint>\n");
-       }
-
-       private String generateOpf(Story story) {
-               StringBuilder builder = new StringBuilder();
-
-               String title = "";
-               String uuid = "";
-               String author = "";
-               String date = "";
-               String publisher = "";
-               String subject = "";
-               String source = "";
-               String lang = "";
-               if (story.getMeta() != null) {
-                       MetaData meta = story.getMeta();
-                       title = meta.getTitle();
-                       uuid = meta.getUuid();
-                       author = meta.getAuthor();
-                       date = meta.getDate();
-                       publisher = meta.getPublisher();
-                       subject = meta.getSubject();
-                       source = meta.getSource();
-                       lang = meta.getLang();
-               }
-
-               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
-               builder.append("\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"BookId\" version=\"2.0\">");
-               builder.append("\n   <metadata xmlns:opf=\"http://www.idpf.org/2007/opf\"");
-               builder.append("\n             xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
-               builder.append("\n      <dc:title>" + StringUtils.xmlEscape(title)
-                               + "</dc:title>");
-               builder.append("\n      <dc:creator opf:role=\"aut\" opf:file-as=\""
-                               + StringUtils.xmlEscapeQuote(author) + "\">"
-                               + StringUtils.xmlEscape(author) + "</dc:creator>");
-               builder.append("\n      <dc:date opf:event=\"original-publication\">"
-                               + StringUtils.xmlEscape(date) + "</dc:date>");
-               builder.append("\n      <dc:publisher>"
-                               + StringUtils.xmlEscape(publisher) + "</dc:publisher>");
-               builder.append("\n      <dc:date opf:event=\"epub-publication\"></dc:date>");
-               builder.append("\n      <dc:subject>" + StringUtils.xmlEscape(subject)
-                               + "</dc:subject>");
-               builder.append("\n      <dc:source>" + StringUtils.xmlEscape(source)
-                               + "</dc:source>");
-               builder.append("\n      <dc:rights>Not for commercial use.</dc:rights>");
-               builder.append("\n      <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"
-                               + StringUtils.xmlEscape(uuid) + "</dc:identifier>");
-               builder.append("\n      <dc:language>" + StringUtils.xmlEscape(lang)
-                               + "</dc:language>");
-               builder.append("\n   </metadata>");
-               builder.append("\n   <manifest>");
-               builder.append("\n      <!-- Content Documents -->");
-               builder.append("\n      <item id=\"titlepage\" href=\"title.xhtml\" media-type=\"application/xhtml+xml\"/>");
-               for (int i = 0; i <= story.getChapters().size(); i++) {
-                       String name = String.format("%s%03d", "chapter-", i);
-                       builder.append("\n      <item id=\""
-                                       + StringUtils.xmlEscapeQuote(name) + "\" href=\""
-                                       + StringUtils.xmlEscapeQuote(name)
-                                       + ".xhtml\" media-type=\"application/xhtml+xml\"/>");
-               }
-
-               builder.append("\n      <!-- CSS Style Sheets -->");
-               builder.append("\n      <item id=\"style-css\" href=\"css/style.css\" media-type=\"text/css\"/>");
-
-               builder.append("\n      <!-- Images -->");
-
-               if (story.getMeta() != null && story.getMeta().getCover() != null) {
-                       String format = Instance.getInstance().getConfig()
-                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
-                                       .toLowerCase();
-                       builder.append("\n      <item id=\"cover\" href=\"images/cover."
-                                       + format + "\" media-type=\"image/png\"/>");
-               }
-
-               builder.append("\n      <!-- NCX -->");
-               builder.append("\n      <item id=\"ncx\" href=\"epb.ncx\" media-type=\"application/x-dtbncx+xml\"/>");
-               builder.append("\n   </manifest>");
-               builder.append("\n   <spine toc=\"ncx\">");
-               builder.append("\n      <itemref idref=\"titlepage\" linear=\"yes\"/>");
-               for (int i = 0; i <= story.getChapters().size(); i++) {
-                       String name = String.format("%s%03d", "chapter-", i);
-                       builder.append("\n      <itemref idref=\""
-                                       + StringUtils.xmlEscapeQuote(name) + "\" linear=\"yes\"/>");
-               }
-               builder.append("\n   </spine>");
-               builder.append("\n</package>\n");
-
-               return builder.toString();
-       }
-
-       private String generateTitleXml(Story story) {
-               StringBuilder builder = new StringBuilder();
-
-               String title = "";
-               String tags = "";
-               String author = "";
-               if (story.getMeta() != null) {
-                       MetaData meta = story.getMeta();
-                       title = meta.getTitle();
-                       if (meta.getTags() != null) {
-                               for (String tag : meta.getTags()) {
-                                       if (!tags.isEmpty()) {
-                                               tags += ", ";
-                                       }
-                                       tags += tag;
-                               }
-
-                               if (!tags.isEmpty()) {
-                                       tags = "(" + tags + ")";
-                               }
-                       }
-                       author = meta.getAuthor();
-               }
-
-               String format = Instance.getInstance().getConfig()
-                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-
-               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
-               builder.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
-               builder.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
-               builder.append("\n<head>");
-               builder.append("\n      <title>" + StringUtils.xmlEscape(title) + "</title>");
-               builder.append("\n      <link rel=\"stylesheet\" href=\"css/style.css\" type=\"text/css\"/>");
-               builder.append("\n</head>");
-               builder.append("\n<body>");
-               builder.append("\n      <div class=\"titlepage\">");
-               builder.append("\n              <h1>" + StringUtils.xmlEscape(title) + "</h1>");
-               builder.append("\n                      <div class=\"type\">"
-                               + StringUtils.xmlEscape(tags) + "</div>");
-               builder.append("\n              <div class=\"cover\">");
-               builder.append("\n                      <img alt=\"cover image\" src=\"images/cover."
-                               + format + "\"></img>");
-               builder.append("\n              </div>");
-               builder.append("\n              <div class=\"author\">"
-                               + StringUtils.xmlEscape(author) + "</div>");
-               builder.append("\n      </div>");
-               builder.append("\n</body>");
-               builder.append("\n</html>\n");
-
-               return builder.toString();
-       }
-}
diff --git a/output/Html.java b/output/Html.java
deleted file mode 100644 (file)
index f81ea1d..0000000
+++ /dev/null
@@ -1,264 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStreamWriter;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.StringUtils;
-
-class Html extends BasicOutput {
-       private File dir;
-       protected BufferedWriter writer;
-       private boolean inDialogue = false;
-       private boolean inNormal = false;
-
-       @Override
-       public File process(Story story, File targetDir, String targetName)
-                       throws IOException {
-               String targetNameOrig = targetName;
-
-               File target = new File(targetDir, targetName);
-               target.mkdir();
-               dir = target;
-
-               target = new File(targetDir, targetName + getDefaultExtension(true));
-
-               writer = new BufferedWriter(new OutputStreamWriter(
-                               new FileOutputStream(target), "UTF-8"));
-               try {
-                       super.process(story, targetDir, targetNameOrig);
-               } finally {
-                       writer.close();
-                       writer = null;
-               }
-
-               // write a copy of the originals inside
-               InfoCover.writeInfo(dir, targetName, story.getMeta());
-               InfoCover.writeCover(dir, targetName, story.getMeta());
-               BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
-                               .process(story, dir, targetNameOrig);
-
-               if (story.getMeta().getCover() != null) {
-                       Instance.getInstance().getCache().saveAsImage(story.getMeta().getCover(), new File(dir, "cover"), true);
-               }
-
-               return target;
-       }
-
-       @Override
-       public String getDefaultExtension(boolean readerTarget) {
-               if (readerTarget) {
-                       return File.separator + "index.html";
-               }
-
-               return "";
-       }
-
-       @Override
-       protected void writeStoryHeader(Story story) throws IOException {
-               String title = "";
-               String tags = "";
-               String author = "";
-               Chapter resume = null;
-               if (story.getMeta() != null) {
-                       MetaData meta = story.getMeta();
-                       title = meta.getTitle();
-                       resume = meta.getResume();
-                       if (meta.getTags() != null) {
-                               for (String tag : meta.getTags()) {
-                                       if (!tags.isEmpty()) {
-                                               tags += ", ";
-                                       }
-                                       tags += tag;
-                               }
-
-                               if (!tags.isEmpty()) {
-                                       tags = "(" + tags + ")";
-                               }
-                       }
-                       author = meta.getAuthor();
-               }
-
-               String format = Instance.getInstance().getConfig()
-                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-
-               InputStream inStyle = getClass().getResourceAsStream("html.style.css");
-               if (inStyle == null) {
-                       throw new IOException("Cannot find style.css resource");
-               }
-               try {
-                       IOUtils.write(inStyle, new File(dir, "style.css"));
-               } finally {
-                       inStyle.close();
-               }
-
-               writer.write("<!DOCTYPE html>");
-               writer.write("\n<html>");
-               writer.write("\n<head>");
-               writer.write("\n        <meta http-equiv='content-type' content='text/html; charset=utf-8'>");
-               writer.write("\n        <meta name='viewport' content='width=device-width, initial-scale=1.0'>");
-               writer.write("\n        <link rel='stylesheet' type='text/css' href='style.css'>");
-               writer.write("\n        <title>" + StringUtils.xmlEscape(title) + "</title>");
-               writer.write("\n</head>");
-               writer.write("\n<body>\n");
-
-               writer.write("\n        <div class=\"titlepage\">");
-               writer.write("\n                <h1>" + StringUtils.xmlEscape(title) + "</h1>");
-               writer.write("\n                        <div class=\"type\">" + StringUtils.xmlEscape(tags)
-                               + "</div>");
-               writer.write("\n                <div class=\"cover\">");
-               writer.write("\n                        <img src=\"cover." + format + "\"></img>");
-               writer.write("\n                </div>");
-               writer.write("\n                <div class=\"author\">"
-                               + StringUtils.xmlEscape(author) + "</div>");
-               writer.write("\n        </div>");
-
-               writer.write("\n        <hr/><br/>");
-
-               if (resume != null) {
-                       for (Paragraph para : resume) {
-                               writeParagraph(para);
-                       }
-                       if (inDialogue) {
-                               writer.write("          </div>\n");
-                               inDialogue = false;
-                       }
-                       if (inNormal) {
-                               writer.write("          </div>\n");
-                               inNormal = false;
-                       }
-               }
-
-               writer.write("\n        <br/>");
-       }
-
-       @Override
-       protected void writeStoryFooter(Story story) throws IOException {
-               writer.write("</body>\n");
-       }
-
-       @Override
-       protected void writeChapterHeader(Chapter chap) throws IOException {
-               String nameOrNumber;
-               if (chap.getName() != null && !chap.getName().isEmpty()) {
-                       nameOrNumber = chap.getName();
-               } else {
-                       nameOrNumber = Integer.toString(chap.getNumber());
-               }
-
-               writer.write("\n        <h2>");
-               writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
-                               + chap.getNumber() + "</span>:</span> ");
-               writer.write("\n                <span class='chaptitle'>"
-                               + StringUtils.xmlEscape(nameOrNumber) + "</span>");
-               writer.write("\n        </h2>");
-               writer.write("\n        ");
-               writer.write("\n        <div class='chapter_content'>\n");
-
-               inDialogue = false;
-               inNormal = false;
-       }
-
-       @Override
-       protected void writeChapterFooter(Chapter chap) throws IOException {
-               if (inDialogue) {
-                       writer.write("          </div>\n");
-                       inDialogue = false;
-               }
-               if (inNormal) {
-                       writer.write("          </div>\n");
-                       inNormal = false;
-               }
-
-               writer.write("\n        </div>");
-       }
-
-       @Override
-       protected void writeParagraphHeader(Paragraph para) throws IOException {
-               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
-                       writer.write("          <div class='dialogues'>\n");
-                       inDialogue = true;
-               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
-                       writer.write("          </div>\n");
-                       inDialogue = false;
-               }
-
-               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
-                       writer.write("          <div class='normals'>\n");
-                       inNormal = true;
-               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
-                       writer.write("          </div>\n");
-                       inNormal = false;
-               }
-
-               switch (para.getType()) {
-               case BLANK:
-                       writer.write("          <div class='blank'></div>");
-                       break;
-               case BREAK:
-                       writer.write("          <hr class='break'/>");
-                       break;
-               case NORMAL:
-                       writer.write("          <span class='normal'>");
-                       break;
-               case QUOTE:
-                       writer.write("                  <div class='dialogue'>&mdash; ");
-                       break;
-               case IMAGE:
-                       // TODO check if images work OK
-                       writer.write("<a href='"
-                                       + StringUtils.xmlEscapeQuote(para.getContent()) + "'>"
-                                       + StringUtils.xmlEscape(para.getContent()) + "</a>");
-                       break;
-               }
-       }
-
-       @Override
-       protected void writeParagraphFooter(Paragraph para) throws IOException {
-               switch (para.getType()) {
-               case NORMAL:
-                       writer.write("</span>\n");
-                       break;
-               case QUOTE:
-                       writer.write("</div>\n");
-                       break;
-               default:
-                       writer.write("\n");
-                       break;
-               }
-       }
-
-       @Override
-       protected void writeTextLine(ParagraphType type, String line)
-                       throws IOException {
-               switch (type) {
-               case QUOTE:
-               case NORMAL:
-                       writer.write(decorateText(StringUtils.xmlEscape(line)));
-                       break;
-               default:
-                       break;
-               }
-       }
-
-       @Override
-       protected String enbold(String word) {
-               return "<strong>" + word + "</strong>";
-       }
-
-       @Override
-       protected String italize(String word) {
-               return "<emph>" + word + "</emph>";
-       }
-}
diff --git a/output/InfoCover.java b/output/InfoCover.java
deleted file mode 100644 (file)
index d8ca49a..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-
-public class InfoCover {
-       public static void writeInfo(File targetDir, String targetName,
-                       MetaData meta) throws IOException {
-               File info = new File(targetDir, targetName + ".info");
-
-               BufferedWriter infoWriter = null;
-               try {
-                       infoWriter = new BufferedWriter(new OutputStreamWriter(
-                                       new FileOutputStream(info), "UTF-8"));
-
-                       if (meta != null) {
-                               String tags = "";
-                               if (meta.getTags() != null) {
-                                       for (String tag : meta.getTags()) {
-                                               if (!tags.isEmpty()) {
-                                                       tags += ", ";
-                                               }
-                                               tags += tag;
-                                       }
-                               }
-
-                               writeMeta(infoWriter, "TITLE", meta.getTitle());
-                               writeMeta(infoWriter, "AUTHOR", meta.getAuthor());
-                               writeMeta(infoWriter, "DATE", meta.getDate());
-                               writeMeta(infoWriter, "SUBJECT", meta.getSubject());
-                               writeMeta(infoWriter, "SOURCE", meta.getSource());
-                               writeMeta(infoWriter, "URL", meta.getUrl());
-                               writeMeta(infoWriter, "TAGS", tags);
-                               writeMeta(infoWriter, "UUID", meta.getUuid());
-                               writeMeta(infoWriter, "LUID", meta.getLuid());
-                               writeMeta(infoWriter, "LANG", meta.getLang() == null ? ""
-                                               : meta.getLang().toLowerCase());
-                               writeMeta(infoWriter, "IMAGES_DOCUMENT",
-                                               meta.isImageDocument() ? "true" : "false");
-                               writeMeta(infoWriter, "TYPE", meta.getType());
-                               if (meta.getCover() != null) {
-                                       String format = Instance.getInstance().getConfig()
-                                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-                                       writeMeta(infoWriter, "COVER", targetName + "." + format);
-                               } else {
-                                       writeMeta(infoWriter, "COVER", "");
-                               }
-                               writeMeta(infoWriter, "EPUBCREATOR", BasicOutput.EPUB_CREATOR);
-                               writeMeta(infoWriter, "PUBLISHER", meta.getPublisher());
-                               writeMeta(infoWriter, "WORDCOUNT",
-                                               Long.toString(meta.getWords()));
-                               writeMeta(infoWriter, "CREATION_DATE", meta.getCreationDate());
-                               writeMeta(infoWriter, "FAKE_COVER",
-                                               Boolean.toString(meta.isFakeCover()));
-                       }
-               } finally {
-                       if (infoWriter != null) {
-                               infoWriter.close();
-                       }
-               }
-       }
-
-       public static void writeCover(File targetDir, String targetName,
-                       MetaData meta) {
-               if (meta != null && meta.getCover() != null) {
-                       try {
-                               Instance.getInstance().getCache().saveAsImage(meta.getCover(), new File(targetDir, targetName), true);
-                       } catch (IOException e) {
-                               // Allow to continue without cover
-                               Instance.getInstance().getTraceHandler().error(new IOException("Failed to save the cover image", e));
-                       }
-               }
-       }
-
-       private static void writeMeta(BufferedWriter writer, String key,
-                       String value) throws IOException {
-               if (value == null) {
-                       value = "";
-               }
-
-               writer.write(String.format("%s=\"%s\"\n", key, value.replace("\"", "'")));
-       }
-}
diff --git a/output/InfoText.java b/output/InfoText.java
deleted file mode 100644 (file)
index 935da87..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.IOException;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-
-class InfoText extends Text {
-       // quote chars
-       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
-       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
-       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-       @Override
-       public String getDefaultExtension(boolean readerTarget) {
-               return "";
-       }
-
-       @Override
-       protected void writeChapterHeader(Chapter chap) throws IOException {
-               writer.write("\n");
-
-               if (chap.getName() != null && !chap.getName().isEmpty()) {
-                       writer.write(Instance.getInstance().getTrans().getString(StringId.CHAPTER_NAMED, chap.getNumber(),
-                                       chap.getName()));
-               } else {
-                       writer.write(Instance.getInstance().getTrans().getString(StringId.CHAPTER_UNNAMED, chap.getNumber()));
-               }
-
-               writer.write("\n\n");
-       }
-
-       @Override
-       protected void writeTextLine(ParagraphType type, String line)
-                       throws IOException {
-               switch (type) {
-               case NORMAL:
-               case QUOTE:
-                       StringBuilder builder = new StringBuilder();
-                       for (char car : line.toCharArray()) {
-                               if (car == '—') {
-                                       builder.append("---");
-                               } else if (car == '–') {
-                                       builder.append("--");
-                               } else if (car == openDoubleQuote) {
-                                       builder.append("\"");
-                               } else if (car == closeDoubleQuote) {
-                                       builder.append("\"");
-                               } else if (car == openQuote) {
-                                       builder.append("'");
-                               } else if (car == closeQuote) {
-                                       builder.append("'");
-                               } else {
-                                       builder.append(car);
-                               }
-                       }
-
-                       line = builder.toString();
-                       break;
-               default:
-                       break;
-               }
-
-               super.writeTextLine(type, line);
-       }
-}
diff --git a/output/LaTeX.java b/output/LaTeX.java
deleted file mode 100644 (file)
index a406fc3..0000000
+++ /dev/null
@@ -1,180 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-
-class LaTeX extends BasicOutput {
-       protected BufferedWriter writer;
-       private boolean lastWasQuote = false;
-
-       // quote chars
-       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
-       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
-       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-       @Override
-       public File process(Story story, File targetDir, String targetName)
-                       throws IOException {
-               String targetNameOrig = targetName;
-               targetName += getDefaultExtension(false);
-
-               File target = new File(targetDir, targetName);
-
-               writer = new BufferedWriter(new OutputStreamWriter(
-                               new FileOutputStream(target), "UTF-8"));
-               try {
-                       super.process(story, targetDir, targetNameOrig);
-               } finally {
-                       writer.close();
-                       writer = null;
-               }
-
-               return target;
-       }
-
-       @Override
-       public String getDefaultExtension(boolean readerTarget) {
-               return ".tex";
-       }
-
-       @Override
-       protected void writeStoryHeader(Story story) throws IOException {
-               String date = "";
-               String author = "";
-               String title = "\\title{}";
-               String lang = "";
-               if (story.getMeta() != null) {
-                       MetaData meta = story.getMeta();
-                       title = "\\title{" + latexEncode(meta.getTitle()) + "}";
-                       date = "\\date{" + latexEncode(meta.getDate()) + "}";
-                       author = "\\author{" + latexEncode(meta.getAuthor()) + "}";
-                       lang = meta.getLang().toLowerCase();
-                       if (lang != null && !lang.isEmpty()) {
-                               lang = Instance.getInstance().getConfig().getStringX(Config.CONF_LATEX_LANG, lang);
-                               if (lang == null) {
-                                       System.err.println(Instance.getInstance().getTrans().getString(StringId.LATEX_LANG_UNKNOWN, lang));
-                               }
-                       }
-               }
-
-               writer.append("%\n");
-               writer.append("% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.\n");
-               writer.append("%\n\n");
-               writer.append("\\documentclass[a4paper]{book}\n");
-               if (lang != null && !lang.isEmpty()) {
-                       writer.append("\\usepackage[" + lang + "]{babel}\n");
-               }
-               writer.append("\\usepackage[utf8]{inputenc}\n");
-               writer.append("\\usepackage[T1]{fontenc}\n");
-               writer.append("\\usepackage{lmodern}\n");
-               writer.append("\\newcommand{\\br}{\\vspace{10 mm}}\n");
-               writer.append("\\newcommand{\\say}{--- \\noindent\\emph}\n");
-               writer.append("\\hyphenpenalty=1000\n");
-               writer.append("\\tolerance=5000\n");
-               writer.append("\\begin{document}\n");
-               if (story.getMeta() != null && story.getMeta().getDate() != null)
-                       writer.append(date + "\n");
-               writer.append(title + "\n");
-               writer.append(author + "\n");
-               writer.append("\\maketitle\n");
-               writer.append("\n");
-
-               // TODO: cover
-       }
-
-       @Override
-       protected void writeStoryFooter(Story story) throws IOException {
-               writer.append("\\end{document}\n");
-       }
-
-       @Override
-       protected void writeChapterHeader(Chapter chap) throws IOException {
-               writer.append("\n\n\\chapter{" + latexEncode(chap.getName()) + "}"
-                               + "\n");
-       }
-
-       @Override
-       protected void writeChapterFooter(Chapter chap) throws IOException {
-               writer.write("\n");
-       }
-
-       @Override
-       protected String enbold(String word) {
-               return "\\textsc{" + word + "}";
-       }
-
-       @Override
-       protected String italize(String word) {
-               return "\\emph{" + word + "}";
-       }
-
-       @Override
-       protected void writeTextLine(ParagraphType type, String line)
-                       throws IOException {
-
-               line = decorateText(latexEncode(line));
-
-               switch (type) {
-               case BLANK:
-                       writer.write("\n");
-                       lastWasQuote = false;
-                       break;
-               case BREAK:
-                       writer.write("\n\\br");
-                       writer.write("\n");
-                       lastWasQuote = false;
-                       break;
-               case NORMAL:
-                       writer.write(line);
-                       writer.write("\n");
-                       lastWasQuote = false;
-                       break;
-               case QUOTE:
-                       writer.write("\n\\say{" + line + "}\n");
-                       if (lastWasQuote) {
-                               writer.write("\n\\noindent{}");
-                       }
-                       lastWasQuote = true;
-                       break;
-               case IMAGE:
-                       // TODO what about images in LaTeX?
-                       break;
-               }
-       }
-
-       private String latexEncode(String input) {
-               StringBuilder builder = new StringBuilder();
-               for (char car : input.toCharArray()) {
-                       // TODO: check restricted chars?
-                       if (car == '^' || car == '$' || car == '\\' || car == '#'
-                                       || car == '%') {
-                               builder.append('\\');
-                               builder.append(car);
-                       } else if (car == openQuote) {
-                               builder.append('`');
-                       } else if (car == closeQuote) {
-                               builder.append('\'');
-                       } else if (car == openDoubleQuote) {
-                               builder.append("``");
-                       } else if (car == closeDoubleQuote) {
-                               builder.append("''");
-                       } else {
-                               builder.append(car);
-                       }
-               }
-
-               return builder.toString();
-       }
-}
diff --git a/output/Sysout.java b/output/Sysout.java
deleted file mode 100644 (file)
index f6cd789..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Story;
-
-class Sysout extends BasicOutput {
-       @Override
-       protected void writeStoryHeader(Story story) {
-               System.out.println(story);
-       }
-
-       @Override
-       protected void writeChapterHeader(Chapter chap) {
-               System.out.println(chap);
-       }
-
-       @Override
-       protected void writeParagraphHeader(Paragraph para) {
-               System.out.println(para);
-       }
-}
diff --git a/output/Text.java b/output/Text.java
deleted file mode 100644 (file)
index f0516dc..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-package be.nikiroo.fanfix.output;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-
-class Text extends BasicOutput {
-       protected BufferedWriter writer;
-       protected File targetDir;
-       private boolean nextParaIsCover = true;
-
-       @Override
-       public File process(Story story, File targetDir, String targetName)
-                       throws IOException {
-               String targetNameOrig = targetName;
-               targetName += getDefaultExtension(false);
-
-               this.targetDir = targetDir;
-
-               File target = new File(targetDir, targetName);
-
-               writer = new BufferedWriter(new OutputStreamWriter(
-                               new FileOutputStream(target), "UTF-8"));
-               try {
-                       super.process(story, targetDir, targetNameOrig);
-               } finally {
-                       writer.close();
-                       writer = null;
-               }
-
-               return target;
-       }
-
-       @Override
-       public String getDefaultExtension(boolean readerTarget) {
-               return ".txt";
-       }
-
-       @Override
-       protected void writeStoryHeader(Story story) throws IOException {
-               String title = "";
-               String author = null;
-               String date = null;
-
-               MetaData meta = story.getMeta();
-               if (meta != null) {
-                       title = meta.getTitle() == null ? "" : meta.getTitle();
-                       author = meta.getAuthor();
-                       date = meta.getDate();
-               }
-
-               writer.write(title);
-               writer.write("\n");
-               if (author != null && !author.isEmpty()) {
-                       writer.write(Instance.getInstance().getTrans().getString(StringId.BY) + " " + author);
-               }
-               if (date != null && !date.isEmpty()) {
-                       writer.write(" (");
-                       writer.write(date);
-                       writer.write(")");
-               }
-               writer.write("\n");
-
-               // resume:
-               if (meta != null && meta.getResume() != null) {
-                       writeChapter(meta.getResume());
-               }
-       }
-
-       @Override
-       protected void writeChapterHeader(Chapter chap) throws IOException {
-               String txt;
-               if (chap.getName() != null && !chap.getName().isEmpty()) {
-                       txt = Instance.getInstance().getTrans().getString(StringId.CHAPTER_NAMED, chap.getNumber(), chap.getName());
-               } else {
-                       txt = Instance.getInstance().getTrans().getString(StringId.CHAPTER_UNNAMED, chap.getNumber());
-               }
-
-               writer.write("\n" + txt + "\n");
-               for (int i = 0; i < txt.length(); i++) {
-                       writer.write("—");
-               }
-               writer.write("\n\n");
-       }
-
-       @Override
-       protected void writeParagraphFooter(Paragraph para) throws IOException {
-               writer.write("\n");
-       }
-
-       @Override
-       protected void writeParagraphHeader(Paragraph para) throws IOException {
-               if (para.getType() == ParagraphType.IMAGE) {
-                       File file = new File(targetDir, getCurrentImageBestName(true));
-                       try {
-                               Instance.getInstance().getCache().saveAsImage(para.getContentImage(), file, nextParaIsCover);
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(new IOException("Cannot save an image", e));
-                       }
-               }
-
-               nextParaIsCover = false;
-       }
-
-       @Override
-       protected void writeTextLine(ParagraphType type, String line)
-                       throws IOException {
-               switch (type) {
-               case BLANK:
-                       break;
-               case BREAK:
-                       writer.write("\n* * *\n");
-                       break;
-               case NORMAL:
-               case QUOTE:
-                       writer.write(line);
-                       break;
-               case IMAGE:
-                       writer.write("[" + getCurrentImageBestName(true) + "]");
-                       break;
-               }
-       }
-}
diff --git a/output/epub.style.css b/output/epub.style.css
deleted file mode 100644 (file)
index 2c4a961..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-html {
-       text-align: justify;
-}
-
-.titlepage {
-       padding-left: 10%;
-       padding-right: 10%;
-       width: 80%;
-}
-
-h1 {
-       padding-bottom: 0;
-       margin-bottom: 0;
-       text-align: left;
-}
-
-.type {
-       position: relative;
-       font-size: large;
-       color: #666666;
-       font-weight: bold;
-       padding-bottom: 10px;
-       text-align: left;
-}
-
-.cover, .page-image {
-       width: 100%;
-}
-
-.cover img {
-       height: 45%;
-       max-width: 100%;
-       margin: auto;
-}
-
-.author {
-       text-align: right;
-       font-size: large;
-       font-style: italic;
-}
-
-.book, .chapter_content {
-       NO_text-indent: 40px;
-       padding-top: 40px;
-       padding-left: 5%;
-       padding-right: 5%;
-       width: 90%;
-}
-
-h2 {
-       border: 1px solid black;
-       color: #222222;
-       padding-left: 10px;
-       padding-right: 10px;
-       display: block;
-       padding-bottom: 0;
-       margin-bottom: 0;
-}
-
-h2 .chap {
-       color: #000000;
-       font-size: large;
-       font-variant: small-caps;
-       display: block;
-}
-
-h2 .chap:first-letter {
-       font-weight: bold;
-}
-
-h2 .chapnumber {
-       color: #000000;
-       font-size: xx-large;
-}
-
-h2 .chaptitle {
-       color: #444444;
-       font-size: large;
-       font-style: italic;
-       padding-bottom: 5px;
-       text-align: right;
-       display: block;
-}
-
-.normals {
-}
-
-.normal {
-       /* Can be removed if you want a more "compact" view */
-       display: block;
-}
-
-.blank {
-       /* Can be removed if you want a more "compact" view */
-       height: 24px;
-       width: 100%;
-}
-
-hr.break {
-       /* Can be removed if you want a more "compact" view */
-       margin-top: 48px;
-       margin-bottom: 48px;
-}
-
-.dialogues {
-}
-
-.dialogue {
-       font-style: italic;
-}
\ No newline at end of file
diff --git a/output/html.style.css b/output/html.style.css
deleted file mode 100644 (file)
index 6b6d0d2..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-html {
-       text-align: justify;
-       max-width: 800px;
-       margin: auto;
-}
-
-.titlepage {
-       padding-left: 10%;
-       padding-right: 10%;
-       width: 80%;
-}
-
-h1 {
-       padding-bottom: 0;
-       margin-bottom: 0;
-       text-align: left;
-}
-
-.type {
-       position: relative;
-       font-size: large;
-       color: #666666;
-       font-weight: bold;
-       padding-bottom: 10px;
-       text-align: left;
-}
-
-.cover, .page-image {
-       width: 100%;
-}
-
-.cover img {
-       height: 45%;
-       max-width: 100%;
-       margin: auto;
-}
-
-.author {
-       text-align: right;
-       font-size: large;
-       font-style: italic;
-}
-
-.book, .chapter_content {
-       NO_text-indent: 40px;
-       padding-top: 40px;
-       padding-left: 5%;
-       padding-right: 5%;
-       width: 90%;
-}
-
-h2 {
-       border: 1px solid black;
-       color: #222222;
-       padding-left: 10px;
-       padding-right: 10px;
-       display: block;
-       padding-bottom: 0;
-       margin-bottom: 0;
-}
-
-h2 .chap {
-       color: #000000;
-       font-size: large;
-       font-variant: small-caps;
-       display: block;
-}
-
-h2 .chap:first-letter {
-       font-weight: bold;
-}
-
-h2 .chapnumber {
-       color: #000000;
-       font-size: xx-large;
-}
-
-h2 .chaptitle {
-       color: #444444;
-       font-size: large;
-       font-style: italic;
-       padding-bottom: 5px;
-       text-align: right;
-       display: block;
-}
-
-.normals {
-}
-
-.normal {
-       /* Can be removed if you want a more "compact" view */
-       display: block;
-}
-
-.blank {
-       /* Can be removed if you want a more "compact" view */
-       height: 24px;
-       width: 100%;
-}
-
-hr.break {
-       /* Can be removed if you want a more "compact" view */
-       margin-top: 48px;
-       margin-bottom: 48px;
-}
-
-.dialogues {
-}
-
-.dialogue {
-       font-style: italic;
-}
diff --git a/output/package-info.java b/output/package-info.java
deleted file mode 100644 (file)
index 8314c80..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * This package contains all the output processors.
- * <p>
- * Of those, only {@link be.nikiroo.fanfix.output.BasicOutput} is public,
- * but it contains a method 
- * ({@link be.nikiroo.fanfix.output.BasicOutput#getOutput(be.nikiroo.fanfix.output.BasicOutput.OutputType, boolean,boolean)})
- * to get all the other 
- * {@link be.nikiroo.fanfix.output.BasicOutput.OutputType}s.
- * 
- * @author niki
- */
-package be.nikiroo.fanfix.output;
\ No newline at end of file
diff --git a/package-info.java b/package-info.java
deleted file mode 100644 (file)
index cfd9cbe..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Fanfix is a program that can support a few different websites from 
- * which to retrieve stories, then process them into <tt>epub</tt> (or other)
- * files that you can read anywhere.
- * <p>
- * It has support for a {@link be.nikiroo.fanfix.library.BasicLibrary} system, 
- * too, and can even offer its services over the network.
- * 
- * @author niki
- */
-package be.nikiroo.fanfix;
\ No newline at end of file
diff --git a/reader/BasicReader.java b/reader/BasicReader.java
deleted file mode 100644 (file)
index 9ec0879..0000000
+++ /dev/null
@@ -1,231 +0,0 @@
-package be.nikiroo.fanfix.reader;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.Map;
-import java.util.TreeMap;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.UiConfig;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.library.BasicLibrary;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * The class that handles the different {@link Story} readers you can use.
- * 
- * @author niki
- */
-public abstract class BasicReader {
-       /**
-        * Return an {@link URL} from this {@link String}, be it a file path or an
-        * actual {@link URL}.
-        * 
-        * @param sourceString
-        *            the source
-        * 
-        * @return the corresponding {@link URL}
-        * 
-        * @throws MalformedURLException
-        *             if this is neither a file nor a conventional {@link URL}
-        */
-       public static URL getUrl(String sourceString) throws MalformedURLException {
-               if (sourceString == null || sourceString.isEmpty()) {
-                       throw new MalformedURLException("Empty url");
-               }
-
-               URL source = null;
-               try {
-                       source = new URL(sourceString);
-               } catch (MalformedURLException e) {
-                       File sourceFile = new File(sourceString);
-                       source = sourceFile.toURI().toURL();
-               }
-
-               return source;
-       }
-
-       /**
-        * Describe a {@link Story} from its {@link MetaData} and return a list of
-        * title/value that represent this {@link Story}.
-        * 
-        * @param meta
-        *            the {@link MetaData} to represent
-        * 
-        * @return the information
-        */
-       public static Map<String, String> getMetaDesc(MetaData meta) {
-               Map<String, String> metaDesc = new TreeMap<String, String>();
-
-               // TODO: i18n
-
-               StringBuilder tags = new StringBuilder();
-               for (String tag : meta.getTags()) {
-                       if (tags.length() > 0) {
-                               tags.append(", ");
-                       }
-                       tags.append(tag);
-               }
-
-               // TODO: i18n
-               metaDesc.put("Author", meta.getAuthor());
-               metaDesc.put("Publication date", formatDate(meta.getDate()));
-               metaDesc.put("Published on", meta.getPublisher());
-               metaDesc.put("URL", meta.getUrl());
-               String count = "";
-               if (meta.getWords() > 0) {
-                       count = StringUtils.formatNumber(meta.getWords());
-               }
-               if (meta.isImageDocument()) {
-                       metaDesc.put("Number of images", count);
-               } else {
-                       metaDesc.put("Number of words", count);
-               }
-               metaDesc.put("Source", meta.getSource());
-               metaDesc.put("Subject", meta.getSubject());
-               metaDesc.put("Language", meta.getLang());
-               metaDesc.put("Tags", tags.toString());
-
-               return metaDesc;
-       }
-
-       /**
-        * Open the {@link Story} with an external reader (the program will be
-        * passed the main file associated with this {@link Story}).
-        * 
-        * @param lib
-        *            the {@link BasicLibrary} to select the {@link Story} from
-        * @param luid
-        *            the {@link Story} LUID
-        * @param sync
-        *            execute the process synchronously (wait until it is terminated
-        *            before returning)
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public void openExternal(BasicLibrary lib, String luid, boolean sync)
-                       throws IOException {
-               MetaData meta = lib.getInfo(luid);
-               File target = lib.getFile(luid, null);
-
-               openExternal(meta, target, sync);
-       }
-
-       /**
-        * Open the {@link Story} with an external reader (the program will be
-        * passed the given target file).
-        * 
-        * @param meta
-        *            the {@link Story} to load
-        * @param target
-        *            the target {@link File}
-        * @param sync
-        *            execute the process synchronously (wait until it is terminated
-        *            before returning)
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected void openExternal(MetaData meta, File target, boolean sync)
-                       throws IOException {
-               String program = null;
-               if (meta.isImageDocument()) {
-                       program = Instance.getInstance().getUiConfig().getString(UiConfig.IMAGES_DOCUMENT_READER);
-               } else {
-                       program = Instance.getInstance().getUiConfig().getString(UiConfig.NON_IMAGES_DOCUMENT_READER);
-               }
-
-               if (program != null && program.trim().isEmpty()) {
-                       program = null;
-               }
-
-               start(target, program, sync);
-       }
-
-       /**
-        * Start a file and open it with the given program if given or the first
-        * default system starter we can find.
-        * 
-        * @param target
-        *            the target to open
-        * @param program
-        *            the program to use or NULL for the default system starter
-        * @param sync
-        *            execute the process synchronously (wait until it is terminated
-        *            before returning)
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected void start(File target, String program, boolean sync)
-                       throws IOException {
-
-               Process proc = null;
-               if (program == null) {
-                       boolean ok = false;
-                       for (String starter : new String[] { "xdg-open", "open", "see",
-                                       "start", "run" }) {
-                               try {
-                                       Instance.getInstance().getTraceHandler().trace("starting external program");
-                                       proc = Runtime.getRuntime().exec(new String[] { starter, target.getAbsolutePath() });
-                                       ok = true;
-                                       break;
-                               } catch (IOException e) {
-                               }
-                       }
-                       if (!ok) {
-                               throw new IOException("Cannot find a program to start the file");
-                       }
-               } else {
-                       Instance.getInstance().getTraceHandler().trace("starting external program");
-                       proc = Runtime.getRuntime().exec(
-                                       new String[] { program, target.getAbsolutePath() });
-               }
-
-               if (proc != null && sync) {
-                       try {
-                               proc.waitFor();
-                       } catch (InterruptedException e) {
-                       }
-               }
-       }
-
-       static private String formatDate(String date) {
-               long ms = 0;
-
-               if (date != null && !date.isEmpty()) {
-                       try {
-                               ms = StringUtils.toTime(date);
-                       } catch (ParseException e) {
-                       }
-
-                       if (ms <= 0) {
-                               SimpleDateFormat sdf = new SimpleDateFormat(
-                                               "yyyy-MM-dd'T'HH:mm:ssSSS");
-                               try {
-                                       ms = sdf.parse(date).getTime();
-                               } catch (ParseException e) {
-                               }
-                       }
-
-                       if (ms > 0) {
-                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
-                               return sdf.format(new Date(ms));
-                       }
-               }
-
-               if (date == null) {
-                       date = "";
-               }
-
-               // :(
-               return date;
-       }
-}
diff --git a/reader/CliReader.java b/reader/CliReader.java
deleted file mode 100644 (file)
index 96ca644..0000000
+++ /dev/null
@@ -1,256 +0,0 @@
-package be.nikiroo.fanfix.reader;
-
-import java.io.IOException;
-import java.util.List;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.searchable.BasicSearchable;
-import be.nikiroo.fanfix.searchable.SearchableTag;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Command line {@link Story} reader.
- * <p>
- * Will output stories to the console.
- * 
- * @author niki
- */
-public class CliReader extends BasicReader {
-       public void listBooks(String source) throws IOException {
-               List<MetaData> stories = Instance.getInstance().getLibrary().getList()
-                               .filter(source, null, null);
-
-               for (MetaData story : stories) {
-                       String author = "";
-                       if (story.getAuthor() != null && !story.getAuthor().isEmpty()) {
-                               author = " (" + story.getAuthor() + ")";
-                       }
-
-                       System.out.println(
-                                       story.getLuid() + ": " + story.getTitle() + author);
-               }
-       }
-
-       public void listChapters(Story story) throws IOException {
-               if (story == null || story.getMeta() == null) {
-                       throw new IOException("No story to read");
-               }
-               MetaData meta = story.getMeta();
-               if (meta == null) {
-                       throw new IOException("No story to read");
-               }
-
-               String title = "";
-               String author = "";
-
-               if (meta.getTitle() != null) {
-                       title = meta.getTitle();
-               }
-
-               if (meta.getAuthor() != null) {
-                       author = "©" + meta.getAuthor();
-                       if (meta.getDate() != null && !meta.getDate().isEmpty()) {
-                               author = author + " (" + meta.getDate() + ")";
-                       }
-               }
-
-               System.out.println(title);
-               System.out.println(author);
-               System.out.println("");
-
-               for (Chapter chap : story) {
-                       if (chap.getName() != null && !chap.getName().isEmpty()) {
-                               System.out.println(Instance.getInstance().getTrans().getString(
-                                               StringId.CHAPTER_NAMED, chap.getNumber(),
-                                               chap.getName()));
-                       } else {
-                               System.out.println(Instance.getInstance().getTrans()
-                                               .getString(StringId.CHAPTER_UNNAMED, chap.getNumber()));
-                       }
-               }
-       }
-
-       public void printChapter(Story story, int chapter) throws IOException {
-               if (story == null || story.getMeta() == null) {
-                       throw new IOException("No story to read");
-               }
-               MetaData meta = story.getMeta();
-               if (meta == null) {
-                       throw new IOException("No story to read");
-               }
-
-               if (chapter <= 0 || chapter > story.getChapters().size()) {
-                       System.err.println("Chapter " + chapter + ": no such chapter");
-               } else {
-                       Chapter chap = story.getChapters().get(chapter - 1);
-                       System.out.println(
-                                       "Chapter " + chap.getNumber() + ": " + chap.getName());
-                       System.out.println();
-                       
-                       for (Paragraph para : chap) {
-                               Image img = para.getContentImage();
-                               if (img != null) {
-                                       String sz = StringUtils.formatNumber(img.getSize(), 1);
-                                       System.out.println("[Image: " + sz + "]");
-                               } else {
-                                       System.out.println(
-                                                       para.getContent() == null ? "" : para.getContent());
-                               }
-                               System.out.println("");
-                       }
-               }
-       }
-
-       public void listSearchables() throws IOException {
-               for (SupportType type : SupportType.values()) {
-                       if (BasicSearchable.getSearchable(type) != null) {
-                               System.out.println(type);
-                       }
-               }
-       }
-
-       public void searchBooksByKeyword(SupportType searchOn, String keywords,
-                       int page, int item) throws IOException {
-               BasicSearchable search = BasicSearchable.getSearchable(searchOn);
-
-               if (page == 0) {
-                       System.out.println(search.searchPages(keywords));
-               } else {
-                       List<MetaData> metas = search.search(keywords, page);
-
-                       if (item == 0) {
-                               System.out.println(
-                                               "Page " + page + " of stories for: " + keywords);
-                               displayStories(metas);
-                       } else {
-                               // ! 1-based index !
-                               if (item <= 0 || item > metas.size()) {
-                                       throw new IOException("Index out of bounds: " + item);
-                               }
-
-                               MetaData meta = metas.get(item - 1);
-                               displayStory(meta);
-                       }
-               }
-       }
-
-       public void searchBooksByTag(SupportType searchOn, int page, int item,
-                       Integer... tags) throws IOException {
-
-               BasicSearchable search = BasicSearchable.getSearchable(searchOn);
-               SearchableTag stag = search.getTag(tags);
-
-               if (stag == null) {
-                       // TODO i18n
-                       System.out.println("Known tags: ");
-                       int i = 1;
-                       for (SearchableTag s : search.getTags()) {
-                               System.out.println(String.format("%d: %s", i, s.getName()));
-                               i++;
-                       }
-               } else {
-                       if (page <= 0) {
-                               if (stag.isLeaf()) {
-                                       System.out.println(search.searchPages(stag));
-                               } else {
-                                       System.out.println(stag.getCount());
-                               }
-                       } else {
-                               List<MetaData> metas = null;
-                               List<SearchableTag> subtags = null;
-                               int count;
-
-                               if (stag.isLeaf()) {
-                                       metas = search.search(stag, page);
-                                       count = metas.size();
-                               } else {
-                                       subtags = stag.getChildren();
-                                       count = subtags.size();
-                               }
-
-                               if (item > 0) {
-                                       if (item <= count) {
-                                               if (metas != null) {
-                                                       MetaData meta = metas.get(item - 1);
-                                                       displayStory(meta);
-                                               } else {
-                                                       SearchableTag subtag = subtags.get(item - 1);
-                                                       displayTag(subtag);
-                                               }
-                                       } else {
-                                               System.out.println(
-                                                               "Invalid item: only " + count + " items found");
-                                       }
-                               } else {
-                                       if (metas != null) {
-                                               // TODO i18n
-                                               System.out.println(String.format("Content of %s: ",
-                                                               stag.getFqName()));
-                                               displayStories(metas);
-                                       } else {
-                                               // TODO i18n
-                                               System.out.println(String.format("Subtags of %s: ",
-                                                               stag.getFqName()));
-                                               displayTags(subtags);
-                                       }
-                               }
-                       }
-               }
-       }
-
-       private void displayTag(SearchableTag subtag) {
-               // TODO: i18n
-               String stories = "stories";
-               String num = StringUtils.formatNumber(subtag.getCount());
-               System.out.println(String.format("%s (%s), %s %s", subtag.getName(),
-                               subtag.getFqName(), num, stories));
-       }
-
-       private void displayStory(MetaData meta) {
-               System.out.println(meta.getTitle());
-               System.out.println();
-               System.out.println(meta.getUrl());
-               System.out.println();
-               System.out.println("Tags: " + meta.getTags());
-               System.out.println();
-               for (Paragraph para : meta.getResume()) {
-                       System.out.println(para.getContent());
-                       System.out.println("");
-               }
-       }
-
-       private void displayTags(List<SearchableTag> subtags) {
-               int i = 1;
-               for (SearchableTag subtag : subtags) {
-                       String total = "";
-                       if (subtag.getCount() > 0) {
-                               total = StringUtils.formatNumber(subtag.getCount());
-                       }
-
-                       if (total.isEmpty()) {
-                               System.out
-                                               .println(String.format("%d: %s", i, subtag.getName()));
-                       } else {
-                               System.out.println(String.format("%d: %s (%s)", i,
-                                               subtag.getName(), total));
-                       }
-
-                       i++;
-               }
-       }
-
-       private void displayStories(List<MetaData> metas) {
-               int i = 1;
-               for (MetaData meta : metas) {
-                       System.out.println(i + ": " + meta.getTitle());
-                       i++;
-               }
-       }
-}
diff --git a/reader/TextOutput.java b/reader/TextOutput.java
deleted file mode 100644 (file)
index 60b3a7f..0000000
+++ /dev/null
@@ -1,148 +0,0 @@
-package be.nikiroo.fanfix.reader;
-
-import java.io.IOException;
-import java.util.Arrays;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.output.BasicOutput;
-
-/**
- * This class can export a chapter into HTML3 code ready for Java Swing support.
- * 
- * @author niki
- */
-public class TextOutput {
-       private StringBuilder builder;
-       private BasicOutput output;
-       private Story fakeStory;
-       private boolean chapterName;
-
-       /**
-        * Create a new {@link TextOutput} that will convert a {@link Chapter} into
-        * HTML3 suited for Java Swing.
-        * 
-        * @param standalone
-        *            TRUE if you want a standalone document (with an <HTML> tag)
-        */
-       public TextOutput(final boolean standalone) {
-               builder = new StringBuilder();
-               fakeStory = new Story();
-
-               output = new BasicOutput() {
-                       private boolean paraInQuote;
-
-                       @Override
-                       protected void writeChapterHeader(Chapter chap) throws IOException {
-                               if (standalone) {
-                                       builder.append("<HTML style='line-height: 5px;'>");
-                               }
-
-                               if (chapterName) {
-                                       builder.append("<H1>");
-                                       builder.append("Chapter ");
-                                       builder.append(chap.getNumber());
-                                       if (chap.getName() != null
-                                                       && !chap.getName().trim().isEmpty()) {
-                                               builder.append(": ");
-                                               builder.append(chap.getName());
-                                       }
-                                       builder.append("</H1>");
-                               }
-
-                               builder.append("<DIV align='justify'>");
-                       }
-
-                       @Override
-                       protected void writeChapterFooter(Chapter chap) throws IOException {
-                               if (paraInQuote) {
-                                       builder.append("</DIV>");
-                               }
-                               paraInQuote = false;
-
-                               builder.append("</DIV>");
-
-                               if (standalone) {
-                                       builder.append("</HTML>");
-                               }
-                       }
-
-                       @Override
-                       protected void writeParagraph(Paragraph para) throws IOException {
-                               if ((para.getType() == ParagraphType.QUOTE) == !paraInQuote) {
-                                       paraInQuote = !paraInQuote;
-                                       if (paraInQuote) {
-                                               builder.append("<BR>");
-                                               builder.append("<DIV>");
-                                       } else {
-                                               builder.append("</DIV>");
-                                               builder.append("<BR>");
-                                       }
-                               }
-
-                               switch (para.getType()) {
-                               case NORMAL:
-                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
-                                       builder.append(decorateText(para.getContent()));
-                                       builder.append("<BR>");
-                                       break;
-                               case BLANK:
-                                       builder.append("<FONT SIZE='1'><BR></FONT>");
-                                       break;
-                               case BREAK:
-                                       // Used to be 7777DD
-                                       builder.append("<P COLOR='#AAAAAA' ALIGN='CENTER'>");
-                                       builder.append("<FONT SIZE='5'>* * *</FONT>");
-                                       builder.append("</P>");
-                                       builder.append("<BR>");
-                                       break;
-                               case QUOTE:
-                                       builder.append("<DIV>");
-                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
-                                       builder.append("&mdash;&nbsp;");
-                                       builder.append(decorateText(para.getContent()));
-                                       builder.append("</DIV>");
-
-                                       break;
-                               case IMAGE:
-                               }
-                       }
-
-                       @Override
-                       protected String enbold(String word) {
-                               // Used to be COLOR='#7777DD'
-                               return "<B>" + word + "</B>";
-                       }
-
-                       @Override
-                       protected String italize(String word) {
-                               return "<I COLOR='GRAY'>" + word + "</I>";
-                       }
-               };
-       }
-
-       /**
-        * Convert the chapter into HTML3 code.
-        * 
-        * @param chap
-        *            the {@link Chapter} to convert
-        * @param chapterName
-        *            display the chapter name
-        * 
-        * @return HTML3 code tested with Java Swing
-        */
-       public String convert(Chapter chap, boolean chapterName) {
-               this.chapterName = chapterName;
-               builder.setLength(0);
-               try {
-                       fakeStory.setChapters(Arrays.asList(chap));
-                       output.process(fakeStory, null, null);
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-               }
-               return builder.toString();
-       }
-}
diff --git a/resources/Bundle.java b/resources/Bundle.java
new file mode 100644 (file)
index 0000000..84efcea
--- /dev/null
@@ -0,0 +1,1304 @@
+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 explicitly set
+        *            to NULL (and not just "no set") in the change maps
+        * 
+        * @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 <tt>def</tt> 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, <tt>def</tt> if not found (not present in
+        *         the resource file) or NULL if the item is specified (not -1) and
+        *         does not exist
+        */
+       public String getString(E id, String def, int item) {
+               String rep = getString(id.name(), null);
+               if (rep == null) {
+                       rep = getMetaDef(id.name());
+               }
+
+               if (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, <tt>def</tt> if not found (not present in
+        *         the resource file), NULL if the item is specified (not -1) but
+        *         does not exist and NULL if bad key
+        */
+       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);
+       }
+
+       /**
+        * The default {@link Meta#def()} value for the given enumeration name.
+        * 
+        * @param id
+        *            the enumeration name (the "id")
+        * 
+        * @return the def value in the {@link MetaInfo} or "" if none (never NULL)
+        */
+       protected String getMetaDef(String id) {
+               String rep = "";
+               try {
+                       Meta meta = type.getDeclaredField(id).getAnnotation(Meta.class);
+                       rep = meta.def();
+               } catch (NoSuchFieldException e) {
+               } catch (SecurityException e) {
+               }
+
+               if (rep == null) {
+                       rep = "";
+               }
+
+               return rep;
+       }
+
+       /**
+        * Get the value for the given key if it exists in the internal map, or
+        * <tt>def</tt> if not.
+        * <p>
+        * DO NOT get the default meta value (MetaInfo.def()).
+        * 
+        * @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();
+               if (bundle != null) {
+                       for (Field field : type.getDeclaredFields()) {
+                               try {
+                                       Meta meta = field.getAnnotation(Meta.class);
+                                       if (meta != null) {
+                                               E id = Enum.valueOf(type, field.getName());
+                                               String value = bundle.getString(id.name());
+                                               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/resources/BundleHelper.java b/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/resources/Bundles.java b/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/resources/FixedResourceBundleControl.java b/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/resources/Meta.java b/resources/Meta.java
new file mode 100644 (file)
index 0000000..fb4d491
--- /dev/null
@@ -0,0 +1,132 @@
+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 should be hidden from the user (she will still be able to
+        * modify it if she opens the file manually).
+        * <p>
+        * Defaults to FALSE (visible).
+        * 
+        * @return TRUE if it should stay hidden
+        */
+       boolean hidden() default false;
+
+       /**
+        * 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/resources/MetaInfo.java b/resources/MetaInfo.java
new file mode 100644 (file)
index 0000000..70c6c43
--- /dev/null
@@ -0,0 +1,770 @@
+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 boolean hidden;
+       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.hidden = meta.hidden();
+               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;
+       }
+       
+       /**
+        * This item should be hidden from the user (she will still be able to
+        * modify it if she opens the file manually).
+        * 
+        * @return TRUE if it should stay hidden
+        */
+       public boolean isHidden() {
+               return hidden;
+       }
+
+       /**
+        * 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;
+       }
+
+       /**
+        * The number of sub-items, if any.
+        * 
+        * @return the number or 0
+        */
+       public int size() {
+               return children.size();
+       }
+
+       @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);
+                       if (!info.hidden) {
+                               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/resources/TransBundle.java b/resources/TransBundle.java
new file mode 100644 (file)
index 0000000..7b2edb1
--- /dev/null
@@ -0,0 +1,404 @@
+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);
+                       if (result == null) {
+                               result = getMetaDef(id.name());
+                       }
+               } 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);
+                       if (value == null) {
+                               value = getMetaDef(id.name());
+                       }
+                       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/resources/TransBundle_ResourceList.java b/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/resources/package-info.java b/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/searchable/BasicSearchable.java b/searchable/BasicSearchable.java
deleted file mode 100644 (file)
index cb0b171..0000000
+++ /dev/null
@@ -1,275 +0,0 @@
-package be.nikiroo.fanfix.searchable;
-
-import java.io.IOException;
-import java.net.URL;
-import java.util.List;
-
-import org.jsoup.helper.DataUtil;
-import org.jsoup.nodes.Document;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.supported.BasicSupport;
-import be.nikiroo.fanfix.supported.SupportType;
-
-/**
- * This class supports browsing through stories on the supported websites. It
- * will fetch some {@link MetaData} that satisfy a search query or some tags if
- * supported.
- * 
- * @author niki
- */
-public abstract class BasicSearchable {
-       private SupportType type;
-       private BasicSupport support;
-
-       /**
-        * Create a new {@link BasicSearchable} of the given type.
-        * 
-        * @param type
-        *            the type, must not be NULL
-        */
-       public BasicSearchable(SupportType type) {
-               setType(type);
-               support = BasicSupport.getSupport(getType(), null);
-       }
-
-       /**
-        * Find the given tag by its hierarchical IDs.
-        * <p>
-        * I.E., it will take the tag A, subtag B, subsubtag C...
-        * 
-        * @param ids
-        *            the IDs to look for
-        * 
-        * @return the appropriate tag fully filled, or NULL if not found
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public SearchableTag getTag(Integer... ids) throws IOException {
-               SearchableTag tag = null;
-               List<SearchableTag> tags = getTags();
-
-               for (Integer tagIndex : ids) {
-                       // ! 1-based index !
-                       if (tagIndex == null || tags == null || tagIndex <= 0
-                                       || tagIndex > tags.size()) {
-                               return null;
-                       }
-
-                       tag = tags.get(tagIndex - 1);
-                       fillTag(tag);
-                       tags = tag.getChildren();
-               }
-
-               return tag;
-       }
-
-       /**
-        * The support type.
-        * 
-        * @return the type
-        */
-       public SupportType getType() {
-               return type;
-       }
-
-       /**
-        * The support type.
-        * 
-        * @param type
-        *            the new type
-        */
-       protected void setType(SupportType type) {
-               this.type = type;
-       }
-
-       /**
-        * The associated {@link BasicSupport}.
-        * <p>
-        * Mostly used to download content.
-        * 
-        * @return the support
-        */
-       protected BasicSupport getSupport() {
-               return support;
-       }
-
-       /**
-        * Get a list of tags that can be browsed here.
-        * 
-        * @return the list of tags
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       abstract public List<SearchableTag> getTags() throws IOException;
-
-       /**
-        * Fill the tag (set it 'complete') with more information from the support.
-        * 
-        * @param tag
-        *            the tag to fill
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       abstract public void fillTag(SearchableTag tag) throws IOException;
-
-       /**
-        * Search for the given term and return the number of pages of results of
-        * stories satisfying this search term.
-        * 
-        * @param search
-        *            the term to search for
-        * 
-        * @return a number of pages
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       abstract public int searchPages(String search) throws IOException;
-
-       /**
-        * Search for the given tag and return the number of pages of results of
-        * stories satisfying this tag.
-        * 
-        * @param tag
-        *            the tag to search for
-        * 
-        * @return a number of pages
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       abstract public int searchPages(SearchableTag tag) throws IOException;
-
-       /**
-        * Search for the given term and return a list of stories satisfying this
-        * search term.
-        * <p>
-        * Not that the returned stories will <b>NOT</b> be complete, but will only
-        * contain enough information to present them to the user and retrieve them.
-        * <p>
-        * URL is guaranteed to be usable, LUID will always be NULL.
-        * 
-        * @param search
-        *            the term to search for
-        * @param page
-        *            the page to use for result pagination, index is 1-based
-        * 
-        * @return a list of stories that satisfy that search term
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       abstract public List<MetaData> search(String search, int page)
-                       throws IOException;
-
-       /**
-        * Search for the given tag and return a list of stories satisfying this
-        * tag.
-        * <p>
-        * Not that the returned stories will <b>NOT</b> be complete, but will only
-        * contain enough information to present them to the user and retrieve them.
-        * <p>
-        * URL is guaranteed to be usable, LUID will always be NULL.
-        * 
-        * @param tag
-        *            the tag to search for
-        * @param page
-        *            the page to use for result pagination (see
-        *            {@link SearchableTag#getPages()}, remember to check for -1),
-        *            index is 1-based
-        * 
-        * @return a list of stories that satisfy that search term
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       abstract public List<MetaData> search(SearchableTag tag, int page)
-                       throws IOException;
-
-       /**
-        * Load a document from its url.
-        * 
-        * @param url
-        *            the URL to load
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @return the document
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Document load(String url, boolean stable) throws IOException {
-               return load(new URL(url), stable);
-       }
-
-       /**
-        * Load a document from its url.
-        * 
-        * @param url
-        *            the URL to load
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @return the document
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Document load(URL url, boolean stable) throws IOException {
-               return DataUtil.load(Instance.getInstance().getCache().open(url, support, stable), "UTF-8", url.toString());
-       }
-
-       /**
-        * Return a {@link BasicSearchable} implementation supporting the given
-        * type, or NULL if it does not exist.
-        * 
-        * @param type
-        *            the type, can be NULL (will just return NULL, since we do not
-        *            support it)
-        * 
-        * @return an implementation that supports it, or NULL
-        */
-       static public BasicSearchable getSearchable(SupportType type) {
-               BasicSearchable support = null;
-
-               if (type != null) {
-                       switch (type) {
-                       case FIMFICTION:
-                               // TODO searchable for FIMFICTION
-                               break;
-                       case FANFICTION:
-                               support = new Fanfiction(type);
-                               break;
-                       case MANGAHUB:
-                               // TODO searchable for MANGAHUB
-                               break;
-                       case E621:
-                               // TODO searchable for E621
-                               break;
-                       case YIFFSTAR:
-                               // TODO searchable for YIFFSTAR
-                               break;
-                       case E_HENTAI:
-                               // TODO searchable for E_HENTAI
-                               break;
-                       case MANGA_LEL:
-                               support = new MangaLel();
-                               break;
-                       case CBZ:
-                       case HTML:
-                       case INFO_TEXT:
-                       case TEXT:
-                       case EPUB:
-                               break;
-                       }
-               }
-
-               return support;
-       }
-}
diff --git a/searchable/Fanfiction.java b/searchable/Fanfiction.java
deleted file mode 100644 (file)
index e2fba1f..0000000
+++ /dev/null
@@ -1,411 +0,0 @@
-package be.nikiroo.fanfix.searchable;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.net.URLEncoder;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-import org.jsoup.select.Elements;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * A {@link BasicSearchable} for Fanfiction.NET.
- * 
- * @author niki
- */
-class Fanfiction extends BasicSearchable {
-       static private String BASE_URL = "http://fanfiction.net/";
-
-       /**
-        * Create a new {@link Fanfiction}.
-        * 
-        * @param type
-        *            {@link SupportType#FANFICTION}
-        */
-       public Fanfiction(SupportType type) {
-               super(type);
-       }
-
-       @Override
-       public List<SearchableTag> getTags() throws IOException {
-               String storiesName = null;
-               String crossoversName = null;
-               Map<String, String> stories = new HashMap<String, String>();
-               Map<String, String> crossovers = new HashMap<String, String>();
-
-               Document mainPage = load(BASE_URL, true);
-               Element menu = mainPage.getElementsByClass("dropdown").first();
-               if (menu != null) {
-                       Element ul = menu.getElementsByClass("dropdown-menu").first();
-                       if (ul != null) {
-                               Map<String, String> currentList = null;
-                               for (Element li : ul.getElementsByTag("li")) {
-                                       if (li.hasClass("disabled")) {
-                                               if (storiesName == null) {
-                                                       storiesName = li.text();
-                                                       currentList = stories;
-                                               } else {
-                                                       crossoversName = li.text();
-                                                       currentList = crossovers;
-                                               }
-                                       } else if (currentList != null) {
-                                               Element a = li.getElementsByTag("a").first();
-                                               if (a != null) {
-                                                       currentList.put(a.absUrl("href"), a.text());
-                                               }
-                                       }
-                               }
-                       }
-               }
-
-               List<SearchableTag> tags = new ArrayList<SearchableTag>();
-
-               if (storiesName != null) {
-                       SearchableTag tag = new SearchableTag(null, storiesName, false);
-                       for (String id : stories.keySet()) {
-                               tag.add(new SearchableTag(id, stories.get(id), false, false));
-                       }
-                       tags.add(tag);
-               }
-
-               if (crossoversName != null) {
-                       SearchableTag tag = new SearchableTag(null, crossoversName, false);
-                       for (String id : crossovers.keySet()) {
-                               tag.add(new SearchableTag(id, crossovers.get(id), false, false));
-                       }
-                       tags.add(tag);
-               }
-
-               return tags;
-       }
-
-       @Override
-       public void fillTag(SearchableTag tag) throws IOException {
-               if (tag.getId() == null || tag.isComplete()) {
-                       return;
-               }
-
-               Document doc = load(tag.getId(), false);
-               Element list = doc.getElementById("list_output");
-               if (list != null) {
-                       Element table = list.getElementsByTag("table").first();
-                       if (table != null) {
-                               for (Element div : table.getElementsByTag("div")) {
-                                       Element a = div.getElementsByTag("a").first();
-                                       Element span = div.getElementsByTag("span").first();
-
-                                       if (a != null) {
-                                               String subid = a.absUrl("href");
-                                               boolean crossoverSubtag = subid
-                                                               .contains("/crossovers/");
-
-                                               SearchableTag subtag = new SearchableTag(subid,
-                                                               a.text(), !crossoverSubtag, !crossoverSubtag);
-
-                                               tag.add(subtag);
-                                               if (span != null) {
-                                                       String nr = span.text();
-                                                       if (nr.startsWith("(")) {
-                                                               nr = nr.substring(1);
-                                                       }
-                                                       if (nr.endsWith(")")) {
-                                                               nr = nr.substring(0, nr.length() - 1);
-                                                       }
-                                                       nr = nr.trim();
-
-                                                       // TODO: fix toNumber/fromNumber
-                                                       nr = nr.replaceAll("\\.[0-9]*", "");
-
-                                                       subtag.setCount(StringUtils.toNumber(nr));
-                                               }
-                                       }
-                               }
-                       }
-               }
-
-               tag.setComplete(true);
-       }
-
-       @Override
-       public List<MetaData> search(String search, int page) throws IOException {
-               String encoded = URLEncoder.encode(search.toLowerCase(), "utf-8");
-               String url = BASE_URL + "search/?ready=1&type=story&keywords="
-                               + encoded + "&ppage=" + page;
-
-               return getStories(url, null, null);
-       }
-
-       @Override
-       public List<MetaData> search(SearchableTag tag, int page)
-                       throws IOException {
-               List<MetaData> metas = new ArrayList<MetaData>();
-
-               String url = tag.getId();
-               if (url != null) {
-                       if (page > 1) {
-                               int pos = url.indexOf("&p=");
-                               if (pos >= 0) {
-                                       url = url.replaceAll("(.*\\&p=)[0-9]*(.*)", "$1\\" + page
-                                                       + "$2");
-                               } else {
-                                       url += "&p=" + page;
-                               }
-                       }
-
-                       Document doc = load(url, false);
-
-                       // Update the pages number if needed
-                       if (tag.getPages() < 0 && tag.isLeaf()) {
-                               tag.setPages(getPages(doc));
-                       }
-
-                       // Find out the full subjects (including parents)
-                       String subjects = "";
-                       for (SearchableTag t = tag; t != null; t = t.getParent()) {
-                               if (!subjects.isEmpty()) {
-                                       subjects += ", ";
-                               }
-                               subjects += t.getName();
-                       }
-
-                       metas = getStories(url, doc, subjects);
-               }
-
-               return metas;
-       }
-
-       @Override
-       public int searchPages(String search) throws IOException {
-               String encoded = URLEncoder.encode(search.toLowerCase(), "utf-8");
-               String url = BASE_URL + "search/?ready=1&type=story&keywords="
-                               + encoded;
-
-               return getPages(load(url, false));
-       }
-
-       @Override
-       public int searchPages(SearchableTag tag) throws IOException {
-               if (tag.isLeaf()) {
-                       String url = tag.getId();
-                       return getPages(load(url, false));
-               }
-
-               return 0;
-       }
-
-       /**
-        * Return the number of pages in this stories result listing.
-        * 
-        * @param doc
-        *            the document
-        * 
-        * @return the number of pages or -1 if unknown
-        */
-       private int getPages(Document doc) {
-               int pages = -1;
-
-               if (doc != null) {
-                       Element center = doc.getElementsByTag("center").first();
-                       if (center != null) {
-                               for (Element a : center.getElementsByTag("a")) {
-                                       if (a.absUrl("href").contains("&p=")) {
-                                               int thisLinkPages = -1;
-                                               try {
-                                                       String[] tab = a.absUrl("href").split("=");
-                                                       tab = tab[tab.length - 1].split("&");
-                                                       thisLinkPages = Integer
-                                                                       .parseInt(tab[tab.length - 1]);
-                                               } catch (Exception e) {
-                                               }
-
-                                               pages = Math.max(pages, thisLinkPages);
-                                       }
-                               }
-                       }
-               }
-
-               return pages;
-       }
-
-       /**
-        * Fetch the stories from the given page.
-        * 
-        * @param sourceUrl
-        *            the url of the document
-        * @param doc
-        *            the document to use (if NULL, will be loaded from
-        *            <tt>sourceUrl</tt>)
-        * @param mainSubject
-        *            the main subject (the anime/book/movie item related to the
-        *            stories, like "MLP" or "Doctor Who"), or NULL if none
-        * 
-        * @return the stories found in it
-        * 
-        * @throws IOException
-        *             in case of I/O errors
-        */
-       private List<MetaData> getStories(String sourceUrl, Document doc,
-                       String mainSubject) throws IOException {
-               List<MetaData> metas = new ArrayList<MetaData>();
-
-               if (doc == null) {
-                       doc = load(sourceUrl, false);
-               }
-
-               for (Element story : doc.getElementsByClass("z-list")) {
-                       MetaData meta = new MetaData();
-                       meta.setImageDocument(false);
-                       meta.setSource(getType().getSourceName());
-                       meta.setPublisher(getType().getSourceName());
-                       meta.setType(getType().toString());
-
-                       // Title, URL, Cover
-                       Element stitle = story.getElementsByClass("stitle").first();
-                       if (stitle != null) {
-                               meta.setTitle(stitle.text());
-                               meta.setUrl(stitle.absUrl("href"));
-                               meta.setUuid(meta.getUrl());
-                               Element cover = stitle.getElementsByTag("img").first();
-                               if (cover != null) {
-                                       // note: see data-original if needed?
-                                       String coverUrl = cover.absUrl("src");
-
-                                       try {
-                                               InputStream in = Instance.getInstance().getCache().open(new URL(coverUrl), getSupport(), true);
-                                               try {
-                                                       meta.setCover(new Image(in));
-                                               } finally {
-                                                       in.close();
-                                               }
-                                       } catch (Exception e) {
-                                               // Should not happen on Fanfiction.net
-                                               Instance.getInstance().getTraceHandler().error(new Exception(
-                                                               "Cannot download cover for Fanfiction story in search mode: " + meta.getTitle(), e));
-                                       }
-                               }
-                       }
-
-                       // Author
-                       Elements as = story.getElementsByTag("a");
-                       if (as.size() > 1) {
-                               meta.setAuthor(as.get(1).text());
-                       }
-
-                       // Tags (concatenated text), published date, updated date, Resume
-                       String tags = "";
-                       List<String> tagList = new ArrayList<String>();
-                       Elements divs = story.getElementsByTag("div");
-                       if (divs.size() > 1 && divs.get(1).childNodeSize() > 0) {
-                               String resume = divs.get(1).text();
-                               if (divs.size() > 2) {
-                                       tags = divs.get(2).text();
-                                       resume = resume.substring(0,
-                                                       resume.length() - tags.length()).trim();
-
-                                       for (Element d : divs.get(2).getElementsByAttribute(
-                                                       "data-xutime")) {
-                                               String secs = d.attr("data-xutime");
-                                               try {
-                                                       String date = new SimpleDateFormat("yyyy-MM-dd")
-                                                                       .format(new Date(
-                                                                                       Long.parseLong(secs) * 1000));
-                                                       // (updated, ) published
-                                                       if (meta.getDate() != null) {
-                                                               tagList.add("Updated: " + meta.getDate());
-                                                       }
-                                                       meta.setDate(date);
-                                               } catch (Exception e) {
-                                               }
-                                       }
-                               }
-
-                               meta.setResume(getSupport().makeChapter(new URL(sourceUrl), 0,
-                                               Instance.getInstance().getTrans().getString(StringId.DESCRIPTION), resume));
-                       }
-
-                       // How are the tags ordered?
-                       // We have "Rated: xx", then the language, then all other tags
-                       // If the subject(s) is/are present, they are before "Rated: xx"
-
-                       // ////////////
-                       // Examples: //
-                       // ////////////
-
-                       // Search (Luna) Tags: [Harry Potter, Rated: T, English, Chapters:
-                       // 1, Words: 270, Reviews: 2, Published: 2/19/2013, Luna L.]
-
-                       // Normal (MLP) Tags: [Rated: T, Spanish, Drama/Suspense, Chapters:
-                       // 2, Words: 8,686, Reviews: 1, Favs: 1, Follows: 1, Updated: 4/7,
-                       // Published: 4/2]
-
-                       // Crossover (MLP/Who) Tags: [Rated: K+, English, Adventure/Romance,
-                       // Chapters: 8, Words: 7,788, Reviews: 2, Favs: 2, Follows: 1,
-                       // Published: 9/1/2016]
-
-                       boolean rated = false;
-                       boolean isLang = false;
-                       String subject = mainSubject == null ? "" : mainSubject;
-                       String[] tab = tags.split("  *-  *");
-                       for (int i = 0; i < tab.length; i++) {
-                               String tag = tab[i];
-                               if (tag.startsWith("Rated: ")) {
-                                       rated = true;
-                               }
-
-                               if (!rated) {
-                                       if (!subject.isEmpty()) {
-                                               subject += ", ";
-                                       }
-                                       subject += tag;
-                               } else if (isLang) {
-                                       meta.setLang(tag);
-                                       isLang = false;
-                               } else {
-                                       if (tag.contains(":")) {
-                                               // Handle special tags:
-                                               if (tag.startsWith("Words: ")) {
-                                                       try {
-                                                               meta.setWords(Long.parseLong(tag
-                                                                               .substring("Words: ".length())
-                                                                               .replace(",", "").trim()));
-                                                       } catch (Exception e) {
-                                                       }
-                                               } else if (tag.startsWith("Rated: ")) {
-                                                       tagList.add(tag);
-                                               }
-                                       } else {
-                                               // Normal tags are "/"-separated
-                                               for (String t : tag.split("/")) {
-                                                       tagList.add(t);
-                                               }
-                                       }
-
-                                       if (tag.startsWith("Rated: ")) {
-                                               isLang = true;
-                                       }
-                               }
-                       }
-
-                       meta.setSubject(subject);
-                       meta.setTags(tagList);
-
-                       metas.add(meta);
-               }
-
-               return metas;
-       }
-}
diff --git a/searchable/MangaLel.java b/searchable/MangaLel.java
deleted file mode 100644 (file)
index 5ba21a0..0000000
+++ /dev/null
@@ -1,184 +0,0 @@
-package be.nikiroo.fanfix.searchable;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.net.URLEncoder;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import org.jsoup.helper.DataUtil;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-import org.jsoup.select.Elements;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.StringUtils;
-
-class MangaLel extends BasicSearchable {
-       private String BASE_URL = "http://mangas-lecture-en-ligne.fr/index_lel.php";
-
-       public MangaLel() {
-               super(SupportType.MANGA_LEL);
-       }
-
-       @Override
-       public List<SearchableTag> getTags() throws IOException {
-               List<SearchableTag> tags = new ArrayList<SearchableTag>();
-
-               String url = BASE_URL + "?page=recherche";
-               Document doc = load(url, false);
-
-               Element genre = doc.getElementsByClass("genre").first();
-               if (genre != null) {
-                       for (Element el : genre.getElementsByAttributeValueStarting("for",
-                                       "genre")) {
-                               tags.add(new SearchableTag(el.attr("for"), el.text(), true));
-                       }
-               }
-
-               return tags;
-       }
-
-       @Override
-       public void fillTag(SearchableTag tag) throws IOException {
-               // Tags are always complete
-       }
-
-       @Override
-       public List<MetaData> search(String search, int page) throws IOException {
-               String url = BASE_URL + "?nomProjet="
-                               + URLEncoder.encode(search, "utf-8")
-                               + "&nomAuteur=&nomTeam=&page=recherche&truc=truc";
-
-               // No pagination
-               return getResults(url);
-       }
-
-       @Override
-       public List<MetaData> search(SearchableTag tag, int page)
-                       throws IOException {
-               String url = BASE_URL + "?nomProjet=&nomAuteur=&nomTeam=&"
-                               + tag.getId() + "=on&page=recherche&truc=truc";
-
-               // No pagination
-               return getResults(url);
-       }
-
-       @Override
-       public int searchPages(String search) throws IOException {
-               // No pagination
-               return 1;
-       }
-
-       @Override
-       public int searchPages(SearchableTag tag) throws IOException {
-               if (tag.isLeaf()) {
-                       // No pagination
-                       return 1;
-               }
-
-               return 0;
-       }
-
-       private List<MetaData> getResults(String sourceUrl) throws IOException {
-               List<MetaData> metas = new ArrayList<MetaData>();
-
-               Document doc = DataUtil.load(Instance.getInstance().getCache().open(new URL(sourceUrl), getSupport(), false),
-                               "UTF-8", sourceUrl);
-
-               for (Element result : doc.getElementsByClass("rechercheAffichage")) {
-                       Element a = result.getElementsByTag("a").first();
-                       if (a != null) {
-                               int projectId = -1;
-
-                               MetaData meta = new MetaData();
-
-                               // Target:
-                               // http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet=218
-
-                               // a.absUrl("href"):
-                               // http://mangas-lecture-en-ligne.fr/index_lel?onCommence=oui&idChapitre=2805
-
-                               // ...but we need the PROJECT id, not the CHAPTER id -> use
-                               // <IMG>
-
-                               Elements infos = result.getElementsByClass("texte");
-                               if (infos != null) {
-                                       String[] tab = infos.outerHtml().split("<br>");
-
-                                       meta.setLang("fr");
-                                       meta.setSource(getType().getSourceName());
-                                       meta.setPublisher(getType().getSourceName());
-                                       meta.setType(getType().toString());
-                                       meta.setSubject("manga");
-                                       meta.setImageDocument(true);
-                                       meta.setTitle(getVal(tab, 0));
-                                       meta.setAuthor(getVal(tab, 1));
-                                       meta.setTags(Arrays.asList(getVal(tab, 2).split(" ")));
-
-                                       meta.setResume(getSupport().makeChapter(new URL(sourceUrl), 0,
-                                                       Instance.getInstance().getTrans().getString(StringId.DESCRIPTION), getVal(tab, 5)));
-                               }
-
-                               Element img = result.getElementsByTag("img").first();
-                               if (img != null) {
-                                       try {
-                                               String[] tab = img.attr("src").split("/");
-                                               String str = tab[tab.length - 1];
-                                               tab = str.split("\\.");
-                                               str = tab[0];
-                                               projectId = Integer.parseInt(str);
-
-                                               String coverUrl = img.absUrl("src");
-                                               try {
-                                                       InputStream in = Instance.getInstance().getCache().open(new URL(coverUrl), getSupport(),
-                                                                       true);
-                                                       try {
-                                                               meta.setCover(new Image(in));
-                                                       } finally {
-                                                               in.close();
-                                                       }
-                                               } catch (Exception e) {
-                                                       // Happen often on MangaLEL...
-                                                       Instance.getInstance().getTraceHandler().trace(
-                                                                       "Cannot download cover for MangaLEL story in search mode: "     + meta.getTitle());
-                                               }
-                                       } catch (Exception e) {
-                                               // no project id... cannot use the story :(
-                                               Instance.getInstance().getTraceHandler()
-                                                               .error("Cannot find ProjectId for MangaLEL story in search mode: " + meta.getTitle());
-                                       }
-                               }
-
-                               if (projectId >= 0) {
-                                       meta.setUrl("http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet="
-                                                       + projectId);
-                                       meta.setUuid(meta.getUrl());
-                                       metas.add(meta);
-                               }
-                       }
-               }
-
-               return metas;
-       }
-
-       private String getVal(String[] tab, int i) {
-               String val = "";
-
-               if (i < tab.length) {
-                       val = StringUtils.unhtml(tab[i]);
-                       int pos = val.indexOf(":");
-                       if (pos >= 0) {
-                               val = val.substring(pos + 1).trim();
-                       }
-               }
-
-               return val;
-       }
-}
diff --git a/searchable/SearchableTag.java b/searchable/SearchableTag.java
deleted file mode 100644 (file)
index de86798..0000000
+++ /dev/null
@@ -1,324 +0,0 @@
-package be.nikiroo.fanfix.searchable;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * This class represents a tag that can be searched on a supported website.
- * 
- * @author niki
- */
-public class SearchableTag {
-       private String id;
-       private String name;
-       private boolean complete;
-       private long count;
-
-       private SearchableTag parent;
-       private List<SearchableTag> children;
-
-       /**
-        * The number of stories result pages this tag can get.
-        * <p>
-        * We keep more information than what the getter/setter returns/accepts.
-        * <ul>
-        * <li>-2: this tag does not support stories results (not a leaf tag)</li>
-        * <li>-1: the number is not yet known, but will be known after a
-        * {@link BasicSearchable#fillTag(SearchableTag)} operation</li>
-        * <li>X: the number of pages</li>
-        * </ul>
-        */
-       private int pages;
-
-       /**
-        * Create a new {@link SearchableTag}.
-        * <p>
-        * Note that tags are complete by default.
-        * 
-        * @param id
-        *            the ID (usually a way to find the linked stories later on)
-        * @param name
-        *            the tag name, which can be displayed to the user
-        * @param leaf
-        *            the tag is a leaf tag, that is, it will not return subtags
-        *            with {@link BasicSearchable#fillTag(SearchableTag)} but will
-        *            return stories with
-        *            {@link BasicSearchable#search(SearchableTag, int)}
-        */
-       public SearchableTag(String id, String name, boolean leaf) {
-               this(id, name, leaf, true);
-       }
-
-       /**
-        * Create a new {@link SearchableTag}.
-        * 
-        * @param id
-        *            the ID (usually a way to find the linked stories later on)
-        * @param name
-        *            the tag name, which can be displayed to the user
-        * @param leaf
-        *            the tag is a leaf tag, that is, it will not return subtags
-        *            with {@link BasicSearchable#fillTag(SearchableTag)} but will
-        *            return stories with
-        *            {@link BasicSearchable#search(SearchableTag, int)}
-        * @param complete
-        *            the tag {@link SearchableTag#isComplete()} or not
-        */
-       public SearchableTag(String id, String name, boolean leaf, boolean complete) {
-               this.id = id;
-               this.name = name;
-               this.complete = leaf || complete;
-
-               setLeaf(leaf);
-
-               children = new ArrayList<SearchableTag>();
-       }
-
-       /**
-        * The ID (usually a way to find the linked stories later on).
-        * 
-        * @return the ID
-        */
-       public String getId() {
-               return id;
-       }
-
-       /**
-        * The tag name, which can be displayed to the user.
-        * 
-        * @return then name
-        */
-       public String getName() {
-               return name;
-       }
-
-       /**
-        * The fully qualified tag name, which can be displayed to the user.
-        * <p>
-        * It will display all the tags that lead to this one as well as this one.
-        * 
-        * @return the fully qualified name
-        */
-       public String getFqName() {
-               if (parent != null) {
-                       return parent.getFqName() + " / " + name;
-               }
-
-               return "" + name;
-       }
-
-       /**
-        * Non-complete, non-leaf tags can still be completed via a
-        * {@link BasicSearchable#fillTag(SearchableTag)} operation from a
-        * {@link BasicSearchable}, in order to gain (more?) subtag children.
-        * <p>
-        * Leaf tags are always considered complete.
-        * 
-        * @return TRUE if it is complete
-        */
-       public boolean isComplete() {
-               return complete;
-       }
-
-       /**
-        * Non-complete, non-leaf tags can still be completed via a
-        * {@link BasicSearchable#fillTag(SearchableTag)} operation from a
-        * {@link BasicSearchable}, in order to gain (more?) subtag children.
-        * <p>
-        * Leaf tags are always considered complete.
-        * 
-        * @param complete
-        *            TRUE if it is complete
-        */
-       public void setComplete(boolean complete) {
-               this.complete = isLeaf() || complete;
-       }
-
-       /**
-        * The number of items that can be found with this tag if it is searched.
-        * <p>
-        * Will report the number of subtags by default.
-        * 
-        * @return the number of items
-        */
-       public long getCount() {
-               long count = this.count;
-               if (count <= 0) {
-                       count = children.size();
-               }
-
-               return count;
-       }
-
-       /**
-        * The number of items that can be found with this tag if it is searched.
-        * 
-        * @param count
-        *            the new count
-        */
-       public void setCount(long count) {
-               this.count = count;
-       }
-
-       /**
-        * The number of stories result pages this tag contains, only make sense if
-        * {@link SearchableTag#isLeaf()} returns TRUE.
-        * <p>
-        * Will return -1 if the number is not yet known.
-        * 
-        * @return the number of pages, or -1
-        */
-       public int getPages() {
-               return Math.max(-1, pages);
-       }
-
-       /**
-        * The number of stories result pages this tag contains, only make sense if
-        * {@link SearchableTag#isLeaf()} returns TRUE.
-        * 
-        * @param pages
-        *            the (positive or 0) number of pages
-        */
-       public void setPages(int pages) {
-               this.pages = Math.max(-1, pages);
-       }
-
-       /**
-        * This tag is a leaf tag, that is, it will not return other subtags with
-        * {@link BasicSearchable#fillTag(SearchableTag)} but will return stories
-        * with {@link BasicSearchable#search(SearchableTag, int)}.
-        * 
-        * @return TRUE if it is
-        */
-       public boolean isLeaf() {
-               return pages > -2;
-       }
-
-       /**
-        * This tag is a leaf tag, that is, it will not return other subtags with
-        * {@link BasicSearchable#fillTag(SearchableTag)} but will return stories
-        * with {@link BasicSearchable#search(SearchableTag, int)}.
-        * <p>
-        * Will reset the number of pages to -1.
-        * 
-        * @param leaf
-        *            TRUE if it is
-        */
-       public void setLeaf(boolean leaf) {
-               pages = leaf ? -1 : -2;
-               if (leaf) {
-                       complete = true;
-               }
-       }
-
-       /**
-        * The subtag children of this {@link SearchableTag}.
-        * <p>
-        * Never NULL.
-        * <p>
-        * Note that if {@link SearchableTag#isComplete()} returns false, you can
-        * still fill (more?) subtag children with a {@link BasicSearchable}.
-        * 
-        * @return the subtag children, never NULL
-        */
-       public List<SearchableTag> getChildren() {
-               return children;
-       }
-
-       /**
-        * Add the given {@link SearchableTag} as a subtag child.
-        * 
-        * @param tag
-        *            the tag to add
-        */
-       public void add(SearchableTag tag) {
-               if (tag == null) {
-                       throw new NullPointerException("tag");
-               }
-
-               for (SearchableTag p = this; p != null; p = p.parent) {
-                       if (p.equals(tag)) {
-                               throw new IllegalArgumentException(
-                                               "Tags do not allow recursion");
-                       }
-               }
-               for (SearchableTag p = tag; p != null; p = p.parent) {
-                       if (p.equals(this)) {
-                               throw new IllegalArgumentException(
-                                               "Tags do not allow recursion");
-                       }
-               }
-
-               children.add(tag);
-               tag.parent = this;
-       }
-
-       /**
-        * This {@link SearchableTag} parent tag, or NULL if none.
-        * 
-        * @return the parent or NULL
-        */
-       public SearchableTag getParent() {
-               return parent;
-       }
-
-       /**
-        * Display a DEBUG {@link String} representation of this object.
-        */
-       @Override
-       public String toString() {
-               String rep = name + " [" + id + "]";
-               if (!complete) {
-                       rep += "*";
-               }
-
-               if (getCount() > 0) {
-                       rep += " (" + getCount() + ")";
-               }
-
-               if (!children.isEmpty()) {
-                       String tags = "";
-                       int i = 1;
-                       for (SearchableTag tag : children) {
-                               if (!tags.isEmpty()) {
-                                       tags += ", ";
-                               }
-
-                               if (i > 10) {
-                                       tags += "...";
-                                       break;
-                               }
-
-                               tags += tag;
-                               i++;
-                       }
-
-                       rep += ": " + tags;
-               }
-
-               return rep;
-       }
-
-       @Override
-       public int hashCode() {
-               return getFqName().hashCode();
-       }
-
-       @Override
-       public boolean equals(Object otherObj) {
-               if (otherObj instanceof SearchableTag) {
-                       SearchableTag other = (SearchableTag) otherObj;
-                       if ((id == null && other.id == null)
-                                       || (id != null && id.equals(other.id))) {
-                               if (getFqName().equals(other.getFqName())) {
-                                       if ((parent == null && other.parent == null)
-                                                       || (parent != null && parent.equals(other.parent))) {
-                                               return true;
-                                       }
-                               }
-                       }
-               }
-
-               return false;
-       }
-}
diff --git a/serial/CustomSerializer.java b/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/serial/Exporter.java b/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/serial/Importer.java b/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/serial/SerialUtils.java b/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/serial/server/ConnectAction.java b/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/serial/server/ConnectActionClient.java b/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/serial/server/ConnectActionClientObject.java b/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/serial/server/ConnectActionClientString.java b/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/serial/server/ConnectActionServer.java b/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/serial/server/ConnectActionServerObject.java b/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/serial/server/ConnectActionServerString.java b/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/serial/server/Server.java b/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/serial/server/ServerBridge.java b/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/serial/server/ServerObject.java b/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/serial/server/ServerString.java b/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/streams/Base64.java b/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/streams/Base64InputStream.java b/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/streams/Base64OutputStream.java b/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/streams/BufferedInputStream.java b/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/streams/BufferedOutputStream.java b/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/streams/MarkableFileInputStream.java b/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/streams/NextableInputStream.java b/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/streams/NextableInputStreamStep.java b/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/streams/ReplaceInputStream.java b/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/streams/ReplaceOutputStream.java b/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/streams/StreamUtils.java b/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/subtree.txt b/subtree.txt
deleted file mode 100755 (executable)
index ae585c7..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# Subtrees
-
-# The subtrees used for this program:
-#      ```git subtree add -P src/be/nikiroo/utils git@github.com:nikiroo/nikiroo-utils.git subtree```
-#      ```git subtree add -P src/jexer git@github.com:nikiroo/jexer.git subtree```
-
-# Update all subtrees:
-
-git subtree pull -P src/be/nikiroo/utils git@github.com:nikiroo/nikiroo-utils.git subtree
-git subtree pull -P src/jexer git@github.com:nikiroo/jexer.git subtree
-
diff --git a/supported/BasicSupport.java b/supported/BasicSupport.java
deleted file mode 100644 (file)
index 56a3bb8..0000000
+++ /dev/null
@@ -1,609 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.Map.Entry;
-
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.jsoup.helper.DataUtil;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-import org.jsoup.nodes.Node;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * This class is the base class used by the other support classes. It can be
- * used outside of this package, and have static method that you can use to get
- * access to the correct support class.
- * <p>
- * It will be used with 'resources' (usually web pages or files).
- * 
- * @author niki
- */
-public abstract class BasicSupport {
-       private Document sourceNode;
-       private URL source;
-       private SupportType type;
-       private URL currentReferer; // with only one 'r', as in 'HTTP'...
-       
-       static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
-       static protected BasicSupportImages bsImages = new BasicSupportImages();
-       static protected BasicSupportPara bsPara = new BasicSupportPara(new BasicSupportHelper(), new BasicSupportImages());
-
-       /**
-        * Check if the given resource is supported by this {@link BasicSupport}.
-        * 
-        * @param url
-        *            the resource to check for
-        * 
-        * @return TRUE if it is
-        */
-       protected abstract boolean supports(URL url);
-
-       /**
-        * Return TRUE if the support will return HTML encoded content values for
-        * the chapters content.
-        * 
-        * @return TRUE for HTML
-        */
-       protected abstract boolean isHtml();
-
-       /**
-        * Return the {@link MetaData} of this story.
-        * 
-        * @return the associated {@link MetaData}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract MetaData getMeta() throws IOException;
-
-       /**
-        * Return the story description.
-        * 
-        * @return the description
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract String getDesc() throws IOException;
-
-       /**
-        * Return the list of chapters (name and resource).
-        * <p>
-        * Can be NULL if this {@link BasicSupport} do no use chapters.
-        * 
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the chapters or NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract List<Entry<String, URL>> getChapters(Progress pg)
-                       throws IOException;
-
-       /**
-        * Return the content of the chapter (possibly HTML encoded, if
-        * {@link BasicSupport#isHtml()} is TRUE).
-        * 
-        * @param chapUrl
-        *            the chapter {@link URL}
-        * @param number
-        *            the chapter number
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the content
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract String getChapterContent(URL chapUrl, int number,
-                       Progress pg) throws IOException;
-
-       /**
-        * Return the list of cookies (values included) that must be used to
-        * correctly fetch the resources.
-        * <p>
-        * You are expected to call the super method implementation if you override
-        * it.
-        * 
-        * @return the cookies
-        */
-       public Map<String, String> getCookies() {
-               return new HashMap<String, String>();
-       }
-
-       /**
-        * OAuth authorisation (aka, "bearer XXXXXXX").
-        * 
-        * @return the OAuth string
-        */
-       public String getOAuth() {
-               return null;
-       }
-
-       /**
-        * Return the canonical form of the main {@link URL}.
-        * 
-        * @param source
-        *            the source {@link URL}, which can be NULL
-        * 
-        * @return the canonical form of this {@link URL} or NULL if the source was
-        *         NULL
-        */
-       protected URL getCanonicalUrl(URL source) {
-               return source;
-       }
-
-       /**
-        * The main {@link Node} for this {@link Story}.
-        * 
-        * @return the node
-        */
-       protected Element getSourceNode() {
-               return sourceNode;
-       }
-
-       /**
-        * The main {@link URL} for this {@link Story}.
-        * 
-        * @return the URL
-        */
-       protected URL getSource() {
-               return source;
-       }
-
-       /**
-        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
-        * the current {@link URL} we work on.
-        * 
-        * @return the referer
-        */
-       public URL getCurrentReferer() {
-               return currentReferer;
-       }
-
-       /**
-        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
-        * the current {@link URL} we work on.
-        * 
-        * @param currentReferer
-        *            the new referer
-        */
-       protected void setCurrentReferer(URL currentReferer) {
-               this.currentReferer = currentReferer;
-       }
-
-       /**
-        * The support type.
-        * 
-        * @return the type
-        */
-       public SupportType getType() {
-               return type;
-       }
-
-       /**
-        * The support type.
-        * 
-        * @param type
-        *            the new type
-        */
-       protected void setType(SupportType type) {
-               this.type = type;
-       }
-
-       /**
-        * Open an input link that will be used for the support.
-        * <p>
-        * Can return NULL, in which case you are supposed to work without a source
-        * node.
-        * 
-        * @param source
-        *            the source {@link URL}
-        * 
-        * @return the {@link InputStream}
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Document loadDocument(URL source) throws IOException {
-               String url = getCanonicalUrl(source).toString();
-               return DataUtil.load(Instance.getInstance().getCache().open(source, this, false), "UTF-8", url.toString());
-       }
-
-       /**
-        * Log into the support (can be a no-op depending upon the support).
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected void login() throws IOException {
-       }
-
-       /**
-        * Now that we have processed the {@link Story}, close the resources if any.
-        */
-       protected void close() {
-               setCurrentReferer(null);
-       }
-
-       /**
-        * Process the given story resource into a partially filled {@link Story}
-        * object containing the name and metadata.
-        * 
-        * @param getDesc
-        *            retrieve the description of the story, or not
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Story}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Story processMeta(boolean getDesc, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-
-               pg.setProgress(30);
-
-               Story story = new Story();
-               MetaData meta = getMeta();
-               if (meta.getCreationDate() == null
-                               || meta.getCreationDate().trim().isEmpty()) {
-                       meta.setCreationDate(bsHelper
-                                       .formatDate(StringUtils.fromTime(new Date().getTime())));
-               }
-               story.setMeta(meta);
-               pg.put("meta", meta);
-
-               pg.setProgress(50);
-
-               if (meta.getCover() == null) {
-                       meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
-               }
-
-               pg.setProgress(60);
-
-               if (getDesc) {
-                       String descChapterName = Instance.getInstance().getTrans().getString(StringId.DESCRIPTION);
-                       story.getMeta().setResume(bsPara.makeChapter(this, source, 0, descChapterName, //
-                                       getDesc(), isHtml(), null));
-               }
-
-               pg.done();
-               return story;
-       }
-
-       /**
-        * Utility method to convert the given URL into a JSON object.
-        * <p>
-        * Note that this method expects small JSON files (everything is copied into
-        * memory at least twice).
-        * 
-        * @param url
-        *            the URL to parse
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @return the JSON object
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected JSONObject getJson(String url, boolean stable)
-                       throws IOException {
-               try {
-                       return getJson(new URL(url), stable);
-               } catch (MalformedURLException e) {
-                       throw new IOException("Malformed URL: " + url, e);
-               }
-       }
-
-       /**
-        * Utility method to convert the given URL into a JSON object.
-        * <p>
-        * Note that this method expects small JSON files (everything is copied into
-        * memory at least twice).
-        * 
-        * @param url
-        *            the URL to parse
-        * @param stable
-        *            TRUE for more stable resources, FALSE when they often change
-        * 
-        * @return the JSON object
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected JSONObject getJson(URL url, boolean stable) throws IOException {
-               InputStream in = Instance.getInstance().getCache().open(url, null,
-                               stable);
-               try {
-                       Scanner scan = new Scanner(in);
-                       scan.useDelimiter("\0");
-                       try {
-                               return new JSONObject(scan.next());
-                       } catch (JSONException e) {
-                               throw new IOException(e);
-                       } finally {
-                               scan.close();
-                       }
-               } finally {
-                       in.close();
-               }
-       }
-
-       /**
-        * Process the given story resource into a fully filled {@link Story}
-        * object.
-        * 
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Story}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       // TODO: ADD final when BasicSupport_Deprecated is gone
-       public Story process(Progress pg) throws IOException {
-               setCurrentReferer(source);
-               login();
-               sourceNode = loadDocument(source);
-
-               try {
-                       Story story = doProcess(pg);
-                       
-                       // Check for "no chapters" stories
-                       if (story.getChapters().isEmpty()
-                                       && story.getMeta().getResume() != null
-                                       && !story.getMeta().getResume().getParagraphs().isEmpty()) {
-                               Chapter resume = story.getMeta().getResume();
-                               resume.setName("");
-                               resume.setNumber(1);
-                               story.getChapters().add(resume);
-                               story.getMeta().setWords(resume.getWords());
-
-                               String descChapterName = Instance.getInstance().getTrans()
-                                               .getString(StringId.DESCRIPTION);
-                               resume = new Chapter(0, descChapterName);
-                               story.getMeta().setResume(resume);
-                       }
-                       
-                       return story;
-               } finally {
-                       close();
-               }
-       }
-
-       /**
-        * Actual processing step, without the calls to other methods.
-        * <p>
-        * Will convert the story resource into a fully filled {@link Story} object.
-        * 
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Story}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Story doProcess(Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-               
-               pg.setName("Initialising");
-
-               pg.setProgress(1);
-               Progress pgMeta = new Progress();
-               pg.addProgress(pgMeta, 10);
-               Story story = processMeta(true, pgMeta);
-               pgMeta.done(); // 10%
-               pg.put("meta", story.getMeta());
-
-               Progress pgGetChapters = new Progress();
-               pg.addProgress(pgGetChapters, 10);
-               story.setChapters(new ArrayList<Chapter>());
-               List<Entry<String, URL>> chapters = getChapters(pgGetChapters);
-               pgGetChapters.done(); // 20%
-               
-               if (chapters != null) {
-                       Progress pgChaps = new Progress("Extracting chapters", 0,
-                                       chapters.size() * 300);
-                       pg.addProgress(pgChaps, 80);
-
-                       long words = 0;
-                       int i = 1;
-                       for (Entry<String, URL> chap : chapters) {
-                               pgChaps.setName("Extracting chapter " + i);
-                               URL chapUrl = chap.getValue();
-                               String chapName = chap.getKey();
-                               if (chapUrl != null) {
-                                       setCurrentReferer(chapUrl);
-                               }
-
-                               pgChaps.setProgress(i * 100);
-                               Progress pgGetChapterContent = new Progress();
-                               Progress pgMakeChapter = new Progress();
-                               pgChaps.addProgress(pgGetChapterContent, 100);
-                               pgChaps.addProgress(pgMakeChapter, 100);
-
-                               String content = getChapterContent(chapUrl, i,
-                                               pgGetChapterContent);
-                               pgGetChapterContent.done();
-                               Chapter cc = bsPara.makeChapter(this, chapUrl, i,
-                                               chapName, content, isHtml(), pgMakeChapter);
-                               pgMakeChapter.done();
-
-                               words += cc.getWords();
-                               story.getChapters().add(cc);
-
-                               i++;
-                       }
-                       
-                       story.getMeta().setWords(words);
-
-                       pgChaps.setName("Extracting chapters");
-                       pgChaps.done();
-               }
-
-               pg.done();
-
-               return story;
-       }
-
-       /**
-        * Create a chapter from the given data.
-        * 
-        * @param source
-        *            the source URL for this content, which can be used to try and
-        *            find images if images are present in the format [image-url]
-        * @param number
-        *            the chapter number (0 = description)
-        * @param name
-        *            the chapter name
-        * @param content
-        *            the content of the chapter
-        *            
-        * @return the {@link Chapter}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public Chapter makeChapter(URL source, int number, String name,
-                       String content) throws IOException {
-               return bsPara.makeChapter(this, source, number, name,
-                               content, isHtml(), null);
-       }
-
-       /**
-        * Return a {@link BasicSupport} implementation supporting the given
-        * resource if possible.
-        * 
-        * @param url
-        *            the story resource
-        * 
-        * @return an implementation that supports it, or NULL
-        */
-       public static BasicSupport getSupport(URL url) {
-               if (url == null) {
-                       return null;
-               }
-
-               // TEXT and INFO_TEXT always support files (not URLs though)
-               for (SupportType type : SupportType.values()) {
-                       if (type != SupportType.TEXT && type != SupportType.INFO_TEXT) {
-                               BasicSupport support = getSupport(type, url);
-                               if (support != null && support.supports(url)) {
-                                       return support;
-                               }
-                       }
-               }
-
-               for (SupportType type : new SupportType[] { SupportType.INFO_TEXT,
-                               SupportType.TEXT }) {
-                       BasicSupport support = getSupport(type, url);
-                       if (support != null && support.supports(url)) {
-                               return support;
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Return a {@link BasicSupport} implementation supporting the given type.
-        * 
-        * @param type
-        *            the type, must not be NULL
-        * @param url
-        *            the {@link URL} to support (can be NULL to get an
-        *            "abstract support"; if not NULL, will be used as the source
-        *            URL)
-        * 
-        * @return an implementation that supports it, or NULL
-        */
-       public static BasicSupport getSupport(SupportType type, URL url) {
-               BasicSupport support = null;
-
-               switch (type) {
-               case EPUB:
-                       support = new Epub();
-                       break;
-               case INFO_TEXT:
-                       support = new InfoText();
-                       break;
-               case FIMFICTION:
-                       try {
-                               // Can fail if no client key or NO in options
-                               support = new FimfictionApi();
-                       } catch (IOException e) {
-                               support = new Fimfiction();
-                       }
-                       break;
-               case FANFICTION:
-                       support = new Fanfiction();
-                       break;
-               case TEXT:
-                       support = new Text();
-                       break;
-               case MANGAHUB:
-                       support = new MangaHub();
-                       break;
-               case E621:
-                       support = new E621();
-                       break;
-               case YIFFSTAR:
-                       support = new YiffStar();
-                       break;
-               case E_HENTAI:
-                       support = new EHentai();
-                       break;
-               case MANGA_LEL:
-                       support = new MangaLel();
-                       break;
-               case CBZ:
-                       support = new Cbz();
-                       break;
-               case HTML:
-                       support = new Html();
-                       break;
-               }
-
-               if (support != null) {
-                       support.setType(type);
-                       support.source = support.getCanonicalUrl(url);
-               }
-
-               return support;
-       }
-}
diff --git a/supported/BasicSupportHelper.java b/supported/BasicSupportHelper.java
deleted file mode 100644 (file)
index 7768052..0000000
+++ /dev/null
@@ -1,281 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Helper class for {@link BasicSupport}, mostly dedicated to text formating for
- * the classes that implement {@link BasicSupport}.
- * 
- * @author niki
- */
-public class BasicSupportHelper {
-       /**
-        * Get the default cover related to this subject (see <tt>.info</tt> files).
-        * 
-        * @param subject
-        *            the subject
-        * 
-        * @return the cover if any, or NULL
-        */
-       public Image getDefaultCover(String subject) {
-               if (subject != null && !subject.isEmpty() && Instance.getInstance().getCoverDir() != null) {
-                       try {
-                               File fileCover = new File(Instance.getInstance().getCoverDir(), subject);
-                               return getImage(null, fileCover.toURI().toURL(), subject);
-                       } catch (MalformedURLException e) {
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Return the list of supported image extensions.
-        * 
-        * @param emptyAllowed
-        *            TRUE to allow an empty extension on first place, which can be
-        *            used when you may already have an extension in your input but
-        *            are not sure about it
-        * 
-        * @return the extensions
-        */
-       public String[] getImageExt(boolean emptyAllowed) {
-               if (emptyAllowed) {
-                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
-               }
-
-               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
-       }
-
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param support
-        *            the linked {@link BasicSupport} (can be NULL)
-        * @param source
-        *            the source of the story (for image lookup in the same path if
-        *            the source is a file, can be NULL)
-        * @param line
-        *            the resource to check
-        * 
-        * @return the image if found, or NULL
-        * 
-        */
-       public Image getImage(BasicSupport support, URL source, String line) {
-               URL url = getImageUrl(support, source, line);
-               if (url != null) {
-                       if ("file".equals(url.getProtocol())) {
-                               if (new File(url.getPath()).isDirectory()) {
-                                       return null;
-                               }
-                       }
-                       InputStream in = null;
-                       try {
-                               in = Instance.getInstance().getCache().open(url, support, true);
-                               return new Image(in);
-                       } catch (IOException e) {
-                       } finally {
-                               if (in != null) {
-                                       try {
-                                               in.close();
-                                       } catch (IOException e) {
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param support
-        *            the linked {@link BasicSupport} (can be NULL)
-        * @param source
-        *            the source of the story (for image lookup in the same path if
-        *            the source is a file, can be NULL)
-        * @param line
-        *            the resource to check
-        * 
-        * @return the image URL if found, or NULL
-        * 
-        */
-       public URL getImageUrl(BasicSupport support, URL source, String line) {
-               URL url = null;
-
-               if (line != null) {
-                       // try for files
-                       if (source != null) {
-                               try {
-
-                                       String relPath = null;
-                                       String absPath = null;
-                                       try {
-                                               String path = new File(source.getFile()).getParent();
-                                               relPath = new File(new File(path), line.trim())
-                                                               .getAbsolutePath();
-                                       } catch (Exception e) {
-                                               // Cannot be converted to path (one possibility to take
-                                               // into account: absolute path on Windows)
-                                       }
-                                       try {
-                                               absPath = new File(line.trim()).getAbsolutePath();
-                                       } catch (Exception e) {
-                                               // Cannot be converted to path (at all)
-                                       }
-
-                                       for (String ext : getImageExt(true)) {
-                                               File absFile = new File(absPath + ext);
-                                               File relFile = new File(relPath + ext);
-                                               if (absPath != null && absFile.exists()
-                                                               && absFile.isFile()) {
-                                                       url = absFile.toURI().toURL();
-                                               } else if (relPath != null && relFile.exists()
-                                                               && relFile.isFile()) {
-                                                       url = relFile.toURI().toURL();
-                                               }
-                                       }
-                               } catch (Exception e) {
-                                       // Should not happen since we control the correct arguments
-                               }
-                       }
-
-                       if (url == null) {
-                               // try for URLs
-                               try {
-                                       for (String ext : getImageExt(true)) {
-                                               if (Instance.getInstance().getCache().check(new URL(line + ext), true)) {
-                                                       url = new URL(line + ext);
-                                                       break;
-                                               }
-                                       }
-
-                                       // try out of cache
-                                       if (url == null) {
-                                               for (String ext : getImageExt(true)) {
-                                                       try {
-                                                               url = new URL(line + ext);
-                                                               Instance.getInstance().getCache().refresh(url, support, true);
-                                                               break;
-                                                       } catch (IOException e) {
-                                                               // no image with this ext
-                                                               url = null;
-                                                       }
-                                               }
-                                       }
-                               } catch (MalformedURLException e) {
-                                       // Not an url
-                               }
-                       }
-
-                       // refresh the cached file
-                       if (url != null) {
-                               try {
-                                       Instance.getInstance().getCache().refresh(url, support, true);
-                               } catch (IOException e) {
-                                       // woops, broken image
-                                       url = null;
-                               }
-                       }
-               }
-
-               return url;
-       }
-
-       /**
-        * Fix the author name if it is prefixed with some "by" {@link String}.
-        * 
-        * @param author
-        *            the author with a possible prefix
-        * 
-        * @return the author without prefixes
-        */
-       public String fixAuthor(String author) {
-               if (author != null) {
-                       for (String suffix : new String[] { " ", ":" }) {
-                               for (String byString : Instance.getInstance().getConfig().getList(Config.CONF_BYS)) {
-                                       byString += suffix;
-                                       if (author.toUpperCase().startsWith(byString.toUpperCase())) {
-                                               author = author.substring(byString.length()).trim();
-                                       }
-                               }
-                       }
-
-                       // Special case (without suffix):
-                       if (author.startsWith("©")) {
-                               author = author.substring(1);
-                       }
-               }
-
-               return author;
-       }
-       
-       /**
-        * Try to convert the date to a known, fixed format.
-        * <p>
-        * If it fails to do so, it will return the date as-is.
-        * 
-        * @param date
-        *            the date to convert
-        * 
-        * @return the converted date, or the date as-is
-        */
-       public String formatDate(String date) {
-               long ms = 0;
-
-               if (date != null && !date.isEmpty()) {
-                       // Default Fanfix format:
-                       try {
-                               ms = StringUtils.toTime(date);
-                       } catch (ParseException e) {
-                       }
-
-                       // Second chance:
-                       if (ms <= 0) {
-                               SimpleDateFormat sdf = new SimpleDateFormat(
-                                               "yyyy-MM-dd'T'HH:mm:ssSSS");
-                               try {
-                                       ms = sdf.parse(date).getTime();
-                               } catch (ParseException e) {
-                               }
-                       }
-
-                       // Last chance:
-                       if (ms <= 0 && date.length() >= 10) {
-                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
-                               try {
-                                       ms = sdf.parse(date.substring(0, 10)).getTime();
-                               } catch (ParseException e) {
-                               }
-                       }
-
-                       // If we found something, use THIS format:
-                       if (ms > 0) {
-                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
-                               return sdf.format(new Date(ms));
-                       }
-               }
-
-               if (date == null) {
-                       date = "";
-               }
-
-               // :(
-               return date;
-       }
-}
diff --git a/supported/BasicSupportImages.java b/supported/BasicSupportImages.java
deleted file mode 100644 (file)
index 576cb17..0000000
+++ /dev/null
@@ -1,185 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.utils.Image;
-
-/**
- * Helper class for {@link BasicSupport}, mostly dedicated to images for
- * the classes that implement {@link BasicSupport}.
- * 
- * @author niki
- */
-public class BasicSupportImages {
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param support
-        *            the support to use to download the resource (can be NULL)
-        * @param dir
-        *            the local directory to search, if any
-        * @param line
-        *            the resource to check
-        * 
-        * @return the image if found, or NULL
-        */
-       public Image getImage(BasicSupport support, File dir, String line) {
-               URL url = getImageUrl(support, dir, line);
-               return getImage(support,url);
-       }
-       
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param support
-        *            the support to use to download the resource (can be NULL)
-        * @param url
-        *            the actual URL to check (file or remote, can be NULL)
-        * 
-        * @return the image if found, or NULL
-        */
-       public Image getImage(BasicSupport support, URL url) {
-               if (url != null) {
-                       if ("file".equals(url.getProtocol())) {
-                               if (new File(url.getPath()).isDirectory()) {
-                                       return null;
-                               }
-                       }
-                       InputStream in = null;
-                       try {
-                               in = Instance.getInstance().getCache().open(url, support, true);
-                               return new Image(in);
-                       } catch (IOException e) {
-                       } finally {
-                               if (in != null) {
-                                       try {
-                                               in.close();
-                                       } catch (IOException e) {
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param support
-        *            the support to use to download the resource (can be NULL)
-        * @param dir
-        *            the local directory to search, if any
-        * @param line
-        *            the resource to check
-        * 
-        * @return the image URL if found, or NULL
-        * 
-        */
-       public URL getImageUrl(BasicSupport support, File dir, String line) {
-               URL url = null;
-
-               if (line != null) {
-                       // try for files
-                       if (dir != null && dir.exists() && !dir.isFile()) {
-                               try {
-
-                                       String relPath = null;
-                                       String absPath = null;
-                                       try {
-                                               relPath = new File(dir, line.trim()).getAbsolutePath();
-                                       } catch (Exception e) {
-                                               // Cannot be converted to path (one possibility to take
-                                               // into account: absolute path on Windows)
-                                       }
-                                       try {
-                                               absPath = new File(line.trim()).getAbsolutePath();
-                                       } catch (Exception e) {
-                                               // Cannot be converted to path (at all)
-                                       }
-
-                                       for (String ext : getImageExt(true)) {
-                                               File absFile = new File(absPath + ext);
-                                               File relFile = new File(relPath + ext);
-                                               if (absPath != null && absFile.exists()
-                                                               && absFile.isFile()) {
-                                                       url = absFile.toURI().toURL();
-                                               } else if (relPath != null && relFile.exists()
-                                                               && relFile.isFile()) {
-                                                       url = relFile.toURI().toURL();
-                                               }
-                                       }
-                               } catch (Exception e) {
-                                       // Should not happen since we control the correct arguments
-                               }
-                       }
-
-                       if (url == null) {
-                               // try for URLs
-                               try {
-                                       for (String ext : getImageExt(true)) {
-                                               if (Instance.getInstance().getCache()
-                                                               .check(new URL(line + ext), true)) {
-                                                       url = new URL(line + ext);
-                                                       break;
-                                               }
-                                       }
-
-                                       // try out of cache
-                                       if (url == null) {
-                                               for (String ext : getImageExt(true)) {
-                                                       try {
-                                                               url = new URL(line + ext);
-                                                               Instance.getInstance().getCache().refresh(url, support, true);
-                                                               break;
-                                                       } catch (IOException e) {
-                                                               // no image with this ext
-                                                               url = null;
-                                                       }
-                                               }
-                                       }
-                               } catch (MalformedURLException e) {
-                                       // Not an url
-                               }
-                       }
-
-                       // refresh the cached file
-                       if (url != null) {
-                               try {
-                                       Instance.getInstance().getCache().refresh(url, support, true);
-                               } catch (IOException e) {
-                                       // woops, broken image
-                                       url = null;
-                               }
-                       }
-               }
-
-               return url;
-       }
-
-       /**
-        * Return the list of supported image extensions.
-        * 
-        * @param emptyAllowed
-        *            TRUE to allow an empty extension on first place, which can be
-        *            used when you may already have an extension in your input but
-        *            are not sure about it
-        * 
-        * @return the extensions
-        */
-       public String[] getImageExt(boolean emptyAllowed) {
-               if (emptyAllowed) {
-                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
-               }
-
-               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
-       }
-}
diff --git a/supported/BasicSupportPara.java b/supported/BasicSupportPara.java
deleted file mode 100644 (file)
index 1dbedc9..0000000
+++ /dev/null
@@ -1,579 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Helper class for {@link BasicSupport}, mostly dedicated to {@link Paragraph}
- * and text formating for the {@link BasicSupport} class.
- * 
- * @author niki
- */
-public class BasicSupportPara {
-       // quote chars
-       private static char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
-       private static char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
-       private static char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-       private static char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-       // used by this class:
-       BasicSupportHelper bsHelper;
-       BasicSupportImages bsImages;
-       
-       public BasicSupportPara(BasicSupportHelper bsHelper, BasicSupportImages bsImages) {
-               this.bsHelper = bsHelper;
-               this.bsImages = bsImages;
-       }
-       
-       /**
-        * Create a {@link Chapter} object from the given information, formatting
-        * the content as it should be.
-        * 
-        * @param support
-        *            the linked {@link BasicSupport}
-        * @param source
-        *            the source of the story (for image lookup in the same path if
-        *            the source is a file, can be NULL)
-        * @param number
-        *            the chapter number
-        * @param name
-        *            the chapter name
-        * @param content
-        *            the chapter content
-        * @param pg
-        *            the optional progress reporter
-        * @param html
-        *            TRUE if the input content is in HTML mode
-        * 
-        * @return the {@link Chapter}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public Chapter makeChapter(BasicSupport support, URL source,
-                       int number, String name, String content, boolean html, Progress pg)
-                       throws IOException {
-               // Chapter name: process it correctly, then remove the possible
-               // redundant "Chapter x: " in front of it, or "-" (as in
-               // "Chapter 5: - Fun!" after the ": " was automatically added)
-               String chapterName = processPara(name, false)
-                               .getContent().trim();
-               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
-                       String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
-                       if (chapterName.startsWith(chapterWord)) {
-                               chapterName = chapterName.substring(chapterWord.length())
-                                               .trim();
-                               break;
-                       }
-               }
-
-               if (chapterName.startsWith(Integer.toString(number))) {
-                       chapterName = chapterName.substring(
-                                       Integer.toString(number).length()).trim();
-               }
-
-               while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
-                       chapterName = chapterName.substring(1).trim();
-               }
-               //
-
-               Chapter chap = new Chapter(number, chapterName);
-
-               if (content != null) {
-                       List<Paragraph> paras = makeParagraphs(support, source, content,
-                                       html, pg);
-                       long words = 0;
-                       for (Paragraph para : paras) {
-                               words += para.getWords();
-                       }
-                       chap.setParagraphs(paras);
-                       chap.setWords(words);
-               }
-
-               return chap;
-       }
-
-       /**
-        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
-        * and requotify them (i.e., separate them into QUOTE paragraphs and other
-        * paragraphs (quotes or not)).
-        * 
-        * @param para
-        *            the paragraph to requotify (not necessarily a quote)
-        * @param html
-        *            TRUE if the input content is in HTML mode
-        * 
-        * @return the correctly (or so we hope) quotified paragraphs
-        */
-       protected List<Paragraph> requotify(Paragraph para, boolean html) {
-               List<Paragraph> newParas = new ArrayList<Paragraph>();
-
-               if (para.getType() == ParagraphType.QUOTE
-                               && para.getContent().length() > 2) {
-                       String line = para.getContent();
-                       boolean singleQ = line.startsWith("" + openQuote);
-                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
-
-                       // Do not try when more than one quote at a time
-                       // (some stories are not easily readable if we do)
-                       if (singleQ
-                                       && line.indexOf(closeQuote, 1) < line
-                                                       .lastIndexOf(closeQuote)) {
-                               newParas.add(para);
-                               return newParas;
-                       }
-                       if (doubleQ
-                                       && line.indexOf(closeDoubleQuote, 1) < line
-                                                       .lastIndexOf(closeDoubleQuote)) {
-                               newParas.add(para);
-                               return newParas;
-                       }
-                       //
-
-                       if (!singleQ && !doubleQ) {
-                               line = openDoubleQuote + line + closeDoubleQuote;
-                               newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
-                                               .getWords()));
-                       } else {
-                               char open = singleQ ? openQuote : openDoubleQuote;
-                               char close = singleQ ? closeQuote : closeDoubleQuote;
-
-                               int posDot = -1;
-                               boolean inQuote = false;
-                               int i = 0;
-                               for (char car : line.toCharArray()) {
-                                       if (car == open) {
-                                               inQuote = true;
-                                       } else if (car == close) {
-                                               inQuote = false;
-                                       } else if (car == '.' && !inQuote) {
-                                               posDot = i;
-                                               break;
-                                       }
-                                       i++;
-                               }
-
-                               if (posDot >= 0) {
-                                       String rest = line.substring(posDot + 1).trim();
-                                       line = line.substring(0, posDot + 1).trim();
-                                       long words = 1;
-                                       for (char car : line.toCharArray()) {
-                                               if (car == ' ') {
-                                                       words++;
-                                               }
-                                       }
-                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
-                                       if (!rest.isEmpty()) {
-                                               newParas.addAll(requotify(processPara(rest, html), html));
-                                       }
-                               } else {
-                                       newParas.add(para);
-                               }
-                       }
-               } else {
-                       newParas.add(para);
-               }
-
-               return newParas;
-       }
-
-       /**
-        * Process a {@link Paragraph} from a raw line of text.
-        * <p>
-        * Will also fix quotes and HTML encoding if needed.
-        * 
-        * @param line
-        *            the raw line
-        * @param html
-        *            TRUE if the input content is in HTML mode
-        * 
-        * @return the processed {@link Paragraph}, never NULL
-        */
-       protected Paragraph processPara(String line, boolean html) {
-               if (html) {
-                       line = StringUtils.unhtml(line).trim();
-               }
-               boolean space = true;
-               boolean brk = true;
-               boolean quote = false;
-               boolean tentativeCloseQuote = false;
-               char prev = '\0';
-               int dashCount = 0;
-               long words = 1;
-
-               StringBuilder builder = new StringBuilder();
-               for (char car : line.toCharArray()) {
-                       if (car != '-') {
-                               if (dashCount > 0) {
-                                       // dash, ndash and mdash: - – —
-                                       // currently: always use mdash
-                                       builder.append(dashCount == 1 ? '-' : '—');
-                               }
-                               dashCount = 0;
-                       }
-
-                       if (tentativeCloseQuote) {
-                               tentativeCloseQuote = false;
-                               if (Character.isLetterOrDigit(car)) {
-                                       builder.append("'");
-                               } else {
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.append(closeDoubleQuote);
-                                               continue;
-                                       }
-
-                                       builder.append(closeQuote);
-                               }
-                       }
-
-                       switch (car) {
-                       case ' ': // note: unbreakable space
-                       case ' ':
-                       case '\t':
-                       case '\n': // just in case
-                       case '\r': // just in case
-                               if (builder.length() > 0
-                                               && builder.charAt(builder.length() - 1) != ' ') {
-                                       words++;
-                               }
-                               builder.append(' ');
-                               break;
-
-                       case '\'':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.deleteCharAt(builder.length() - 1);
-                                               builder.append(openDoubleQuote);
-                                       } else {
-                                               builder.append(openQuote);
-                                       }
-                               } else if (prev == ' ' || prev == car) {
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.deleteCharAt(builder.length() - 1);
-                                               builder.append(openDoubleQuote);
-                                       } else {
-                                               builder.append(openQuote);
-                                       }
-                               } else {
-                                       // it is a quote ("I'm off") or a 'quote' ("This
-                                       // 'good' restaurant"...)
-                                       tentativeCloseQuote = true;
-                               }
-                               break;
-
-                       case '"':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       builder.append(openDoubleQuote);
-                               } else if (prev == ' ') {
-                                       builder.append(openDoubleQuote);
-                               } else {
-                                       builder.append(closeDoubleQuote);
-                               }
-                               break;
-
-                       case '-':
-                               if (space) {
-                                       quote = true;
-                               } else {
-                                       dashCount++;
-                               }
-                               space = false;
-                               break;
-
-                       case '*':
-                       case '~':
-                       case '/':
-                       case '\\':
-                       case '<':
-                       case '>':
-                       case '=':
-                       case '+':
-                       case '_':
-                       case '–':
-                       case '—':
-                               space = false;
-                               builder.append(car);
-                               break;
-
-                       case '‘':
-                       case '`':
-                       case '‹':
-                       case '﹁':
-                       case '〈':
-                       case '「':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       builder.append(openQuote);
-                               } else {
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.deleteCharAt(builder.length() - 1);
-                                               builder.append(openDoubleQuote);
-                                       } else {
-                                               builder.append(openQuote);
-                                       }
-                               }
-                               space = false;
-                               brk = false;
-                               break;
-
-                       case '’':
-                       case '›':
-                       case '﹂':
-                       case '〉':
-                       case '」':
-                               space = false;
-                               brk = false;
-                               // handle double-single quotes as double quotes
-                               if (prev == car) {
-                                       builder.deleteCharAt(builder.length() - 1);
-                                       builder.append(closeDoubleQuote);
-                               } else {
-                                       builder.append(closeQuote);
-                               }
-                               break;
-
-                       case '«':
-                       case '“':
-                       case '﹃':
-                       case '《':
-                       case '『':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       builder.append(openDoubleQuote);
-                               } else {
-                                       builder.append(openDoubleQuote);
-                               }
-                               space = false;
-                               brk = false;
-                               break;
-
-                       case '»':
-                       case '”':
-                       case '﹄':
-                       case '》':
-                       case '』':
-                               space = false;
-                               brk = false;
-                               builder.append(closeDoubleQuote);
-                               break;
-
-                       default:
-                               space = false;
-                               brk = false;
-                               builder.append(car);
-                               break;
-                       }
-
-                       prev = car;
-               }
-
-               if (tentativeCloseQuote) {
-                       tentativeCloseQuote = false;
-                       builder.append(closeQuote);
-               }
-
-               line = builder.toString().trim();
-
-               ParagraphType type = ParagraphType.NORMAL;
-               if (space) {
-                       type = ParagraphType.BLANK;
-               } else if (brk) {
-                       type = ParagraphType.BREAK;
-               } else if (quote) {
-                       type = ParagraphType.QUOTE;
-               }
-
-               return new Paragraph(type, line, words);
-       }
-
-       /**
-        * Convert the given content into {@link Paragraph}s.
-        * 
-        * @param support
-        *            the linked {@link BasicSupport} (can be NULL), used to
-        *            download optional image content in []
-        * @param source
-        *            the source URL of the story (for image lookup in the same path
-        *            if the source is a file, can be NULL)
-        * @param content
-        *            the textual content
-        * @param html
-        *            TRUE if the input content is in HTML mode
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Paragraph}s (can be empty but never NULL)
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected List<Paragraph> makeParagraphs(BasicSupport support,
-                       URL source, String content, boolean html, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               if (html) {
-                       // Special <HR> processing:
-                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
-                                       "<br/>* * *<br/>");
-               }
-
-               List<Paragraph> paras = new ArrayList<Paragraph>();
-
-               if (content != null && !content.trim().isEmpty()) {
-                       if (html) {
-                               String[] tab = content.split("(<p>|</p>|<br>|<br/>)");
-                               pg.setMinMax(0, tab.length);
-                               int i = 1;
-                               for (String line : tab) {
-                                       if (line.startsWith("[") && line.endsWith("]")) {
-                                               pg.setName("Extracting image " + i);
-                                       }
-                                       paras.add(makeParagraph(support, source, line.trim(), html));
-                                       pg.setProgress(i++);
-                               }
-                       } else {
-                               List<String> lines = new ArrayList<String>();
-                               BufferedReader buff = null;
-                               try {
-                                       buff = new BufferedReader(
-                                                       new InputStreamReader(new ByteArrayInputStream(
-                                                                       content.getBytes("UTF-8")), "UTF-8"));
-                                       for (String line = buff.readLine(); line != null; line = buff
-                                                       .readLine()) {
-                                               lines.add(line.trim());
-                                       }
-                               } finally {
-                                       if (buff != null) {
-                                               buff.close();
-                                       }
-                               }
-
-                               pg.setMinMax(0, lines.size());
-                               int i = 0;
-                               for (String line : lines) {
-                                       if (line.startsWith("[") && line.endsWith("]")) {
-                                               pg.setName("Extracting image " + i);
-                                       }
-                                       paras.add(makeParagraph(support, source, line, html));
-                                       pg.setProgress(i++);
-                               }
-                       }
-
-                       pg.done();
-                       pg.setName(null);
-
-                       // Check quotes for "bad" format
-                       List<Paragraph> newParas = new ArrayList<Paragraph>();
-                       for (Paragraph para : paras) {
-                               newParas.addAll(requotify(para, html));
-                       }
-                       paras = newParas;
-
-                       // Remove double blanks/brks
-                       fixBlanksBreaks(paras);
-               }
-
-               return paras;
-       }
-
-       /**
-        * Convert the given line into a single {@link Paragraph}.
-        * 
-        * @param support
-        *            the linked {@link BasicSupport} (can be NULL), used to
-        *            download optional image content in []
-        * @param source
-        *            the source URL of the story (for image lookup in the same path
-        *            if the source is a file, can be NULL)
-        * @param line
-        *            the textual content of the paragraph
-        * @param html
-        *            TRUE if the input content is in HTML mode
-        * 
-        * @return the {@link Paragraph}, never NULL
-        */
-       protected Paragraph makeParagraph(BasicSupport support, URL source,
-                       String line, boolean html) {
-               Image image = null;
-               if (line.startsWith("[") && line.endsWith("]")) {
-                       image = bsHelper.getImage(support, source, line
-                                       .substring(1, line.length() - 1).trim());
-               }
-
-               if (image != null) {
-                       return new Paragraph(image);
-               }
-
-               return processPara(line, html);
-       }
-
-       /**
-        * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
-        * those {@link Paragraph}s.
-        * <p>
-        * The resulting list will not contain a starting or trailing blank/break
-        * nor 2 blanks or breaks following each other.
-        * 
-        * @param paras
-        *            the list of {@link Paragraph}s to fix
-        */
-       protected void fixBlanksBreaks(List<Paragraph> paras) {
-               boolean space = false;
-               boolean brk = true;
-               for (int i = 0; i < paras.size(); i++) {
-                       Paragraph para = paras.get(i);
-                       boolean thisSpace = para.getType() == ParagraphType.BLANK;
-                       boolean thisBrk = para.getType() == ParagraphType.BREAK;
-
-                       if (i > 0 && space && thisBrk) {
-                               paras.remove(i - 1);
-                               i--;
-                       } else if ((space || brk) && (thisSpace || thisBrk)) {
-                               paras.remove(i);
-                               i--;
-                       }
-
-                       space = thisSpace;
-                       brk = thisBrk;
-               }
-
-               // Remove blank/brk at start
-               if (paras.size() > 0
-                               && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
-                                               0).getType() == ParagraphType.BREAK)) {
-                       paras.remove(0);
-               }
-
-               // Remove blank/brk at end
-               int last = paras.size() - 1;
-               if (paras.size() > 0
-                               && (paras.get(last).getType() == ParagraphType.BLANK || paras
-                                               .get(last).getType() == ParagraphType.BREAK)) {
-                       paras.remove(last);
-               }
-       }
-}
diff --git a/supported/BasicSupport_Deprecated.java b/supported/BasicSupport_Deprecated.java
deleted file mode 100644 (file)
index a50ee3c..0000000
+++ /dev/null
@@ -1,1327 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import java.util.Map.Entry;
-import java.util.Scanner;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * DEPRECATED: use the new Jsoup 'Node' system.
- * <p>
- * This class is the base class used by the other support classes. It can be
- * used outside of this package, and have static method that you can use to get
- * access to the correct support class.
- * <p>
- * It will be used with 'resources' (usually web pages or files).
- * 
- * @author niki
- */
-@Deprecated
-public abstract class BasicSupport_Deprecated extends BasicSupport {
-       private InputStream in;
-
-       // quote chars
-       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
-       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
-       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-       // New methods not used in Deprecated mode
-       @Override
-       protected String getDesc() throws IOException {
-               throw new RuntimeException("should not be used by legacy code");
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               throw new RuntimeException("should not be used by legacy code");
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg)
-                       throws IOException {
-               throw new RuntimeException("should not be used by legacy code");
-       }
-
-       @Override
-       protected String getChapterContent(URL chapUrl, int number, Progress pg)
-                       throws IOException {
-               throw new RuntimeException("should not be used by legacy code");
-       }
-
-       @Override
-       public Story process(Progress pg) throws IOException {
-               return process(getSource(), pg);
-       }
-
-       //
-
-       /**
-        * Return the {@link MetaData} of this story.
-        * 
-        * @param source
-        *            the source of the story
-        * @param in
-        *            the input (the main resource)
-        * 
-        * @return the associated {@link MetaData}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract MetaData getMeta(URL source, InputStream in)
-                       throws IOException;
-
-       /**
-        * Return the story description.
-        * 
-        * @param source
-        *            the source of the story
-        * @param in
-        *            the input (the main resource)
-        * 
-        * @return the description
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract String getDesc(URL source, InputStream in)
-                       throws IOException;
-
-       /**
-        * Return the list of chapters (name and resource).
-        * 
-        * @param source
-        *            the source of the story
-        * @param in
-        *            the input (the main resource)
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the chapters
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract List<Entry<String, URL>> getChapters(URL source,
-                       InputStream in, Progress pg) throws IOException;
-
-       /**
-        * Return the content of the chapter (possibly HTML encoded, if
-        * {@link BasicSupport_Deprecated#isHtml()} is TRUE).
-        * 
-        * @param source
-        *            the source of the story
-        * @param in
-        *            the input (the main resource)
-        * @param number
-        *            the chapter number
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the content
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected abstract String getChapterContent(URL source, InputStream in,
-                       int number, Progress pg) throws IOException;
-
-       /**
-        * Process the given story resource into a partially filled {@link Story}
-        * object containing the name and metadata, except for the description.
-        * 
-        * @param url
-        *            the story resource
-        * 
-        * @return the {@link Story}
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       public Story processMeta(URL url) throws IOException {
-               return processMeta(url, true, false, null);
-       }
-
-       /**
-        * Process the given story resource into a partially filled {@link Story}
-        * object containing the name and metadata.
-        * 
-        * @param url
-        *            the story resource
-        * @param close
-        *            close "this" and "in" when done
-        * @param getDesc
-        *            retrieve the description of the story, or not
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Story}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Story processMeta(URL url, boolean close, boolean getDesc,
-                       Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-
-               login();
-               pg.setProgress(10);
-
-               url = getCanonicalUrl(url);
-
-               setCurrentReferer(url);
-
-               in = openInput(url); // NULL allowed here
-               try {
-                       preprocess(url, getInput());
-                       pg.setProgress(30);
-
-                       Story story = new Story();
-                       MetaData meta = getMeta(url, getInput());
-                       if (meta.getCreationDate() == null
-                                       || meta.getCreationDate().trim().isEmpty()) {
-                               meta.setCreationDate(bsHelper.formatDate(
-                                               StringUtils.fromTime(new Date().getTime())));
-                       }
-                       story.setMeta(meta);
-                       pg.put("meta", meta);
-
-                       pg.setProgress(50);
-
-                       if (meta.getCover() == null) {
-                               meta.setCover(getDefaultCover(meta.getSubject()));
-                       }
-
-                       pg.setProgress(60);
-
-                       if (getDesc) {
-                               String descChapterName = Instance.getInstance().getTrans().getString(StringId.DESCRIPTION);
-                               story.getMeta().setResume(makeChapter(url, 0, descChapterName, getDesc(url, getInput()), null));
-                       }
-
-                       pg.setProgress(100);
-                       return story;
-               } finally {
-                       if (close) {
-                               close();
-
-                               if (in != null) {
-                                       in.close();
-                               }
-                       }
-               }
-       }
-
-       /**
-        * Process the given story resource into a fully filled {@link Story}
-        * object.
-        * 
-        * @param url
-        *            the story resource
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Story}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Story process(URL url, Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-
-               url = getCanonicalUrl(url);
-               pg.setProgress(1);
-               try {
-                       Progress pgMeta = new Progress();
-                       pg.addProgress(pgMeta, 10);
-                       Story story = processMeta(url, false, true, pgMeta);
-                       pg.put("meta", story.getMeta());
-                       if (!pgMeta.isDone()) {
-                               pgMeta.setProgress(pgMeta.getMax()); // 10%
-                       }
-
-                       setCurrentReferer(url);
-
-                       Progress pgGetChapters = new Progress();
-                       pg.addProgress(pgGetChapters, 10);
-                       story.setChapters(new ArrayList<Chapter>());
-                       List<Entry<String, URL>> chapters = getChapters(url, getInput(),
-                                       pgGetChapters);
-                       if (!pgGetChapters.isDone()) {
-                               pgGetChapters.setProgress(pgGetChapters.getMax()); // 20%
-                       }
-
-                       if (chapters != null) {
-                               Progress pgChaps = new Progress("Extracting chapters", 0,
-                                               chapters.size() * 300);
-                               pg.addProgress(pgChaps, 80);
-
-                               long words = 0;
-                               int i = 1;
-                               for (Entry<String, URL> chap : chapters) {
-                                       pgChaps.setName("Extracting chapter " + i);
-                                       InputStream chapIn = null;
-                                       if (chap.getValue() != null) {
-                                               setCurrentReferer(chap.getValue());
-                                               chapIn = Instance.getInstance().getCache().open(chap.getValue(), this, false);
-                                       }
-                                       pgChaps.setProgress(i * 100);
-                                       try {
-                                               Progress pgGetChapterContent = new Progress();
-                                               Progress pgMakeChapter = new Progress();
-                                               pgChaps.addProgress(pgGetChapterContent, 100);
-                                               pgChaps.addProgress(pgMakeChapter, 100);
-
-                                               String content = getChapterContent(url, chapIn, i,
-                                                               pgGetChapterContent);
-                                               if (!pgGetChapterContent.isDone()) {
-                                                       pgGetChapterContent.setProgress(pgGetChapterContent
-                                                                       .getMax());
-                                               }
-
-                                               Chapter cc = makeChapter(url, i, chap.getKey(),
-                                                               content, pgMakeChapter);
-                                               if (!pgMakeChapter.isDone()) {
-                                                       pgMakeChapter.setProgress(pgMakeChapter.getMax());
-                                               }
-
-                                               words += cc.getWords();
-                                               story.getChapters().add(cc);
-                                       } finally {
-                                               if (chapIn != null) {
-                                                       chapIn.close();
-                                               }
-                                       }
-
-                                       i++;
-                               }
-                               
-                               story.getMeta().setWords(words);
-
-                               pgChaps.setName("Extracting chapters");
-                       } else {
-                               pg.setProgress(80);
-                       }
-
-                       // Check for "no chapters" stories
-                       if (story.getChapters().isEmpty()
-                                       && story.getMeta().getResume() != null
-                                       && !story.getMeta().getResume().getParagraphs().isEmpty()) {
-                               Chapter resume = story.getMeta().getResume();
-                               resume.setName("");
-                               resume.setNumber(1);
-                               story.getChapters().add(resume);
-                               story.getMeta().setWords(resume.getWords());
-
-                               String descChapterName = Instance.getInstance().getTrans()
-                                               .getString(StringId.DESCRIPTION);
-                               resume = new Chapter(0, descChapterName);
-                               story.getMeta().setResume(resume);
-                       }
-
-                       return story;
-               } finally {
-                       close();
-
-                       if (in != null) {
-                               in.close();
-                       }
-               }
-       }
-
-       /**
-        * Prepare the support if needed before processing.
-        * 
-        * @param source
-        *            the source of the story
-        * @param in
-        *            the input (the main resource)
-        * 
-        * @throws IOException
-        *             on I/O error
-        */
-       @SuppressWarnings("unused")
-       protected void preprocess(URL source, InputStream in) throws IOException {
-       }
-
-       /**
-        * Create a {@link Chapter} object from the given information, formatting
-        * the content as it should be.
-        * 
-        * @param source
-        *            the source of the story
-        * @param number
-        *            the chapter number
-        * @param name
-        *            the chapter name
-        * @param content
-        *            the chapter content
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Chapter}, never NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected Chapter makeChapter(URL source, int number, String name,
-                       String content, Progress pg) throws IOException {
-               // Chapter name: process it correctly, then remove the possible
-               // redundant "Chapter x: " in front of it, or "-" (as in
-               // "Chapter 5: - Fun!" after the ": " was automatically added)
-               String chapterName = processPara(name).getContent().trim();
-               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
-                       String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
-                       if (chapterName.startsWith(chapterWord)) {
-                               chapterName = chapterName.substring(chapterWord.length())
-                                               .trim();
-                               break;
-                       }
-               }
-
-               if (chapterName.startsWith(Integer.toString(number))) {
-                       chapterName = chapterName.substring(
-                                       Integer.toString(number).length()).trim();
-               }
-
-               while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
-                       chapterName = chapterName.substring(1).trim();
-               }
-               //
-
-               Chapter chap = new Chapter(number, chapterName);
-
-               if (content != null) {
-                       List<Paragraph> paras = makeParagraphs(source, content, pg);
-                       long words = 0;
-                       for (Paragraph para : paras) {
-                               words += para.getWords();
-                       }
-                       chap.setParagraphs(paras);
-                       chap.setWords(words);
-               }
-
-               return chap;
-
-       }
-
-       /**
-        * Convert the given content into {@link Paragraph}s.
-        * 
-        * @param source
-        *            the source URL of the story
-        * @param content
-        *            the textual content
-        * @param pg
-        *            the optional progress reporter
-        * 
-        * @return the {@link Paragraph}s (can be empty, but never NULL)
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected List<Paragraph> makeParagraphs(URL source, String content,
-                       Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               if (isHtml()) {
-                       // Special <HR> processing:
-                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
-                                       "<br/>* * *<br/>");
-               }
-
-               List<Paragraph> paras = new ArrayList<Paragraph>();
-               if (content != null && !content.trim().isEmpty()) {
-                       if (isHtml()) {
-                               String[] tab = content.split("(<p>|</p>|<br>|<br/>)");
-                               pg.setMinMax(0, tab.length);
-                               int i = 1;
-                               for (String line : tab) {
-                                       if (line.startsWith("[") && line.endsWith("]")) {
-                                               pg.setName("Extracting image " + i);
-                                       }
-                                       paras.add(makeParagraph(source, line.trim()));
-                                       pg.setProgress(i++);
-                               }
-                               pg.setName(null);
-                       } else {
-                               List<String> lines = new ArrayList<String>();
-                               BufferedReader buff = null;
-                               try {
-                                       buff = new BufferedReader(
-                                                       new InputStreamReader(new ByteArrayInputStream(
-                                                                       content.getBytes("UTF-8")), "UTF-8"));
-                                       for (String line = buff.readLine(); line != null; line = buff
-                                                       .readLine()) {
-                                               lines.add(line.trim());
-                                       }
-                               } finally {
-                                       if (buff != null) {
-                                               buff.close();
-                                       }
-                               }
-
-                               pg.setMinMax(0, lines.size());
-                               int i = 0;
-                               for (String line : lines) {
-                                       if (line.startsWith("[") && line.endsWith("]")) {
-                                               pg.setName("Extracting image " + i);
-                                       }
-                                       paras.add(makeParagraph(source, line));
-                                       pg.setProgress(i++);
-                               }
-                               pg.setName(null);
-                       }
-
-                       // Check quotes for "bad" format
-                       List<Paragraph> newParas = new ArrayList<Paragraph>();
-                       for (Paragraph para : paras) {
-                               newParas.addAll(requotify(para));
-                       }
-                       paras = newParas;
-
-                       // Remove double blanks/brks
-                       fixBlanksBreaks(paras);
-               }
-
-               return paras;
-       }
-
-       /**
-        * Convert the given line into a single {@link Paragraph}.
-        * 
-        * @param source
-        *            the source URL of the story
-        * @param line
-        *            the textual content of the paragraph
-        * 
-        * @return the {@link Paragraph}, never NULL
-        */
-       private Paragraph makeParagraph(URL source, String line) {
-               Image image = null;
-               if (line.startsWith("[") && line.endsWith("]")) {
-                       image = getImage(this, source, line.substring(1, line.length() - 1)
-                                       .trim());
-               }
-
-               if (image != null) {
-                       return new Paragraph(image);
-               }
-
-               return processPara(line);
-       }
-
-       /**
-        * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
-        * those {@link Paragraph}s.
-        * <p>
-        * The resulting list will not contain a starting or trailing blank/break
-        * nor 2 blanks or breaks following each other.
-        * 
-        * @param paras
-        *            the list of {@link Paragraph}s to fix
-        */
-       protected void fixBlanksBreaks(List<Paragraph> paras) {
-               boolean space = false;
-               boolean brk = true;
-               for (int i = 0; i < paras.size(); i++) {
-                       Paragraph para = paras.get(i);
-                       boolean thisSpace = para.getType() == ParagraphType.BLANK;
-                       boolean thisBrk = para.getType() == ParagraphType.BREAK;
-
-                       if (i > 0 && space && thisBrk) {
-                               paras.remove(i - 1);
-                               i--;
-                       } else if ((space || brk) && (thisSpace || thisBrk)) {
-                               paras.remove(i);
-                               i--;
-                       }
-
-                       space = thisSpace;
-                       brk = thisBrk;
-               }
-
-               // Remove blank/brk at start
-               if (paras.size() > 0
-                               && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
-                                               0).getType() == ParagraphType.BREAK)) {
-                       paras.remove(0);
-               }
-
-               // Remove blank/brk at end
-               int last = paras.size() - 1;
-               if (paras.size() > 0
-                               && (paras.get(last).getType() == ParagraphType.BLANK || paras
-                                               .get(last).getType() == ParagraphType.BREAK)) {
-                       paras.remove(last);
-               }
-       }
-
-       /**
-        * Get the default cover related to this subject (see <tt>.info</tt> files).
-        * 
-        * @param subject
-        *            the subject
-        * 
-        * @return the cover if any, or NULL
-        */
-       static Image getDefaultCover(String subject) {
-               if (subject != null && !subject.isEmpty() && Instance.getInstance().getCoverDir() != null) {
-                       try {
-                               File fileCover = new File(Instance.getInstance().getCoverDir(), subject);
-                               return getImage(null, fileCover.toURI().toURL(), subject);
-                       } catch (MalformedURLException e) {
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Return the list of supported image extensions.
-        * 
-        * @param emptyAllowed
-        *            TRUE to allow an empty extension on first place, which can be
-        *            used when you may already have an extension in your input but
-        *            are not sure about it
-        * 
-        * @return the extensions
-        */
-       static String[] getImageExt(boolean emptyAllowed) {
-               if (emptyAllowed) {
-                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
-               }
-
-               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
-       }
-
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param source
-        *            the story source
-        * @param line
-        *            the resource to check
-        * 
-        * @return the image if found, or NULL
-        * 
-        */
-       static Image getImage(BasicSupport_Deprecated support, URL source,
-                       String line) {
-               URL url = getImageUrl(support, source, line);
-               if (url != null) {
-                       if ("file".equals(url.getProtocol())) {
-                               if (new File(url.getPath()).isDirectory()) {
-                                       return null;
-                               }
-                       }
-                       InputStream in = null;
-                       try {
-                               in = Instance.getInstance().getCache().open(url, getSupport(url), true);
-                               return new Image(in);
-                       } catch (IOException e) {
-                       } finally {
-                               if (in != null) {
-                                       try {
-                                               in.close();
-                                       } catch (IOException e) {
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Check if the given resource can be a local image or a remote image, then
-        * refresh the cache with it if it is.
-        * 
-        * @param source
-        *            the story source
-        * @param line
-        *            the resource to check
-        * 
-        * @return the image URL if found, or NULL
-        * 
-        */
-       static URL getImageUrl(BasicSupport_Deprecated support, URL source,
-                       String line) {
-               URL url = null;
-
-               if (line != null) {
-                       // try for files
-                       if (source != null) {
-                               try {
-                                       String relPath = null;
-                                       String absPath = null;
-                                       try {
-                                               String path = new File(source.getFile()).getParent();
-                                               relPath = new File(new File(path), line.trim())
-                                                               .getAbsolutePath();
-                                       } catch (Exception e) {
-                                               // Cannot be converted to path (one possibility to take
-                                               // into account: absolute path on Windows)
-                                       }
-                                       try {
-                                               absPath = new File(line.trim()).getAbsolutePath();
-                                       } catch (Exception e) {
-                                               // Cannot be converted to path (at all)
-                                       }
-
-                                       for (String ext : getImageExt(true)) {
-                                               File absFile = new File(absPath + ext);
-                                               File relFile = new File(relPath + ext);
-                                               if (absPath != null && absFile.exists()
-                                                               && absFile.isFile()) {
-                                                       url = absFile.toURI().toURL();
-                                               } else if (relPath != null && relFile.exists()
-                                                               && relFile.isFile()) {
-                                                       url = relFile.toURI().toURL();
-                                               }
-                                       }
-                               } catch (Exception e) {
-                                       // Should not happen since we control the correct arguments
-                               }
-                       }
-
-                       if (url == null) {
-                               // try for URLs
-                               try {
-                                       for (String ext : getImageExt(true)) {
-                                               if (Instance.getInstance().getCache().check(new URL(line + ext), true)) {
-                                                       url = new URL(line + ext);
-                                                       break;
-                                               }
-                                       }
-
-                                       // try out of cache
-                                       if (url == null) {
-                                               for (String ext : getImageExt(true)) {
-                                                       try {
-                                                               url = new URL(line + ext);
-                                                               Instance.getInstance().getCache().refresh(url, support, true);
-                                                               break;
-                                                       } catch (IOException e) {
-                                                               // no image with this ext
-                                                               url = null;
-                                                       }
-                                               }
-                                       }
-                               } catch (MalformedURLException e) {
-                                       // Not an url
-                               }
-                       }
-
-                       // refresh the cached file
-                       if (url != null) {
-                               try {
-                                       Instance.getInstance().getCache().refresh(url, support, true);
-                               } catch (IOException e) {
-                                       // woops, broken image
-                                       url = null;
-                               }
-                       }
-               }
-
-               return url;
-       }
-
-       /**
-        * Open the input file that will be used through the support.
-        * <p>
-        * Can return NULL, in which case you are supposed to work without an
-        * {@link InputStream}.
-        * 
-        * @param source
-        *            the source {@link URL}
-        * 
-        * @return the {@link InputStream}
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       protected InputStream openInput(URL source) throws IOException {
-               return Instance.getInstance().getCache().open(source, this, false);
-       }
-
-       /**
-        * Reset then return {@link BasicSupport_Deprecated#in}.
-        * 
-        * @return {@link BasicSupport_Deprecated#in}
-        */
-       protected InputStream getInput() {
-               return reset(in);
-       }
-
-       /**
-        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
-        * and requotify them (i.e., separate them into QUOTE paragraphs and other
-        * paragraphs (quotes or not)).
-        * 
-        * @param para
-        *            the paragraph to requotify (not necessarily a quote)
-        * 
-        * @return the correctly (or so we hope) quotified paragraphs
-        */
-       protected List<Paragraph> requotify(Paragraph para) {
-               List<Paragraph> newParas = new ArrayList<Paragraph>();
-
-               if (para.getType() == ParagraphType.QUOTE
-                               && para.getContent().length() > 2) {
-                       String line = para.getContent();
-                       boolean singleQ = line.startsWith("" + openQuote);
-                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
-
-                       // Do not try when more than one quote at a time
-                       // (some stories are not easily readable if we do)
-                       if (singleQ
-                                       && line.indexOf(closeQuote, 1) < line
-                                                       .lastIndexOf(closeQuote)) {
-                               newParas.add(para);
-                               return newParas;
-                       }
-                       if (doubleQ
-                                       && line.indexOf(closeDoubleQuote, 1) < line
-                                                       .lastIndexOf(closeDoubleQuote)) {
-                               newParas.add(para);
-                               return newParas;
-                       }
-                       //
-
-                       if (!singleQ && !doubleQ) {
-                               line = openDoubleQuote + line + closeDoubleQuote;
-                               newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
-                                               .getWords()));
-                       } else {
-                               char open = singleQ ? openQuote : openDoubleQuote;
-                               char close = singleQ ? closeQuote : closeDoubleQuote;
-
-                               int posDot = -1;
-                               boolean inQuote = false;
-                               int i = 0;
-                               for (char car : line.toCharArray()) {
-                                       if (car == open) {
-                                               inQuote = true;
-                                       } else if (car == close) {
-                                               inQuote = false;
-                                       } else if (car == '.' && !inQuote) {
-                                               posDot = i;
-                                               break;
-                                       }
-                                       i++;
-                               }
-
-                               if (posDot >= 0) {
-                                       String rest = line.substring(posDot + 1).trim();
-                                       line = line.substring(0, posDot + 1).trim();
-                                       long words = 1;
-                                       for (char car : line.toCharArray()) {
-                                               if (car == ' ') {
-                                                       words++;
-                                               }
-                                       }
-                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
-                                       if (!rest.isEmpty()) {
-                                               newParas.addAll(requotify(processPara(rest)));
-                                       }
-                               } else {
-                                       newParas.add(para);
-                               }
-                       }
-               } else {
-                       newParas.add(para);
-               }
-
-               return newParas;
-       }
-
-       /**
-        * Process a {@link Paragraph} from a raw line of text.
-        * <p>
-        * Will also fix quotes and HTML encoding if needed.
-        * 
-        * @param line
-        *            the raw line
-        * 
-        * @return the processed {@link Paragraph}, never NULL
-        */
-       protected Paragraph processPara(String line) {
-               line = ifUnhtml(line).trim();
-
-               boolean space = true;
-               boolean brk = true;
-               boolean quote = false;
-               boolean tentativeCloseQuote = false;
-               char prev = '\0';
-               int dashCount = 0;
-               long words = 1;
-
-               StringBuilder builder = new StringBuilder();
-               for (char car : line.toCharArray()) {
-                       if (car != '-') {
-                               if (dashCount > 0) {
-                                       // dash, ndash and mdash: - – —
-                                       // currently: always use mdash
-                                       builder.append(dashCount == 1 ? '-' : '—');
-                               }
-                               dashCount = 0;
-                       }
-
-                       if (tentativeCloseQuote) {
-                               tentativeCloseQuote = false;
-                               if (Character.isLetterOrDigit(car)) {
-                                       builder.append("'");
-                               } else {
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.append(closeDoubleQuote);
-                                               continue;
-                                       }
-
-                                       builder.append(closeQuote);
-                               }
-                       }
-
-                       switch (car) {
-                       case ' ': // note: unbreakable space
-                       case ' ':
-                       case '\t':
-                       case '\n': // just in case
-                       case '\r': // just in case
-                               if (builder.length() > 0
-                                               && builder.charAt(builder.length() - 1) != ' ') {
-                                       words++;
-                               }
-                               builder.append(' ');
-                               break;
-
-                       case '\'':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.deleteCharAt(builder.length() - 1);
-                                               builder.append(openDoubleQuote);
-                                       } else {
-                                               builder.append(openQuote);
-                                       }
-                               } else if (prev == ' ' || prev == car) {
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.deleteCharAt(builder.length() - 1);
-                                               builder.append(openDoubleQuote);
-                                       } else {
-                                               builder.append(openQuote);
-                                       }
-                               } else {
-                                       // it is a quote ("I'm off") or a 'quote' ("This
-                                       // 'good' restaurant"...)
-                                       tentativeCloseQuote = true;
-                               }
-                               break;
-
-                       case '"':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       builder.append(openDoubleQuote);
-                               } else if (prev == ' ') {
-                                       builder.append(openDoubleQuote);
-                               } else {
-                                       builder.append(closeDoubleQuote);
-                               }
-                               break;
-
-                       case '-':
-                               if (space) {
-                                       quote = true;
-                               } else {
-                                       dashCount++;
-                               }
-                               space = false;
-                               break;
-
-                       case '*':
-                       case '~':
-                       case '/':
-                       case '\\':
-                       case '<':
-                       case '>':
-                       case '=':
-                       case '+':
-                       case '_':
-                       case '–':
-                       case '—':
-                               space = false;
-                               builder.append(car);
-                               break;
-
-                       case '‘':
-                       case '`':
-                       case '‹':
-                       case '﹁':
-                       case '〈':
-                       case '「':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       builder.append(openQuote);
-                               } else {
-                                       // handle double-single quotes as double quotes
-                                       if (prev == car) {
-                                               builder.deleteCharAt(builder.length() - 1);
-                                               builder.append(openDoubleQuote);
-                                       } else {
-                                               builder.append(openQuote);
-                                       }
-                               }
-                               space = false;
-                               brk = false;
-                               break;
-
-                       case '’':
-                       case '›':
-                       case '﹂':
-                       case '〉':
-                       case '」':
-                               space = false;
-                               brk = false;
-                               // handle double-single quotes as double quotes
-                               if (prev == car) {
-                                       builder.deleteCharAt(builder.length() - 1);
-                                       builder.append(closeDoubleQuote);
-                               } else {
-                                       builder.append(closeQuote);
-                               }
-                               break;
-
-                       case '«':
-                       case '“':
-                       case '﹃':
-                       case '《':
-                       case '『':
-                               if (space || (brk && quote)) {
-                                       quote = true;
-                                       builder.append(openDoubleQuote);
-                               } else {
-                                       builder.append(openDoubleQuote);
-                               }
-                               space = false;
-                               brk = false;
-                               break;
-
-                       case '»':
-                       case '”':
-                       case '﹄':
-                       case '》':
-                       case '』':
-                               space = false;
-                               brk = false;
-                               builder.append(closeDoubleQuote);
-                               break;
-
-                       default:
-                               space = false;
-                               brk = false;
-                               builder.append(car);
-                               break;
-                       }
-
-                       prev = car;
-               }
-
-               if (tentativeCloseQuote) {
-                       tentativeCloseQuote = false;
-                       builder.append(closeQuote);
-               }
-
-               line = builder.toString().trim();
-
-               ParagraphType type = ParagraphType.NORMAL;
-               if (space) {
-                       type = ParagraphType.BLANK;
-               } else if (brk) {
-                       type = ParagraphType.BREAK;
-               } else if (quote) {
-                       type = ParagraphType.QUOTE;
-               }
-
-               return new Paragraph(type, line, words);
-       }
-
-       /**
-        * Remove the HTML from the input <b>if</b>
-        * {@link BasicSupport_Deprecated#isHtml()} is true.
-        * 
-        * @param input
-        *            the input
-        * 
-        * @return the no html version if needed
-        */
-       private String ifUnhtml(String input) {
-               if (isHtml() && input != null) {
-                       return StringUtils.unhtml(input);
-               }
-
-               return input;
-       }
-
-       /**
-        * Reset the given {@link InputStream} and return it.
-        * 
-        * @param in
-        *            the {@link InputStream} to reset
-        * 
-        * @return the same {@link InputStream} after reset
-        */
-       static protected InputStream reset(InputStream in) {
-               try {
-                       if (in != null) {
-                               in.reset();
-                       }
-               } catch (IOException e) {
-               }
-
-               return in;
-       }
-
-       /**
-        * Return the first line from the given input which correspond to the given
-        * selectors.
-        * 
-        * @param in
-        *            the input
-        * @param needle
-        *            a string that must be found inside the target line (also
-        *            supports "^" at start to say "only if it starts with" the
-        *            needle)
-        * @param relativeLine
-        *            the line to return based upon the target line position (-1 =
-        *            the line before, 0 = the target line...)
-        * 
-        * @return the line, or NULL if not found
-        */
-       static protected String getLine(InputStream in, String needle,
-                       int relativeLine) {
-               return getLine(in, needle, relativeLine, true);
-       }
-
-       /**
-        * Return a line from the given input which correspond to the given
-        * selectors.
-        * 
-        * @param in
-        *            the input
-        * @param needle
-        *            a string that must be found inside the target line (also
-        *            supports "^" at start to say "only if it starts with" the
-        *            needle)
-        * @param relativeLine
-        *            the line to return based upon the target line position (-1 =
-        *            the line before, 0 = the target line...)
-        * @param first
-        *            takes the first result (as opposed to the last one, which will
-        *            also always spend the input)
-        * 
-        * @return the line, or NULL if not found
-        */
-       static protected String getLine(InputStream in, String needle,
-                       int relativeLine, boolean first) {
-               String rep = null;
-
-               reset(in);
-
-               List<String> lines = new ArrayList<String>();
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               int index = -1;
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       lines.add(scan.next());
-
-                       if (index == -1) {
-                               if (needle.startsWith("^")) {
-                                       if (lines.get(lines.size() - 1).startsWith(
-                                                       needle.substring(1))) {
-                                               index = lines.size() - 1;
-                                       }
-
-                               } else {
-                                       if (lines.get(lines.size() - 1).contains(needle)) {
-                                               index = lines.size() - 1;
-                                       }
-                               }
-                       }
-
-                       if (index >= 0 && index + relativeLine < lines.size()) {
-                               rep = lines.get(index + relativeLine);
-                               if (first) {
-                                       break;
-                               }
-                       }
-               }
-
-               return rep;
-       }
-
-       /**
-        * Return the text between the key and the endKey (and optional subKey can
-        * be passed, in this case we will look for the key first, then take the
-        * text between the subKey and the endKey).
-        * <p>
-        * Will only match the first line with the given key if more than one are
-        * possible. Which also means that if the subKey or endKey is not found on
-        * that line, NULL will be returned.
-        * 
-        * @param in
-        *            the input
-        * @param key
-        *            the key to match (also supports "^" at start to say
-        *            "only if it starts with" the key)
-        * @param subKey
-        *            the sub key or NULL if none
-        * @param endKey
-        *            the end key or NULL for "up to the end"
-        * @return the text or NULL if not found
-        */
-       static protected String getKeyLine(InputStream in, String key,
-                       String subKey, String endKey) {
-               return getKeyText(getLine(in, key, 0), key, subKey, endKey);
-       }
-
-       /**
-        * Return the text between the key and the endKey (and optional subKey can
-        * be passed, in this case we will look for the key first, then take the
-        * text between the subKey and the endKey).
-        * 
-        * @param in
-        *            the input
-        * @param key
-        *            the key to match (also supports "^" at start to say
-        *            "only if it starts with" the key)
-        * @param subKey
-        *            the sub key or NULL if none
-        * @param endKey
-        *            the end key or NULL for "up to the end"
-        * @return the text or NULL if not found
-        */
-       static protected String getKeyText(String in, String key, String subKey,
-                       String endKey) {
-               String result = null;
-
-               String line = in;
-               if (line != null && line.contains(key)) {
-                       line = line.substring(line.indexOf(key) + key.length());
-                       if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
-                               if (subKey != null) {
-                                       line = line.substring(line.indexOf(subKey)
-                                                       + subKey.length());
-                               }
-                               if (endKey == null || line.contains(endKey)) {
-                                       if (endKey != null) {
-                                               line = line.substring(0, line.indexOf(endKey));
-                                               result = line;
-                                       }
-                               }
-                       }
-               }
-
-               return result;
-       }
-
-       /**
-        * Return the text between the key and the endKey (optional subKeys can be
-        * passed, in this case we will look for the subKeys first, then take the
-        * text between the key and the endKey).
-        * 
-        * @param in
-        *            the input
-        * @param key
-        *            the key to match
-        * @param endKey
-        *            the end key or NULL for "up to the end"
-        * @param afters
-        *            the sub-keys to find before checking for key/endKey
-        * 
-        * @return the text or NULL if not found
-        */
-       static protected String getKeyTextAfter(String in, String key,
-                       String endKey, String... afters) {
-
-               if (in != null && !in.isEmpty()) {
-                       int pos = indexOfAfter(in, 0, afters);
-                       if (pos < 0) {
-                               return null;
-                       }
-
-                       in = in.substring(pos);
-               }
-
-               return getKeyText(in, key, null, endKey);
-       }
-
-       /**
-        * Return the first index after all the given "afters" have been found in
-        * the {@link String}, or -1 if it was not possible.
-        * 
-        * @param in
-        *            the input
-        * @param startAt
-        *            start at this position in the string
-        * @param afters
-        *            the sub-keys to find before checking for key/endKey
-        * 
-        * @return the text or NULL if not found
-        */
-       static protected int indexOfAfter(String in, int startAt, String... afters) {
-               int pos = -1;
-               if (in != null && !in.isEmpty()) {
-                       pos = startAt;
-                       if (afters != null) {
-                               for (int i = 0; pos >= 0 && i < afters.length; i++) {
-                                       String subKey = afters[i];
-                                       if (!subKey.isEmpty()) {
-                                               pos = in.indexOf(subKey, pos);
-                                               if (pos >= 0) {
-                                                       pos += subKey.length();
-                                               }
-                                       }
-                               }
-                       }
-               }
-
-               return pos;
-       }
-}
diff --git a/supported/Cbz.java b/supported/Cbz.java
deleted file mode 100644 (file)
index 7fe496d..0000000
+++ /dev/null
@@ -1,222 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.streams.MarkableFileInputStream;
-
-/**
- * Support class for CBZ files (works better with CBZ created with this program,
- * as they have some metadata available).
- * 
- * @author niki
- */
-class Cbz extends Epub {
-       @Override
-       protected boolean supports(URL url) {
-               return url.toString().toLowerCase().endsWith(".cbz");
-       }
-
-       @Override
-       protected String getDataPrefix() {
-               return "";
-       }
-
-       @Override
-       protected boolean requireInfo() {
-               return false;
-       }
-
-       @Override
-       protected boolean isImagesDocumentByDefault() {
-               return true;
-       }
-
-       @Override
-       protected boolean getCover() {
-               return false;
-       }
-
-       @Override
-       public Story doProcess(Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               } else {
-                       pg.setMinMax(0, 100);
-               }
-               
-               pg.setName("Initialising");
-
-               Progress pgMeta = new Progress();
-               pg.addProgress(pgMeta, 10);
-               Story story = processMeta(true, pgMeta);
-               MetaData meta = story.getMeta();
-
-               pgMeta.done(); // 10%
-               
-               File tmpDir = Instance.getInstance().getTempFiles().createTempDir("info-text");
-               String basename = null;
-
-               Map<String, Image> images = new HashMap<String, Image>();
-               InputStream cbzIn = null;
-               ZipInputStream zipIn = null;
-               try {
-                       cbzIn = new MarkableFileInputStream(getSourceFileOriginal());
-                       zipIn = new ZipInputStream(cbzIn);
-                       for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
-                                       .getNextEntry()) {
-                               if (!entry.isDirectory()
-                                               && entry.getName().startsWith(getDataPrefix())) {
-                                       String entryLName = entry.getName().toLowerCase();
-                                       boolean imageEntry = false;
-                                       for (String ext : bsImages.getImageExt(false)) {
-                                               if (entryLName.endsWith(ext)) {
-                                                       imageEntry = true;
-                                               }
-                                       }
-                                       
-                                       if (imageEntry) {
-                                               String uuid = meta.getUuid() + "_" + entry.getName();
-                                               try {
-                                                       images.put(uuid, new Image(zipIn));
-                                               } catch (Exception e) {
-                                                       Instance.getInstance().getTraceHandler().error(e);
-                                               }
-
-                                               if (pg.getProgress() < 85) {
-                                                       pg.add(1);
-                                               }
-                                       } else if (entryLName.endsWith(".info")) {
-                                               basename = entryLName.substring(0, entryLName.length()
-                                                               - ".info".length());
-                                               IOUtils.write(zipIn, new File(tmpDir, entryLName));
-                                       } else if (entryLName.endsWith(".txt")) {
-                                               IOUtils.write(zipIn, new File(tmpDir, entryLName));
-                                       }
-                               }
-                       }
-                       
-                       String ext = "."
-                                       + Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-                       String coverName = meta.getUuid() + "_" + basename + ext;
-                       Image cover = images.get(coverName);
-                       images.remove(coverName);
-
-                       pg.setProgress(85);
-
-                       // ZIP order is not correct for us
-                       List<String> imagesList = new ArrayList<String>(images.keySet());
-                       Collections.sort(imagesList);
-
-                       pg.setProgress(90);
-
-                       // only the description/cover is kept
-                       Story origStory = getStoryFromTxt(tmpDir, basename);
-                       if (origStory != null) {
-                               if (origStory.getMeta().getCover() == null) {
-                                       origStory.getMeta().setCover(story.getMeta().getCover());
-                               }
-                               story.setMeta(origStory.getMeta());
-                       }
-                       if (story.getMeta().getCover() == null) {
-                               story.getMeta().setCover(cover);
-                       }
-                       story.setChapters(new ArrayList<Chapter>());
-
-                       // Check if we can find non-images chapters, for hybrid-cbz support
-                       if (origStory != null) {
-                               for (Chapter chap : origStory) {
-                                       Boolean isImages = null;
-                                       for (Paragraph para : chap) {
-                                               ParagraphType t = para.getType();
-                                               if (isImages == null && !t.isText(true)) {
-                                                       isImages = true;
-                                               }
-                                               if (t.isText(false)) {
-                                                       String line = para.getContent();
-                                                       // Images are saved in text mode as "[image-link]"
-                                                       if (!(line.startsWith("[") && line.endsWith("]"))) {
-                                                               isImages = false;
-                                                       }
-                                               }
-                                       }
-
-                                       if (isImages != null && !isImages) {
-                                               story.getChapters().add(chap);
-                                               chap.setNumber(story.getChapters().size());
-                                       }
-                               }
-                       }
-
-                       if (!imagesList.isEmpty()) {
-                               Chapter chap = new Chapter(story.getChapters().size() + 1, "");
-                               story.getChapters().add(chap);
-
-                               for (String uuid : imagesList) {
-                                       try {
-                                               chap.getParagraphs().add(
-                                                               new Paragraph(images.get(uuid)));
-                                       } catch (Exception e) {
-                                               Instance.getInstance().getTraceHandler().error(e);
-                                       }
-                               }
-                       }
-
-                       if (meta.getCover() == null && !images.isEmpty()) {
-                               meta.setCover(images.get(imagesList.get(0)));
-                               meta.setFakeCover(true);
-                       }
-               } finally {
-                       IOUtils.deltree(tmpDir);
-                       if (zipIn != null) {
-                               zipIn.close();
-                       }
-                       if (cbzIn != null) {
-                               cbzIn.close();
-                       }
-               }
-
-               pg.done();
-               return story;
-       }
-
-       private Story getStoryFromTxt(File tmpDir, String basename) {
-               Story origStory = null;
-
-               File txt = new File(tmpDir, basename + ".txt");
-               if (!txt.exists()) {
-                       basename = null;
-               }
-               if (basename != null) {
-                       try {
-                               BasicSupport support = BasicSupport.getSupport(txt.toURI()
-                                               .toURL());
-                               origStory = support.process(null);
-                       } catch (Exception e) {
-                               basename = null;
-                       }
-               }
-
-               return origStory;
-
-       }
-}
diff --git a/supported/E621.java b/supported/E621.java
deleted file mode 100644 (file)
index adf8d28..0000000
+++ /dev/null
@@ -1,432 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.net.URLDecoder;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map.Entry;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.jsoup.helper.DataUtil;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-import be.nikiroo.utils.Version;
-
-/**
- * Support class for <a href="http://e621.net/">e621.net</a> and
- * <a href="http://e926.net/">e926.net</a>, a Furry website supporting comics,
- * including some of MLP.
- * <p>
- * <a href="http://e926.net/">e926.net</a> only shows the "clean" images and
- * comics, but it can be difficult to browse.
- * 
- * @author niki
- */
-class E621 extends BasicSupport {
-       @Override
-       protected boolean supports(URL url) {
-               String host = url.getHost();
-               if (host.startsWith("www.")) {
-                       host = host.substring("www.".length());
-               }
-
-               return ("e621.net".equals(host) || "e926.net".equals(host))
-                               && (isPool(url) || isSearchOrSet(url));
-       }
-
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle());
-               meta.setAuthor(getAuthor());
-               meta.setDate(bsHelper.formatDate(getDate()));
-               meta.setTags(getTags());
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(getSource().toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(getSource().toString());
-               meta.setLuid("");
-               meta.setLang("en");
-               meta.setSubject("Furry");
-               meta.setType(getType().toString());
-               meta.setImageDocument(true);
-               meta.setCover(getCover());
-               meta.setFakeCover(true);
-
-               return meta;
-       }
-
-       @Override
-       protected String getDesc() throws IOException {
-               if (isSearchOrSet(getSource())) {
-                       StringBuilder builder = new StringBuilder();
-                       builder.append("A collection of images from ")
-                                       .append(getSource().getHost()).append("\n") //
-                                       .append("\tTime of creation: "
-                                                       + StringUtils.fromTime(new Date().getTime()))
-                                       .append("\n") //
-                                       .append("\tTags: ");//
-                       for (String tag : getTags()) {
-                               builder.append("\t\t").append(tag);
-                       }
-
-                       return builder.toString();
-               }
-
-               if (isPool(getSource())) {
-                       Element el = getSourceNode().getElementById("description");
-                       if (el != null) {
-                               return el.text();
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg)
-                       throws IOException {
-               int i = 1;
-               String jsonUrl = getJsonUrl();
-               if (jsonUrl != null) {
-                       for (i = 1; true; i++) {
-                               if (i > 1) {
-                                       try {
-                                               // The API does not accept more than 2 request per sec,
-                                               // and asks us to limit at one per sec when possible
-                                               Thread.sleep(1000);
-                                       } catch (InterruptedException e) {
-                                       }
-                               }
-
-                               try {
-                                       JSONObject json = getJson(jsonUrl + "&page=" + i, false);
-                                       if (!json.has("posts"))
-                                               break;
-                                       JSONArray posts = json.getJSONArray("posts");
-                                       if (posts.isEmpty())
-                                               break;
-                               } catch (Exception e) {
-                                       e.printStackTrace();
-                               }
-                       }
-
-                       // The last page was empty:
-                       i--;
-               }
-
-               // The pages and images are in reverse order on /posts/
-               List<Entry<String, URL>> chapters = new LinkedList<Entry<String, URL>>();
-               for (int page = i; page > 0; page--) {
-                       chapters.add(new AbstractMap.SimpleEntry<String, URL>(
-                                       "Page " + Integer.toString(i - page + 1),
-                                       new URL(jsonUrl + "&page=" + page)));
-               }
-
-               return chapters;
-       }
-
-       @Override
-       protected String getChapterContent(URL chapUrl, int number, Progress pg)
-                       throws IOException {
-               StringBuilder builder = new StringBuilder();
-
-               JSONObject json = getJson(chapUrl, false);
-               JSONArray postsArr = json.getJSONArray("posts");
-
-               // The pages and images are in reverse order on /posts/
-               List<JSONObject> posts = new ArrayList<JSONObject>(postsArr.length());
-               for (int i = postsArr.length() - 1; i >= 0; i--) {
-                       Object o = postsArr.get(i);
-                       if (o instanceof JSONObject)
-                               posts.add((JSONObject) o);
-               }
-
-               for (JSONObject post : posts) {
-                       if (!post.has("file"))
-                               continue;
-                       JSONObject file = post.getJSONObject("file");
-                       if (!file.has("url"))
-                               continue;
-
-                       try {
-                               String url = file.getString("url");
-                               builder.append("[");
-                               builder.append(url);
-                               builder.append("]<br/>");
-                       } catch (JSONException e) {
-                               // Can be NULL if filtered
-                               // When the value is NULL, we get an exception
-                               // but the "has" method still returns true
-                               Instance.getInstance().getTraceHandler()
-                                               .error("Cannot get image for chapter " + number + " of "
-                                                               + getSource());
-                       }
-               }
-
-               return builder.toString();
-       }
-
-       @Override
-       protected URL getCanonicalUrl(URL source) {
-               // Convert search-pools into proper pools
-               if (source.getPath().equals("/posts") && source.getQuery() != null
-                               && source.getQuery().startsWith("tags=pool%3A")) {
-                       String poolNumber = source.getQuery()
-                                       .substring("tags=pool%3A".length());
-                       try {
-                               Integer.parseInt(poolNumber);
-                               String base = source.getProtocol() + "://" + source.getHost();
-                               if (source.getPort() != -1) {
-                                       base = base + ":" + source.getPort();
-                               }
-                               source = new URL(base + "/pools/" + poolNumber);
-                       } catch (NumberFormatException e) {
-                               // Not a simple pool, skip
-                       } catch (MalformedURLException e) {
-                               // Cannot happen
-                       }
-               }
-
-               if (isSetOriginalUrl(source)) {
-                       try {
-                               Document doc = DataUtil.load(Instance.getInstance().getCache()
-                                               .open(source, this, false), "UTF-8", source.toString());
-                               for (Element shortname : doc
-                                               .getElementsByClass("set-shortname")) {
-                                       for (Element el : shortname.getElementsByTag("a")) {
-                                               if (!el.attr("href").isEmpty())
-                                                       return new URL(el.absUrl("href"));
-                                       }
-                               }
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(e);
-                       }
-               }
-
-               if (isPool(source)) {
-                       try {
-                               return new URL(
-                                               source.toString().replace("/pool/show/", "/pools/"));
-                       } catch (MalformedURLException e) {
-                       }
-               }
-
-               return super.getCanonicalUrl(source);
-       }
-
-       private String getTitle() {
-               String title = "";
-
-               Element el = getSourceNode().getElementsByTag("title").first();
-               if (el != null) {
-                       title = el.text().trim();
-               }
-
-               for (String s : new String[] { "e621", "-", "e621", "Pool", "-" }) {
-                       if (title.startsWith(s)) {
-                               title = title.substring(s.length()).trim();
-                       }
-                       if (title.endsWith(s)) {
-                               title = title.substring(0, title.length() - s.length()).trim();
-                       }
-               }
-
-               if (isSearchOrSet(getSource())) {
-                       title = title.isEmpty() ? "e621" : "[e621] " + title;
-               }
-
-               return title;
-       }
-
-       private String getAuthor() {
-               List<String> list = new ArrayList<String>();
-               String jsonUrl = getJsonUrl();
-               if (jsonUrl != null) {
-                       try {
-                               JSONObject json = getJson(jsonUrl, false);
-                               JSONArray posts = json.getJSONArray("posts");
-                               for (Object obj : posts) {
-                                       if (!(obj instanceof JSONObject))
-                                               continue;
-
-                                       JSONObject post = (JSONObject) obj;
-                                       if (!post.has("tags"))
-                                               continue;
-
-                                       JSONObject tags = post.getJSONObject("tags");
-                                       if (!tags.has("artist"))
-                                               continue;
-
-                                       JSONArray artists = tags.getJSONArray("artist");
-                                       for (Object artist : artists) {
-                                               if (list.contains(artist.toString()))
-                                                       continue;
-
-                                               list.add(artist.toString());
-                                       }
-                               }
-                       } catch (Exception e) {
-                               e.printStackTrace();
-                       }
-               }
-
-               StringBuilder builder = new StringBuilder();
-               for (String artist : list) {
-                       if (builder.length() > 0) {
-                               builder.append(", ");
-                       }
-                       builder.append(artist);
-               }
-
-               return builder.toString();
-       }
-
-       private String getDate() {
-               String jsonUrl = getJsonUrl();
-               if (jsonUrl != null) {
-                       try {
-                               JSONObject json = getJson(jsonUrl, false);
-                               JSONArray posts = json.getJSONArray("posts");
-                               for (Object obj : posts) {
-                                       if (!(obj instanceof JSONObject))
-                                               continue;
-
-                                       JSONObject post = (JSONObject) obj;
-                                       if (!post.has("created_at"))
-                                               continue;
-
-                                       return post.getString("created_at");
-                               }
-                       } catch (Exception e) {
-                               e.printStackTrace();
-                       }
-               }
-
-               return "";
-       }
-
-       // no tags for pools
-       private List<String> getTags() {
-               List<String> tags = new ArrayList<String>();
-               if (isSearchOrSet(getSource())) {
-                       String str = getTagsFromUrl(getSource());
-                       for (String tag : str.split("\\+")) {
-                               try {
-                                       tags.add(URLDecoder.decode(tag.trim(), "UTF-8").trim());
-                               } catch (UnsupportedEncodingException e) {
-                               }
-                       }
-               }
-
-               return tags;
-       }
-
-       // returns "xxx+ddd+ggg" if "tags=xxx+ddd+ggg" was present in the query
-       private String getTagsFromUrl(URL url) {
-               String tags = url == null ? "" : url.getQuery();
-               int pos = tags.indexOf("tags=");
-
-               if (pos >= 0) {
-                       tags = tags.substring(pos).substring("tags=".length());
-               } else {
-                       return "";
-               }
-
-               pos = tags.indexOf('&');
-               if (pos > 0) {
-                       tags = tags.substring(0, pos);
-               }
-               pos = tags.indexOf('/');
-               if (pos > 0) {
-                       tags = tags.substring(0, pos);
-               }
-
-               return tags;
-       }
-
-       private Image getCover() throws IOException {
-               Image image = null;
-               List<Entry<String, URL>> chapters = getChapters(null);
-               if (!chapters.isEmpty()) {
-                       URL chap1Url = chapters.get(0).getValue();
-                       String imgsChap1 = getChapterContent(chap1Url, 1, null);
-                       if (!imgsChap1.isEmpty()) {
-                               imgsChap1 = imgsChap1.split("]")[0].substring(1).trim();
-                               image = bsImages.getImage(this, new URL(imgsChap1));
-                       }
-               }
-
-               return image;
-       }
-
-       // always /posts.json/ url
-       private String getJsonUrl() {
-               String url = null;
-               if (isSearchOrSet(getSource())) {
-                       url = getSource().toString().replace("/posts", "/posts.json");
-               }
-
-               if (isPool(getSource())) {
-                       String poolNumber = getSource().getPath()
-                                       .substring("/pools/".length());
-                       url = "https://e621.net/posts.json" + "?tags=pool%3A" + poolNumber;
-               }
-
-               if (url != null) {
-                       // Note: one way to override the blacklist
-                       String login = Instance.getInstance().getConfig()
-                                       .getString(Config.LOGIN_E621_LOGIN);
-                       String apk = Instance.getInstance().getConfig()
-                                       .getString(Config.LOGIN_E621_APIKEY);
-
-                       if (login != null && !login.isEmpty() && apk != null
-                                       && !apk.isEmpty()) {
-                               url = String.format("%s&login=%s&api_key=%s&_client=%s", url,
-                                               login, apk, "fanfix-" + Version.getCurrentVersion());
-                       }
-               }
-
-               return url;
-       }
-
-       // note: will be removed at getCanonicalUrl()
-       private boolean isSetOriginalUrl(URL originalUrl) {
-               return originalUrl.getPath().startsWith("/post_sets/");
-       }
-
-       private boolean isPool(URL url) {
-               return url.getPath().startsWith("/pools/")
-                               || url.getPath().startsWith("/pool/show/");
-       }
-
-       // set will be renamed into search by canonical url
-       private boolean isSearchOrSet(URL url) {
-               return
-               // search:
-               (url.getPath().equals("/posts") && url.getQuery().contains("tags="))
-                               // or set:
-                               || isSetOriginalUrl(url);
-       }
-}
diff --git a/supported/EHentai.java b/supported/EHentai.java
deleted file mode 100644 (file)
index 3c73432..0000000
+++ /dev/null
@@ -1,292 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Scanner;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Support class for <a href="https://e-hentai.org/">e-hentai.org</a>, a website
- * supporting mostly but not always NSFW comics, including some of MLP.
- * 
- * @author niki
- */
-class EHentai extends BasicSupport_Deprecated {
-       @Override
-       protected MetaData getMeta(URL source, InputStream in) throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle(reset(in)));
-               meta.setAuthor(getAuthor(reset(in)));
-               meta.setDate(getDate(reset(in)));
-               meta.setTags(getTags(reset(in)));
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(source.toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(source.toString());
-               meta.setLuid("");
-               meta.setLang(getLang(reset(in)));
-               meta.setSubject("Hentai");
-               meta.setType(getType().toString());
-               meta.setImageDocument(true);
-               meta.setCover(getCover(source, reset(in)));
-               meta.setFakeCover(true);
-
-               return meta;
-       }
-
-       @Override
-       public Story process(URL url, Progress pg) throws IOException {
-               // There is no chapters on e621, just pagination...
-               Story story = super.process(url, pg);
-
-               Chapter only = new Chapter(1, "");
-               for (Chapter chap : story) {
-                       only.getParagraphs().addAll(chap.getParagraphs());
-               }
-
-               story.getChapters().clear();
-               story.getChapters().add(only);
-
-               return story;
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return "e-hentai.org".equals(url.getHost());
-       }
-
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       public Map<String, String> getCookies() {
-               Map<String, String> cookies = super.getCookies();
-               cookies.put("nw", "1");
-               return cookies;
-       }
-
-       private Image getCover(URL source, InputStream in) {
-               Image author = null;
-               String coverLine = getKeyLine(in, "<div id=\"gd1\"", " url(", ")");
-               if (coverLine != null) {
-                       coverLine = StringUtils.unhtml(coverLine).trim();
-                       author = getImage(this, source, coverLine);
-               }
-
-               return author;
-       }
-
-       private String getAuthor(InputStream in) {
-               String author = null;
-
-               List<String> tagsAuthor = getTagsAuthor(in);
-               if (!tagsAuthor.isEmpty()) {
-                       author = tagsAuthor.get(0);
-               }
-
-               return author;
-       }
-
-       private String getLang(InputStream in) {
-               String lang = null;
-
-               String langLine = getKeyLine(in, "class=\"gdt1\">Language",
-                               "class=\"gdt2\"", "</td>");
-               if (langLine != null) {
-                       langLine = StringUtils.unhtml(langLine).trim();
-                       if (langLine.equalsIgnoreCase("English")) {
-                               lang = "en";
-                       } else if (langLine.equalsIgnoreCase("Japanese")) {
-                               lang = "jp";
-                       } else if (langLine.equalsIgnoreCase("French")) {
-                               lang = "fr";
-                       } else {
-                               // TODO find the code for other languages?
-                               lang = langLine;
-                       }
-               }
-
-               return lang;
-       }
-
-       private String getDate(InputStream in) {
-               String date = null;
-
-               String dateLine = getKeyLine(in, "class=\"gdt1\">Posted",
-                               "class=\"gdt2\"", "</td>");
-               if (dateLine != null) {
-                       dateLine = StringUtils.unhtml(dateLine).trim();
-                       if (dateLine.length() > 10) {
-                               dateLine = dateLine.substring(0, 10).trim();
-                       }
-
-                       date = dateLine;
-               }
-
-               return date;
-       }
-
-       private List<String> getTags(InputStream in) {
-               List<String> tags = new ArrayList<String>();
-               List<String> tagsAuthor = getTagsAuthor(in);
-
-               for (int i = 1; i < tagsAuthor.size(); i++) {
-                       tags.add(tagsAuthor.get(i));
-               }
-
-               return tags;
-       }
-
-       private List<String> getTagsAuthor(InputStream in) {
-               List<String> tags = new ArrayList<String>();
-               String tagLine = getKeyLine(in, "<meta name=\"description\"", "Tags: ",
-                               null);
-               if (tagLine != null) {
-                       for (String tag : tagLine.split(",")) {
-                               String candi = tag.trim();
-                               if (!candi.isEmpty() && !tags.contains(candi)) {
-                                       tags.add(candi);
-                               }
-                       }
-               }
-
-               return tags;
-       }
-
-       private String getTitle(InputStream in) {
-               String siteName = " - E-Hentai Galleries";
-
-               String title = getLine(in, "<title>", 0);
-               if (title != null) {
-                       title = StringUtils.unhtml(title).trim();
-                       if (title.endsWith(siteName)) {
-                               title = title.substring(0, title.length() - siteName.length())
-                                               .trim();
-                       }
-               }
-
-               return title;
-       }
-
-       @Override
-       protected String getDesc(URL source, InputStream in) throws IOException {
-               String desc = null;
-
-               String descLine = getKeyLine(in, "Uploader Comment", null,
-                               "<div class=\"c7\"");
-               if (descLine != null) {
-                       desc = StringUtils.unhtml(descLine);
-               }
-
-               return desc;
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
-                       Progress pg) throws IOException {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-               int last = 0; // no pool/show when only one page, first page == page 0
-
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter(">");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (line.contains(source.toString())) {
-                               String page = line.substring(line.indexOf(source.toString()));
-                               String pkey = "?p=";
-                               if (page.contains(pkey)) {
-                                       page = page.substring(page.indexOf(pkey) + pkey.length());
-                                       String number = "";
-                                       while (!page.isEmpty() && page.charAt(0) >= '0'
-                                                       && page.charAt(0) <= '9') {
-                                               number += page.charAt(0);
-                                               page = page.substring(1);
-                                       }
-                                       if (number.isEmpty()) {
-                                               number = "0";
-                                       }
-
-                                       int current = Integer.parseInt(number);
-                                       if (last < current) {
-                                               last = current;
-                                       }
-                               }
-                       }
-               }
-
-               for (int i = 0; i <= last; i++) {
-                       urls.add(new AbstractMap.SimpleEntry<String, URL>(Integer
-                                       .toString(i + 1), new URL(source.toString() + "?p=" + i)));
-               }
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL source, InputStream in, int number,
-                       Progress pg) throws IOException {
-               String staticSite = "https://e-hentai.org/s/";
-               List<URL> pages = new ArrayList<URL>();
-
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\"");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (line.startsWith(staticSite)) {
-                               try {
-                                       pages.add(new URL(line));
-                               } catch (MalformedURLException e) {
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException("Parsing error, a link is not correctly parsed: " + line, e));
-                               }
-                       }
-               }
-
-               if (pg == null) {
-                       pg = new Progress();
-               }
-               pg.setMinMax(0, pages.size());
-               pg.setProgress(0);
-
-               StringBuilder builder = new StringBuilder();
-
-               for (URL page : pages) {
-                       InputStream pageIn = Instance.getInstance().getCache().open(page, this, false);
-                       try {
-                               String link = getKeyLine(pageIn, "id=\"img\"", "src=\"", "\"");
-                               if (link != null && !link.isEmpty()) {
-                                       builder.append("[");
-                                       builder.append(link);
-                                       builder.append("]<br/>");
-                               }
-                               pg.add(1);
-                       } finally {
-                               if (pageIn != null) {
-                                       pageIn.close();
-                               }
-                       }
-               }
-
-               pg.done();
-               return builder.toString();
-       }
-}
diff --git a/supported/Epub.java b/supported/Epub.java
deleted file mode 100644 (file)
index 965a27a..0000000
+++ /dev/null
@@ -1,260 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.net.URLDecoder;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-import org.jsoup.nodes.Document;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.StringUtils;
-import be.nikiroo.utils.streams.MarkableFileInputStream;
-
-/**
- * Support class for EPUB files created with this program (as we need some
- * metadata available in those we create).
- * 
- * @author niki
- */
-class Epub extends InfoText {
-       private MetaData meta;
-       private File tmpDir;
-       private String desc;
-
-       private URL fakeSource;
-       private InputStream fakeIn;
-
-       public File getSourceFileOriginal() {
-               return super.getSourceFile();
-       }
-
-       @Override
-       protected File getSourceFile() {
-               try {
-                       return new File(fakeSource.toURI());
-               } catch (URISyntaxException e) {
-                       Instance.getInstance().getTraceHandler().error(new IOException(
-                                       "Cannot get the source file from the info-text URL", e));
-               }
-
-               return null;
-       }
-
-       @Override
-       protected InputStream getInput() {
-               if (fakeIn != null) {
-                       try {
-                               fakeIn.reset();
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(new IOException(
-                                               "Cannot reset the Epub Text stream", e));
-                       }
-
-                       return fakeIn;
-               }
-
-               return null;
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return url.getPath().toLowerCase().endsWith(".epub");
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               return meta;
-       }
-
-       @Override
-       protected Document loadDocument(URL source) throws IOException {
-               super.loadDocument(source); // prepares super.getSourceFile() and
-                                                                       // super.getInput()
-
-               InputStream in = super.getInput();
-               ZipInputStream zipIn = null;
-               try {
-                       zipIn = new ZipInputStream(in);
-                       tmpDir = Instance.getInstance().getTempFiles()
-                                       .createTempDir("fanfic-reader-parser");
-                       File tmp = new File(tmpDir, "file.txt");
-                       File tmpInfo = new File(tmpDir, "file.info");
-
-                       fakeSource = tmp.toURI().toURL();
-                       Image cover = null;
-
-                       String url;
-                       try {
-                               url = getSource().toURI().toURL().toString();
-                       } catch (URISyntaxException e1) {
-                               url = getSource().toString();
-                       }
-                       String title = null;
-                       String author = null;
-
-                       for (ZipEntry entry = zipIn
-                                       .getNextEntry(); entry != null; entry = zipIn
-                                                       .getNextEntry()) {
-                               if (!entry.isDirectory()
-                                               && entry.getName().startsWith(getDataPrefix())) {
-                                       String entryLName = entry.getName().toLowerCase();
-                                       entryLName = entryLName.substring(getDataPrefix().length());
-
-                                       boolean imageEntry = false;
-                                       for (String ext : bsImages.getImageExt(false)) {
-                                               if (entryLName.endsWith(ext)) {
-                                                       imageEntry = true;
-                                               }
-                                       }
-
-                                       if (entryLName.equals("version")) {
-                                               // Nothing to do for now ("first"
-                                               // version is 3.0)
-                                       } else if (entryLName.endsWith(".info")) {
-                                               // Info file
-                                               IOUtils.write(zipIn, tmpInfo);
-                                       } else if (imageEntry) {
-                                               // Cover
-                                               if (getCover() && cover == null) {
-                                                       try {
-                                                               cover = new Image(zipIn);
-                                                       } catch (Exception e) {
-                                                               Instance.getInstance().getTraceHandler()
-                                                                               .error(e);
-                                                       }
-                                               }
-                                       } else if (entryLName.equals("url")) {
-                                               String[] descArray = StringUtils
-                                                               .unhtml(IOUtils.readSmallStream(zipIn)).trim()
-                                                               .split("\n");
-                                               if (descArray.length > 0) {
-                                                       url = descArray[0].trim();
-                                               }
-                                       } else if (entryLName.endsWith(".desc")) {
-                                               // // For old files
-                                               // if (this.desc != null) {
-                                               // this.desc = IOUtils.readSmallStream(zipIn).trim();
-                                               // }
-                                       } else if (entryLName.equals("summary")) {
-                                               String[] descArray = StringUtils
-                                                               .unhtml(IOUtils.readSmallStream(zipIn)).trim()
-                                                               .split("\n");
-                                               int skip = 0;
-                                               if (descArray.length > 1) {
-                                                       title = descArray[0].trim();
-                                                       skip = 1;
-                                                       if (descArray.length > 2
-                                                                       && descArray[1].startsWith("©")) {
-                                                               author = descArray[1].substring(1).trim();
-                                                               skip = 2;
-                                                       }
-                                               }
-                                               // this.desc = "";
-                                               // for (int i = skip; i < descArray.length; i++) {
-                                               // this.desc += descArray[i].trim() + "\n";
-                                               // }
-                                               //
-                                               // this.desc = this.desc.trim();
-                                       } else {
-                                               // Hopefully the data file
-                                               IOUtils.write(zipIn, tmp);
-                                       }
-                               }
-                       }
-
-                       if (requireInfo() && !tmp.exists()) {
-                               throw new IOException(
-                                               "file not supported (maybe not created with this program or corrupt)");
-                       }
-
-                       if (tmp.exists()) {
-                               this.fakeIn = new MarkableFileInputStream(tmp);
-                       }
-
-                       if (tmpInfo.exists()) {
-                               meta = InfoReader.readMeta(tmpInfo, true);
-                               tmpInfo.delete();
-                       } else {
-                               if (title == null || title.isEmpty()) {
-                                       title = getSourceFileOriginal().getName();
-                                       String exts[] = new String[] {".epub", ".cbz"};
-                                       for (String ext : exts) {
-                                               if (title.toLowerCase().endsWith(ext)) {
-                                                       title = title.substring(0,
-                                                                       title.length() - ext.length());
-                                               }
-                                       }
-                                       title = URLDecoder.decode(title, "UTF-8").trim();
-                               }
-
-                               meta = new MetaData();
-                               meta.setLang("en");
-                               meta.setTags(Arrays.asList("[no_info]"));
-                               meta.setSource(getType().getSourceName());
-                               meta.setUuid(url);
-                               meta.setUrl(url);
-                               meta.setTitle(title);
-                               meta.setAuthor(author);
-                               meta.setImageDocument(isImagesDocumentByDefault());
-                               
-                               InfoReader.completeMeta(tmp, meta);
-                       }
-
-                       if (meta.getCover() == null) {
-                               if (cover != null) {
-                                       meta.setCover(cover);
-                               } else {
-                                       meta.setCover(InfoReader.getCoverByName(
-                                                       getSourceFileOriginal().toURI().toURL()));
-                               }
-                       }
-               } finally {
-                       if (zipIn != null) {
-                               zipIn.close();
-                       }
-                       if (in != null) {
-                               in.close();
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected void close() {
-               if (tmpDir != null) {
-                       IOUtils.deltree(tmpDir);
-               }
-
-               tmpDir = null;
-
-               super.close();
-       }
-
-       protected String getDataPrefix() {
-               return "DATA/";
-       }
-
-       protected boolean requireInfo() {
-               return true;
-       }
-
-       protected boolean getCover() {
-               return true;
-       }
-
-       protected boolean isImagesDocumentByDefault() {
-               return false;
-       }
-}
diff --git a/supported/Fanfiction.java b/supported/Fanfiction.java
deleted file mode 100644 (file)
index 16b44f8..0000000
+++ /dev/null
@@ -1,331 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.text.SimpleDateFormat;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import java.util.Map.Entry;
-import java.util.Scanner;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Support class for <a href="http://www.fanfiction.net/">Faniction.net</a>
- * stories, a website dedicated to fanfictions of many, many different
- * universes, from TV shows to novels to games.
- * 
- * @author niki
- */
-class Fanfiction extends BasicSupport_Deprecated {
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       protected MetaData getMeta(URL source, InputStream in) throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle(reset(in)));
-               meta.setAuthor(getAuthor(reset(in)));
-               meta.setDate(getDate(reset(in)));
-               meta.setTags(getTags(reset(in)));
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(source.toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(source.toString());
-               meta.setLuid("");
-               meta.setLang("en"); // TODO find language of book
-               meta.setSubject(getSubject(reset(in)));
-               meta.setType(getType().toString());
-               meta.setImageDocument(false);
-               meta.setCover(getCover(source, reset(in)));
-
-               return meta;
-       }
-
-       private String getSubject(InputStream in) {
-               String line = getLine(in, "id=pre_story_links", 0);
-               if (line != null) {
-                       int pos = line.lastIndexOf('"');
-                       if (pos >= 1) {
-                               line = line.substring(pos + 1);
-                               pos = line.indexOf('<');
-                               if (pos >= 0) {
-                                       return StringUtils.unhtml(line.substring(0, pos)).trim();
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       private List<String> getTags(InputStream in) {
-               List<String> tags = new ArrayList<String>();
-
-               String key = "title=\"Send Private Message\"";
-               String line = getLine(in, key, 2);
-               if (line != null) {
-                       key = "Rated:";
-                       int pos = line.indexOf(key);
-                       if (pos >= 0) {
-                               line = line.substring(pos + key.length());
-                               key = "Chapters:";
-                               pos = line.indexOf(key);
-                               if (pos >= 0) {
-                                       line = line.substring(0, pos);
-                                       line = StringUtils.unhtml(line).trim();
-                                       if (line.endsWith("-")) {
-                                               line = line.substring(0, line.length() - 1);
-                                       }
-
-                                       for (String tag : line.split("-")) {
-                                               tags.add(StringUtils.unhtml(tag).trim());
-                                       }
-                               }
-                       }
-               }
-
-               return tags;
-       }
-
-       private String getTitle(InputStream in) {
-               int i = 0;
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (line.contains("xcontrast_txt")) {
-                               if ((++i) == 2) {
-                                       line = StringUtils.unhtml(line).trim();
-                                       if (line.startsWith("Follow/Fav")) {
-                                               line = line.substring("Follow/Fav".length()).trim();
-                                       }
-
-                                       return StringUtils.unhtml(line).trim();
-                               }
-                       }
-               }
-
-               return "";
-       }
-
-       private String getAuthor(InputStream in) {
-               String author = null;
-
-               int i = 0;
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (line.contains("xcontrast_txt")) {
-                               if ((++i) == 3) {
-                                       author = StringUtils.unhtml(line).trim();
-                                       break;
-                               }
-                       }
-               }
-
-               return bsHelper.fixAuthor(author);
-       }
-
-       private String getDate(InputStream in) {
-               String key = "Published: <span data-xutime='";
-               String line = getLine(in, key, 0);
-               if (line != null) {
-                       int pos = line.indexOf(key);
-                       if (pos >= 0) {
-                               line = line.substring(pos + key.length());
-                               pos = line.indexOf('\'');
-                               if (pos >= 0) {
-                                       line = line.substring(0, pos).trim();
-                                       try {
-                                               SimpleDateFormat sdf = new SimpleDateFormat(
-                                                               "yyyy-MM-dd");
-                                               return sdf
-                                                               .format(new Date(1000 * Long.parseLong(line)));
-                                       } catch (NumberFormatException e) {
-                                               Instance.getInstance().getTraceHandler()
-                                                               .error(new IOException("Cannot convert publication date: " + line, e));
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected String getDesc(URL source, InputStream in) {
-               return getLine(in, "title=\"Send Private Message\"", 1);
-       }
-
-       private Image getCover(URL url, InputStream in) {
-               String key = "class='cimage";
-               String line = getLine(in, key, 0);
-               if (line != null) {
-                       int pos = line.indexOf(key);
-                       if (pos >= 0) {
-                               line = line.substring(pos + key.length());
-                               key = "src='";
-                               pos = line.indexOf(key);
-                               if (pos >= 0) {
-                                       line = line.substring(pos + key.length());
-                                       pos = line.indexOf('\'');
-                                       if (pos >= 0) {
-                                               line = line.substring(0, pos);
-                                               if (line.startsWith("//")) {
-                                                       line = url.getProtocol() + "://"
-                                                                       + line.substring(2);
-                                               } else if (line.startsWith("//")) {
-                                                       line = url.getProtocol() + "://" + url.getHost()
-                                                                       + "/" + line.substring(1);
-                                               } else {
-                                                       line = url.getProtocol() + "://" + url.getHost()
-                                                                       + "/" + url.getPath() + "/" + line;
-                                               }
-
-                                               return getImage(this, null, line);
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
-                       Progress pg) {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-
-               String base = source.toString();
-               int pos = base.lastIndexOf('/');
-               String suffix = base.substring(pos); // including '/' at start
-               base = base.substring(0, pos);
-               if (base.endsWith("/1")) {
-                       base = base.substring(0, base.length() - 1); // including '/' at end
-               }
-
-               String line = getLine(in, "id=chap_select", 0);
-               String key = "<option  value=";
-               int i = 1;
-
-               if (line != null) {
-                       for (pos = line.indexOf(key); pos >= 0; pos = line
-                                       .indexOf(key, pos), i++) {
-                               pos = line.indexOf('>', pos);
-                               if (pos >= 0) {
-                                       int endOfName = line.indexOf('<', pos);
-                                       if (endOfName >= 0) {
-                                               String name = line.substring(pos + 1, endOfName);
-                                               String chapNum = i + ".";
-                                               if (name.startsWith(chapNum)) {
-                                                       name = name.substring(chapNum.length(),
-                                                                       name.length());
-                                               }
-
-                                               try {
-                                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(
-                                                                       name.trim(), new URL(base + i + suffix)));
-                                               } catch (MalformedURLException e) {
-                                                       Instance.getInstance().getTraceHandler().error(
-                                                                       new IOException("Cannot parse chapter " + i + " url: " + (base + i + suffix), e));
-                                               }
-                                       }
-                               }
-                       }
-               } else {
-                       // only one chapter:
-                       final String chapName = getTitle(reset(in));
-                       final URL chapURL = source;
-                       urls.add(new Entry<String, URL>() {
-                               @Override
-                               public URL setValue(URL value) {
-                                       return null;
-                               }
-
-                               @Override
-                               public URL getValue() {
-                                       return chapURL;
-                               }
-
-                               @Override
-                               public String getKey() {
-                                       return chapName;
-                               }
-                       });
-               }
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL source, InputStream in, int number,
-                       Progress pg) {
-               StringBuilder builder = new StringBuilder();
-               String startAt = "class='storytext ";
-               String endAt1 = "function review_init";
-               String endAt2 = "id=chap_select";
-               boolean ok = false;
-
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (!ok && line.contains(startAt)) {
-                               ok = true;
-                       } else if (ok && (line.contains(endAt1) || line.contains(endAt2))) {
-                               ok = false;
-                               break;
-                       }
-
-                       if (ok) {
-                               // First line may contain the title and chap name again
-                               if (builder.length() == 0) {
-                                       int pos = line.indexOf("<hr");
-                                       if (pos >= 0) {
-                                               boolean chaptered = false;
-                                               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
-                                                       String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER,
-                                                                       lang);
-                                                       int posChap = line.indexOf(chapterWord + " ");
-                                                       if (posChap < pos) {
-                                                               chaptered = true;
-                                                               break;
-                                                       }
-                                               }
-
-                                               if (chaptered) {
-                                                       line = line.substring(pos);
-                                               }
-                                       }
-                               }
-
-                               builder.append(line);
-                               builder.append(' ');
-                       }
-               }
-
-               return builder.toString();
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return "fanfiction.net".equals(url.getHost())
-                               || "www.fanfiction.net".equals(url.getHost());
-       }
-}
diff --git a/supported/Fimfiction.java b/supported/Fimfiction.java
deleted file mode 100644 (file)
index 77267c1..0000000
+++ /dev/null
@@ -1,253 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Scanner;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a>
- * stories, a website dedicated to My Little Pony.
- * 
- * @author niki
- */
-class Fimfiction extends BasicSupport_Deprecated {
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       protected MetaData getMeta(URL source, InputStream in) throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle(reset(in)));
-               meta.setAuthor(getAuthor(reset(in)));
-               meta.setDate(getDate(reset(in)));
-               meta.setTags(getTags(reset(in)));
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(source.toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(source.toString());
-               meta.setLuid("");
-               meta.setLang("en");
-               meta.setSubject("MLP");
-               meta.setType(getType().toString());
-               meta.setImageDocument(false);
-               meta.setCover(getCover(reset(in)));
-
-               return meta;
-       }
-
-       @Override
-       public Map<String, String> getCookies() {
-               Map<String, String> cookies = new HashMap<String, String>();
-               cookies.put("view_mature", "true");
-               return cookies;
-       }
-
-       private List<String> getTags(InputStream in) {
-               List<String> tags = new ArrayList<String>();
-               tags.add("MLP");
-
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               boolean started = false;
-               while (scan.hasNext()) {
-                       String line = scan.next();
-
-                       if (!started) {
-                               started = line.contains("\"story_container\"");
-                       }
-
-                       if (started && line.contains("class=\"tag-")) {
-                               if (line.contains("index.php")) {
-                                       break; // end of *this story* tags
-                               }
-
-                               String keyword = "title=\"";
-                               Scanner tagScanner = new Scanner(line);
-                               tagScanner.useDelimiter(keyword);
-                               if (tagScanner.hasNext()) {
-                                       tagScanner.next();// Ignore first one
-                               }
-                               while (tagScanner.hasNext()) {
-                                       String tag = tagScanner.next();
-                                       if (tag.contains("\"")) {
-                                               tag = tag.split("\"")[0];
-                                               tag = StringUtils.unhtml(tag).trim();
-                                               if (!tag.isEmpty() && !tags.contains(tag)) {
-                                                       tags.add(tag);
-                                               }
-                                       }
-                               }
-                               tagScanner.close();
-                       }
-               }
-
-               return tags;
-       }
-
-       private String getTitle(InputStream in) {
-               String line = getLine(in, " property=\"og:title\"", 0);
-               if (line != null) {
-                       int pos = -1;
-                       for (int i = 0; i < 3; i++) {
-                               pos = line.indexOf('"', pos + 1);
-                       }
-
-                       if (pos >= 0) {
-                               line = line.substring(pos + 1);
-                               pos = line.indexOf('"');
-                               if (pos >= 0) {
-                                       return StringUtils.unhtml(line.substring(0, pos)).trim();
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       private String getAuthor(InputStream in) {
-               String line = getLine(in, " href=\"/user/", 0);
-               if (line != null) {
-                       int pos = line.indexOf('"');
-                       if (pos >= 0) {
-                               line = line.substring(pos + 1);
-                               pos = line.indexOf('"');
-                               if (pos >= 0) {
-                                       line = line.substring(0, pos);
-                                       pos = line.lastIndexOf('/');
-                                       if (pos >= 0) {
-                                               line = line.substring(pos + 1);
-                                               return line.replace('+', ' ');
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       private String getDate(InputStream in) {
-               String line = getLine(in, "<span class=\"date\">", 0);
-               if (line != null) {
-                       int pos = -1;
-                       for (int i = 0; i < 3; i++) {
-                               pos = line.indexOf('>', pos + 1);
-                       }
-
-                       if (pos >= 0) {
-                               line = line.substring(pos + 1);
-                               pos = line.indexOf('<');
-                               if (pos >= 0) {
-                                       return line.substring(0, pos).trim();
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected String getDesc(URL source, InputStream in) {
-               // the og: meta version is the SHORT resume, this is the LONG resume
-               return getLine(in, "class=\"description-text bbcode\"", 1);
-       }
-
-       private Image getCover(InputStream in) {
-               // Note: the 'og:image' is the SMALL cover, not the full version
-               String cover = getLine(in, "class=\"story_container__story_image\"", 1);
-               if (cover != null) {
-                       int pos = cover.indexOf('"');
-                       if (pos >= 0) {
-                               cover = cover.substring(pos + 1);
-                               pos = cover.indexOf('"');
-                               if (pos >= 0) {
-                                       cover = cover.substring(0, pos);
-                               }
-                       }
-               }
-
-               return getImage(this, null, cover);
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
-                       Progress pg) {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               boolean started = false;
-               while (scan.hasNext()) {
-                       String line = scan.next().trim();
-
-                       if (!started) {
-                               started = line.equals("<!--Chapters-->");
-                       } else {
-                               if (line.equals("</form>")) {
-                                       break;
-                               }
-
-                               if (line.startsWith("<a href=")
-                                               || line.contains("class=\"chapter-title\"")) {
-                                       // Chapter name
-                                       String name = line;
-                                       int pos = name.indexOf('>');
-                                       if (pos >= 0) {
-                                               name = name.substring(pos + 1);
-                                               pos = name.indexOf('<');
-                                               if (pos >= 0) {
-                                                       name = name.substring(0, pos);
-                                               }
-                                       }
-                                       // Chapter content
-                                       pos = line.indexOf('/');
-                                       if (pos >= 0) {
-                                               line = line.substring(pos); // we take the /, not +1
-                                               pos = line.indexOf('"');
-                                               if (pos >= 0) {
-                                                       line = line.substring(0, pos);
-                                               }
-                                       }
-
-                                       try {
-                                               urls.add(new AbstractMap.SimpleEntry<String, URL>(name,
-                                                               new URL("http://www.fimfiction.net" + line)));
-                                       } catch (MalformedURLException e) {
-                                               Instance.getInstance().getTraceHandler().error(e);
-                                       }
-                               }
-                       }
-               }
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL source, InputStream in, int number,
-                       Progress pg) {
-               return getLine(in, "<div class=\"bbcode\">", 1);
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return "fimfiction.net".equals(url.getHost())
-                               || "www.fimfiction.net".equals(url.getHost());
-       }
-}
diff --git a/supported/FimfictionApi.java b/supported/FimfictionApi.java
deleted file mode 100644 (file)
index 43d01d1..0000000
+++ /dev/null
@@ -1,417 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.TreeMap;
-
-import org.jsoup.nodes.Document;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-
-/**
- * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a>
- * stories, a website dedicated to My Little Pony.
- * <p>
- * This version uses the new, official API of FimFiction.
- * 
- * @author niki
- */
-class FimfictionApi extends BasicSupport {
-       private String oauth;
-       private String json;
-
-       private Map<Integer, String> chapterNames;
-       private Map<Integer, String> chapterContents;
-
-       public FimfictionApi() throws IOException {
-               if (Instance.getInstance().getConfig().getBoolean(Config.LOGIN_FIMFICTION_APIKEY_FORCE_HTML, false)) {
-                       throw new IOException("Configuration is set to force HTML scrapping");
-               }
-
-               String oauth = Instance.getInstance().getConfig().getString(Config.LOGIN_FIMFICTION_APIKEY_TOKEN);
-
-               if (oauth == null || oauth.isEmpty()) {
-                       String clientId = Instance.getInstance().getConfig().getString(Config.LOGIN_FIMFICTION_APIKEY_CLIENT_ID)
-                                       + "";
-                       String clientSecret = Instance.getInstance().getConfig()
-                                       .getString(Config.LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET) + "";
-
-                       if (clientId.trim().isEmpty() || clientSecret.trim().isEmpty()) {
-                               throw new IOException("API key required for the beta API v2");
-                       }
-
-                       oauth = generateOAuth(clientId, clientSecret);
-
-                       Instance.getInstance().getConfig().setString(Config.LOGIN_FIMFICTION_APIKEY_TOKEN, oauth);
-                       Instance.getInstance().getConfig().updateFile();
-               }
-
-               this.oauth = oauth;
-       }
-
-       @Override
-       protected Document loadDocument(URL source) throws IOException {
-               json = getJsonData();
-               return null;
-       }
-
-       @Override
-       public String getOAuth() {
-               return oauth;
-       }
-
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       /**
-        * Extract the full JSON data we will later use to build the {@link Story}.
-        * 
-        * @return the data in a JSON format
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       private String getJsonData() throws IOException {
-               // extract the ID from:
-               // https://www.fimfiction.net/story/123456/name-of-story
-               String storyId = getKeyText(getSource().toString(), "/story/", null,
-                               "/");
-
-               // Selectors, so to download all I need and only what I need
-               String storyContent = "fields[story]=title,description,date_published,cover_image";
-               String authorContent = "fields[author]=name";
-               String chapterContent = "fields[chapter]=chapter_number,title,content_html,authors_note_html";
-               String includes = "author,chapters,tags";
-
-               String urlString = String.format(
-                               "https://www.fimfiction.net/api/v2/stories/%s?" //
-                                               + "%s&%s&%s&" //
-                                               + "include=%s", //
-                               storyId, //
-                               storyContent, authorContent, chapterContent,//
-                               includes);
-
-               // URL params must be URL-encoded: "[ ]" <-> "%5B %5D"
-               urlString = urlString.replace("[", "%5B").replace("]", "%5D");
-
-               URL url = new URL(urlString);
-               InputStream jsonIn = Instance.getInstance().getCache().open(url, this, false);
-               try {
-                       return IOUtils.readSmallStream(jsonIn);
-               } finally {
-                       jsonIn.close();
-               }
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getKeyJson(json, 0, "type", "story", "title"));
-               meta.setAuthor(getKeyJson(json, 0, "type", "user", "name"));
-               meta.setDate(bsHelper.formatDate(
-                               getKeyJson(json, 0, "type", "story", "date_published")));
-               meta.setTags(getTags());
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(getSource().toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(getSource().toString());
-               meta.setLuid("");
-               meta.setLang("en");
-               meta.setSubject("MLP");
-               meta.setType(getType().toString());
-               meta.setImageDocument(false);
-
-               String coverImageLink = getKeyJson(json, 0, "type", "story",
-                               "cover_image", "full");
-               if (!coverImageLink.trim().isEmpty()) {
-                       URL coverImageUrl = new URL(coverImageLink.trim());
-
-                       // No need to use the oauth, cookies... for the cover
-                       // Plus: it crashes on Android because of the referer
-                       try {
-                               InputStream in = Instance.getInstance().getCache().open(coverImageUrl, null, true);
-                               try {
-                                       meta.setCover(new Image(in));
-                               } finally {
-                                       in.close();
-                               }
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler()
-                                               .error(new IOException("Cannot get the story cover, ignoring...", e));
-                       }
-               }
-
-               return meta;
-       }
-
-       private List<String> getTags() {
-               List<String> tags = new ArrayList<String>();
-               tags.add("MLP");
-
-               int pos = 0;
-               while (pos >= 0) {
-                       pos = indexOfJsonAfter(json, pos, "type", "story_tag");
-                       if (pos >= 0) {
-                               tags.add(getKeyJson(json, pos, "name").trim());
-                       }
-               }
-
-               return tags;
-       }
-
-       @Override
-       protected String getDesc() {
-               String desc = getKeyJson(json, 0, "type", "story", "description");
-               return unbbcode(desc);
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg) {
-               chapterNames = new TreeMap<Integer, String>();
-               chapterContents = new TreeMap<Integer, String>();
-
-               int pos = 0;
-               while (pos >= 0) {
-                       pos = indexOfJsonAfter(json, pos, "type", "chapter");
-                       if (pos >= 0) {
-                               int posNumber = indexOfJsonAfter(json, pos, "chapter_number");
-                               int posComa = json.indexOf(",", posNumber);
-                               final int number = Integer.parseInt(json.substring(posNumber,
-                                               posComa).trim());
-                               final String title = getKeyJson(json, pos, "title");
-                               String notes = getKeyJson(json, pos, "authors_note_html");
-                               String content = getKeyJson(json, pos, "content_html");
-
-                               if (!notes.trim().isEmpty()) {
-                                       notes = "<br/>* * *<br/>" + notes;
-                               }
-
-                               chapterNames.put(number, title);
-                               chapterContents.put(number, content + notes);
-                       }
-               }
-
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-               for (String title : chapterNames.values()) {
-                       urls.add(new AbstractMap.SimpleEntry<String, URL>(title, null));
-               }
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL source, int number, Progress pg) {
-               return chapterContents.get(number);
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return "fimfiction.net".equals(url.getHost())
-                               || "www.fimfiction.net".equals(url.getHost());
-       }
-
-       /**
-        * Generate a new token from the client ID and secret.
-        * <p>
-        * Note that those tokens are long-lived, and it would be badly seen to
-        * create a lot of them without due cause.
-        * <p>
-        * So, please cache and re-use them.
-        * 
-        * @param clientId
-        *            the client ID offered on FimFiction
-        * @param clientSecret
-        *            the client secret that goes with it
-        * 
-        * @return a new generated token linked to that client ID
-        * 
-        * @throws IOException
-        *             in case of I/O errors
-        */
-       static private String generateOAuth(String clientId, String clientSecret)
-                       throws IOException {
-               URL url = new URL("https://www.fimfiction.net/api/v2/token");
-               Map<String, String> params = new HashMap<String, String>();
-               params.put("client_id", clientId);
-               params.put("client_secret", clientSecret);
-               params.put("grant_type", "client_credentials");
-               InputStream in = Instance.getInstance().getCache().openNoCache(url, null, params, null, null);
-
-               String jsonToken = IOUtils.readSmallStream(in);
-               in.close();
-
-               // Extract token type and token from: {
-               // token_type = "Bearer",
-               // access_token = "xxxxxxxxxxxxxx"
-               // }
-
-               String tokenType = getKeyText(jsonToken, "\"token_type\"", "\"", "\"");
-               String token = getKeyText(jsonToken, "\"access_token\"", "\"", "\"");
-
-               return tokenType + " " + token;
-       }
-
-       // afters: [name, value] pairs (or "" for any of them), can end without
-       // value
-       static private int indexOfJsonAfter(String json, int startAt,
-                       String... afterKeys) {
-               ArrayList<String> afters = new ArrayList<String>();
-               boolean name = true;
-               for (String key : afterKeys) {
-                       if (key != null && !key.isEmpty()) {
-                               afters.add("\"" + key + "\"");
-                       } else {
-                               afters.add("\"");
-                               afters.add("\"");
-                       }
-
-                       if (name) {
-                               afters.add(":");
-                       }
-
-                       name = !name;
-               }
-
-               return indexOfAfter(json, startAt, afters.toArray(new String[] {}));
-       }
-
-       // afters: [name, value] pairs (or "" for any of them), can end without
-       // value but will then be empty, not NULL
-       static private String getKeyJson(String json, int startAt,
-                       String... afterKeys) {
-               int pos = indexOfJsonAfter(json, startAt, afterKeys);
-               if (pos < 0) {
-                       return "";
-               }
-
-               String result = "";
-               String wip = json.substring(pos);
-
-               pos = nextUnescapedQuote(wip, 0);
-               if (pos >= 0) {
-                       wip = wip.substring(pos + 1);
-                       pos = nextUnescapedQuote(wip, 0);
-                       if (pos >= 0) {
-                               result = wip.substring(0, pos);
-                       }
-               }
-
-               result = result.replace("\\t", "\t").replace("\\\"", "\"");
-
-               return result;
-       }
-
-       // next " but don't take \" into account
-       static private int nextUnescapedQuote(String result, int pos) {
-               while (pos >= 0) {
-                       pos = result.indexOf("\"", pos);
-                       if (pos == 0 || (pos > 0 && result.charAt(pos - 1) != '\\')) {
-                               break;
-                       }
-
-                       if (pos < result.length()) {
-                               pos++;
-                       }
-               }
-
-               return pos;
-       }
-
-       // quick & dirty filter
-       static private String unbbcode(String bbcode) {
-               String text = bbcode.replace("\\r\\n", "<br/>") //
-                               .replace("[i]", "_").replace("[/i]", "_") //
-                               .replace("[b]", "*").replace("[/b]", "*") //
-                               .replaceAll("\\[[^\\]]*\\]", "");
-               return text;
-       }
-
-       /**
-        * Return the text between the key and the endKey (and optional subKey can
-        * be passed, in this case we will look for the key first, then take the
-        * text between the subKey and the endKey).
-        * 
-        * @param in
-        *            the input
-        * @param key
-        *            the key to match (also supports "^" at start to say
-        *            "only if it starts with" the key)
-        * @param subKey
-        *            the sub key or NULL if none
-        * @param endKey
-        *            the end key or NULL for "up to the end"
-        * @return the text or NULL if not found
-        */
-       static private String getKeyText(String in, String key, String subKey,
-                       String endKey) {
-               String result = null;
-
-               String line = in;
-               if (line != null && line.contains(key)) {
-                       line = line.substring(line.indexOf(key) + key.length());
-                       if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
-                               if (subKey != null) {
-                                       line = line.substring(line.indexOf(subKey)
-                                                       + subKey.length());
-                               }
-                               if (endKey == null || line.contains(endKey)) {
-                                       if (endKey != null) {
-                                               line = line.substring(0, line.indexOf(endKey));
-                                               result = line;
-                                       }
-                               }
-                       }
-               }
-
-               return result;
-       }
-
-       /**
-        * Return the first index after all the given "afters" have been found in
-        * the {@link String}, or -1 if it was not possible.
-        * 
-        * @param in
-        *            the input
-        * @param startAt
-        *            start at this position in the string
-        * @param afters
-        *            the sub-keys to find before checking for key/endKey
-        * 
-        * @return the text or NULL if not found
-        */
-       static private int indexOfAfter(String in, int startAt, String... afters) {
-               int pos = -1;
-               if (in != null && !in.isEmpty()) {
-                       pos = startAt;
-                       if (afters != null) {
-                               for (int i = 0; pos >= 0 && i < afters.length; i++) {
-                                       String subKey = afters[i];
-                                       if (!subKey.isEmpty()) {
-                                               pos = in.indexOf(subKey, pos);
-                                               if (pos >= 0) {
-                                                       pos += subKey.length();
-                                               }
-                                       }
-                               }
-                       }
-               }
-
-               return pos;
-       }
-}
diff --git a/supported/Html.java b/supported/Html.java
deleted file mode 100644 (file)
index 900fa0a..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-
-import be.nikiroo.fanfix.Instance;
-
-/**
- * Support class for HTML files created with this program (as we need some
- * metadata available in those we create).
- * 
- * @author niki
- */
-class Html extends InfoText {
-       @Override
-       protected boolean supports(URL url) {
-               try {
-                       File txt = getTxt(url);
-                       if (txt != null) {
-                               return super.supports(txt.toURI().toURL());
-                       }
-               } catch (MalformedURLException e) {
-               }
-
-               return false;
-       }
-
-       @Override
-       protected File getInfoFile() {
-               File source = getSourceFile();
-               if ("index.html".equals(source.getName())) {
-                       source = source.getParentFile();
-               }
-
-               String src = source.getPath();
-               File infoFile = new File(src + ".info");
-               if (!infoFile.exists() && src.endsWith(".txt")) {
-                       infoFile = new File(
-                                       src.substring(0, src.length() - ".txt".length()) + ".info");
-               }
-
-               return infoFile;
-       }
-
-       @Override
-       public URL getCanonicalUrl(URL source) {
-               File txt = getTxt(source);
-               if (txt != null) {
-                       try {
-                               source = txt.toURI().toURL();
-                       } catch (MalformedURLException e) {
-                               Instance.getInstance().getTraceHandler()
-                                               .error(new IOException("Cannot convert the right URL for " + source, e));
-                       }
-               }
-
-               return source;
-       }
-
-       /**
-        * Return the associated TXT source file if it can be found.
-        * 
-        * @param source
-        *            the source URL
-        * 
-        * @return the supported source text file or NULL
-        */
-       private static File getTxt(URL source) {
-               try {
-                       File fakeFile = new File(source.toURI());
-                       if (fakeFile.getName().equals("index.html")) { // "story/index.html"
-                               fakeFile = new File(fakeFile.getParent()); // -> "story/"
-                       }
-
-                       if (fakeFile.isDirectory()) { // "story/"
-                               fakeFile = new File(fakeFile, fakeFile.getName() + ".txt"); // "story/story.txt"
-                       }
-
-                       if (fakeFile.getName().endsWith(".txt")) {
-                               return fakeFile;
-                       }
-               } catch (Exception e) {
-               }
-
-               return null;
-       }
-}
diff --git a/supported/InfoReader.java b/supported/InfoReader.java
deleted file mode 100644 (file)
index d5eeeb1..0000000
+++ /dev/null
@@ -1,377 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Scanner;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.streams.MarkableFileInputStream;
-
-// not complete: no "description" tag
-public class InfoReader {
-       static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
-       // static protected BasicSupportImages bsImages = new BasicSupportImages();
-       // static protected BasicSupportPara bsPara = new BasicSupportPara(new
-       // BasicSupportHelper(), new BasicSupportImages());
-
-       public static MetaData readMeta(File infoFile, boolean withCover)
-                       throws IOException {
-               if (infoFile == null) {
-                       throw new IOException("File is null");
-               }
-
-               if (infoFile.exists()) {
-                       InputStream in = new MarkableFileInputStream(infoFile);
-                       try {
-                               MetaData meta = createMeta(infoFile.toURI().toURL(), in,
-                                               withCover);
-
-                               // Some old .info files were using UUID for URL...
-                               if (!hasIt(meta.getUrl()) && meta.getUuid() != null
-                                               && (meta.getUuid().startsWith("http://")
-                                                               || meta.getUuid().startsWith("https://"))) {
-                                       meta.setUrl(meta.getUuid());
-                               }
-
-                               // Some old .info files don't have those now required fields...
-                               // So we check if we can find the info in another way (many
-                               // formats have a copy of the original text file)
-                               if (!hasIt(meta.getTitle(), meta.getAuthor(), meta.getDate(),
-                                               meta.getUrl())) {
-
-                                       // TODO: not nice, would be better to do it properly...
-                                       String base = infoFile.getPath();
-                                       if (base.endsWith(".info")) {
-                                               base = base.substring(0,
-                                                               base.length() - ".info".length());
-                                       }
-                                       File textFile = new File(base);
-                                       if (!textFile.exists()) {
-                                               textFile = new File(base + ".txt");
-                                       }
-                                       if (!textFile.exists()) {
-                                               textFile = new File(base + ".text");
-                                       }
-
-                                       completeMeta(textFile, meta);
-                                       //
-                               }
-
-                               return meta;
-                       } finally {
-                               in.close();
-                       }
-               }
-
-               throw new FileNotFoundException(
-                               "File given as argument does not exists: "
-                                               + infoFile.getAbsolutePath());
-       }
-       
-       /**
-        * Complete the given {@link MetaData} with the original text file if needed
-        * and possible.
-        * 
-        * @param textFile
-        *            the original text file
-        * @param meta
-        *            the {@link MetaData} to complete if needed and possible
-        * 
-        * @throws IOException
-        *             in case of I/O errors
-        */
-       static public void completeMeta(File textFile,
-                       MetaData meta)  throws IOException {
-               if (textFile != null && textFile.exists()) {
-                       final URL source = textFile.toURI().toURL();
-                       final MetaData[] superMetaA = new MetaData[1];
-                       @SuppressWarnings("unused")
-                       Text unused = new Text() {
-                               private boolean loaded = loadDocument();
-
-                               @Override
-                               public SupportType getType() {
-                                       return SupportType.TEXT;
-                               }
-
-                               protected boolean loadDocument() throws IOException {
-                                       loadDocument(source);
-                                       superMetaA[0] = getMeta();
-                                       return true;
-                               }
-
-                               @Override
-                               protected Image getCover(File sourceFile) {
-                                       return null;
-                               }
-                       };
-
-                       MetaData superMeta = superMetaA[0];
-                       if (!hasIt(meta.getTitle())) {
-                               meta.setTitle(superMeta.getTitle());
-                       }
-                       if (!hasIt(meta.getAuthor())) {
-                               meta.setAuthor(superMeta.getAuthor());
-                       }
-                       if (!hasIt(meta.getDate())) {
-                               meta.setDate(superMeta.getDate());
-                       }
-                       if (!hasIt(meta.getUrl())) {
-                               meta.setUrl(superMeta.getUrl());
-                       }
-               }
-       }
-
-       /**
-        * Check if we have non-empty values for all the given {@link String}s.
-        * 
-        * @param values
-        *            the values to check
-        * 
-        * @return TRUE if none of them was NULL or empty
-        */
-       static private boolean hasIt(String... values) {
-               for (String value : values) {
-                       if (value == null || value.trim().isEmpty()) {
-                               return false;
-                       }
-               }
-
-               return true;
-       }
-
-       private static MetaData createMeta(URL sourceInfoFile, InputStream in,
-                       boolean withCover) throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getInfoTag(in, "TITLE"));
-               meta.setAuthor(getInfoTag(in, "AUTHOR"));
-               meta.setDate(bsHelper.formatDate(getInfoTag(in, "DATE")));
-               meta.setTags(getInfoTagList(in, "TAGS", ","));
-               meta.setSource(getInfoTag(in, "SOURCE"));
-               meta.setUrl(getInfoTag(in, "URL"));
-               meta.setPublisher(getInfoTag(in, "PUBLISHER"));
-               meta.setUuid(getInfoTag(in, "UUID"));
-               meta.setLuid(getInfoTag(in, "LUID"));
-               meta.setLang(getInfoTag(in, "LANG"));
-               meta.setSubject(getInfoTag(in, "SUBJECT"));
-               meta.setType(getInfoTag(in, "TYPE"));
-               meta.setImageDocument(getInfoTagBoolean(in, "IMAGES_DOCUMENT", false));
-               if (withCover) {
-                       String infoTag = getInfoTag(in, "COVER");
-                       if (infoTag != null && !infoTag.trim().isEmpty()) {
-                               meta.setCover(bsHelper.getImage(null, sourceInfoFile, infoTag));
-                       }
-                       if (meta.getCover() == null) {
-                               // Second chance: try to check for a cover next to the info file
-                               meta.setCover(getCoverByName(sourceInfoFile));
-                       }
-               }
-               try {
-                       meta.setWords(Long.parseLong(getInfoTag(in, "WORDCOUNT")));
-               } catch (NumberFormatException e) {
-                       meta.setWords(0);
-               }
-               meta.setCreationDate(
-                               bsHelper.formatDate(getInfoTag(in, "CREATION_DATE")));
-               meta.setFakeCover(Boolean.parseBoolean(getInfoTag(in, "FAKE_COVER")));
-
-               if (withCover && meta.getCover() == null) {
-                       meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
-               }
-
-               return meta;
-       }
-
-       /**
-        * Return the cover image if it is next to the source file.
-        * 
-        * @param sourceInfoFile
-        *            the source file
-        * 
-        * @return the cover if present, NULL if not
-        */
-       public static Image getCoverByName(URL sourceInfoFile) {
-               Image cover = null;
-
-               File basefile = new File(sourceInfoFile.getFile());
-
-               String ext = "." + Instance.getInstance().getConfig()
-                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
-
-               // Without removing ext
-               cover = bsHelper.getImage(null, sourceInfoFile,
-                               basefile.getAbsolutePath() + ext);
-
-               // Try without ext
-               String name = basefile.getName();
-               int pos = name.lastIndexOf(".");
-               if (cover == null && pos > 0) {
-                       name = name.substring(0, pos);
-                       basefile = new File(basefile.getParent(), name);
-
-                       cover = bsHelper.getImage(null, sourceInfoFile,
-                                       basefile.getAbsolutePath() + ext);
-               }
-
-               return cover;
-       }
-
-       private static boolean getInfoTagBoolean(InputStream in, String key,
-                       boolean def) throws IOException {
-               Boolean value = getInfoTagBoolean(in, key);
-               return value == null ? def : value;
-       }
-
-       private static Boolean getInfoTagBoolean(InputStream in, String key)
-                       throws IOException {
-               String value = getInfoTag(in, key);
-               if (value != null && !value.trim().isEmpty()) {
-                       value = value.toLowerCase().trim();
-                       return value.equals("1") || value.equals("on")
-                                       || value.equals("true") || value.equals("yes");
-               }
-
-               return null;
-       }
-
-       private static List<String> getInfoTagList(InputStream in, String key,
-                       String separator) throws IOException {
-               List<String> list = new ArrayList<String>();
-               String tt = getInfoTag(in, key);
-               if (tt != null) {
-                       for (String tag : tt.split(separator)) {
-                               list.add(tag.trim());
-                       }
-               }
-
-               return list;
-       }
-
-       /**
-        * Return the value of the given tag in the <tt>.info</tt> file if present.
-        * 
-        * @param key
-        *            the tag key
-        * 
-        * @return the value or NULL
-        * 
-        * @throws IOException
-        *             in case of I/O error
-        */
-       private static String getInfoTag(InputStream in, String key)
-                       throws IOException {
-               key = "^" + key + "=";
-
-               if (in != null) {
-                       in.reset();
-                       String value = getLine(in, key, 0);
-                       if (value != null && !value.isEmpty()) {
-                               value = value.trim().substring(key.length() - 1).trim();
-                               if (value.length() > 1 && //
-                                               (value.startsWith("'") && value.endsWith("'")
-                                                               || value.startsWith("\"")
-                                                                               && value.endsWith("\""))) {
-                                       value = value.substring(1, value.length() - 1).trim();
-                               }
-
-                               // Some old files ended up with TITLE="'xxxxx'"
-                               if ("^TITLE=".equals(key)) {
-                                       if (value.startsWith("'") && value.endsWith("'")
-                                                       && value.length() > 1) {
-                                               value = value.substring(1, value.length() - 1).trim();
-                                       }
-                               }
-
-                               return value;
-                       }
-               }
-
-               return null;
-       }
-
-       /**
-        * Return the first line from the given input which correspond to the given
-        * selectors.
-        * 
-        * @param in
-        *            the input
-        * @param needle
-        *            a string that must be found inside the target line (also
-        *            supports "^" at start to say "only if it starts with" the
-        *            needle)
-        * @param relativeLine
-        *            the line to return based upon the target line position (-1 =
-        *            the line before, 0 = the target line...)
-        * 
-        * @return the line
-        */
-       static private String getLine(InputStream in, String needle,
-                       int relativeLine) {
-               return getLine(in, needle, relativeLine, true);
-       }
-
-       /**
-        * Return a line from the given input which correspond to the given
-        * selectors.
-        * 
-        * @param in
-        *            the input
-        * @param needle
-        *            a string that must be found inside the target line (also
-        *            supports "^" at start to say "only if it starts with" the
-        *            needle)
-        * @param relativeLine
-        *            the line to return based upon the target line position (-1 =
-        *            the line before, 0 = the target line...)
-        * @param first
-        *            takes the first result (as opposed to the last one, which will
-        *            also always spend the input)
-        * 
-        * @return the line
-        */
-       static private String getLine(InputStream in, String needle,
-                       int relativeLine, boolean first) {
-               String rep = null;
-
-               List<String> lines = new ArrayList<String>();
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               int index = -1;
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       lines.add(scan.next());
-
-                       if (index == -1) {
-                               if (needle.startsWith("^")) {
-                                       if (lines.get(lines.size() - 1)
-                                                       .startsWith(needle.substring(1))) {
-                                               index = lines.size() - 1;
-                                       }
-
-                               } else {
-                                       if (lines.get(lines.size() - 1).contains(needle)) {
-                                               index = lines.size() - 1;
-                                       }
-                               }
-                       }
-
-                       if (index >= 0 && index + relativeLine < lines.size()) {
-                               rep = lines.get(index + relativeLine);
-                               if (first) {
-                                       break;
-                               }
-                       }
-               }
-
-               return rep;
-       }
-}
diff --git a/supported/InfoText.java b/supported/InfoText.java
deleted file mode 100644 (file)
index 2af8c7e..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.URL;
-
-import be.nikiroo.fanfix.data.MetaData;
-
-/**
- * Support class for <tt>.info</tt> text files ({@link Text} files with a
- * <tt>.info</tt> metadata file next to them).
- * <p>
- * The <tt>.info</tt> file is supposed to be written by this program, or
- * compatible.
- * 
- * @author niki
- */
-class InfoText extends Text {
-       protected File getInfoFile() {
-               return new File(assureNoTxt(getSourceFile()).getPath() + ".info");
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               return InfoReader.readMeta(getInfoFile(), true);
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return supports(url, true);
-       }
-}
diff --git a/supported/MangaHub.java b/supported/MangaHub.java
deleted file mode 100644 (file)
index 706a591..0000000
+++ /dev/null
@@ -1,214 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.net.HttpURLConnection;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map.Entry;
-
-import org.jsoup.nodes.Element;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Support class for <a href="https://mangahub.io/">MangaHub</a>, a website
- * dedicated to Manga.
- * 
- * @author niki
- */
-class MangaHub extends BasicSupport {
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle());
-               meta.setDate("");
-               meta.setAuthor(getAuthor());
-               meta.setTags(getTags());
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(getSource().toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(getSource().toString());
-               meta.setLuid("");
-               meta.setLang("en");
-               meta.setSubject("manga");
-               meta.setType(getType().toString());
-               meta.setImageDocument(true);
-               meta.setCover(getCover());
-
-               return meta;
-       }
-
-       private String getTitle() {
-               Element doc = getSourceNode();
-
-               Element el = doc.getElementsByTag("h1").first();
-               if (el != null) {
-                       return StringUtils.unhtml(el.text()).trim();
-               }
-
-               return null;
-       }
-
-       private String getAuthor() {
-               String author = "";
-
-               Element el = getSourceNode().select("h1+div span:not([class])").first();
-               if (el != null)
-                       author = StringUtils.unhtml(el.text()).trim();
-               return author;
-       }
-
-       private List<String> getTags() {
-               return getListA("genre-label");
-       }
-
-       private List<String> getListA(String uniqueClass) {
-               List<String> list = new ArrayList<String>();
-
-               Element doc = getSourceNode();
-               Element el = doc.getElementsByClass(uniqueClass).first();
-               if (el != null) {
-                       for (Element valueA : el.getElementsByTag("a")) {
-                               list.add(StringUtils.unhtml(valueA.text()).trim());
-                       }
-               }
-
-               return list;
-       }
-
-       @Override
-       protected String getDesc() {
-               Element doc = getSourceNode();
-               Element title = doc.getElementsByClass("fullcontent").first();
-               if (title != null) {
-                       return StringUtils.unhtml(title.text()).trim();
-               }
-
-               return null;
-       }
-
-       private Image getCover() {
-               Element doc = getSourceNode();
-               Element cover = doc.getElementsByClass("manga-thumb").first();
-               if (cover != null) {
-                       try {
-                               return bsImages.getImage(this, new URL(cover.absUrl("src")));
-                       } catch (MalformedURLException e) {
-                               Instance.getInstance().getTraceHandler().error(e);
-                       }
-               }
-
-               return null;
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg) {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-
-               Element doc = getSourceNode();
-               for (Element el : doc.getElementsByClass("list-group-item")) {
-                       Element urlEl = el.getElementsByTag("a").first();
-                       if (urlEl == null)
-                               continue;
-
-                       String url = urlEl.absUrl("href");
-
-                       String title = "";
-                       el = el.getElementsByClass("text-secondary").first();
-                       if (el != null) {
-                               title = StringUtils.unhtml(el.text()).trim();
-                       }
-
-                       try {
-                               urls.add(new AbstractMap.SimpleEntry<String, URL>(title, new URL(url)));
-                       } catch (Exception e) {
-                               Instance.getInstance().getTraceHandler().error(e);
-                       }
-               }
-
-               // by default, the chapters are in reversed order
-               Collections.reverse(urls);
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL chapUrl, int number, Progress pg) throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               // 1. Get the title and chapter url part
-               String path = chapUrl.getPath();
-               if (path.endsWith("/")) {
-                       path = path.substring(0, path.length() - "/".length());
-               }
-               String tab[] = path.split("/");
-               String chap = tab[tab.length - 1];
-               String title = tab[tab.length - 2];
-
-               if (chap.startsWith("chapter-")) {
-                       chap = chap.substring("chapter-".length());
-               }
-
-               // 2. generate an image base
-               String base = "https://img.mghubcdn.com/file/imghub/" + title + "/" + chap + "/";
-
-               // 3. add each chapter
-               StringBuilder builder = new StringBuilder();
-
-               int i = 1;
-               String url = base + i + ".jpg";
-               while (getHttpStatus(new URL(url)) != 404) {
-                       builder.append("[");
-                       builder.append(url);
-                       builder.append("]<br/>");
-
-                       i++;
-                       url = base + i + ".jpg";
-               }
-
-               return builder.toString();
-       }
-
-       // HTTP response code, or -1 if other error
-       // TODO: move that to Downloader?
-       private int getHttpStatus(URL url) {
-               try {
-                       HttpURLConnection conn = (HttpURLConnection) url.openConnection();
-                       try {
-                               conn.setRequestMethod("HEAD");
-                               conn.setRequestProperty("User-Agent", Instance.getInstance().getConfig().getString(Config.NETWORK_USER_AGENT));
-                               conn.setRequestProperty("Accept-Encoding", "gzip");
-                               conn.setRequestProperty("Accept", "*/*");
-                               conn.setRequestProperty("Charset", "utf-8");
-
-                               return conn.getResponseCode();
-                       } finally {
-                               conn.disconnect();
-                       }
-               } catch (Exception e) {
-                       return -1;
-               }
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return "mangahub.io".equals(url.getHost()) || "www.mangahub.io".equals(url.getHost());
-       }
-}
diff --git a/supported/MangaLel.java b/supported/MangaLel.java
deleted file mode 100644 (file)
index de0b871..0000000
+++ /dev/null
@@ -1,231 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map.Entry;
-
-import org.jsoup.helper.DataUtil;
-import org.jsoup.nodes.Element;
-import org.jsoup.select.Elements;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-class MangaLel extends BasicSupport {
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle());
-               meta.setAuthor(getAuthor());
-               meta.setDate(bsHelper.formatDate(getDate()));
-               meta.setTags(getTags());
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(getSource().toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(getSource().toString());
-               meta.setLuid("");
-               meta.setLang("fr");
-               meta.setSubject("manga");
-               meta.setType(getType().toString());
-               meta.setImageDocument(true);
-               meta.setCover(getCover());
-
-               return meta;
-       }
-
-       private String getTitle() {
-               Element doc = getSourceNode();
-               Element h4 = doc.getElementsByTag("h4").first();
-               if (h4 != null) {
-                       return StringUtils.unhtml(h4.text()).trim();
-               }
-
-               return null;
-       }
-
-       private String getAuthor() {
-               Element doc = getSourceNode();
-               Element tabEls = doc.getElementsByClass("presentation-projet").first();
-               if (tabEls != null) {
-                       String[] tab = tabEls.outerHtml().split("<br>");
-                       return getVal(tab, 1);
-               }
-
-               return "";
-       }
-
-       private List<String> getTags() {
-               Element doc = getSourceNode();
-               Element tabEls = doc.getElementsByClass("presentation-projet").first();
-               if (tabEls != null) {
-                       String[] tab = tabEls.outerHtml().split("<br>");
-                       List<String> tags = new ArrayList<String>();
-                       for (String tag : getVal(tab, 3).split(" ")) {
-                               tags.add(tag);
-                       }
-                       return tags;
-               }
-
-               return new ArrayList<String>();
-
-       }
-
-       private String getDate() {
-               Element doc = getSourceNode();
-               Element table = doc.getElementsByClass("table").first();
-
-               // We take the first date we find
-               String value = "";
-               if (table != null) {
-                       Elements els;
-                       els = table.getElementsByTag("tr");
-                       if (els.size() >= 2) {
-                               els = els.get(1).getElementsByTag("td");
-                               if (els.size() >= 3) {
-                                       value = StringUtils.unhtml(els.get(2).text()).trim();
-                               }
-                       }
-               }
-
-               return value;
-       }
-
-       @Override
-       protected String getDesc() {
-               Element doc = getSourceNode();
-               Element tabEls = doc.getElementsByClass("presentation-projet").first();
-               if (tabEls != null) {
-                       String[] tab = tabEls.outerHtml().split("<br>");
-                       return getVal(tab, 4);
-               }
-
-               return "";
-       }
-
-       private Image getCover() {
-               Element doc = getSourceNode();
-               Element container = doc.getElementsByClass("container").first();
-
-               if (container != null) {
-
-                       Elements imgs = container.getElementsByTag("img");
-                       Element img = null;
-                       if (imgs.size() >= 1) {
-                               img = imgs.get(0);
-                               if (img.hasClass("banniere-team-projet")) {
-                                       img = null;
-                                       if (imgs.size() >= 2) {
-                                               img = imgs.get(1);
-                                       }
-                               }
-                       }
-
-                       if (img != null) {
-                               String coverUrl = img.absUrl("src");
-
-                               InputStream coverIn;
-                               try {
-                                       coverIn = Instance.getInstance().getCache().open(new URL(coverUrl), this, true);
-                                       try {
-                                               return new Image(coverIn);
-                                       } finally {
-                                               coverIn.close();
-                                       }
-                               } catch (IOException e) {
-                                       Instance.getInstance().getTraceHandler().error(e);
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       private String getVal(String[] tab, int i) {
-               String val = "";
-
-               if (i < tab.length) {
-                       val = StringUtils.unhtml(tab[i]);
-                       int pos = val.indexOf(":");
-                       if (pos >= 0) {
-                               val = val.substring(pos + 1).trim();
-                       }
-               }
-
-               return val;
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg)
-                       throws IOException {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-
-               Element doc = getSourceNode();
-               Element table = doc.getElementsByClass("table").first();
-               if (table != null) {
-                       for (Element tr : table.getElementsByTag("tr")) {
-                               Element a = tr.getElementsByTag("a").first();
-                               if (a != null) {
-                                       String name = StringUtils.unhtml(a.text()).trim();
-                                       URL url = new URL(a.absUrl("href"));
-                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(name, url));
-                               }
-                       }
-               }
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL chapUrl, int number, Progress pg)
-                       throws IOException {
-               if (pg == null) {
-                       pg = new Progress();
-               }
-
-               StringBuilder builder = new StringBuilder();
-
-               InputStream in = Instance.getInstance().getCache().open(chapUrl, this, false);
-               try {
-                       Element pageDoc = DataUtil.load(in, "UTF-8", chapUrl.toString());
-                       Element content = pageDoc.getElementById("content");
-                       Elements linkEls = content.getElementsByTag("img");
-                       for (Element linkEl : linkEls) {
-                               if (linkEl.absUrl("src").isEmpty()) {
-                                       continue;
-                               }
-
-                               builder.append("[");
-                               builder.append(linkEl.absUrl("src"));
-                               builder.append("]<br/>");
-                       }
-
-               } finally {
-                       in.close();
-               }
-
-               return builder.toString();
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               // URL structure (the projectId is the manga key):
-               // http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet=999
-
-               return "mangas-lecture-en-ligne.fr".equals(url.getHost());
-       }
-}
diff --git a/supported/SupportType.java b/supported/SupportType.java
deleted file mode 100644 (file)
index 7ace726..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-
-/**
- * The supported input types for which we can get a {@link BasicSupport} object.
- * 
- * @author niki
- */
-public enum SupportType {
-       /** EPUB files created with this program */
-       EPUB,
-       /** Pure text file with some rules */
-       TEXT,
-       /** TEXT but with associated .info file */
-       INFO_TEXT,
-       /** My Little Pony fanfictions */
-       FIMFICTION,
-       /** Fanfictions from a lot of different universes */
-       FANFICTION,
-       /** Website with lots of Mangas */
-       MANGAHUB,
-       /** Furry website with comics support */
-       E621,
-       /** Furry website with stories */
-       YIFFSTAR,
-       /** Comics and images groups, mostly but not only NSFW */
-       E_HENTAI,
-       /** Website with lots of Mangas, in French */
-       MANGA_LEL,
-       /** CBZ files */
-       CBZ,
-       /** HTML files */
-       HTML;
-
-       /**
-        * The name of this support type (a short version).
-        * 
-        * @return the name
-        */
-       public String getSourceName() {
-               switch (this) {
-               case CBZ:
-                       return "cbz";
-               case E621:
-                       return "e621.net";
-               case E_HENTAI:
-                       return "e-hentai.org";
-               case EPUB:
-                       return "epub";
-               case FANFICTION:
-                       return "Fanfiction.net";
-               case FIMFICTION:
-                       return "FimFiction.net";
-               case HTML:
-                       return "html";
-               case INFO_TEXT:
-                       return "info-text";
-               case MANGA_LEL:
-                       return "MangaLEL";
-               case MANGAHUB:
-                       return "MangaHub.io";
-               case TEXT:
-                       return "text";
-               case YIFFSTAR:
-                       return "YiffStar";
-               }
-
-               return "";
-       }
-
-       /**
-        * A description of this support type (more information than
-        * {@link SupportType#getSourceName()}).
-        * 
-        * @return the description
-        */
-       public String getDesc() {
-               String desc = Instance.getInstance().getTrans().getStringX(StringId.INPUT_DESC, this.name());
-
-               if (desc == null) {
-                       desc = Instance.getInstance().getTrans().getString(StringId.INPUT_DESC, this);
-               }
-
-               return desc;
-       }
-
-       @Override
-       public String toString() {
-               return super.toString().toLowerCase();
-       }
-
-       /**
-        * Call {@link SupportType#valueOf(String)} after conversion to upper case.
-        * 
-        * @param typeName
-        *            the possible type name
-        * 
-        * @return NULL or the type
-        */
-       public static SupportType valueOfUC(String typeName) {
-               return SupportType.valueOf(typeName == null ? null : typeName
-                               .toUpperCase());
-       }
-
-       /**
-        * Call {@link SupportType#valueOf(String)} after conversion to upper case
-        * but return NULL for NULL instead of raising exception.
-        * 
-        * @param typeName
-        *            the possible type name
-        * 
-        * @return NULL or the type
-        */
-       public static SupportType valueOfNullOkUC(String typeName) {
-               if (typeName == null) {
-                       return null;
-               }
-
-               return SupportType.valueOfUC(typeName);
-       }
-
-       /**
-        * Call {@link SupportType#valueOf(String)} after conversion to upper case
-        * but return NULL in case of error instead of raising an exception.
-        * 
-        * @param typeName
-        *            the possible type name
-        * 
-        * @return NULL or the type
-        */
-       public static SupportType valueOfAllOkUC(String typeName) {
-               try {
-                       return SupportType.valueOfUC(typeName);
-               } catch (Exception e) {
-                       return null;
-               }
-       }
-}
\ No newline at end of file
diff --git a/supported/Text.java b/supported/Text.java
deleted file mode 100644 (file)
index 232eab6..0000000
+++ /dev/null
@@ -1,390 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map.Entry;
-import java.util.Scanner;
-
-import org.jsoup.nodes.Document;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.ImageUtils;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.streams.MarkableFileInputStream;
-
-/**
- * Support class for local stories encoded in textual format, with a few rules:
- * <ul>
- * <li>The title must be on the first line</li>
- * <li>The author (preceded by nothing, "by " or "©") must be on the second
- * line, possibly with the publication date in parenthesis (i.e., "
- * <tt>By Unknown (3rd October 1998)</tt>")</li>
- * <li>Chapters must be declared with "<tt>Chapter x</tt>" or "
- * <tt>Chapter x: NAME OF THE CHAPTER</tt>", where "<tt>x</tt>" is the chapter
- * number</li>
- * <li>A description of the story must be given as chapter number 0</li>
- * <li>A cover may be present, with the same filename but a PNG, JPEG or JPG
- * extension</li>
- * </ul>
- * 
- * @author niki
- */
-class Text extends BasicSupport {
-       private File sourceFile;
-       private InputStream in;
-
-       protected File getSourceFile() {
-               return sourceFile;
-       }
-
-       protected InputStream getInput() {
-               if (in != null) {
-                       try {
-                               in.reset();
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(new IOException("Cannot reset the Text stream", e));
-                       }
-
-                       return in;
-               }
-
-               return null;
-       }
-
-       @Override
-       protected boolean isHtml() {
-               return false;
-       }
-
-       @Override
-       protected Document loadDocument(URL source) throws IOException {
-               try {
-                       sourceFile = new File(source.toURI());
-                       in = new MarkableFileInputStream(sourceFile);
-               } catch (URISyntaxException e) {
-                       throw new IOException("Cannot load the text document: " + source);
-               }
-
-               return null;
-       }
-
-       @Override
-       protected MetaData getMeta() throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle());
-               meta.setAuthor(getAuthor());
-               meta.setDate(bsHelper.formatDate(getDate()));
-               meta.setTags(new ArrayList<String>());
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(getSourceFile().toURI().toURL().toString());
-               meta.setPublisher("");
-               meta.setUuid(getSourceFile().toString());
-               meta.setLuid("");
-               meta.setLang(getLang()); // default is EN
-               meta.setSubject(getSourceFile().getParentFile().getName());
-               meta.setType(getType().toString());
-               meta.setImageDocument(false);
-               meta.setCover(getCover(getSourceFile()));
-               
-               return meta;
-       }
-
-       private String getLang() {
-               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
-               Scanner scan = new Scanner(getInput(), "UTF-8");
-               scan.useDelimiter("\\n");
-               scan.next(); // Title
-               scan.next(); // Author (Date)
-               String chapter0 = scan.next(); // empty or Chapter 0
-               while (chapter0.isEmpty()) {
-                       chapter0 = scan.next();
-               }
-
-               String lang = detectChapter(chapter0, 0);
-               if (lang == null) {
-                       // No description??
-                       lang = detectChapter(chapter0, 1);
-               }
-
-               if (lang == null) {
-                       lang = "en";
-               } else {
-                       lang = lang.toLowerCase();
-               }
-
-               return lang;
-       }
-
-       private String getTitle() {
-               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
-               Scanner scan = new Scanner(getInput(), "UTF-8");
-               scan.useDelimiter("\\n");
-               return scan.next();
-       }
-
-       private String getAuthor() {
-               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
-               Scanner scan = new Scanner(getInput(), "UTF-8");
-               scan.useDelimiter("\\n");
-               scan.next();
-               String authorDate = scan.next();
-
-               String author = authorDate;
-               int pos = authorDate.indexOf('(');
-               if (pos >= 0) {
-                       author = authorDate.substring(0, pos);
-               }
-
-               return bsHelper.fixAuthor(author);
-       }
-
-       private String getDate() {
-               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
-               Scanner scan = new Scanner(getInput(), "UTF-8");
-               scan.useDelimiter("\\n");
-               scan.next();
-               String authorDate = scan.next();
-
-               String date = "";
-               int pos = authorDate.indexOf('(');
-               if (pos >= 0) {
-                       date = authorDate.substring(pos + 1).trim();
-                       pos = date.lastIndexOf(')');
-                       if (pos >= 0) {
-                               date = date.substring(0, pos).trim();
-                       }
-               }
-
-               return date;
-       }
-
-       @Override
-       protected String getDesc() throws IOException {
-               String content = getChapterContent(null, 0, null).trim();
-               if (!content.isEmpty()) {
-                       Chapter desc = bsPara.makeChapter(this, null, 0, "Description",
-                                       content, isHtml(), null);
-                       StringBuilder builder = new StringBuilder();
-                       for (Paragraph para : desc) {
-                               if (builder.length() > 0) {
-                                       builder.append("\n");
-                               }
-                               builder.append(para.getContent());
-                       }
-               }
-
-               return content;
-       }
-
-       protected Image getCover(File sourceFile) {
-               String path = sourceFile.getName();
-
-               for (String ext : new String[] { ".txt", ".text", ".story" }) {
-                       if (path.endsWith(ext)) {
-                               path = path.substring(0, path.length() - ext.length());
-                       }
-               }
-
-               Image cover = bsImages.getImage(this, sourceFile.getParentFile(), path);
-               if (cover != null) {
-                       try {
-                               File tmp = Instance.getInstance().getTempFiles().createTempFile("test_cover_image");
-                               ImageUtils.getInstance().saveAsImage(cover, tmp, "png");
-                               tmp.delete();
-                       } catch (IOException e) {
-                               cover = null;
-                       }
-               }
-
-               return cover;
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg)
-                       throws IOException {
-               List<Entry<String, URL>> chaps = new ArrayList<Entry<String, URL>>();
-               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
-               Scanner scan = new Scanner(getInput(), "UTF-8");
-               scan.useDelimiter("\\n");
-               String line = "first is not empty";
-               while (scan.hasNext()) {
-                       boolean prevLineEmpty = line.trim().isEmpty();
-                       line = scan.next();
-                       if (prevLineEmpty && detectChapter(line, chaps.size() + 1) != null) {
-                               String chapName = Integer.toString(chaps.size() + 1);
-                               int pos = line.indexOf(':');
-                               if (pos >= 0 && pos + 1 < line.length()) {
-                                       chapName = line.substring(pos + 1).trim();
-                               }
-
-                               chaps.add(new AbstractMap.SimpleEntry<String, URL>(//
-                                               chapName, //
-                                               getSourceFile().toURI().toURL()));
-                       }
-               }
-               
-               return chaps;
-       }
-
-       @Override
-       protected String getChapterContent(URL source, int number, Progress pg)
-                       throws IOException {
-               StringBuilder builder = new StringBuilder();
-               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
-               Scanner scan = new Scanner(getInput(), "UTF-8");
-               scan.useDelimiter("\\n");
-               scan.next(); // title
-               scan.next(); // author
-               scan.next(); // date or empty
-               Boolean inChap = null;
-               String line = "";
-               while (scan.hasNext()) {
-                       if (number == 0 && !line.trim().isEmpty()) {
-                               // We found pre-chapter content, we are checking for
-                               // Chapter 0 (fake chapter) --> keep the content
-                               if (inChap == null)
-                                       inChap = true;
-                       }
-                       line = scan.next();
-                       if ((inChap == null || !inChap) && detectChapter(line, number) != null) {
-                               inChap = true;
-                       } else if (detectChapter(line, number + 1) != null) {
-                               break;
-                       } else if (inChap != null && inChap) {
-                               builder.append(line);
-                               builder.append("\n");
-                       }
-               }
-
-               return builder.toString();
-       }
-
-       @Override
-       protected void close() {
-               InputStream in = getInput();
-               if (in != null) {
-                       try {
-                               in.close();
-                       } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler()
-                                               .error(new IOException("Cannot close the text source file input", e));
-                       }
-               }
-
-               super.close();
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               return supports(url, false);
-       }
-
-       /**
-        * Check if we supports this {@link URL}, that is, if the info file can be
-        * found OR not found.
-        * <p>
-        * It must also be a file, not another kind of URL.
-        * 
-        * @param url
-        *            the {@link URL} to check
-        * @param info
-        *            TRUE to require the info file, FALSE to forbid the info file
-        * 
-        * @return TRUE if it is supported
-        */
-       protected boolean supports(URL url, boolean info) {
-               if (!"file".equals(url.getProtocol())) {
-                       return false;
-               }
-
-               boolean infoPresent = false;
-               File file;
-               try {
-                       file = new File(url.toURI());
-                       file = assureNoTxt(file);
-                       file = new File(file.getPath() + ".info");
-               } catch (URISyntaxException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-                       file = null;
-               }
-
-               infoPresent = (file != null && file.exists());
-
-               return infoPresent == info;
-       }
-
-       /**
-        * Remove the ".txt" (or ".text") extension if it is present.
-        * 
-        * @param file
-        *            the file to process
-        * 
-        * @return the same file or a copy of it without the ".txt" extension if it
-        *         was present
-        */
-       protected File assureNoTxt(File file) {
-               for (String ext : new String[] { ".txt", ".text" }) {
-                       if (file.getName().endsWith(ext)) {
-                               file = new File(file.getPath().substring(0,
-                                               file.getPath().length() - ext.length()));
-                       }
-               }
-
-               return file;
-       }
-
-       /**
-        * Check if the given line looks like the given starting chapter in a
-        * supported language, and return the language if it does (or NULL if not).
-        * 
-        * @param line
-        *            the line to check
-        * @param number
-        *            the specific chapter number to check for
-        * 
-        * @return the language or NULL
-        */
-       static private String detectChapter(String line, int number) {
-               line = line.toUpperCase();
-               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
-                       String chapter = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
-                       if (chapter != null && !chapter.isEmpty()) {
-                               chapter = chapter.toUpperCase() + " ";
-                               if (line.startsWith(chapter)) {
-                                       // We want "[CHAPTER] [number]: [name]", with ": [name]"
-                                       // optional
-                                       String test = line.substring(chapter.length()).trim();
-
-                                       String possibleNum = test.trim();
-                                       if (possibleNum.indexOf(':') > 0) {
-                                               possibleNum = possibleNum.substring(0,
-                                                               possibleNum.indexOf(':')).trim();
-                                       }
-
-                                       if (test.startsWith(Integer.toString(number))) {
-                                               test = test
-                                                               .substring(Integer.toString(number).length())
-                                                               .trim();
-                                               if (test.isEmpty() || test.startsWith(":")) {
-                                                       return lang;
-                                               }
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-}
diff --git a/supported/YiffStar.java b/supported/YiffStar.java
deleted file mode 100644 (file)
index 6974e9a..0000000
+++ /dev/null
@@ -1,267 +0,0 @@
-package be.nikiroo.fanfix.supported;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Scanner;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.Image;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.StringUtils;
-
-/**
- * Support class for <a href="https://sofurry.com/">SoFurry.com</a>, a Furry
- * website supporting images and stories (we only retrieve the stories).
- * 
- * @author niki
- */
-class YiffStar extends BasicSupport_Deprecated {
-       @Override
-       protected MetaData getMeta(URL source, InputStream in) throws IOException {
-               MetaData meta = new MetaData();
-
-               meta.setTitle(getTitle(reset(in)));
-               meta.setAuthor(getAuthor(reset(in)));
-               meta.setDate("");
-               meta.setTags(getTags(reset(in)));
-               meta.setSource(getType().getSourceName());
-               meta.setUrl(source.toString());
-               meta.setPublisher(getType().getSourceName());
-               meta.setUuid(source.toString());
-               meta.setLuid("");
-               meta.setLang("en");
-               meta.setSubject("Furry");
-               meta.setType(getType().toString());
-               meta.setImageDocument(false);
-               meta.setCover(getCover(source, reset(in)));
-
-               return meta;
-       }
-
-       @Override
-       protected boolean supports(URL url) {
-               String host = url.getHost();
-               if (host.startsWith("www.")) {
-                       host = host.substring("www.".length());
-               }
-
-               return "sofurry.com".equals(host);
-       }
-
-       @Override
-       protected boolean isHtml() {
-               return true;
-       }
-
-       @Override
-       public void login() throws IOException {
-               // Note: this should not be necessary anymore
-               // (the "/guest" trick is enough)
-               String login = Instance.getInstance().getConfig().getString(Config.LOGIN_YIFFSTAR_USER);
-               String password = Instance.getInstance().getConfig().getString(Config.LOGIN_YIFFSTAR_PASS);
-
-               if (login != null && !login.isEmpty() && password != null
-                               && !password.isEmpty()) {
-
-                       Map<String, String> post = new HashMap<String, String>();
-                       post.put("LoginForm[sfLoginUsername]", login);
-                       post.put("LoginForm[sfLoginPassword]", password);
-                       post.put("YII_CSRF_TOKEN", "");
-                       post.put("yt1", "Login");
-                       post.put("returnUrl", "/");
-
-                       // Cookies will actually be retained by the cache manager once
-                       // logged in
-                       Instance.getInstance().getCache()
-                                       .openNoCache(new URL("https://www.sofurry.com/user/login"), this, post, null, null).close();
-               }
-       }
-
-       @Override
-       public URL getCanonicalUrl(URL source) {
-               try {
-                       if (source.getPath().startsWith("/view")) {
-                               source = guest(source.toString());
-                               // NO CACHE because we don't want the NotLoggedIn message later
-                               InputStream in = Instance.getInstance().getCache().openNoCache(source, this, null, null, null);
-                               String line = getLine(in, "/browse/folder/", 0);
-                               if (line != null) {
-                                       String[] tab = line.split("\"");
-                                       if (tab.length > 1) {
-                                               String groupUrl = source.getProtocol() + "://"
-                                                               + source.getHost() + tab[1];
-                                               return guest(groupUrl);
-                                       }
-                               }
-                       }
-               } catch (Exception e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-               }
-
-               return super.getCanonicalUrl(source);
-       }
-
-       private List<String> getTags(InputStream in) {
-               List<String> tags = new ArrayList<String>();
-
-               String line = getLine(in, "class=\"sf-story-big-tags", 0);
-               if (line != null) {
-                       String[] tab = StringUtils.unhtml(line).split(",");
-                       for (String possibleTag : tab) {
-                               String tag = possibleTag.trim();
-                               if (!tag.isEmpty() && !tag.equals("...") && !tags.contains(tag)) {
-                                       tags.add(tag);
-                               }
-                       }
-               }
-
-               return tags;
-       }
-
-       private Image getCover(URL source, InputStream in) throws IOException {
-
-               List<Entry<String, URL>> chaps = getChapters(source, in, null);
-               if (!chaps.isEmpty()) {
-                       in = Instance.getInstance().getCache().open(chaps.get(0).getValue(), this, true);
-                       String line = getLine(in, " name=\"og:image\"", 0);
-                       if (line != null) {
-                               int pos = -1;
-                               for (int i = 0; i < 3; i++) {
-                                       pos = line.indexOf('"', pos + 1);
-                               }
-
-                               if (pos >= 0) {
-                                       line = line.substring(pos + 1);
-                                       pos = line.indexOf('"');
-                                       if (pos >= 0) {
-                                               line = line.substring(0, pos);
-                                               if (line.contains("/thumb?")) {
-                                                       line = line.replace("/thumb?",
-                                                                       "/auxiliaryContent?type=25&");
-                                                       return getImage(this, null, line);
-                                               }
-                                       }
-                               }
-                       }
-               }
-
-               return null;
-       }
-
-       private String getAuthor(InputStream in) {
-               String author = getLine(in, "class=\"onlinestatus", 0);
-               if (author != null) {
-                       return StringUtils.unhtml(author).trim();
-               }
-
-               return null;
-       }
-
-       private String getTitle(InputStream in) {
-               String title = getLine(in, "class=\"sflabel pagetitle", 0);
-               if (title != null) {
-                       if (title.contains("(series)")) {
-                               title = title.replace("(series)", "");
-                       }
-                       return StringUtils.unhtml(title).trim();
-               }
-
-               return null;
-       }
-
-       @Override
-       protected String getDesc(URL source, InputStream in) throws IOException {
-               return null; // TODO: no description at all? Cannot find one...
-       }
-
-       @Override
-       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
-                       Progress pg) throws IOException {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (line.contains("\"/view/") && line.contains("title=")) {
-                               String[] tab = line.split("\"");
-                               if (tab.length > 5) {
-                                       String link = tab[5];
-                                       if (link.startsWith("/")) {
-                                               link = source.getProtocol() + "://" + source.getHost()
-                                                               + link;
-                                       }
-                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(
-                                                       StringUtils.unhtml(line).trim(), guest(link)));
-                               }
-                       }
-               }
-
-               return urls;
-       }
-
-       @Override
-       protected String getChapterContent(URL source, InputStream in, int number,
-                       Progress pg) throws IOException {
-               StringBuilder builder = new StringBuilder();
-
-               String startAt = "id=\"sfContentBody";
-               String endAt = "id=\"recommendationArea";
-               boolean ok = false;
-
-               @SuppressWarnings("resource")
-               Scanner scan = new Scanner(in, "UTF-8");
-               scan.useDelimiter("\\n");
-               while (scan.hasNext()) {
-                       String line = scan.next();
-                       if (!ok && line.contains(startAt)) {
-                               ok = true;
-                       } else if (ok && line.contains(endAt)) {
-                               ok = false;
-                               break;
-                       }
-
-                       if (ok) {
-                               builder.append(line);
-                               builder.append(' ');
-                       }
-               }
-
-               return builder.toString();
-       }
-
-       /**
-        * Return a {@link URL} from the given link, but add the "/guest" part to it
-        * to make sure we don't need to be logged-in to see it.
-        * 
-        * @param link
-        *            the link
-        * 
-        * @return the {@link URL}
-        * 
-        * @throws MalformedURLException
-        *             in case of data error
-        */
-       private URL guest(String link) throws MalformedURLException {
-               if (link.contains("?")) {
-                       if (link.contains("/?")) {
-                               return new URL(link.replace("?", "guest?"));
-                       }
-
-                       return new URL(link.replace("?", "/guest?"));
-               }
-
-               return new URL(link + "/guest");
-       }
-}
diff --git a/supported/package-info.java b/supported/package-info.java
deleted file mode 100644 (file)
index 1762e32..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * This package contains different implementation of 
- * {@link be.nikiroo.fanfix.supported.BasicSupport} to cater to different 
- * sources.
- * <p>
- * You are expected to use the static methods from 
- * {@link be.nikiroo.fanfix.supported.BasicSupport} to get those you need.
- * 
- * @author niki
- */
-package be.nikiroo.fanfix.supported;
\ No newline at end of file
diff --git a/test/BasicSupportDeprecatedTest.java b/test/BasicSupportDeprecatedTest.java
deleted file mode 100644 (file)
index c8c4b40..0000000
+++ /dev/null
@@ -1,451 +0,0 @@
-package be.nikiroo.fanfix.test;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map.Entry;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.supported.BasicSupport;
-import be.nikiroo.fanfix.supported.BasicSupport_Deprecated;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.test.TestCase;
-import be.nikiroo.utils.test.TestLauncher;
-
-class BasicSupportDeprecatedTest extends TestLauncher {
-       // quote chars
-       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
-       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
-       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-       public BasicSupportDeprecatedTest(String[] args) {
-               super("BasicSupportDeprecated", args);
-
-               addSeries(new TestLauncher("General", args) {
-                       {
-                               addTest(new TestCase("BasicSupport.makeParagraphs()") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty() {
-                                                       @Override
-                                                       protected boolean isHtml() {
-                                                               return true;
-                                                       }
-
-                                                       @Override
-                                                       public void fixBlanksBreaks(List<Paragraph> paras) {
-                                                       }
-
-                                                       @Override
-                                                       public List<Paragraph> requotify(Paragraph para) {
-                                                               List<Paragraph> paras = new ArrayList<Paragraph>(
-                                                                               1);
-                                                               paras.add(para);
-                                                               return paras;
-                                                       }
-                                               };
-
-                                               List<Paragraph> paras = null;
-
-                                               paras = support.makeParagraphs(null, "", null);
-                                               assertEquals(
-                                                               "An empty content should not generate paragraphs",
-                                                               0, paras.size());
-
-                                               paras = support.makeParagraphs(null,
-                                                               "Line 1</p><p>Line 2</p><p>Line 3</p>", null);
-                                               assertEquals(5, paras.size());
-                                               assertEquals("Line 1", paras.get(0).getContent());
-                                               assertEquals(ParagraphType.BLANK, paras.get(1)
-                                                               .getType());
-                                               assertEquals("Line 2", paras.get(2).getContent());
-                                               assertEquals(ParagraphType.BLANK, paras.get(3)
-                                                               .getType());
-                                               assertEquals("Line 3", paras.get(4).getContent());
-
-                                               paras = support.makeParagraphs(null,
-                                                               "<p>Line1</p><p>Line2</p><p>Line3</p>", null);
-                                               assertEquals(6, paras.size());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.removeDoubleBlanks()") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty() {
-                                                       @Override
-                                                       protected boolean isHtml() {
-                                                               return true;
-                                                       }
-                                               };
-
-                                               List<Paragraph> paras = null;
-
-                                               paras = support
-                                                               .makeParagraphs(
-                                                                               null,
-                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>",
-                                                                               null);
-                                               assertEquals(5, paras.size());
-
-                                               paras = support
-                                                               .makeParagraphs(
-                                                                               null,
-                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>* * *",
-                                                                               null);
-                                               assertEquals(5, paras.size());
-
-                                               paras = support.makeParagraphs(null, "1<p>* * *<p>2",
-                                                               null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-
-                                               paras = support.makeParagraphs(null,
-                                                               "1<p><br/><p>* * *<p>2", null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-
-                                               paras = support.makeParagraphs(null,
-                                                               "1<p>* * *<br/><p><br><p>2", null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-
-                                               paras = support.makeParagraphs(null,
-                                                               "1<p><br/><br>* * *<br/><p><br><p>2", null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.processPara() quotes") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty() {
-                                                       @Override
-                                                       protected boolean isHtml() {
-                                                               return true;
-                                                       }
-                                               };
-
-                                               Paragraph para;
-
-                                               // sanity check
-                                               para = support.processPara("");
-                                               assertEquals(ParagraphType.BLANK, para.getType());
-                                               //
-
-                                               para = support.processPara("\"Yes, my Lord!\"");
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-
-                                               para = support.processPara("«Yes, my Lord!»");
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-
-                                               para = support.processPara("'Yes, my Lord!'");
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
-                                                               para.getContent());
-
-                                               para = support.processPara("‹Yes, my Lord!›");
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
-                                                               para.getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase(
-                                               "BasicSupport.processPara() double-simple quotes") {
-                                       @Override
-                                       public void setUp() throws Exception {
-                                               super.setUp();
-
-                                       }
-
-                                       @Override
-                                       public void tearDown() throws Exception {
-
-                                               super.tearDown();
-                                       }
-
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty() {
-                                                       @Override
-                                                       protected boolean isHtml() {
-                                                               return true;
-                                                       }
-                                               };
-
-                                               Paragraph para;
-
-                                               para = support.processPara("''Yes, my Lord!''");
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-
-                                               para = support.processPara("‹‹Yes, my Lord!››");
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.processPara() apostrophe") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty() {
-                                                       @Override
-                                                       protected boolean isHtml() {
-                                                               return true;
-                                                       }
-                                               };
-
-                                               Paragraph para;
-
-                                               String text = "Nous étions en été, mais cela aurait être l'hiver quand nous n'étions encore qu'à Aubeuge";
-                                               para = support.processPara(text);
-                                               assertEquals(ParagraphType.NORMAL, para.getType());
-                                               assertEquals(text, para.getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.processPara() words count") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty() {
-                                                       @Override
-                                                       protected boolean isHtml() {
-                                                               return true;
-                                                       }
-                                               };
-
-                                               Paragraph para;
-
-                                               para = support.processPara("«Yes, my Lord!»");
-                                               assertEquals(3, para.getWords());
-
-                                               para = support.processPara("One, twee, trois.");
-                                               assertEquals(3, para.getWords());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.requotify() words count") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportEmpty support = new BasicSupportEmpty();
-
-                                               char openDoubleQuote = Instance.getInstance().getTrans()
-                                                               .getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-                                               char closeDoubleQuote = Instance.getInstance().getTrans()
-                                                               .getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-                                               String content = null;
-                                               Paragraph para = null;
-                                               List<Paragraph> paras = null;
-                                               long words = 0;
-
-                                               content = "One, twee, trois.";
-                                               para = new Paragraph(ParagraphType.NORMAL, content,
-                                                               content.split(" ").length);
-                                               paras = support.requotify(para);
-                                               words = 0;
-                                               for (Paragraph p : paras) {
-                                                       words += p.getWords();
-                                               }
-                                               assertEquals("Bad words count in a single paragraph",
-                                                               para.getWords(), words);
-
-                                               content = "Such WoW! So Web2.0! With Colours!";
-                                               para = new Paragraph(ParagraphType.NORMAL, content,
-                                                               content.split(" ").length);
-                                               paras = support.requotify(para);
-                                               words = 0;
-                                               for (Paragraph p : paras) {
-                                                       words += p.getWords();
-                                               }
-                                               assertEquals("Bad words count in a single paragraph",
-                                                               para.getWords(), words);
-
-                                               content = openDoubleQuote + "Such a good idea!"
-                                                               + closeDoubleQuote
-                                                               + ", she said. This ought to be a new para.";
-                                               para = new Paragraph(ParagraphType.QUOTE, content,
-                                                               content.split(" ").length);
-                                               paras = support.requotify(para);
-                                               words = 0;
-                                               for (Paragraph p : paras) {
-                                                       words += p.getWords();
-                                               }
-                                               assertEquals(
-                                                               "Bad words count in a requotified paragraph",
-                                                               para.getWords(), words);
-                                       }
-                               });
-                       }
-               });
-
-               addSeries(new TestLauncher("Text", args) {
-                       {
-                               addTest(new TestCase("Chapter detection simple") {
-                                       private File tmp;
-
-                                       @Override
-                                       public void setUp() throws Exception {
-                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
-                                               IOUtils.writeSmallFile(tmp.getParentFile(),
-                                                               tmp.getName(), "TITLE"
-                                                                               + "\n"//
-                                                                               + "By nona"
-                                                                               + "\n" //
-                                                                               + "\n" //
-                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
-                                                                               + "'sume." + "\n" + "\n"
-                                                                               + "Chapter 1: chap1" + "\n" + "\n"
-                                                                               + "Fanfan." + "\n" + "\n"
-                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
-                                                                               + "Tulipe." + "\n");
-                                       }
-
-                                       @Override
-                                       public void tearDown() throws Exception {
-                                               tmp.delete();
-                                       }
-
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupport support = BasicSupport.getSupport(
-                                                               SupportType.TEXT, tmp.toURI().toURL());
-
-                                               Story story = support.process(null);
-
-                                               assertEquals(2, story.getChapters().size());
-                                               assertEquals(1, story.getChapters().get(1)
-                                                               .getParagraphs().size());
-                                               assertEquals("Tulipe.", story.getChapters().get(1)
-                                                               .getParagraphs().get(0).getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase("Chapter detection with String 'Chapter'") {
-                                       private File tmp;
-
-                                       @Override
-                                       public void setUp() throws Exception {
-                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
-                                               IOUtils.writeSmallFile(tmp.getParentFile(),
-                                                               tmp.getName(), "TITLE"
-                                                                               + "\n"//
-                                                                               + "By nona"
-                                                                               + "\n" //
-                                                                               + "\n" //
-                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
-                                                                               + "'sume." + "\n" + "\n"
-                                                                               + "Chapter 1: chap1" + "\n" + "\n"
-                                                                               + "Chapter fout-la-merde" + "\n"
-                                                                               + "\n"//
-                                                                               + "Fanfan." + "\n" + "\n"
-                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
-                                                                               + "Tulipe." + "\n");
-                                       }
-
-                                       @Override
-                                       public void tearDown() throws Exception {
-                                               tmp.delete();
-                                       }
-
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupport support = BasicSupport.getSupport(
-                                                               SupportType.TEXT, tmp.toURI().toURL());
-
-                                               Story story = support.process(null);
-
-                                               assertEquals(2, story.getChapters().size());
-                                               assertEquals(1, story.getChapters().get(1)
-                                                               .getParagraphs().size());
-                                               assertEquals("Tulipe.", story.getChapters().get(1)
-                                                               .getParagraphs().get(0).getContent());
-                                       }
-                               });
-                       }
-               });
-       }
-
-       private class BasicSupportEmpty extends BasicSupport_Deprecated {
-               @Override
-               protected boolean supports(URL url) {
-                       return false;
-               }
-
-               @Override
-               protected boolean isHtml() {
-                       return false;
-               }
-
-               @Override
-               protected MetaData getMeta(URL source, InputStream in)
-                               throws IOException {
-                       return null;
-               }
-
-               @Override
-               protected String getDesc(URL source, InputStream in) throws IOException {
-                       return null;
-               }
-
-               @Override
-               protected List<Entry<String, URL>> getChapters(URL source,
-                               InputStream in, Progress pg) throws IOException {
-                       return null;
-               }
-
-               @Override
-               protected String getChapterContent(URL source, InputStream in,
-                               int number, Progress pg) throws IOException {
-                       return null;
-               }
-
-               @Override
-               // and make it public!
-               public List<Paragraph> makeParagraphs(URL source, String content,
-                               Progress pg) throws IOException {
-                       return super.makeParagraphs(source, content, pg);
-               }
-
-               @Override
-               // and make it public!
-               public void fixBlanksBreaks(List<Paragraph> paras) {
-                       super.fixBlanksBreaks(paras);
-               }
-
-               @Override
-               // and make it public!
-               public Paragraph processPara(String line) {
-                       return super.processPara(line);
-               }
-
-               @Override
-               // and make it public!
-               public List<Paragraph> requotify(Paragraph para) {
-                       return super.requotify(para);
-               }
-       }
-}
diff --git a/test/BasicSupportUtilitiesTest.java b/test/BasicSupportUtilitiesTest.java
deleted file mode 100644 (file)
index 9cec220..0000000
+++ /dev/null
@@ -1,397 +0,0 @@
-package be.nikiroo.fanfix.test;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.StringId;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.supported.BasicSupport;
-import be.nikiroo.fanfix.supported.BasicSupportHelper;
-import be.nikiroo.fanfix.supported.BasicSupportImages;
-import be.nikiroo.fanfix.supported.BasicSupportPara;
-import be.nikiroo.fanfix.supported.SupportType;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.test.TestCase;
-import be.nikiroo.utils.test.TestLauncher;
-
-class BasicSupportUtilitiesTest extends TestLauncher {
-       // quote chars
-       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
-       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
-       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-       
-       public BasicSupportUtilitiesTest(String[] args) {
-               super("BasicSupportUtilities", args);
-               
-               addSeries(new TestLauncher("General", args) {
-                       {
-                               addTest(new TestCase("BasicSupport.makeParagraphs()") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic bsPara = new BasicSupportParaPublic() {
-                                                       @Override
-                                                       public void fixBlanksBreaks(List<Paragraph> paras) {
-                                                       }
-
-                                                       @Override
-                                                       public List<Paragraph> requotify(Paragraph para, boolean html) {
-                                                               List<Paragraph> paras = new ArrayList<Paragraph>(
-                                                                               1);
-                                                               paras.add(para);
-                                                               return paras;
-                                                       }
-                                               };
-
-                                               List<Paragraph> paras = null;
-
-                                               paras = bsPara.makeParagraphs(null, null, "", true, null);
-                                               assertEquals(
-                                                               "An empty content should not generate paragraphs",
-                                                               0, paras.size());
-
-                                               paras = bsPara.makeParagraphs(null, null,
-                                                               "Line 1</p><p>Line 2</p><p>Line 3</p>", true, null);
-                                               assertEquals(5, paras.size());
-                                               assertEquals("Line 1", paras.get(0).getContent());
-                                               assertEquals(ParagraphType.BLANK, paras.get(1)
-                                                               .getType());
-                                               assertEquals("Line 2", paras.get(2).getContent());
-                                               assertEquals(ParagraphType.BLANK, paras.get(3)
-                                                               .getType());
-                                               assertEquals("Line 3", paras.get(4).getContent());
-
-                                               paras = bsPara.makeParagraphs(null, null,
-                                                               "<p>Line1</p><p>Line2</p><p>Line3</p>", true, null);
-                                               assertEquals(6, paras.size());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.removeDoubleBlanks()") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
-
-                                               List<Paragraph> paras = null;
-
-                                               paras = support
-                                                               .makeParagraphs(
-                                                                               null,
-                                                                               null,
-                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>",
-                                                                               true,
-                                                                               null);
-                                               assertEquals(5, paras.size());
-
-                                               paras = support
-                                                               .makeParagraphs(
-                                                                               null,
-                                                                               null,
-                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>* * *",
-                                                                               true,
-                                                                               null);
-                                               assertEquals(5, paras.size());
-
-                                               paras = support.makeParagraphs(null, null, "1<p>* * *<p>2",
-                                                               true, null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-
-                                               paras = support.makeParagraphs(null, null,
-                                                               "1<p><br/><p>* * *<p>2", true, null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-
-                                               paras = support.makeParagraphs(null, null,
-                                                               "1<p>* * *<br/><p><br><p>2", true, null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-
-                                               paras = support.makeParagraphs(null, null,
-                                                               "1<p><br/><br>* * *<br/><p><br><p>2", true, null);
-                                               assertEquals(3, paras.size());
-                                               assertEquals(ParagraphType.BREAK, paras.get(1)
-                                                               .getType());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.processPara() quotes") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
-
-                                               Paragraph para;
-
-                                               // sanity check
-                                               para = support.processPara("", true);
-                                               assertEquals(ParagraphType.BLANK, para.getType());
-                                               //
-
-                                               para = support.processPara("\"Yes, my Lord!\"", true);
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-
-                                               para = support.processPara("«Yes, my Lord!»", true);
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-
-                                               para = support.processPara("'Yes, my Lord!'", true);
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
-                                                               para.getContent());
-
-                                               para = support.processPara("‹Yes, my Lord!›", true);
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
-                                                               para.getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase(
-                                               "BasicSupport.processPara() double-simple quotes") {
-                                       @Override
-                                       public void setUp() throws Exception {
-                                               super.setUp();
-
-                                       }
-
-                                       @Override
-                                       public void tearDown() throws Exception {
-
-                                               super.tearDown();
-                                       }
-
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
-
-                                               Paragraph para;
-
-                                               para = support.processPara("''Yes, my Lord!''", true);
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-
-                                               para = support.processPara("‹‹Yes, my Lord!››", true);
-                                               assertEquals(ParagraphType.QUOTE, para.getType());
-                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
-                                                               + closeDoubleQuote, para.getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.processPara() apostrophe") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
-
-                                               Paragraph para;
-
-                                               String text = "Nous étions en été, mais cela aurait être l'hiver quand nous n'étions encore qu'à Aubeuge";
-                                               para = support.processPara(text, true);
-                                               assertEquals(ParagraphType.NORMAL, para.getType());
-                                               assertEquals(text, para.getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.processPara() words count") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
-
-                                               Paragraph para;
-
-                                               para = support.processPara("«Yes, my Lord!»", true);
-                                               assertEquals(3, para.getWords());
-
-                                               para = support.processPara("One, twee, trois.", true);
-                                               assertEquals(3, para.getWords());
-                                       }
-                               });
-
-                               addTest(new TestCase("BasicSupport.requotify() words count") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
-
-                                               char openDoubleQuote = Instance.getInstance().getTrans()
-                                                               .getCharacter(StringId.OPEN_DOUBLE_QUOTE);
-                                               char closeDoubleQuote = Instance.getInstance().getTrans()
-                                                               .getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
-
-                                               String content = null;
-                                               Paragraph para = null;
-                                               List<Paragraph> paras = null;
-                                               long words = 0;
-
-                                               content = "One, twee, trois.";
-                                               para = new Paragraph(ParagraphType.NORMAL, content,
-                                                               content.split(" ").length);
-                                               paras = support.requotify(para, false);
-                                               words = 0;
-                                               for (Paragraph p : paras) {
-                                                       words += p.getWords();
-                                               }
-                                               assertEquals("Bad words count in a single paragraph",
-                                                               para.getWords(), words);
-
-                                               content = "Such WoW! So Web2.0! With Colours!";
-                                               para = new Paragraph(ParagraphType.NORMAL, content,
-                                                               content.split(" ").length);
-                                               paras = support.requotify(para, false);
-                                               words = 0;
-                                               for (Paragraph p : paras) {
-                                                       words += p.getWords();
-                                               }
-                                               assertEquals("Bad words count in a single paragraph",
-                                                               para.getWords(), words);
-
-                                               content = openDoubleQuote + "Such a good idea!"
-                                                               + closeDoubleQuote
-                                                               + ", she said. This ought to be a new para.";
-                                               para = new Paragraph(ParagraphType.QUOTE, content,
-                                                               content.split(" ").length);
-                                               paras = support.requotify(para, false);
-                                               words = 0;
-                                               for (Paragraph p : paras) {
-                                                       words += p.getWords();
-                                               }
-                                               assertEquals(
-                                                               "Bad words count in a requotified paragraph",
-                                                               para.getWords(), words);
-                                       }
-                               });
-                       }
-               });
-
-               addSeries(new TestLauncher("Text", args) {
-                       {
-                               addTest(new TestCase("Chapter detection simple") {
-                                       private File tmp;
-
-                                       @Override
-                                       public void setUp() throws Exception {
-                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
-                                               IOUtils.writeSmallFile(tmp.getParentFile(),
-                                                               tmp.getName(), "TITLE"
-                                                                               + "\n"//
-                                                                               + "By nona"
-                                                                               + "\n" //
-                                                                               + "\n" //
-                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
-                                                                               + "'sume." + "\n" + "\n"
-                                                                               + "Chapter 1: chap1" + "\n" + "\n"
-                                                                               + "Fanfan." + "\n" + "\n"
-                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
-                                                                               + "Tulipe." + "\n");
-                                       }
-
-                                       @Override
-                                       public void tearDown() throws Exception {
-                                               tmp.delete();
-                                       }
-
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupport support = BasicSupport.getSupport(
-                                                               SupportType.TEXT, tmp.toURI().toURL());
-
-                                               Story story = support.process(null);
-
-                                               assertEquals(2, story.getChapters().size());
-                                               assertEquals(1, story.getChapters().get(1)
-                                                               .getParagraphs().size());
-                                               assertEquals("Tulipe.", story.getChapters().get(1)
-                                                               .getParagraphs().get(0).getContent());
-                                       }
-                               });
-
-                               addTest(new TestCase("Chapter detection with String 'Chapter'") {
-                                       private File tmp;
-
-                                       @Override
-                                       public void setUp() throws Exception {
-                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
-                                               IOUtils.writeSmallFile(tmp.getParentFile(),
-                                                               tmp.getName(), "TITLE"
-                                                                               + "\n"//
-                                                                               + "By nona"
-                                                                               + "\n" //
-                                                                               + "\n" //
-                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
-                                                                               + "'sume." + "\n" + "\n"
-                                                                               + "Chapter 1: chap1" + "\n" + "\n"
-                                                                               + "Chapter fout-la-merde" + "\n"
-                                                                               + "\n"//
-                                                                               + "Fanfan." + "\n" + "\n"
-                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
-                                                                               + "Tulipe." + "\n");
-                                       }
-
-                                       @Override
-                                       public void tearDown() throws Exception {
-                                               tmp.delete();
-                                       }
-
-                                       @Override
-                                       public void test() throws Exception {
-                                               BasicSupport support = BasicSupport.getSupport(
-                                                               SupportType.TEXT, tmp.toURI().toURL());
-
-                                               Story story = support.process(null);
-
-                                               assertEquals(2, story.getChapters().size());
-                                               assertEquals(1, story.getChapters().get(1)
-                                                               .getParagraphs().size());
-                                               assertEquals("Tulipe.", story.getChapters().get(1)
-                                                               .getParagraphs().get(0).getContent());
-                                       }
-                               });
-                       }
-               });
-       }
-       
-       class BasicSupportParaPublic extends BasicSupportPara {
-               public BasicSupportParaPublic() {
-                       super(new BasicSupportHelper(), new BasicSupportImages());
-               }
-               
-               @Override
-               // and make it public!
-               public Paragraph makeParagraph(BasicSupport support, URL source,
-                               String line, boolean html) {
-                       return super.makeParagraph(support, source, line, html);
-               }
-               
-               @Override
-               // and make it public!
-               public List<Paragraph> makeParagraphs(BasicSupport support,
-                               URL source, String content, boolean html, Progress pg)
-                               throws IOException {
-                       return super.makeParagraphs(support, source, content, html, pg);
-               }
-               
-               @Override
-               // and make it public!
-               public Paragraph processPara(String line, boolean html) {
-                       return super.processPara(line, html);
-               }
-               
-               @Override
-               // and make it public!
-               public List<Paragraph> requotify(Paragraph para, boolean html) {
-                       return super.requotify(para, html);
-               }
-       }
-}
diff --git a/test/ConversionTest.java b/test/ConversionTest.java
deleted file mode 100644 (file)
index 2b22364..0000000
+++ /dev/null
@@ -1,294 +0,0 @@
-package be.nikiroo.fanfix.test;
-
-import java.io.File;
-import java.io.FilenameFilter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.Main;
-import be.nikiroo.fanfix.output.BasicOutput;
-import be.nikiroo.fanfix.output.BasicOutput.OutputType;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.Progress;
-import be.nikiroo.utils.TraceHandler;
-import be.nikiroo.utils.test.TestCase;
-import be.nikiroo.utils.test.TestLauncher;
-
-class ConversionTest extends TestLauncher {
-       private class MainWithConvert extends Main {
-               @Override
-               public int convert(String urlString, OutputType type, String target,
-                               boolean infoCover, Progress pg) {
-                       return super.convert(urlString, type, target, infoCover, pg);
-               }
-       }
-       
-       private String testUri;
-       private String expectedDir;
-       private String resultDir;
-       private List<BasicOutput.OutputType> realTypes;
-       private Map<String, List<String>> skipCompare;
-       private Map<String, List<String>> skipCompareCross;
-
-       public ConversionTest(String testName, final String testUri,
-                       final String expectedDir, final String resultDir, String[] args) {
-               super("Conversion - " + testName, args);
-
-               this.testUri = testUri;
-               this.expectedDir = expectedDir;
-               this.resultDir = resultDir;
-
-               // Special mode SYSOUT is not a file type (System.out)
-               realTypes = new ArrayList<BasicOutput.OutputType>();
-               for (BasicOutput.OutputType type : BasicOutput.OutputType.values()) {
-                       if (!BasicOutput.OutputType.SYSOUT.equals(type)) {
-                               realTypes.add(type);
-                       }
-               }
-
-               if (!testUri.startsWith("http://") && !testUri.startsWith("https://")) {
-                       addTest(new TestCase("Read the test file") {
-                               @Override
-                               public void test() throws Exception {
-                                       assertEquals("The test file \"" + testUri
-                                                       + "\" cannot be found", true,
-                                                       new File(testUri).exists());
-                               }
-                       });
-               }
-
-               addTest(new TestCase("Assure directories exist") {
-                       @Override
-                       public void test() throws Exception {
-                               new File(expectedDir).mkdirs();
-                               new File(resultDir).mkdirs();
-                               assertEquals("The Expected directory \"" + expectedDir
-                                               + "\" cannot be created", true,
-                                               new File(expectedDir).exists());
-                               assertEquals("The Result directory \"" + resultDir
-                                               + "\" cannot be created", true,
-                                               new File(resultDir).exists());
-                       }
-               });
-
-               for (BasicOutput.OutputType type : realTypes) {
-                       addTest(getTestFor(type));
-               }
-       }
-
-       @Override
-       protected void start() throws Exception {
-               skipCompare = new HashMap<String, List<String>>();
-               skipCompareCross = new HashMap<String, List<String>>();
-
-               skipCompare.put("epb.ncx", Arrays.asList(
-                               "               <meta name=\"dtb:uid\" content=",
-                               "               <meta name=\"epub-creator\" content=\""));
-               skipCompare.put("epb.opf", Arrays.asList("      <dc:subject>",
-                               "      <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"));
-               skipCompare.put(".info", Arrays.asList("CREATION_DATE=",
-                               "URL=\"file:/", "UUID=EPUBCREATOR=\"", ""));
-               skipCompare.put("URL", Arrays.asList("file:/"));
-
-               for (String key : skipCompare.keySet()) {
-                       skipCompareCross.put(key, skipCompare.get(key));
-               }
-
-               skipCompareCross.put(".info", Arrays.asList(""));
-               skipCompareCross.put("epb.opf", Arrays.asList("      <dc:"));
-               skipCompareCross.put("title.xhtml",
-                               Arrays.asList("                 <div class=\"type\">"));
-               skipCompareCross.put("index.html",
-                               Arrays.asList("                 <div class=\"type\">"));
-               skipCompareCross.put("URL", Arrays.asList(""));
-       }
-
-       @Override
-       protected void stop() throws Exception {
-       }
-
-       private TestCase getTestFor(final BasicOutput.OutputType type) {
-               return new TestCase(type + " output mode") {
-                       @Override
-                       public void test() throws Exception {
-                               File target = generate(this, testUri, new File(resultDir), type);
-                               target = new File(target.getAbsolutePath()
-                                               + type.getDefaultExtension(false));
-
-                               // Check conversion:
-                               compareFiles(this, new File(expectedDir), new File(resultDir),
-                                               type, "Generate " + type);
-
-                               // LATEX not supported as input
-                               if (BasicOutput.OutputType.LATEX.equals(type)) {
-                                       return;
-                               }
-
-                               // Cross-checks:
-                               for (BasicOutput.OutputType crossType : realTypes) {
-                                       File crossDir = Test.tempFiles
-                                                       .createTempDir("cross-result");
-
-                                       generate(this, target.getAbsolutePath(), crossDir,
-                                                       crossType);
-                                       compareFiles(this, new File(resultDir), crossDir,
-                                                       crossType, "Cross compare " + crossType
-                                                                       + " generated from " + type);
-                               }
-                       }
-               };
-       }
-
-       private File generate(TestCase testCase, String testUri, File resultDir,
-                       BasicOutput.OutputType type) throws Exception {
-               final List<String> errors = new ArrayList<String>();
-
-               TraceHandler previousTraceHandler = Instance.getInstance().getTraceHandler();
-               Instance.getInstance().setTraceHandler(new TraceHandler(true, true, 0) {
-                       @Override
-                       public void error(String message) {
-                               errors.add(message);
-                       }
-
-                       @Override
-                       public void error(Exception e) {
-                               error(" ");
-                               for (Throwable t = e; t != null; t = t.getCause()) {
-                                       error(((t == e) ? "(" : "..caused by: (")
-                                                       + t.getClass().getSimpleName() + ") "
-                                                       + t.getMessage());
-                                       for (StackTraceElement s : t.getStackTrace()) {
-                                               error("\t" + s.toString());
-                                       }
-                               }
-                       }
-               });
-
-               try {
-                       File target = new File(resultDir, type.toString());
-                       int code = new MainWithConvert().convert(testUri, type,
-                                       target.getAbsolutePath(), false, null);
-
-                       String error = "";
-                       for (String err : errors) {
-                               if (!error.isEmpty())
-                                       error += "\n";
-                               error += err;
-                       }
-                       testCase.assertEquals("The conversion returned an error message: "
-                                       + error, 0, errors.size());
-                       if (code != 0) {
-                               testCase.fail("The conversion failed with return code: " + code);
-                       }
-
-                       return target;
-               } finally {
-                       Instance.getInstance().setTraceHandler(previousTraceHandler);
-               }
-       }
-
-       private void compareFiles(TestCase testCase, File expectedDir,
-                       File resultDir, final BasicOutput.OutputType limitTiFiles,
-                       final String errMess) throws Exception {
-
-               Map<String, List<String>> skipCompare = errMess.startsWith("Cross") ? this.skipCompareCross
-                               : this.skipCompare;
-
-               FilenameFilter filter = null;
-               if (limitTiFiles != null) {
-                       filter = new FilenameFilter() {
-                               @Override
-                               public boolean accept(File dir, String name) {
-                                       return name.toLowerCase().startsWith(
-                                                       limitTiFiles.toString().toLowerCase());
-                               }
-                       };
-               }
-
-               List<String> resultFiles;
-               List<String> expectedFiles;
-               {
-                       String[] resultArr = resultDir.list(filter);
-                       Arrays.sort(resultArr);
-                       resultFiles = Arrays.asList(resultArr);
-                       String[] expectedArr = expectedDir.list(filter);
-                       Arrays.sort(expectedArr);
-                       expectedFiles = Arrays.asList(expectedArr);
-               }
-
-               testCase.assertEquals(errMess, expectedFiles, resultFiles);
-
-               for (int i = 0; i < resultFiles.size(); i++) {
-                       File expected = new File(expectedDir, expectedFiles.get(i));
-                       File result = new File(resultDir, resultFiles.get(i));
-
-                       testCase.assertEquals(errMess + ": type mismatch: expected a "
-                                       + (expected.isDirectory() ? "directory" : "file")
-                                       + ", received a "
-                                       + (result.isDirectory() ? "directory" : "file"),
-                                       expected.isDirectory(), result.isDirectory());
-
-                       if (expected.isDirectory()) {
-                               compareFiles(testCase, expected, result, null, errMess);
-                               continue;
-                       }
-
-                       if (expected.getName().endsWith(".cbz")
-                                       || expected.getName().endsWith(".epub")) {
-                               File tmpExpected = Test.tempFiles.createTempDir(expected
-                                               .getName() + "[zip-content]");
-                               File tmpResult = Test.tempFiles.createTempDir(result.getName()
-                                               + "[zip-content]");
-                               IOUtils.unzip(expected, tmpExpected);
-                               IOUtils.unzip(result, tmpResult);
-                               compareFiles(testCase, tmpExpected, tmpResult, null, errMess);
-                       } else {
-                               List<String> expectedLines = Arrays.asList(IOUtils
-                                               .readSmallFile(expected).split("\n"));
-                               List<String> resultLines = Arrays.asList(IOUtils.readSmallFile(
-                                               result).split("\n"));
-
-                               String name = expected.getAbsolutePath();
-                               if (name.startsWith(expectedDir.getAbsolutePath())) {
-                                       name = expectedDir.getName()
-                                                       + name.substring(expectedDir.getAbsolutePath()
-                                                                       .length());
-                               }
-
-                               testCase.assertEquals(errMess + ": " + 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;
-                                       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;
-                                       }
-
-                                       testCase.assertEquals(errMess + ": line " + (j + 1)
-                                                       + " is not the same in file " + name, expectedLine,
-                                                       resultLine);
-                               }
-                       }
-               }
-       }
-}
diff --git a/test/LibraryTest.java b/test/LibraryTest.java
deleted file mode 100644 (file)
index da44438..0000000
+++ /dev/null
@@ -1,258 +0,0 @@
-package be.nikiroo.fanfix.test;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import be.nikiroo.fanfix.data.Chapter;
-import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.fanfix.data.Paragraph;
-import be.nikiroo.fanfix.data.Story;
-import be.nikiroo.fanfix.library.BasicLibrary;
-import be.nikiroo.fanfix.library.LocalLibrary;
-import be.nikiroo.fanfix.output.BasicOutput.OutputType;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.test.TestCase;
-import be.nikiroo.utils.test.TestLauncher;
-
-class LibraryTest extends TestLauncher {
-       private BasicLibrary lib;
-       private File tmp;
-
-       public LibraryTest(String[] args) {
-               super("Library", args);
-
-               final String luid1 = "001"; // A
-               final String luid2 = "002"; // B
-               final String luid3 = "003"; // B then A, then B
-               final String name1 = "My story 1";
-               final String name2 = "My story 2";
-               final String name3 = "My story 3";
-               final String name3ex = "My story 3 [edited]";
-               final String source1 = "Source A";
-               final String source2 = "Source B";
-               final String author1 = "Unknown author";
-               final String author2 = "Another other otter author";
-
-               final String errMess = "The resulting stories in the list are not what we expected";
-
-               addSeries(new TestLauncher("Local", args) {
-                       {
-                               addTest(new TestCase("getList") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = lib.getList().getMetas();
-                                               assertEquals(errMess, Arrays.asList(),
-                                                               titlesAsList(metas));
-                                       }
-                               });
-
-                               addTest(new TestCase("save") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               lib.save(story(luid1, name1, source1, author1), luid1,
-                                                               null);
-
-                                               List<MetaData> metas = lib.getList().getMetas();
-                                               assertEquals(errMess, Arrays.asList(name1),
-                                                               titlesAsList(metas));
-                                       }
-                               });
-
-                               addTest(new TestCase("save more") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = null;
-
-                                               lib.save(story(luid2, name2, source2, author1), luid2,
-                                                               null);
-
-                                               metas = lib.getList().getMetas();
-                                               assertEquals(errMess, Arrays.asList(name1, name2),
-                                                               titlesAsList(metas));
-
-                                               lib.save(story(luid3, name3, source2, author1), luid3,
-                                                               null);
-
-                                               metas = lib.getList().getMetas();
-                                               assertEquals(errMess,
-                                                               Arrays.asList(name1, name2, name3),
-                                                               titlesAsList(metas));
-                                       }
-                               });
-
-                               addTest(new TestCase("save override luid (change author)") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               // same luid as a previous one
-                                               lib.save(story(luid3, name3ex, source2, author2),
-                                                               luid3, null);
-
-                                               List<MetaData> metas = lib.getList().getMetas();
-                                               assertEquals(errMess,
-                                                               Arrays.asList(name1, name2, name3ex),
-                                                               titlesAsList(metas));
-                                       }
-                               });
-
-                               addTest(new TestCase("getList with results") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = lib.getList().getMetas();
-                                               assertEquals(3, metas.size());
-                                       }
-                               });
-
-                               addTest(new TestCase("getList by source") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = null;
-
-                                               metas = lib.getList().filter(source1, null, null);
-                                               assertEquals(1, metas.size());
-
-                                               metas = lib.getList().filter(source2, null, null);
-                                               assertEquals(2, metas.size());
-
-                                               metas = lib.getList().filter((String)null, null, null);
-                                               assertEquals(3, metas.size());
-                                       }
-                               });
-
-                               addTest(new TestCase("getList by author") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = null;
-
-                                               metas = lib.getList().filter(null, author1, null);
-                                               assertEquals(2, metas.size());
-
-                                               metas = lib.getList().filter(null, author2, null);
-                                               assertEquals(1, metas.size());
-
-                                               metas = lib.getList().filter((String)null, null, null);
-                                               assertEquals(3, metas.size());
-                                       }
-                               });
-
-                               addTest(new TestCase("changeType") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = null;
-
-                                               lib.changeSource(luid3, source1, null);
-
-                                               metas = lib.getList().filter(source1, null, null);
-                                               assertEquals(2, metas.size());
-
-                                               metas = lib.getList().filter(source2, null, null);
-                                               assertEquals(1, metas.size());
-
-                                               metas = lib.getList().filter((String)null, null, null);
-                                               assertEquals(3, metas.size());
-                                       }
-                               });
-
-                               addTest(new TestCase("save override luid (change source)") {
-                                       @Override
-                                       public void test() throws Exception {
-                                               List<MetaData> metas = null;
-
-                                               // same luid as a previous one
-                                               lib.save(story(luid3, "My story 3", source2, author2),
-                                                               luid3, null);
-
-                                               metas = lib.getList().filter(source1, null, null);
-                                               assertEquals(1, metas.size());
-
-                                               metas = lib.getList().filter(source2, null, null);
-                                               assertEquals(2, metas.size());
-
-                                               metas = lib.getList().filter((String)null, null, null);
-                                               assertEquals(3, metas.size());
-                                       }
-                               });
-                       }
-               });
-       }
-
-       @Override
-       protected void start() throws Exception {
-               tmp = File.createTempFile(".test-fanfix", ".library");
-               tmp.delete();
-               tmp.mkdir();
-
-               lib = new LocalLibrary(tmp, OutputType.INFO_TEXT, OutputType.CBZ);
-       }
-
-       @Override
-       protected void stop() throws Exception {
-               IOUtils.deltree(tmp);
-       }
-
-       /**
-        * Return the (sorted) list of titles present in this list of
-        * {@link MetaData}s.
-        * 
-        * @param metas
-        *            the meta
-        * 
-        * @return the sorted list
-        */
-       private List<String> titlesAsList(List<MetaData> metas) {
-               List<String> list = new ArrayList<String>();
-               for (MetaData meta : metas) {
-                       list.add(meta.getTitle());
-               }
-
-               Collections.sort(list);
-               return list;
-       }
-
-       private Story story(String luid, String title, String source, String author) {
-               Story story = new Story();
-
-               MetaData meta = new MetaData();
-               meta.setLuid(luid);
-               meta.setTitle(title);
-               meta.setSource(source);
-               meta.setAuthor(author);
-               story.setMeta(meta);
-
-               Chapter resume = chapter(0, "Resume");
-               meta.setResume(resume);
-
-               List<Chapter> chapters = new ArrayList<Chapter>();
-               chapters.add(chapter(1, "Chap 1"));
-               chapters.add(chapter(2, "Chap 2"));
-               story.setChapters(chapters);
-
-               long words = 0;
-               for (Chapter chap : story.getChapters()) {
-                       words += chap.getWords();
-               }
-               meta.setWords(words);
-
-               return story;
-       }
-
-       private Chapter chapter(int number, String name) {
-               Chapter chapter = new Chapter(number, name);
-
-               List<Paragraph> paragraphs = new ArrayList<Paragraph>();
-               paragraphs.add(new Paragraph(Paragraph.ParagraphType.NORMAL,
-                               "some words in this paragraph please thank you", 8));
-
-               chapter.setParagraphs(paragraphs);
-
-               long words = 0;
-               for (Paragraph para : chapter.getParagraphs()) {
-                       words += para.getWords();
-               }
-               chapter.setWords(words);
-
-               return chapter;
-       }
-}
diff --git a/test/Test.java b/test/Test.java
deleted file mode 100644 (file)
index 5ec24a4..0000000
+++ /dev/null
@@ -1,177 +0,0 @@
-package be.nikiroo.fanfix.test;
-
-import java.io.File;
-import java.io.IOException;
-
-import be.nikiroo.fanfix.Instance;
-import be.nikiroo.fanfix.bundles.Config;
-import be.nikiroo.fanfix.bundles.ConfigBundle;
-import be.nikiroo.utils.IOUtils;
-import be.nikiroo.utils.TempFiles;
-import be.nikiroo.utils.resources.Bundles;
-import be.nikiroo.utils.test.TestLauncher;
-
-/**
- * Tests for Fanfix.
- * 
- * @author niki
- */
-public class Test extends TestLauncher {
-       //
-       // 4 files can control the test:
-       // - test/VERBOSE: enable verbose mode
-       // - test/OFFLINE: to forbid any downloading
-       // - test/URLS: to allow testing URLs
-       // - test/FORCE_REFRESH: to force a clear of the cache
-       //
-       // Note that test/CACHE can be kept, as it will contain all internet related
-       // files you need (if you allow URLs, run the test once which will populate
-       // the CACHE then go OFFLINE, it will still work).
-       //
-       // The test files will be:
-       // - test/*.url: URL to download in text format, content = URL
-       // - test/*.story: text mode story, content = story
-       //
-
-       /**
-        * The temporary files handler.
-        */
-       static TempFiles tempFiles;
-
-       /**
-        * Create the Fanfix {@link TestLauncher}.
-        * 
-        * @param args
-        *            the arguments to configure the number of columns and the ok/ko
-        *            {@link String}s
-        * @param urlsAllowed
-        *            allow testing URLs (<tt>.url</tt> files)
-        * 
-        * @throws IOException
-        */
-       public Test(String[] args, boolean urlsAllowed) throws IOException {
-               super("Fanfix", args);
-               Instance.getInstance().setTraceHandler(null);
-               addSeries(new BasicSupportUtilitiesTest(args));
-               addSeries(new BasicSupportDeprecatedTest(args));
-               addSeries(new LibraryTest(args));
-
-               File sources = new File("test/");
-               if (sources.isDirectory()) {
-                       for (File file : sources.listFiles()) {
-                               if (file.isDirectory()) {
-                                       continue;
-                               }
-
-                               String expectedDir = new File(file.getParentFile(), "expected_"
-                                               + file.getName()).getAbsolutePath();
-                               String resultDir = new File(file.getParentFile(), "result_"
-                                               + file.getName()).getAbsolutePath();
-
-                               String uri;
-                               if (urlsAllowed && file.getName().endsWith(".url")) {
-                                       uri = IOUtils.readSmallFile(file).trim();
-                               } else if (file.getName().endsWith(".story")) {
-                                       uri = file.getAbsolutePath();
-                               } else {
-                                       continue;
-                               }
-
-                               addSeries(new ConversionTest(file.getName(), uri, expectedDir,
-                                               resultDir, args));
-                       }
-               }
-       }
-
-       /**
-        * Main entry point of the program.
-        * 
-        * @param args
-        *            the arguments passed to the {@link TestLauncher}s.
-        * @throws IOException
-        *             in case of I/O error
-        */
-       static public void main(String[] args) throws IOException {
-               Instance.init();
-
-               // Verbose mode:
-               boolean verbose = new File("test/VERBOSE").exists();
-
-               // Can force refresh
-               boolean forceRefresh = new File("test/FORCE_REFRESH").exists();
-
-               // Allow URLs:
-               boolean urlsAllowed = new File("test/URLS").exists();
-
-               
-               // Only download files if allowed:
-               boolean offline = new File("test/OFFLINE").exists();
-               Instance.getInstance().getCache().setOffline(offline);
-
-
-               
-               int result = 0;
-               tempFiles = new TempFiles("fanfix-test");
-               try {
-                       File tmpConfig = tempFiles.createTempDir("fanfix-config");
-                       File localCache = new File("test/CACHE");
-                       prepareCache(localCache, forceRefresh);
-
-                       ConfigBundle config = new ConfigBundle();
-                       Bundles.setDirectory(tmpConfig.getAbsolutePath());
-                       config.setString(Config.CACHE_DIR, localCache.getAbsolutePath());
-                       config.setInteger(Config.CACHE_MAX_TIME_STABLE, -1);
-                       config.setInteger(Config.CACHE_MAX_TIME_CHANGING, -1);
-                       config.updateFile(tmpConfig.getPath());
-                       System.setProperty("CONFIG_DIR", tmpConfig.getAbsolutePath());
-
-                       Instance.init(true);
-                       Instance.getInstance().getCache().setOffline(offline);
-
-                       TestLauncher tests = new Test(args, urlsAllowed);
-                       tests.setDetails(verbose);
-
-                       result = tests.launch();
-
-                       IOUtils.deltree(tmpConfig);
-                       prepareCache(localCache, forceRefresh);
-               } finally {
-                       // Test temp files
-                       tempFiles.close();
-
-                       // This is usually done in Fanfix.Main:
-                       Instance.getInstance().getTempFiles().close();
-               }
-
-               System.exit(result);
-       }
-
-       /**
-        * Prepare the cache (or clean it up).
-        * <p>
-        * The cache directory will always exist if this method succeed
-        * 
-        * @param localCache
-        *            the cache directory
-        * @param forceRefresh
-        *            TRUE to force acache refresh (delete all files)
-        * 
-        * @throw IOException if the cache cannot be created
-        */
-       private static void prepareCache(File localCache, boolean forceRefresh)
-                       throws IOException {
-               // if needed
-               localCache.mkdirs();
-
-               if (!localCache.isDirectory()) {
-                       throw new IOException("Cannot get a cache");
-               }
-
-               // delete local cached files (_*) or all files if forceRefresh
-               for (File f : localCache.listFiles()) {
-                       if (forceRefresh || f.getName().startsWith("_")) {
-                               IOUtils.deltree(f);
-                       }
-               }
-       }
-}
diff --git a/test/TestCase.java b/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/test/TestLauncher.java b/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/test/expected_test.story/cbz.cbz b/test/expected_test.story/cbz.cbz
deleted file mode 100644 (file)
index 28cc25b..0000000
Binary files a/test/expected_test.story/cbz.cbz and /dev/null differ
diff --git a/test/expected_test.story/epub.epub b/test/expected_test.story/epub.epub
deleted file mode 100644 (file)
index 776d05d..0000000
Binary files a/test/expected_test.story/epub.epub and /dev/null differ
diff --git a/test/expected_test.story/html/html.info b/test/expected_test.story/html/html.info
deleted file mode 100644 (file)
index 506e477..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-TITLE="The trials of Fanfan"
-AUTHOR="UnknownArtist366"
-DATE="2018"
-SUBJECT="test"
-SOURCE="text"
-URL="file:/media/xubuntu/sd32/workspace/fanfix/test/test.story"
-TAGS=""
-UUID="/media/xubuntu/sd32/workspace/fanfix/test/test.story"
-LUID=""
-LANG="en"
-IMAGES_DOCUMENT="false"
-TYPE="html"
-COVER=""
-EPUBCREATOR="Fanfix (by Niki)"
-PUBLISHER=""
-WORDCOUNT="57"
-CREATION_DATE="2018-03-28 08:40:18"
-FAKE_COVER="false"
diff --git a/test/expected_test.story/html/html.txt b/test/expected_test.story/html/html.txt
deleted file mode 100644 (file)
index a3a3af3..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-The trials of Fanfan
-by UnknownArtist366 (2018)
-
-Chapter 0: Description
-——————————————————————
-
-This ‘story’ is nothing more than a test file to check
-that the program can convert it into different
-formats correctly.
-
-Chapter 1: Quotes
-—————————————————
-
-“Yes, quotes!”, I said.
-‘Those can start with a single- or double quote sign’, I continued.
-“They also supports other characters, and an optionnal leading dash.”
-“The optionnal leading dash is enough to signify “quote”.”
-
-Chapter 2: “Quote” test
-———————————————————————
-
-This test was just for the chapter title.
-We can also check for breaks.
-
-* * *
-
-This was a break space.
diff --git a/test/expected_test.story/html/index.html b/test/expected_test.story/html/index.html
deleted file mode 100644 (file)
index aade9c3..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-       <meta http-equiv='content-type' content='text/html; charset=utf-8'>
-       <meta name='viewport' content='width=device-width, initial-scale=1.0'>
-       <link rel='stylesheet' type='text/css' href='style.css'>
-       <title>The trials of Fanfan</title>
-</head>
-<body>
-
-       <div class="titlepage">
-               <h1>The trials of Fanfan</h1>
-                       <div class="type"></div>
-               <div class="cover">
-                       <img src="cover.png"></img>
-               </div>
-               <div class="author">UnknownArtist366</div>
-       </div>
-       <hr/><br/>              <div class='normals'>
-               <span class='normal'>This ‘story’ is nothing more than a test file to check</span>
-               <span class='normal'>that the program can convert it into different</span>
-               <span class='normal'>formats correctly.</span>
-               </div>
-
-       <br/>
-       <h2>
-               <span class='chap'>Chapter <span class='chapnumber'>1</span>:</span> 
-               <span class='chaptitle'>Quotes</span>
-       </h2>
-       
-       <div class='chapter_content'>
-               <div class='dialogues'>
-                       <div class='dialogue'>&mdash; “Yes, quotes!”, I said.</div>
-                       <div class='dialogue'>&mdash; ‘Those can start with a single- or double quote sign’, I continued.</div>
-                       <div class='dialogue'>&mdash; “They also supports other characters, and an optionnal leading dash.”</div>
-                       <div class='dialogue'>&mdash; “The optionnal leading dash is enough to signify “quote”.”</div>
-               </div>
-
-       </div>
-       <h2>
-               <span class='chap'>Chapter <span class='chapnumber'>2</span>:</span> 
-               <span class='chaptitle'>“Quote” test</span>
-       </h2>
-       
-       <div class='chapter_content'>
-               <div class='normals'>
-               <span class='normal'>This test was just for the chapter title.</span>
-               <span class='normal'>We can also check for breaks.</span>
-               </div>
-               <hr class='break'/>
-               <div class='normals'>
-               <span class='normal'>This was a break space.</span>
-               </div>
-
-       </div></body>
diff --git a/test/expected_test.story/html/style.css b/test/expected_test.story/html/style.css
deleted file mode 100644 (file)
index 6b6d0d2..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-html {
-       text-align: justify;
-       max-width: 800px;
-       margin: auto;
-}
-
-.titlepage {
-       padding-left: 10%;
-       padding-right: 10%;
-       width: 80%;
-}
-
-h1 {
-       padding-bottom: 0;
-       margin-bottom: 0;
-       text-align: left;
-}
-
-.type {
-       position: relative;
-       font-size: large;
-       color: #666666;
-       font-weight: bold;
-       padding-bottom: 10px;
-       text-align: left;
-}
-
-.cover, .page-image {
-       width: 100%;
-}
-
-.cover img {
-       height: 45%;
-       max-width: 100%;
-       margin: auto;
-}
-
-.author {
-       text-align: right;
-       font-size: large;
-       font-style: italic;
-}
-
-.book, .chapter_content {
-       NO_text-indent: 40px;
-       padding-top: 40px;
-       padding-left: 5%;
-       padding-right: 5%;
-       width: 90%;
-}
-
-h2 {
-       border: 1px solid black;
-       color: #222222;
-       padding-left: 10px;
-       padding-right: 10px;
-       display: block;
-       padding-bottom: 0;
-       margin-bottom: 0;
-}
-
-h2 .chap {
-       color: #000000;
-       font-size: large;
-       font-variant: small-caps;
-       display: block;
-}
-
-h2 .chap:first-letter {
-       font-weight: bold;
-}
-
-h2 .chapnumber {
-       color: #000000;
-       font-size: xx-large;
-}
-
-h2 .chaptitle {
-       color: #444444;
-       font-size: large;
-       font-style: italic;
-       padding-bottom: 5px;
-       text-align: right;
-       display: block;
-}
-
-.normals {
-}
-
-.normal {
-       /* Can be removed if you want a more "compact" view */
-       display: block;
-}
-
-.blank {
-       /* Can be removed if you want a more "compact" view */
-       height: 24px;
-       width: 100%;
-}
-
-hr.break {
-       /* Can be removed if you want a more "compact" view */
-       margin-top: 48px;
-       margin-bottom: 48px;
-}
-
-.dialogues {
-}
-
-.dialogue {
-       font-style: italic;
-}
diff --git a/test/expected_test.story/info_text b/test/expected_test.story/info_text
deleted file mode 100644 (file)
index 768b3cc..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-The trials of Fanfan
-by UnknownArtist366 (2018)
-
-Chapter 0: Description
-
-This 'story' is nothing more than a test file to check
-that the program can convert it into different
-formats correctly.
-
-Chapter 1: Quotes
-
-"Yes, quotes!", I said.
-'Those can start with a single- or double quote sign', I continued.
-"They also supports other characters, and an optionnal leading dash."
-"The optionnal leading dash is enough to signify "quote"."
-
-Chapter 2: “Quote” test
-
-This test was just for the chapter title.
-We can also check for breaks.
-
-* * *
-
-This was a break space.
diff --git a/test/expected_test.story/info_text.info b/test/expected_test.story/info_text.info
deleted file mode 100644 (file)
index cc522dd..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-TITLE="The trials of Fanfan"
-AUTHOR="UnknownArtist366"
-DATE="2018"
-SUBJECT="test"
-SOURCE="text"
-URL="file:/media/xubuntu/sd32/workspace/fanfix/test/test.story"
-TAGS=""
-UUID="/media/xubuntu/sd32/workspace/fanfix/test/test.story"
-LUID=""
-LANG="en"
-IMAGES_DOCUMENT="false"
-TYPE="info_text"
-COVER=""
-EPUBCREATOR="Fanfix (by Niki)"
-PUBLISHER=""
-WORDCOUNT="57"
-CREATION_DATE="2018-03-28 08:39:39"
-FAKE_COVER="false"
diff --git a/test/expected_test.story/latex.tex b/test/expected_test.story/latex.tex
deleted file mode 100644 (file)
index d4dd68f..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-%
-% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.
-%
-
-\documentclass[a4paper]{book}
-\usepackage[english]{babel}
-\usepackage[utf8]{inputenc}
-\usepackage[T1]{fontenc}
-\usepackage{lmodern}
-\newcommand{\br}{\vspace{10 mm}}
-\newcommand{\say}{--- \noindent\emph}
-\hyphenpenalty=1000
-\tolerance=5000
-\begin{document}
-\date{2018}
-\title{The trials of Fanfan}
-\author{UnknownArtist366}
-\maketitle
-
-
-
-\chapter{Quotes}
-
-\say{``Yes, quotes!'', I said.}
-
-\say{`Those can start with a single- or double quote sign', I continued.}
-
-\noindent{}
-\say{``They also supports other characters, and an optionnal leading dash.''}
-
-\noindent{}
-\say{``The optionnal leading dash is enough to signify ``quote''.''}
-
-\noindent{}
-
-
-\chapter{``Quote'' test}
-This test was just for the chapter title.
-We can also check for breaks.
-
-\br
-This was a break space.
-
-\end{document}
diff --git a/test/expected_test.story/text.txt b/test/expected_test.story/text.txt
deleted file mode 100644 (file)
index a3a3af3..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-The trials of Fanfan
-by UnknownArtist366 (2018)
-
-Chapter 0: Description
-——————————————————————
-
-This ‘story’ is nothing more than a test file to check
-that the program can convert it into different
-formats correctly.
-
-Chapter 1: Quotes
-—————————————————
-
-“Yes, quotes!”, I said.
-‘Those can start with a single- or double quote sign’, I continued.
-“They also supports other characters, and an optionnal leading dash.”
-“The optionnal leading dash is enough to signify “quote”.”
-
-Chapter 2: “Quote” test
-———————————————————————
-
-This test was just for the chapter title.
-We can also check for breaks.
-
-* * *
-
-This was a break space.
diff --git a/test/test.story b/test/test.story
deleted file mode 100644 (file)
index 7365b60..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-The trials of Fanfan
-by UnknownArtist366 (2018)
-
-Chapter 0: Description
-
-This 'story' is nothing more than a test file to check
-that the program can convert it into different
-formats correctly.
-
-Chapter 1: Quotes
-- "Yes, quotes!", I said.
-'Those can start with a single- or double quote sign', I continued.
-- «They also supports other characters, and an optionnal leading dash.»
-- The optionnal leading dash is enough to signify "quote".
-
-Chapter 2: "Quote" test
-This test was just for the chapter title.
-We can also check for breaks.
-
-* * *
-
-This was a break space.
-
diff --git a/test_code/BufferedInputStreamTest.java b/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/test_code/BufferedOutputStreamTest.java b/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/test_code/BundleTest.java b/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/test_code/CryptUtilsTest.java b/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/test_code/IOUtilsTest.java b/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/test_code/NextableInputStreamTest.java b/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/test_code/ProgressTest.java b/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/test_code/ReplaceInputStreamTest.java b/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/test_code/ReplaceOutputStreamTest.java b/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/test_code/SerialServerTest.java b/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/test_code/SerialTest.java b/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/test_code/StringUtilsTest.java b/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/test_code/TempFilesTest.java b/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/test_code/Test.java b/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/test_code/VersionTest.java b/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/test_code/bundle_test.properties b/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/ui/BreadCrumbsBar.java b/ui/BreadCrumbsBar.java
new file mode 100644 (file)
index 0000000..ed7e0bb
--- /dev/null
@@ -0,0 +1,230 @@
+
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+import javax.swing.BoxLayout;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JToggleButton;
+import javax.swing.SwingWorker;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+
+public class BreadCrumbsBar<T> extends ListenerPanel {
+       private class BreadCrumb extends JPanel {
+               private JToggleButton button;
+               private JToggleButton down;
+
+               public BreadCrumb(final DataNode<T> node) {
+                       this.setLayout(new BorderLayout());
+
+                       if (!node.isRoot()) {
+                               button = new JToggleButton(node.toString());
+                               button.addActionListener(new ActionListener() {
+                                       @Override
+                                       public void actionPerformed(ActionEvent e) {
+                                               button.setSelected(false);
+                                               if (!node.isRoot()) {
+                                                       // TODO: allow clicking on root? option?
+                                                       setSelectedNode(node);
+                                               }
+                                       }
+                               });
+
+                               this.add(button, BorderLayout.CENTER);
+                       }
+
+                       if ((node.isRoot() && node.getChildren().isEmpty())
+                                       || !node.getChildren().isEmpty()) {
+                               // TODO allow an image or ">", viewer
+                               down = new JToggleButton(">");
+                               final JPopupMenu popup = new JPopupMenu();
+
+                               for (final DataNode<T> child : node.getChildren()) {
+                                       popup.add(new AbstractAction(child.toString()) {
+                                               private static final long serialVersionUID = 1L;
+
+                                               @Override
+                                               public void actionPerformed(ActionEvent e) {
+                                                       setSelectedNode(child);
+                                               }
+                                       });
+                               }
+
+                               down.addActionListener(new ActionListener() {
+                                       @Override
+                                       public void actionPerformed(ActionEvent ev) {
+                                               if (down.isSelected()) {
+                                                       popup.show(down, 0, down.getBounds().height);
+                                               } else {
+                                                       popup.setVisible(false);
+                                               }
+                                       }
+                               });
+
+                               popup.addPopupMenuListener(new PopupMenuListener() {
+                                       @Override
+                                       public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+                                       }
+
+                                       @Override
+                                       public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
+                                               down.setSelected(false);
+                                       }
+
+                                       @Override
+                                       public void popupMenuCanceled(PopupMenuEvent e) {
+                                       }
+                               });
+
+                               this.add(down, BorderLayout.EAST);
+                       }
+               }
+       }
+
+       static public final String CHANGE_ACTION = "change";
+
+       private boolean vertical;
+       private DataNode<T> node;
+       private List<BreadCrumb> crumbs = new ArrayList<BreadCrumb>();
+
+       public BreadCrumbsBar(final DataTree<T> tree) {
+               vertical = true; // to force an update
+               setVertical(false);
+
+               addComponentListener(new ComponentAdapter() {
+                       @Override
+                       public void componentResized(ComponentEvent e) {
+                               super.componentResized(e);
+                               synchronized (crumbs) {
+                                       for (BreadCrumb crumb : crumbs) {
+                                               setCrumbSize(crumb);
+                                       }
+                               }
+                       }
+               });
+
+               setSelectedNode(new DataNode<T>(null, null));
+
+               new SwingWorker<DataNode<T>, Void>() {
+                       @Override
+                       protected DataNode<T> doInBackground() throws Exception {
+                               tree.loadData();
+                               return tree.getRoot();
+                       }
+
+                       @Override
+                       protected void done() {
+                               try {
+                                       DataNode<T> node = get();
+
+                                       setSelectedNode(null);
+                                       BreadCrumbsBar.this.node = node;
+                                       addCrumb(node);
+
+                                       // TODO: option?
+                                       if (node.size() > 0) {
+                                               setSelectedNode(node.getChildren().get(0));
+                                       } else {
+                                               revalidate();
+                                               repaint();
+                                       }
+                               } catch (Exception e) {
+                                       e.printStackTrace();
+                               }
+                       }
+               }.execute();
+       }
+
+       public void setVertical(boolean vertical) {
+               if (vertical != this.vertical) {
+                       synchronized (crumbs) {
+                               this.vertical = vertical;
+
+                               for (BreadCrumb crumb : crumbs) {
+                                       this.remove(crumb);
+                               }
+
+                               if (vertical) {
+                                       this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+                               } else {
+                                       this.setLayout(new WrapLayout(WrapLayout.LEADING));
+                               }
+
+                               for (BreadCrumb crumb : crumbs) {
+                                       this.add(crumb);
+                                       setCrumbSize(crumb);
+                               }
+                       }
+
+                       this.revalidate();
+                       this.repaint();
+               }
+       }
+
+       public DataNode<T> getSelectedNode() {
+               return node;
+       }
+
+       public void setSelectedNode(DataNode<T> node) {
+               if (this.node == node) {
+                       return;
+               }
+
+               synchronized (crumbs) {
+                       // clear until common ancestor (can clear all!)
+                       while (this.node != null && !this.node.isParentOf(node)) {
+                               this.node = this.node.getParent();
+                               this.remove(crumbs.remove(crumbs.size() - 1));
+                       }
+
+                       // switch root if needed and possible
+                       if (this.node == null && node != null) {
+                               this.node = node.getRoot();
+                               addCrumb(this.node);
+                       }
+
+                       // re-create until node
+                       while (node != null && this.node != node) {
+                               DataNode<T> ancestorOrNode = node;
+                               for (DataNode<T> child : this.node.getChildren()) {
+                                       if (child.isParentOf(node))
+                                               ancestorOrNode = child;
+                               }
+
+                               this.node = ancestorOrNode;
+                               addCrumb(this.node);
+                       }
+               }
+
+               this.revalidate();
+               this.repaint();
+
+               fireActionPerformed(CHANGE_ACTION);
+       }
+
+       private void addCrumb(DataNode<T> node) {
+               BreadCrumb crumb = new BreadCrumb(node);
+               this.crumbs.add(crumb);
+               setCrumbSize(crumb);
+               this.add(crumb);
+       }
+
+       private void setCrumbSize(BreadCrumb crumb) {
+               if (vertical) {
+                       crumb.setMaximumSize(new Dimension(this.getWidth(),
+                                       crumb.getMinimumSize().height));
+               } else {
+                       crumb.setMaximumSize(null);
+               }
+       }
+}
diff --git a/ui/ConfigEditor.java b/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/ui/ConfigItem.java b/ui/ConfigItem.java
new file mode 100644 (file)
index 0000000..3ae029e
--- /dev/null
@@ -0,0 +1,574 @@
+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.List;
+
+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 code base */
+       private final ConfigItemBase<JComponent, E> base;
+
+       /** The main panel with all the fields in it. */
+       private JPanel main;
+
+       /**
+        * 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) {
+               base = new ConfigItemBase<JComponent, E>(info, autoDirtyHandling) {
+                       @Override
+                       protected JComponent createEmptyField(int item) {
+                               return ConfigItem.this.createEmptyField(item);
+                       }
+
+                       @Override
+                       protected Object getFromInfo(int item) {
+                               return ConfigItem.this.getFromInfo(item);
+                       }
+
+                       @Override
+                       protected void setToInfo(Object value, int item) {
+                               ConfigItem.this.setToInfo(value, item);
+                       }
+
+                       @Override
+                       protected Object getFromField(int item) {
+                               return ConfigItem.this.getFromField(item);
+                       }
+
+                       @Override
+                       protected void setToField(Object value, int item) {
+                               ConfigItem.this.setToField(value, item);
+                       }
+
+                       @Override
+                       public JComponent createField(int item) {
+                               JComponent field = super.createField(item);
+
+                               int height = Math.max(getMinimumHeight(),
+                                               field.getMinimumSize().height);
+                               field.setPreferredSize(new Dimension(200, height));
+
+                               return field;
+                       }
+
+                       @Override
+                       public List<JComponent> reload() {
+                               List<JComponent> removed = base.reload();
+                               if (!removed.isEmpty()) {
+                                       for (JComponent c : removed) {
+                                               main.remove(c);
+                                       }
+                                       main.revalidate();
+                                       main.repaint();
+                               }
+
+                               return removed;
+                       }
+
+                       @Override
+                       protected JComponent removeItem(int item) {
+                               JComponent removed = super.removeItem(item);
+                               main.remove(removed);
+                               main.revalidate();
+                               main.repaint();
+
+                               return removed;
+                       }
+               };
+       }
+
+       /**
+        * 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 (getInfo().isArray()) {
+                       this.setLayout(new BorderLayout());
+                       add(label(nhgap), BorderLayout.WEST);
+
+                       main = new JPanel();
+
+                       main.setLayout(new BoxLayout(main, BoxLayout.Y_AXIS));
+                       int size = getInfo().getListSize(false);
+                       for (int i = 0; i < size; i++) {
+                               addItemWithMinusPanel(i);
+                       }
+                       main.revalidate();
+                       main.repaint();
+
+                       final JButton add = new JButton();
+                       setImage(add, img64add, "+");
+
+                       add.addActionListener(new ActionListener() {
+                               @Override
+                               public void actionPerformed(ActionEvent e) {
+                                       addItemWithMinusPanel(base.getFieldsSize());
+                                       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 = base.createField(-1);
+                       add(field, BorderLayout.CENTER);
+               }
+       }
+
+       /** The {@link MetaInfo} linked to the field. */
+       public MetaInfo<E> getInfo() {
+               return base.getInfo();
+       }
+
+       /**
+        * Retrieve the associated graphical component that was created with
+        * {@link ConfigItemBase#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) {
+               return base.getField(item);
+       }
+
+       /**
+        * 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 ConfigItemBase#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) {
+               base.setDirtyItem(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) {
+               return base.hasValueChanged(value, item);
+       }
+
+       private void addItemWithMinusPanel(int item) {
+               JPanel minusPanel = createMinusPanel(item);
+               JComponent field = base.addItem(item, minusPanel);
+               minusPanel.add(field, BorderLayout.CENTER);
+       }
+
+       private JPanel createMinusPanel(final int item) {
+               JPanel minusPanel = new JPanel(new BorderLayout());
+
+               final JButton remove = new JButton();
+               setImage(remove, img64remove, "-");
+
+               remove.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               base.removeItem(item);
+                       }
+               });
+
+               minusPanel.add(remove, BorderLayout.EAST);
+
+               main.add(minusPanel);
+               main.revalidate();
+               main.repaint();
+
+               return minusPanel;
+       }
+
+       /**
+        * 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 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(getInfo().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 = (getInfo().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, getInfo()
+                                               .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/ui/ConfigItemBase.java b/ui/ConfigItemBase.java
new file mode 100644 (file)
index 0000000..21b5755
--- /dev/null
@@ -0,0 +1,467 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+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 <T>
+ *            the graphical base type to use (i.e., T or TWidget)
+ * @param <E>
+ *            the type of {@link Bundle} to edit
+ */
+public abstract class ConfigItemBase<T, E extends Enum<E>> {
+       /** 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 T field;
+       private List<T> fields = new ArrayList<T>();
+
+       /** The fields to panel map to get the actual item added to 'main'. */
+       private Map<Integer, T> itemFields = new HashMap<Integer, T>();
+
+       /** The {@link MetaInfo} linked to the field. */
+       private MetaInfo<E> info;
+
+       /** The {@link MetaInfo} linked to the field. */
+       public MetaInfo<E> getInfo() {
+               return info;
+       }
+
+       /**
+        * The number of fields, for arrays.
+        * 
+        * @return
+        */
+       public int getFieldsSize() {
+               return fields.size();
+       }
+
+       /**
+        * The number of fields to panel map to get the actual item added to 'main'.
+        */
+       public int getItemFieldsSize() {
+               return itemFields.size();
+       }
+
+       /**
+        * Add a new item in an array-value {@link MetaInfo}.
+        * 
+        * @param item
+        *            the index of the new item
+        * @param panel
+        *            a linked T, if we want to link it into the itemFields (can be
+        *            NULL) -- that way, we can get it back later on
+        *            {@link ConfigItemBase#removeItem(int)}
+        * 
+        * @return the newly created graphical field
+        */
+       public T addItem(final int item, T panel) {
+               if (panel != null) {
+                       itemFields.put(item, panel);
+               }
+               return createField(item);
+       }
+
+       /**
+        * The counter-part to {@link ConfigItemBase#addItem(int, Object)}, to
+        * remove a specific item of an array-values {@link MetaInfo}; all the
+        * remaining items will be shifted as required (so, always the last
+        * graphical object will be removed).
+        * 
+        * @param item
+        *            the index of the item to remove
+        * 
+        * @return the linked graphical T to remove if any (always the latest
+        *         graphical object if any)
+        */
+       protected T 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);
+               }
+
+               return itemFields.remove(last);
+       }
+
+       /**
+        * Prepare a new {@link ConfigItemBase} 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 ConfigItemBase#setDirtyItem(int)}
+        */
+       protected ConfigItemBase(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 ConfigItemBase#createField(int)}.
+        * <p>
+        * Note that {@link ConfigItemBase#reload(int)} will be called after it was
+        * created by {@link ConfigItemBase#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 T 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 ConfigItemBase#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, T 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 ConfigItemBase#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
+        */
+       public T 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 ConfigItemBase#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)
+        */
+       public 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
+        */
+       public 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}.
+        * 
+        * @return for arrays, the list of graphical T objects we don't need any
+        *         more (never NULL, but can be empty)
+        */
+       public List<T> reload() {
+               List<T> removed = new ArrayList<T>();
+               if (info.isArray()) {
+                       while (!itemFields.isEmpty()) {
+                               removed.add(itemFields.remove(itemFields.size() - 1));
+                       }
+                       for (int item = 0; item < info.getListSize(false); item++) {
+                               reload(item);
+                       }
+               } else {
+                       reload(-1);
+               }
+
+               return removed;
+       }
+
+       /**
+        * 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, null);
+               }
+
+               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
+        */
+       public T createField(final int item) {
+               T 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();
+                       }
+               });
+
+               return field;
+       }
+}
diff --git a/ui/ConfigItemBoolean.java b/ui/ConfigItemBoolean.java
new file mode 100644 (file)
index 0000000..de89f68
--- /dev/null
@@ -0,0 +1,67 @@
+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 getInfo().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) {
+               getInfo().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 \""
+                                                       + getInfo().getName()
+                                                       + "\", we consider it is FALSE");
+               }
+
+               return new JCheckBox();
+       }
+}
diff --git a/ui/ConfigItemBrowse.java b/ui/ConfigItemBrowse.java
new file mode 100644 (file)
index 0000000..9a54e52
--- /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 = getInfo().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) {
+               getInfo().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/ui/ConfigItemColor.java b/ui/ConfigItemColor.java
new file mode 100644 (file)
index 0000000..951ff45
--- /dev/null
@@ -0,0 +1,169 @@
+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 getInfo().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) {
+               getInfo().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 = getInfo().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,
+                                               getInfo().getName(), initialColor);
+                               if (newColor != null) {
+                                       getInfo().setColor(newColor.getRGB(), item);
+                                       field.setText(getInfo().getString(item, false));
+                                       colorWheel.setIcon(getIcon(17,
+                                                       getInfo().getColor(item, true)));
+                               }
+                       }
+               });
+
+               field.addKeyListener(new KeyAdapter() {
+                       @Override
+                       public void keyTyped(KeyEvent e) {
+                               getInfo().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/ui/ConfigItemCombobox.java b/ui/ConfigItemCombobox.java
new file mode 100644 (file)
index 0000000..07a6115
--- /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 getInfo().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) {
+               getInfo().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/ui/ConfigItemInteger.java b/ui/ConfigItemInteger.java
new file mode 100644 (file)
index 0000000..10c5d9d
--- /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 getInfo().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) {
+               getInfo().setInteger((Integer) value, item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(int item) {
+               return new JSpinner();
+       }
+}
diff --git a/ui/ConfigItemLocale.java b/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/ui/ConfigItemPassword.java b/ui/ConfigItemPassword.java
new file mode 100644 (file)
index 0000000..e8ad2f2
--- /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 getInfo().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) {
+               getInfo().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/ui/ConfigItemString.java b/ui/ConfigItemString.java
new file mode 100644 (file)
index 0000000..46333c0
--- /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 getInfo().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) {
+               getInfo().setString((String) value, item);
+       }
+
+       @Override
+       protected JComponent createEmptyField(int item) {
+               return new JTextField();
+       }
+}
diff --git a/ui/DataNode.java b/ui/DataNode.java
new file mode 100644 (file)
index 0000000..b4dbe7b
--- /dev/null
@@ -0,0 +1,104 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.swing.Icon;
+
+public class DataNode<T> {
+       private DataNode<T> parent;
+       private List<? extends DataNode<T>> children;
+       private T userData;
+
+       public DataNode(List<? extends DataNode<T>> children, T userData) {
+               if (children == null) {
+                       children = new ArrayList<DataNode<T>>();
+               }
+
+               this.children = children;
+               this.userData = userData;
+
+               for (DataNode<T> child : children) {
+                       child.parent = this;
+               }
+       }
+
+       public DataNode<T> getRoot() {
+               DataNode<T> root = this;
+               while (root.parent != null) {
+                       root = root.parent;
+               }
+
+               return root;
+       }
+
+       public DataNode<T> getParent() {
+               return parent;
+       }
+
+       public List<? extends DataNode<T>> getChildren() {
+               return children;
+       }
+
+       public int size() {
+               return children.size();
+       }
+
+       public boolean isRoot() {
+               return this == getRoot();
+       }
+
+       public boolean isSiblingOf(DataNode<T> node) {
+               if (this == node) {
+                       return true;
+               }
+
+               return node != null && parent != null && parent.children.contains(node);
+       }
+
+       public boolean isParentOf(DataNode<T> node) {
+               if (node == null || node.parent == null)
+                       return false;
+
+               if (this == node.parent)
+                       return true;
+
+               return isParentOf(node.parent);
+       }
+
+       public boolean isChildOf(DataNode<T> node) {
+               if (node == null || node.size() == 0)
+                       return false;
+
+               return node.isParentOf(this);
+       }
+
+       public T getUserData() {
+               return userData;
+       }
+
+       /**
+        * The total number of nodes present in this {@link DataNode} (including
+        * itself and descendants).
+        * 
+        * @return the number
+        */
+       public int count() {
+               int s = 1;
+               for (DataNode<T> child : children) {
+                       s += child.count();
+               }
+
+               return s;
+       }
+
+       @Override
+       public String toString() {
+               if (userData == null) {
+                       return "";
+               }
+
+               return userData.toString();
+       }
+}
\ No newline at end of file
diff --git a/ui/DataTree.java b/ui/DataTree.java
new file mode 100644 (file)
index 0000000..6b3657d
--- /dev/null
@@ -0,0 +1,68 @@
+package be.nikiroo.utils.ui;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.MutableTreeNode;
+
+public abstract class DataTree<E> {
+       protected DataNode<E> data;
+
+       public DataNode<E> loadData() throws IOException {
+               return this.data = extractData();
+       }
+
+       public DataNode<E> getRoot() {
+               return getRoot(null);
+       }
+
+       public DataNode<E> getRoot(String filter) {
+               return filterNode(data, filter);
+       }
+
+       protected abstract DataNode<E> extractData() throws IOException;
+
+       // filter cannot be null nor empty
+       protected abstract boolean checkFilter(String filter, E userData);
+
+       protected boolean checkFilter(DataNode<E> node, String filter) {
+               if (filter == null || filter.isEmpty()) {
+                       return true;
+               }
+
+               if (checkFilter(filter, node.getUserData()))
+                       return true;
+
+               for (DataNode<E> child : node.getChildren()) {
+                       if (checkFilter(child, filter))
+                               return true;
+               }
+
+               return false;
+       }
+
+       protected void sort(List<String> values) {
+               Collections.sort(values, new Comparator<String>() {
+                       @Override
+                       public int compare(String o1, String o2) {
+                               return ("" + o1).compareToIgnoreCase("" + o2);
+                       }
+               });
+       }
+
+       // note: we always send TAHT node, but filter children
+       private DataNode<E> filterNode(DataNode<E> source, String filter) {
+               List<DataNode<E>> children = new ArrayList<DataNode<E>>();
+               for (DataNode<E> child : source.getChildren()) {
+                       if (checkFilter(child, filter)) {
+                               children.add(filterNode(child, filter));
+                       }
+               }
+
+               return new DataNode<E>(children, source.getUserData());
+       }
+}
diff --git a/ui/DelayWorker.java b/ui/DelayWorker.java
new file mode 100644 (file)
index 0000000..2a16c98
--- /dev/null
@@ -0,0 +1,220 @@
+package be.nikiroo.utils.ui;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeSet;
+
+import javax.swing.SwingWorker;
+
+/**
+ * This class helps you delay some graphical actions and execute the most recent
+ * ones when under contention.
+ * <p>
+ * How does it work?
+ * <ul>
+ * <li>it takes an ID and an associated {@link SwingWorker} and will call
+ * {@link SwingWorker#execute()} after a small delay (see
+ * {@link DelayWorker#DelayWorker(int)})</li>
+ * <li>if a second call to {@link DelayWorker#delay(String, SwingWorker)} comes
+ * with the same ID before the first one is done, it will be put on a waiting
+ * queue</li>
+ * <li>if a third call still with the same ID comes, its associated worker will
+ * <b>replace</b> the one in the queue (only one worker per ID in the queue,
+ * always the latest one)</li>
+ * <li>when the first worker is done, it will check the waiting queue and
+ * execute that latest worker if any</li>
+ * </ul>
+ * 
+ * @author niki
+ *
+ */
+@SuppressWarnings("rawtypes")
+public class DelayWorker {
+       private Map<String, SwingWorker> lazyEnCours;
+       private Object lazyEnCoursLock;
+
+       private TreeSet<String> wip;
+
+       private Object waiter;
+
+       private boolean cont;
+       private boolean paused;
+       private Thread loop;
+
+       /**
+        * Create a new {@link DelayWorker} with the given delay (in milliseconds)
+        * before each drain of the queue.
+        * 
+        * @param delayMs
+        *            the delay in milliseconds (can be 0, cannot be negative)
+        */
+       public DelayWorker(final int delayMs) {
+               if (delayMs < 0) {
+                       throw new IllegalArgumentException(
+                                       "A waiting delay cannot be negative");
+               }
+
+               lazyEnCours = new HashMap<String, SwingWorker>();
+               lazyEnCoursLock = new Object();
+               wip = new TreeSet<String>();
+               waiter = new Object();
+               cont = true;
+               paused = false;
+
+               loop = new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               while (cont) {
+                                       try {
+                                               Thread.sleep(delayMs);
+                                       } catch (InterruptedException e) {
+                                       }
+
+                                       Map<String, SwingWorker> workers = new HashMap<String, SwingWorker>();
+                                       synchronized (lazyEnCoursLock) {
+                                               for (String key : new ArrayList<String>(
+                                                               lazyEnCours.keySet())) {
+                                                       if (!wip.contains(key)) {
+                                                               workers.put(key, lazyEnCours.remove(key));
+                                                       }
+                                               }
+                                       }
+
+                                       for (final String key : workers.keySet()) {
+                                               SwingWorker worker = workers.get(key);
+
+                                               synchronized (lazyEnCoursLock) {
+                                                       wip.add(key);
+                                               }
+
+                                               worker.addPropertyChangeListener(
+                                                               new PropertyChangeListener() {
+                                                                       @Override
+                                                                       public void propertyChange(
+                                                                                       PropertyChangeEvent evt) {
+                                                                               synchronized (lazyEnCoursLock) {
+                                                                                       wip.remove(key);
+                                                                               }
+                                                                               wakeup();
+                                                                       }
+                                                               });
+
+                                               // Start it, at last
+                                               worker.execute();
+                                       }
+
+                                       synchronized (waiter) {
+                                               do {
+                                                       try {
+                                                               if (cont)
+                                                                       waiter.wait();
+                                                       } catch (InterruptedException e) {
+                                                       }
+                                               } while (cont && paused);
+                                       }
+                               }
+                       }
+               });
+
+               loop.setDaemon(true);
+               loop.setName("Loop for DelayWorker");
+       }
+
+       /**
+        * Start the internal loop that will drain the processing queue. <b>MUST
+        * NOT</b> be started twice (but see {@link DelayWorker#pause()} and
+        * {@link DelayWorker#resume()} instead).
+        */
+       public void start() {
+               loop.start();
+       }
+
+       /**
+        * Pause the system until {@link DelayWorker#resume()} is called -- note
+        * that it will still continue on the processes currently scheduled to run,
+        * but will pause after that.
+        * <p>
+        * Can be called even if already paused, will just do nothing in that
+        * context.
+        */
+       public void pause() {
+               paused = true;
+       }
+
+       /**
+        * Check if the {@link DelayWorker} is currently paused.
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isPaused() {
+               return paused;
+       }
+
+       /**
+        * Resume the system after a pause.
+        * <p>
+        * Can be called even if already running, will just do nothing in that
+        * context.
+        */
+       public void resume() {
+               synchronized (waiter) {
+                       paused = false;
+                       wakeup();
+               }
+       }
+
+       /**
+        * Stop the system.
+        * <p>
+        * Note: this is final, you <b>MUST NOT</b> call {@link DelayWorker#start()}
+        * a second time (but see {@link DelayWorker#pause()} and
+        * {@link DelayWorker#resume()} instead).
+        */
+       public void stop() {
+               synchronized (waiter) {
+                       cont = false;
+                       wakeup();
+               }
+       }
+
+       /**
+        * Clear all the processes that were put on the queue but not yet scheduled
+        * to be executed -- note that it will still continue on the processes
+        * currently scheduled to run.
+        */
+       public void clear() {
+               synchronized (lazyEnCoursLock) {
+                       lazyEnCours.clear();
+                       wip.clear();
+               }
+       }
+
+       /**
+        * Put a new process in the delay queue.
+        * 
+        * @param id
+        *            the ID of this process (if you want to skip workers when they
+        *            are superseded by a new one, you need to use the same ID key)
+        * @param worker
+        *            the process to delay
+        */
+       public void delay(String id, SwingWorker worker) {
+               synchronized (lazyEnCoursLock) {
+                       lazyEnCours.put(id, worker);
+               }
+
+               wakeup();
+       }
+
+       /**
+        * Wake up the loop thread.
+        */
+       private void wakeup() {
+               synchronized (waiter) {
+                       waiter.notifyAll();
+               }
+       }
+}
diff --git a/ui/ImageTextAwt.java b/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/ui/ImageUtilsAwt.java b/ui/ImageUtilsAwt.java
new file mode 100644 (file)
index 0000000..c273e0d
--- /dev/null
@@ -0,0 +1,334 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Dimension;
+import java.awt.Graphics2D;
+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.IOUtils;
+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 {
+       /**
+        * A rotation to perform on an image.
+        * 
+        * @author niki
+        */
+       public enum Rotation {
+               /** No rotation */
+               NONE,
+               /** Rotate the image to the right */
+               RIGHT,
+               /** Rotate the image to the left */
+               LEFT,
+               /** Rotate the image by 180° */
+               UTURN
+       }
+
+       @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 {
+               return fromImage(img, Rotation.NONE);
+       }
+
+       /**
+        * Convert the given {@link Image} into a {@link BufferedImage} object,
+        * respecting the EXIF transformations if any.
+        * 
+        * @param img
+        *            the {@link Image}
+        * @param rotation
+        *            the rotation to apply, if any (can be null, same as
+        *            {@link Rotation#NONE})
+        * 
+        * @return the {@link Image} object
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       public static BufferedImage fromImage(Image img, Rotation rotation)
+                       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 {
+                                               byte[] data = null;
+                                               InputStream inData = img.newInputStream();
+                                               try {
+                                                       data = IOUtils.toByteArray(inData);
+                                               } finally {
+                                                       inData.close();
+                                               }
+                                               extra = ", content: " + new String(data, "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 (rotation == null)
+                               rotation = Rotation.NONE;
+
+                       switch (rotation) {
+                       case RIGHT:
+                               if (affineTransform == null) {
+                                       affineTransform = new AffineTransform();
+                               }
+                               affineTransform.translate(height, 0);
+                               affineTransform.rotate(Math.PI / 2);
+
+                               int tmp = width;
+                               width = height;
+                               height = tmp;
+
+                               break;
+                       case LEFT:
+                               if (affineTransform == null) {
+                                       affineTransform = new AffineTransform();
+                               }
+                               affineTransform.translate(0, width);
+                               affineTransform.rotate(3 * Math.PI / 2);
+
+                               int temp = width;
+                               width = height;
+                               height = temp;
+
+                               break;
+                       case UTURN:
+                               if (affineTransform == null) {
+                                       affineTransform = new AffineTransform();
+                               }
+                               affineTransform.translate(width, height);
+                               affineTransform.rotate(Math.PI);
+                               break;
+                       default:
+                               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;
+       }
+
+       /**
+        * Scale a dimension.
+        * 
+        * @param imageSize
+        *            the actual image size
+        * @param areaSize
+        *            the base size of the target to get snap sizes for
+        * @param zoom
+        *            the zoom factor (ignored on snap mode)
+        * @param snapMode
+        *            NULL for no snap mode, TRUE to snap to width and FALSE for
+        *            snap to height)
+        * 
+        * @return the scaled (minimum is 1x1)
+        */
+       public static Dimension scaleSize(Dimension imageSize, Dimension areaSize,
+                       double zoom, Boolean snapMode) {
+               Integer[] sz = scaleSize(imageSize.width, imageSize.height,
+                               areaSize.width, areaSize.height, zoom, snapMode);
+               return new Dimension(sz[0], sz[1]);
+       }
+
+       /**
+        * Resize the given image.
+        * 
+        * @param image
+        *            the image to resize
+        * @param areaSize
+        *            the base size of the target dimension for snap sizes
+        * @param zoom
+        *            the zoom factor (ignored on snap mode)
+        * @param snapMode
+        *            NULL for no snap mode, TRUE to snap to width and FALSE for
+        *            snap to height)
+        * 
+        * @return a new, resized image
+        */
+       public static BufferedImage scaleImage(BufferedImage image,
+                       Dimension areaSize, double zoom, Boolean snapMode) {
+               Dimension scaledSize = scaleSize(
+                               new Dimension(image.getWidth(), image.getHeight()), areaSize,
+                               zoom, snapMode);
+
+               return scaleImage(image, scaledSize);
+       }
+
+       /**
+        * Resize the given image.
+        * 
+        * @param image
+        *            the image to resize
+        * @param targetSize
+        *            the target size
+        * 
+        * @return a new, resized image
+        */
+       public static BufferedImage scaleImage(BufferedImage image,
+                       Dimension targetSize) {
+               BufferedImage resizedImage = new BufferedImage(targetSize.width,
+                               targetSize.height, BufferedImage.TYPE_4BYTE_ABGR);
+               Graphics2D g = resizedImage.createGraphics();
+               try {
+                       g.drawImage(image, 0, 0, targetSize.width, targetSize.height, null);
+               } finally {
+                       g.dispose();
+               }
+
+               return resizedImage;
+       }
+}
diff --git a/ui/ListModel.java b/ui/ListModel.java
new file mode 100644 (file)
index 0000000..7cc23b8
--- /dev/null
@@ -0,0 +1,570 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Component;
+import java.awt.Point;
+import java.awt.Window;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.swing.JList;
+import javax.swing.JPopupMenu;
+import javax.swing.ListCellRenderer;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.utils.ui.compat.DefaultListModel6;
+import be.nikiroo.utils.ui.compat.JList6;
+import be.nikiroo.utils.ui.compat.ListCellRenderer6;
+
+/**
+ * A {@link javax.swing.ListModel} that can maintain 2 lists; one with the
+ * actual data (the elements), and a second one with the items that are
+ * currently displayed (the items).
+ * <p>
+ * It also offers filter options, supports hovered changes and some more utility
+ * functions.
+ * 
+ * @author niki
+ *
+ * @param <T>
+ *            the type of elements and items (the same type)
+ */
+public class ListModel<T> extends DefaultListModel6<T> {
+       private static final long serialVersionUID = 1L;
+
+       /** How long to wait before displaying a tooltip, in milliseconds. */
+       private static final int DELAY_TOOLTIP_MS = 1000;
+
+       /**
+        * A filter interface, to check for a condition (note that a Predicate class
+        * already exists in Java 1.8+, and is compatible with this one if you
+        * change the signatures -- but I support java 1.6+).
+        * 
+        * @author niki
+        *
+        * @param <T>
+        *            the type of elements and items (the same type)
+        */
+       public interface Predicate<T> {
+               /**
+                * Check if an item or an element pass a filter.
+                * 
+                * @param item
+                *            the item to test
+                * 
+                * @return TRUE if the test passed, FALSE if not
+                */
+               public boolean test(T item);
+       }
+
+       /**
+        * A simple interface your elements must implement if you want to use
+        * {@link ListModel#generateRenderer(ListModel)}.
+        * 
+        * @author niki
+        */
+       public interface Hoverable {
+               /**
+                * The element is currently selected.
+                * 
+                * @param selected
+                *            TRUE for selected, FALSE for unselected
+                */
+               public void setSelected(boolean selected);
+
+               /**
+                * The element is currently under the mouse cursor.
+                * 
+                * @param hovered
+                *            TRUE if it is, FALSE if not
+                */
+               public void setHovered(boolean hovered);
+       }
+
+       /**
+        * An interface required to support tooltips on this {@link ListModel}.
+        * 
+        * @author niki
+        *
+        * @param <T>
+        *            the type of elements and items (the same type)
+        */
+       public interface TooltipCreator<T> {
+               /**
+                * Generate a tooltip {@link Window} for this element.
+                * <p>
+                * Note that the tooltip can be of two modes: undecorated or standalone.
+                * An undecorated tooltip will be taken care of by this
+                * {@link ListModel}, but a standalone one is supposed to be its own
+                * Dialog or Frame (it won't be automatically closed).
+                * 
+                * @param t
+                *            the element to generate a tooltip for
+                * @param undecorated
+                *            TRUE for undecorated tooltip, FALSE for standalone
+                *            tooltips
+                * 
+                * @return the generated tooltip or NULL for none
+                */
+               public Window generateTooltip(T t, boolean undecorated);
+       }
+
+       private int hoveredIndex;
+       private List<T> items = new ArrayList<T>();
+       private boolean keepSelection = true;
+
+       private DelayWorker tooltipWatcher;
+       private JPopupMenu popup;
+       private TooltipCreator<T> tooltipCreator;
+       private Window tooltip;
+
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       private JList list;
+
+       /**
+        * Create a new {@link ListModel}.
+        * 
+        * @param list
+        *            the {@link JList6} we will handle the data of (cannot be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       public ListModel(JList6<T> list) {
+               this((JList) list);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * <p>
+        * Note that you must take care of passing a {@link JList} that only handles
+        * elements of the type of this {@link ListModel} -- you can also use
+        * {@link ListModel#ListModel(JList6)} instead.
+        * 
+        * @param list
+        *            the {@link JList} we will handle the data of (cannot be NULL,
+        *            must only contain elements of the type of this
+        *            {@link ListModel})
+        */
+       @SuppressWarnings({ "unchecked", "rawtypes" }) // JList<?> not in Java 1.6
+       public ListModel(final JList list) {
+               this.list = list;
+
+               list.setModel(this);
+
+               // We always have it ready
+               tooltipWatcher = new DelayWorker(DELAY_TOOLTIP_MS);
+               tooltipWatcher.start();
+
+               list.addMouseMotionListener(new MouseAdapter() {
+                       @Override
+                       public void mouseMoved(final MouseEvent me) {
+                               if (ListModel.this.popup != null
+                                               && ListModel.this.popup.isShowing())
+                                       return;
+
+                               Point p = new Point(me.getX(), me.getY());
+                               final int index = list.locationToIndex(p);
+                               if (index != hoveredIndex) {
+                                       int oldIndex = hoveredIndex;
+                                       hoveredIndex = index;
+                                       fireElementChanged(oldIndex);
+                                       fireElementChanged(index);
+
+                                       if (ListModel.this.tooltipCreator != null) {
+                                               showTooltip(null);
+
+                                               tooltipWatcher.delay("tooltip",
+                                                               new SwingWorker<Void, Void>() {
+                                                                       @Override
+                                                                       protected Void doInBackground()
+                                                                                       throws Exception {
+                                                                               return null;
+                                                                       }
+
+                                                                       @Override
+                                                                       protected void done() {
+                                                                               showTooltip(null);
+
+                                                                               if (index < 0
+                                                                                               || index != hoveredIndex) {
+                                                                                       return;
+                                                                               }
+
+                                                                               if (ListModel.this.popup != null
+                                                                                               && ListModel.this.popup
+                                                                                                               .isShowing()) {
+                                                                                       return;
+                                                                               }
+
+                                                                               showTooltip(newTooltip(index, me));
+                                                                       }
+                                                               });
+                                       }
+                               }
+                       }
+               });
+
+               list.addMouseListener(new MouseAdapter() {
+                       @Override
+                       public void mousePressed(MouseEvent e) {
+                               check(e);
+                       }
+
+                       @Override
+                       public void mouseReleased(MouseEvent e) {
+                               check(e);
+                       }
+
+                       @Override
+                       public void mouseExited(MouseEvent e) {
+                               if (ListModel.this.popup != null
+                                               && ListModel.this.popup.isShowing())
+                                       return;
+
+                               if (hoveredIndex > -1) {
+                                       int oldIndex = hoveredIndex;
+                                       hoveredIndex = -1;
+                                       fireElementChanged(oldIndex);
+                               }
+                       }
+
+                       private void check(MouseEvent e) {
+                               if (ListModel.this.popup == null) {
+                                       return;
+                               }
+
+                               if (e.isPopupTrigger()) {
+                                       if (list.getSelectedIndices().length <= 1) {
+                                               list.setSelectedIndex(
+                                                               list.locationToIndex(e.getPoint()));
+                                       }
+
+                                       showTooltip(null);
+                                       ListModel.this.popup.show(list, e.getX(), e.getY());
+                               }
+                       }
+
+               });
+       }
+
+       /**
+        * (Try and) keep the elements that were selected when filtering.
+        * <p>
+        * This will use toString on the elements to identify them, and can be a bit
+        * resource intensive.
+        * 
+        * @return TRUE if we do
+        */
+       public boolean isKeepSelection() {
+               return keepSelection;
+       }
+
+       /**
+        * (Try and) keep the elements that were selected when filtering.
+        * <p>
+        * This will use toString on the elements to identify them, and can be a bit
+        * resource intensive.
+        * 
+        * @param keepSelection
+        *            TRUE to try and keep them selected
+        */
+       public void setKeepSelection(boolean keepSelection) {
+               this.keepSelection = keepSelection;
+       }
+
+       /**
+        * The popup to use and keep track of (can be NULL).
+        * 
+        * @return the current popup
+        */
+       public JPopupMenu getPopup() {
+               return popup;
+       }
+
+       /**
+        * The popup to use and keep track of (can be NULL).
+        * 
+        * @param popup
+        *            the new popup
+        */
+       public void setPopup(JPopupMenu popup) {
+               this.popup = popup;
+       }
+
+       /**
+        * You can use a {@link TooltipCreator} if you want the list to display
+        * tooltips on mouse hover (can be NULL).
+        * 
+        * @return the current {@link TooltipCreator}
+        */
+       public TooltipCreator<T> getTooltipCreator() {
+               return tooltipCreator;
+       }
+
+       /**
+        * You can use a {@link TooltipCreator} if you want the list to display
+        * tooltips on mouse hover (can be NULL).
+        * 
+        * @param tooltipCreator
+        *            the new {@link TooltipCreator}
+        */
+       public void setTooltipCreator(TooltipCreator<T> tooltipCreator) {
+               this.tooltipCreator = tooltipCreator;
+       }
+
+       /**
+        * Check if this element is currently under the mouse.
+        * 
+        * @param element
+        *            the element to check
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isHovered(T element) {
+               return indexOf(element) == hoveredIndex;
+       }
+
+       /**
+        * Check if this element is currently under the mouse.
+        * 
+        * @param index
+        *            the index of the element to check
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isHovered(int index) {
+               return index == hoveredIndex;
+       }
+
+       /**
+        * Add an item to the model.
+        * 
+        * @param item
+        *            the new item to add
+        */
+       public void addItem(T item) {
+               items.add(item);
+       }
+
+       /**
+        * Add items to the model.
+        * 
+        * @param items
+        *            the new items to add
+        */
+       public void addAllItems(Collection<T> items) {
+               this.items.addAll(items);
+       }
+
+       /**
+        * Removes the first occurrence of the specified element from this list, if
+        * it is present (optional operation).
+        * 
+        * @param item
+        *            the item to remove if possible (can be NULL)
+        * 
+        * @return TRUE if one element was removed, FALSE if not found
+        */
+       public boolean removeItem(T item) {
+               return items.remove(item);
+       }
+
+       /**
+        * Remove the items that pass the given filter (or all items if the filter
+        * is NULL).
+        * 
+        * @param filter
+        *            the filter (if the filter returns TRUE, the item will be
+        *            removed)
+        * 
+        * @return TRUE if at least one item was removed
+        */
+       public boolean removeItemIf(Predicate<T> filter) {
+               boolean changed = false;
+               if (filter == null) {
+                       changed = !items.isEmpty();
+                       clearItems();
+               } else {
+                       for (int i = 0; i < items.size(); i++) {
+                               if (filter.test(items.get(i))) {
+                                       items.remove(i--);
+                                       changed = true;
+                               }
+                       }
+               }
+
+               return changed;
+       }
+
+       /**
+        * Removes all the items from this model.
+        */
+       public void clearItems() {
+               items.clear();
+       }
+
+       /**
+        * Filter the current elements.
+        * <p>
+        * This method will clear all the elements then look into all the items:
+        * those that pass the given filter will be copied as elements.
+        * 
+        * @param filter
+        *            the filter to select which elements to keep; an item that pass
+        *            the filter will be copied as an element (can be NULL, in that
+        *            case all items will be copied as elements)
+        */
+       @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
+       public void filter(Predicate<T> filter) {
+               ListSnapshot snapshot = null;
+
+               if (keepSelection)
+                       snapshot = new ListSnapshot(list);
+
+               clear();
+               for (T item : items) {
+                       if (filter == null || filter.test(item)) {
+                               addElement(item);
+                       }
+               }
+
+               if (keepSelection)
+                       snapshot.apply();
+
+               list.repaint();
+       }
+
+       /**
+        * Return the currently selected elements.
+        * 
+        * @return the selected elements
+        */
+       public List<T> getSelectedElements() {
+               List<T> selected = new ArrayList<T>();
+               for (int index : list.getSelectedIndices()) {
+                       selected.add(get(index));
+               }
+
+               return selected;
+       }
+
+       /**
+        * Return the selected element if <b>one</b> and <b>only one</b> element is
+        * selected. I.E., if zero, two or more elements are selected, NULL will be
+        * returned.
+        * 
+        * @return the element if it is the only selected element, NULL otherwise
+        */
+       public T getUniqueSelectedElement() {
+               List<T> selected = getSelectedElements();
+               if (selected.size() == 1) {
+                       return selected.get(0);
+               }
+
+               return null;
+       }
+
+       /**
+        * Notify that this element has been changed.
+        * 
+        * @param index
+        *            the index of the element
+        */
+       public void fireElementChanged(int index) {
+               if (index >= 0) {
+                       fireContentsChanged(this, index, index);
+               }
+       }
+
+       /**
+        * Notify that this element has been changed.
+        * 
+        * @param element
+        *            the element
+        */
+       public void fireElementChanged(T element) {
+               int index = indexOf(element);
+               if (index >= 0) {
+                       fireContentsChanged(this, index, index);
+               }
+       }
+
+       @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
+       @Override
+       public T get(int index) {
+               return (T) super.get(index);
+       }
+
+       private Window newTooltip(final int index, final MouseEvent me) {
+               final T value = ListModel.this.get(index);
+               final Window newTooltip = tooltipCreator.generateTooltip(value, true);
+               if (newTooltip != null) {
+                       newTooltip.addMouseListener(new MouseAdapter() {
+                               @Override
+                               public void mouseClicked(MouseEvent e) {
+                                       Window promotedTooltip = tooltipCreator
+                                                       .generateTooltip(value, false);
+                                       if (promotedTooltip != null) {
+                                               promotedTooltip.setLocation(me.getXOnScreen(),
+                                                               me.getYOnScreen());
+                                               promotedTooltip.setVisible(true);
+                                       }
+
+                                       newTooltip.setVisible(false);
+                               }
+                       });
+
+                       newTooltip.setLocation(me.getXOnScreen(), me.getYOnScreen());
+                       showTooltip(newTooltip);
+               }
+
+               return newTooltip;
+       }
+
+       private void showTooltip(Window tooltip) {
+               synchronized (tooltipWatcher) {
+                       if (this.tooltip != null) {
+                               this.tooltip.setVisible(false);
+                               this.tooltip.dispose();
+                       }
+
+                       this.tooltip = tooltip;
+
+                       if (tooltip != null) {
+                               tooltip.setVisible(true);
+                       }
+               }
+       }
+
+       /**
+        * Generate a {@link ListCellRenderer} that supports {@link Hoverable}
+        * elements.
+        * 
+        * @param <T>
+        *            the type of elements and items (the same type), which should
+        *            implement {@link Hoverable} (it will not cause issues if not,
+        *            but then, it will be a default renderer)
+        * @param model
+        *            the model to use
+        * 
+        * @return a suitable, {@link Hoverable} compatible renderer
+        */
+       static public <T extends Component> ListCellRenderer6<T> generateRenderer(
+                       final ListModel<T> model) {
+               return new ListCellRenderer6<T>() {
+                       @Override
+                       public Component getListCellRendererComponent(JList6<T> list,
+                                       T item, int index, boolean isSelected,
+                                       boolean cellHasFocus) {
+                               if (item instanceof Hoverable) {
+                                       Hoverable hoverable = (Hoverable) item;
+                                       hoverable.setSelected(isSelected);
+                                       hoverable.setHovered(model.isHovered(index));
+                               }
+
+                               return item;
+                       }
+               };
+       }
+}
diff --git a/ui/ListSnapshot.java b/ui/ListSnapshot.java
new file mode 100644 (file)
index 0000000..d2e89c8
--- /dev/null
@@ -0,0 +1,62 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JList;
+
+public class ListSnapshot {
+       private JList list;
+       private List<Object> elements = new ArrayList<Object>();
+
+       public ListSnapshot(JList list) {
+               this.list = list;
+
+               for (int index : list.getSelectedIndices()) {
+                       elements.add(list.getModel().getElementAt(index));
+               }
+       }
+
+       public void apply() {
+               applyTo(list);
+       }
+
+       public void applyTo(JList list) {
+               List<Integer> indices = new ArrayList<Integer>();
+               for (int i = 0; i < list.getModel().getSize(); i++) {
+                       Object newObject = list.getModel().getElementAt(i);
+                       for (Object oldObject : elements) {
+                               if (isSameElement(oldObject, newObject)) {
+                                       indices.add(i);
+                                       break;
+                               }
+                       }
+               }
+
+               int a[] = new int[indices.size()];
+               for (int i = 0; i < indices.size(); i++) {
+                       a[i] = indices.get(i);
+               }
+               list.setSelectedIndices(a);
+       }
+
+       // You can override this
+       protected boolean isSameElement(Object oldElement, Object newElement) {
+               if (oldElement == null || newElement == null)
+                       return oldElement == null && newElement == null;
+
+               return oldElement.toString().equals(newElement.toString());
+       }
+
+       @Override
+       public String toString() {
+               StringBuilder builder = new StringBuilder();
+               builder.append("List Snapshot of: ").append(list).append("\n");
+               builder.append("Selected elements:\n");
+               for (Object element : elements) {
+                       builder.append("\t").append(element).append("\n");
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/ui/ListenerItem.java b/ui/ListenerItem.java
new file mode 100644 (file)
index 0000000..3fa41c8
--- /dev/null
@@ -0,0 +1,53 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.event.ActionListener;
+
+/**
+ * The default {@link ActionListener} add/remove/fire methods.
+ * 
+ * @author niki
+ */
+public interface ListenerItem {
+       /**
+        * Check that this {@link ListenerItem} currently has
+        * {@link ActionListener}s that listen on it.
+        * 
+        * @return TRUE if it has
+        */
+       public boolean hasListeners();
+
+       /**
+        * Check how many events are currently waiting for an
+        * {@link ActionListener}.
+        * 
+        * @return the number of waiting events (can be 0)
+        */
+       public int getWaitingEventCount();
+
+       /**
+        * Adds the specified action listener to receive action events from this
+        * {@link ListenerItem}.
+        *
+        * @param listener
+        *            the action listener to be added
+        */
+       public void addActionListener(ActionListener listener);
+
+       /**
+        * Removes the specified action listener so that it no longer receives
+        * action events from this {@link ListenerItem}.
+        *
+        * @param listener
+        *            the action listener to be removed
+        */
+       public void removeActionListener(ActionListener listener);
+
+       /**
+        * Notify the listeners of an action.
+        * 
+        * @param listenerCommand
+        *            A string that may specify a command (possibly one of several)
+        *            associated with the event
+        */
+       public void fireActionPerformed(String listenerCommand);
+}
diff --git a/ui/ListenerPanel.java b/ui/ListenerPanel.java
new file mode 100644 (file)
index 0000000..ada0796
--- /dev/null
@@ -0,0 +1,73 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.LinkedList;
+import java.util.Queue;
+
+import javax.swing.JPanel;
+
+/**
+ * A {@link JPanel} with the default {@link ActionListener} add/remove/fire
+ * methods.
+ * <p>
+ * Note that it will queue all events until at least one listener comes (or
+ * comes back!); this first (or at least currently unique) listener will drain
+ * the queue.
+ * 
+ * @author niki
+ */
+public class ListenerPanel extends JPanel implements ListenerItem {
+       private static final long serialVersionUID = 1L;
+
+       /** Waiting queue until at least one listener is here to get the events. */
+       private final Queue<ActionEvent> waitingQueue;
+
+       /**
+        * Create a new {@link ListenerPanel}.
+        */
+       public ListenerPanel() {
+               waitingQueue = new LinkedList<ActionEvent>();
+       }
+
+       @Override
+       public synchronized boolean hasListeners() {
+               return listenerList.getListenerList().length > 1;
+       }
+
+       @Override
+       public synchronized int getWaitingEventCount() {
+               return waitingQueue.size();
+       }
+
+       @Override
+       public synchronized void addActionListener(ActionListener listener) {
+               if (!hasListeners()) {
+                       while (!waitingQueue.isEmpty()) {
+                               listener.actionPerformed(waitingQueue.remove());
+                       }
+               }
+
+               listenerList.add(ActionListener.class, listener);
+       }
+
+       @Override
+       public synchronized void removeActionListener(ActionListener listener) {
+               listenerList.remove(ActionListener.class, listener);
+       }
+
+       @Override
+       public synchronized void fireActionPerformed(String listenerCommand) {
+               ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED,
+                               listenerCommand);
+
+               ActionListener[] listeners = getListeners(ActionListener.class);
+               if (listeners.length > 0) {
+                       for (ActionListener action : listeners) {
+                               action.actionPerformed(e);
+                       }
+               } else {
+                       waitingQueue.add(e);
+               }
+       }
+}
diff --git a/ui/NavBar.java b/ui/NavBar.java
new file mode 100644 (file)
index 0000000..607b2cf
--- /dev/null
@@ -0,0 +1,414 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.Icon;
+import javax.swing.JButton;
+import javax.swing.JLabel;
+import javax.swing.JTextField;
+
+/**
+ * A Swing-based navigation bar, that displays first/previous/next/last page
+ * buttons.
+ * 
+ * @author niki
+ */
+public class NavBar extends ListenerPanel {
+       private static final long serialVersionUID = 1L;
+
+       /** The event that is fired on page change. */
+       public static final String PAGE_CHANGED = "page changed";
+
+       private JTextField page;
+       private JLabel pageLabel;
+       private JLabel maxPage;
+       private JLabel label;
+
+       private int index = 0;
+       private int min = 0;
+       private int max = 0;
+       private String extraLabel = null;
+
+       private boolean vertical;
+
+       private JButton first;
+       private JButton previous;
+       private JButton next;
+       private JButton last;
+
+       /**
+        * Create a new navigation bar.
+        * <p>
+        * The minimum must be lower or equal to the maximum, but a max of "-1"
+        * means "infinite".
+        * <p>
+        * A {@link NavBar#PAGE_CHANGED} event will be fired on startup.
+        * 
+        * @param min
+        *            the minimum page number (cannot be negative)
+        * @param max
+        *            the maximum page number (cannot be lower than min, except if
+        *            -1 (infinite))
+        * 
+        * @throws IndexOutOfBoundsException
+        *             if min &gt; max and max is not "-1"
+        */
+       public NavBar(int min, int max) {
+               if (min > max && max != -1) {
+                       throw new IndexOutOfBoundsException(
+                                       String.format("min (%d) > max (%d)", min, max));
+               }
+
+               // Page navigation
+               first = new JButton();
+               first.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               first();
+                       }
+               });
+
+               previous = new JButton();
+               previous.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               previous();
+                       }
+               });
+
+               final int defaultHeight = new JButton("dummy")
+                               .getPreferredSize().height;
+               final int width4 = new JButton("1234").getPreferredSize().width;
+               page = new JTextField(Integer.toString(min));
+               page.setPreferredSize(new Dimension(width4, defaultHeight));
+               page.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               try {
+                                       int pageNb = Integer.parseInt(page.getText());
+                                       if (pageNb < NavBar.this.min || pageNb > NavBar.this.max) {
+                                               throw new NumberFormatException("invalid");
+                                       }
+
+                                       if (setIndex(pageNb))
+                                               fireActionPerformed(PAGE_CHANGED);
+                               } catch (NumberFormatException nfe) {
+                                       page.setText(Integer.toString(index));
+                               }
+                       }
+               });
+
+               pageLabel = new JLabel(Integer.toString(min));
+               pageLabel.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 10));
+
+               maxPage = new JLabel("of " + max);
+               maxPage.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 10));
+
+               next = new JButton();
+               next.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               next();
+                       }
+               });
+
+               last = new JButton();
+               last.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               last();
+                       }
+               });
+
+               label = new JLabel("");
+
+               // Set the << < > >> "icons"
+               setIcons(null, null, null, null);
+
+               this.min = min;
+               this.max = max;
+               this.index = min;
+
+               updateEnabled();
+               updateLabel();
+               setOrientation(vertical);
+
+               fireActionPerformed(PAGE_CHANGED);
+       }
+
+       /**
+        * The current index, must be between {@link NavBar#min} and
+        * {@link NavBar#max}, both inclusive.
+        * 
+        * @return the index
+        */
+       public int getIndex() {
+               return index;
+       }
+
+       /**
+        * The current index, should be between {@link NavBar#min} and
+        * {@link NavBar#max}, both inclusive.
+        * 
+        * @param index
+        *            the new index
+        * 
+        * @return TRUE if the index changed, FALSE if not (either it was already at
+        *         that value, or it is outside of the bounds set by
+        *         {@link NavBar#min} and {@link NavBar#max})
+        */
+       public synchronized boolean setIndex(int index) {
+               if (index != this.index) {
+                       if (index < min || (index > max && max != -1)) {
+                               return false;
+                       }
+
+                       this.index = index;
+                       updateLabel();
+                       updateEnabled();
+
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * The minimun page number. Cannot be negative.
+        * 
+        * @return the min
+        */
+       public int getMin() {
+               return min;
+       }
+
+       /**
+        * The minimum page number. Cannot be negative.
+        * <p>
+        * May update the index if needed (if the index is &lt; the new min).
+        * <p>
+        * Will also (always) update the label and enable/disable the required
+        * buttons.
+        * 
+        * @param min
+        *            the new min
+        */
+       public synchronized void setMin(int min) {
+               this.min = min;
+               if (index < min) {
+                       index = min;
+               }
+
+               updateEnabled();
+               updateLabel();
+       }
+
+       /**
+        * The maximum page number. Cannot be lower than min, except if -1
+        * (infinite).
+        * 
+        * @return the max
+        */
+       public int getMax() {
+               return max;
+       }
+
+       /**
+        * The maximum page number. Cannot be lower than min, except if -1
+        * (infinite).
+        * <p>
+        * May update the index if needed (if the index is &gt; the new max).
+        * <p>
+        * Will also (always) update the label and enable/disable the required
+        * buttons.
+        * 
+        * @param max
+        *            the new max
+        */
+       public synchronized void setMax(int max) {
+               this.max = max;
+               if (index > max && max != -1) {
+                       index = max;
+               }
+
+               maxPage.setText("of " + max);
+               updateEnabled();
+               updateLabel();
+       }
+
+       /**
+        * The current extra label to display.
+        * 
+        * @return the current label
+        */
+       public String getExtraLabel() {
+               return extraLabel;
+       }
+
+       /**
+        * The current extra label to display.
+        * 
+        * @param currentLabel
+        *            the new current label
+        */
+       public void setExtraLabel(String currentLabel) {
+               this.extraLabel = currentLabel;
+               updateLabel();
+       }
+
+       /**
+        * Change the page to the next one.
+        * 
+        * @return TRUE if it changed
+        */
+       public synchronized boolean next() {
+               if (setIndex(index + 1)) {
+                       fireActionPerformed(PAGE_CHANGED);
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Change the page to the previous one.
+        * 
+        * @return TRUE if it changed
+        */
+       public synchronized boolean previous() {
+               if (setIndex(index - 1)) {
+                       fireActionPerformed(PAGE_CHANGED);
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Change the page to the first one.
+        * 
+        * @return TRUE if it changed
+        */
+       public synchronized boolean first() {
+               if (setIndex(min)) {
+                       fireActionPerformed(PAGE_CHANGED);
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Change the page to the last one.
+        * 
+        * @return TRUE if it changed
+        */
+       public synchronized boolean last() {
+               if (setIndex(max)) {
+                       fireActionPerformed(PAGE_CHANGED);
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Set icons for the buttons instead of square brackets.
+        * <p>
+        * Any NULL value will make the button use square brackets again.
+        * 
+        * @param first
+        *            the icon of the button "go to first page"
+        * @param previous
+        *            the icon of the button "go to previous page"
+        * @param next
+        *            the icon of the button "go to next page"
+        * @param last
+        *            the icon of the button "go to last page"
+        */
+       public void setIcons(Icon first, Icon previous, Icon next, Icon last) {
+               this.first.setIcon(first);
+               this.first.setText(first == null ? "<<" : "");
+               this.previous.setIcon(previous);
+               this.previous.setText(previous == null ? "<" : "");
+               this.next.setIcon(next);
+               this.next.setText(next == null ? ">" : "");
+               this.last.setIcon(last);
+               this.last.setText(last == null ? ">>" : "");
+       }
+
+       /**
+        * The general orientation of the component.
+        * 
+        * @return TRUE for vertical orientation, FALSE for horisontal orientation
+        */
+       public boolean getOrientation() {
+               return vertical;
+       }
+
+       /**
+        * Update the general orientation of the component.
+        * 
+        * @param vertical
+        *            TRUE for vertical orientation, FALSE for horisontal
+        *            orientation
+        * 
+        * @return TRUE if it changed something
+        */
+       public boolean setOrientation(boolean vertical) {
+               if (getWidth() == 0 || this.vertical != vertical) {
+                       this.vertical = vertical;
+
+                       BoxLayout layout = new BoxLayout(this,
+                                       vertical ? BoxLayout.Y_AXIS : BoxLayout.X_AXIS);
+                       this.removeAll();
+                       setLayout(layout);
+
+                       this.add(first);
+                       this.add(previous);
+                       if (vertical) {
+                               this.add(pageLabel);
+                       } else {
+                               this.add(page);
+                       }
+                       this.add(maxPage);
+                       this.add(next);
+                       this.add(last);
+
+                       if (!vertical) {
+                               this.add(label);
+                       }
+
+                       this.revalidate();
+                       this.repaint();
+
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Update the label displayed in the UI.
+        */
+       private void updateLabel() {
+               label.setText(getExtraLabel());
+               pageLabel.setText(Integer.toString(index));
+               page.setText(Integer.toString(index));
+       }
+
+       /**
+        * Update the navigation buttons "enabled" state according to the current
+        * index value.
+        */
+       private synchronized void updateEnabled() {
+               first.setEnabled(index > min);
+               previous.setEnabled(index > min);
+               next.setEnabled(index < max || max == -1);
+               last.setEnabled(index < max || max == -1);
+       }
+}
diff --git a/ui/ProgressBar.java b/ui/ProgressBar.java
new file mode 100644 (file)
index 0000000..11e1e6c
--- /dev/null
@@ -0,0 +1,188 @@
+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 ProgressBar(Progress pg) {
+               this();
+               setProgress(pg);
+       }
+
+       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/ui/TreeCellSpanner.java b/ui/TreeCellSpanner.java
new file mode 100644 (file)
index 0000000..703bfa1
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ *    This program is free software: you can redistribute it and/or modify
+ *    it under the terms of the GNU Lesser General Public License as published
+ *    by the Free Software Foundation, either version 3 of the License, or
+ *    (at your option) any later version.
+ *
+ *    This program is distributed in the hope that it will be useful,
+ *    but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *    GNU Lesser General Public License for more details.
+ *
+ *    You should have received a copy of the GNU Lesser General Public License
+ *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.utils.ui;
+
+import java.awt.*;
+import java.awt.event.*;
+
+import javax.swing.*;
+import javax.swing.tree.*;
+
+import java.util.*;
+
+public class TreeCellSpanner extends Container implements TreeCellRenderer, ComponentListener {
+       
+       public TreeCellSpanner(JTree tree, TreeCellRenderer renderer) {
+               if (tree == null || renderer == null)
+                       throw new NullPointerException();
+               this.tree = tree;
+               this.renderer = renderer;
+               treeParent = tree.getParent();
+               if (treeParent != null && treeParent instanceof JViewport) {
+                       treeParent.addComponentListener(this);
+               } else {
+                       treeParent = null;
+                       tree.addComponentListener(this);
+               }
+       }
+       
+       protected final JTree tree;
+       
+       private TreeCellRenderer renderer;
+       
+       private Component rendererComponent;
+       
+       private Container treeParent;
+       
+       private Map<TreePath,Integer> offsets = new HashMap<TreePath,Integer>();
+       
+       private TreePath path;
+       
+       public TreeCellRenderer getRenderer() {
+               return renderer;
+       }
+       
+       @Override
+       public Component getTreeCellRendererComponent(JTree tree, Object value,
+                       boolean selected, boolean expanded, boolean leaf, int row,
+                       boolean hasFocus) {
+               path = tree.getPathForRow(row);
+               if (path != null && path.getLastPathComponent() != value)
+                       path = null;
+               rendererComponent = renderer.getTreeCellRendererComponent(
+                               tree, value, selected, expanded, leaf, row, hasFocus);
+               if (getComponentCount() < 1 || getComponent(0) != rendererComponent) {
+                       removeAll();
+                       add(rendererComponent);
+               }
+               return this;
+       }
+       
+       @Override
+       public void doLayout() {
+               int x = getX();
+               if (x < 0)
+                       return;
+               if (path != null) {
+                       Integer offset = offsets.get(path);
+                       if (offset == null || offset.intValue() != x) {
+                               offsets.put(path, x);
+                               fireTreePathChanged(path);
+                       }
+               }
+               rendererComponent.setBounds(getX(), getY(), getWidth(), getHeight());
+       }
+       
+       @Override
+       public void paint(Graphics g) {
+               if (rendererComponent != null)
+                       rendererComponent.paint(g);
+       }
+       
+       @Override
+       public Dimension getPreferredSize() {
+               Dimension s = rendererComponent.getPreferredSize();
+               // check if path count is greater than 1 to exclude the root
+               if (path != null && path.getPathCount() > 1) {
+                       Integer offset = offsets.get(path);
+                       if (offset != null) {
+                               int width;
+                               if (tree.getParent() == treeParent) {
+                                       width = treeParent.getWidth();
+                               } else {
+                                       if (treeParent != null) {
+                                               treeParent.removeComponentListener(this);
+                                               tree.addComponentListener(this);
+                                               treeParent = null;
+                                       }
+                                       if (tree.getParent() instanceof JViewport) {
+                                               treeParent = tree.getParent();
+                                               tree.removeComponentListener(this);
+                                               treeParent.addComponentListener(this);
+                                               width = treeParent.getWidth();
+                                       } else {
+                                               width = tree.getWidth();
+                                       }
+                               }
+                               s.width = width - offset;
+                       }
+               }
+               return s;
+       }
+
+       
+       protected void fireTreePathChanged(TreePath path) {
+               if (path.getPathCount() > 1) {
+                       // this cannot be used for the root node or else
+                       // the entire tree will keep being revalidated ad infinitum
+                       TreeModel model = tree.getModel();
+                       Object node = path.getLastPathComponent();
+                       if (node instanceof TreeNode && (model instanceof DefaultTreeModel
+                                       || (model instanceof TreeModelTransformer<?> &&
+                                       (model=((TreeModelTransformer<?>)model).getModel()) instanceof DefaultTreeModel))) {
+                               ((DefaultTreeModel)model).nodeChanged((TreeNode)node);
+                       } else {
+                               model.valueForPathChanged(path, node.toString());
+                       }
+               } else {
+                       // root!
+                       
+               }
+       }
+
+       
+       private int lastWidth;
+
+       @Override
+       public void componentHidden(ComponentEvent e) {}
+
+       @Override
+       public void componentMoved(ComponentEvent e) {}
+
+       @Override
+       public void componentResized(ComponentEvent e) {
+               if (e.getComponent().getWidth() != lastWidth) {
+                       lastWidth = e.getComponent().getWidth();
+                       for (int row=tree.getRowCount(); --row>=0;) {
+                               fireTreePathChanged(tree.getPathForRow(row));
+                       }
+               }
+       }
+
+       @Override
+       public void componentShown(ComponentEvent e) {}
+       
+}
diff --git a/ui/TreeModelTransformer.java b/ui/TreeModelTransformer.java
new file mode 100644 (file)
index 0000000..9568f17
--- /dev/null
@@ -0,0 +1,1217 @@
+/*
+ *    This program is free software: you can redistribute it and/or modify
+ *    it under the terms of the GNU Lesser General Public License as published
+ *    by the Free Software Foundation, either version 3 of the License, or
+ *    (at your option) any later version.
+ *
+ *    This program is distributed in the hope that it will be useful,
+ *    but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *    GNU Lesser General Public License for more details.
+ *
+ *    You should have received a copy of the GNU Lesser General Public License
+ *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.utils.ui;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.JTree;
+import javax.swing.SortOrder;
+import javax.swing.event.EventListenerList;
+import javax.swing.event.TreeExpansionEvent;
+import javax.swing.event.TreeExpansionListener;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+import javax.swing.event.TreeWillExpandListener;
+import javax.swing.tree.ExpandVetoException;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreePath;
+
+
+public class TreeModelTransformer<N> implements TreeModel {
+
+       public TreeModelTransformer(JTree tree, TreeModel model) {
+               if (tree == null)
+                       throw new IllegalArgumentException();
+               if (model == null)
+                       throw new IllegalArgumentException();
+               this.tree = tree;
+               this.model = model;
+               handler = createHandler();
+               addListeners();
+       }
+       
+       private JTree tree;
+       
+       private TreeModel model;
+
+       private Handler handler;
+       
+       private Filter<N> filter;
+       
+       private TreePath filterStartPath;
+       
+       private int filterDepthLimit;
+       
+       private SortOrder sortOrder = SortOrder.UNSORTED;
+       
+       private Map<Object,Converter> converters;
+       
+       protected EventListenerList listenerList = new EventListenerList();
+
+       protected Handler createHandler() {
+               return new Handler();
+       }
+       
+       protected void addListeners() {
+               tree.addTreeExpansionListener(handler);
+               model.addTreeModelListener(handler);
+       }
+
+       protected void removeListeners() {
+               tree.removeTreeExpansionListener(handler);
+               model.removeTreeModelListener(handler);
+       }
+       
+       public void dispose() {
+               removeListeners();
+       }
+       
+       public TreeModel getModel() {
+               return model;
+       }
+
+       private Converter getConverter(Object node) {
+               return converters == null ? null : converters.get(node);
+       }
+
+       int convertRowIndexToView(Object parent, int index) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.convertRowIndexToView(index);
+               return index;
+       }
+
+       int convertRowIndexToModel(Object parent, int index) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.convertRowIndexToModel(index);
+               return index;
+       }
+
+       @Override
+       public Object getChild(Object parent, int index) {
+               return model.getChild(parent, convertRowIndexToModel(parent, index));
+       }
+
+       @Override
+       public int getChildCount(Object parent) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.getChildCount();
+               return model.getChildCount(parent);
+       }
+
+       @Override
+       public int getIndexOfChild(Object parent, Object child) {
+               int index = model.getIndexOfChild(parent, child);
+               if (index < 0)
+                       return -1;
+               return convertRowIndexToView(parent, index);
+       }
+
+       @Override
+       public Object getRoot() {
+               return model.getRoot();
+       }
+
+       @Override
+       public boolean isLeaf(Object node) {
+               return model.isLeaf(node);
+       }
+
+       @Override
+       public void valueForPathChanged(TreePath path, Object newValue) {
+               model.valueForPathChanged(path, newValue);
+       }
+
+       @Override
+       public void addTreeModelListener(TreeModelListener l) {
+               listenerList.add(TreeModelListener.class, l);
+       }
+       
+       @Override
+       public void removeTreeModelListener(TreeModelListener l) {
+               listenerList.remove(TreeModelListener.class, l);
+       }
+       
+       /**
+        * Set the comparator that compares nodes in sorting.
+        * @param comparator
+        * @see #getComparator()
+        */
+       public void setComparator(Comparator<N> comparator) {
+               handler.setComparator(comparator);
+       }
+       
+       /**
+        * @return comparator that compares nodes
+        * @see #setComparator(Comparator)
+        */
+       public Comparator<N> getComparator() {
+               return handler.getComparator();
+       }
+       
+       public void setSortOrder(SortOrder newOrder) {
+               SortOrder oldOrder = sortOrder;
+               if (oldOrder == newOrder)
+                       return;
+               sortOrder = newOrder;
+               ArrayList<TreePath> paths = null;
+               switch (newOrder) {
+               case ASCENDING:
+                       if (oldOrder == SortOrder.DESCENDING) {
+                               flip();
+                       } else {
+                               paths = sort();
+                       }
+                       break;
+               case DESCENDING:
+                       if (oldOrder == SortOrder.ASCENDING) {
+                               flip();
+                       } else {
+                               paths = sort();
+                       }
+                       break;
+               case UNSORTED:
+                       unsort();
+                       break;
+               }
+               fireTreeStructureChangedAndExpand(new TreePath(getRoot()), paths, true);
+       }
+       
+       public SortOrder getSortOrder() {
+               return sortOrder;
+       }
+       
+       public void toggleSortOrder() {
+               setSortOrder(sortOrder == SortOrder.ASCENDING ?
+                               SortOrder.DESCENDING : SortOrder.ASCENDING);
+       }
+       
+       
+       /**
+        * Flip all sorted paths.
+        */
+       private void flip() {
+               for (Converter c : converters.values()) {
+                       flip(c.viewToModel);
+               }
+       }
+
+       /**
+        * Flip array.
+        * @param array
+        */
+       private static void flip(int[] array) {
+               for (int left=0, right=array.length-1;
+                               left<right; left++, right--) {
+                       int tmp = array[left];
+                       array[left] = array[right];
+                       array[right] = tmp;
+               }
+       }
+       
+       private void unsort() {
+               if (filter == null) {
+                       converters = null;
+               } else {
+                       Iterator<Converter> cons = converters.values().iterator();
+                       while (cons.hasNext()) {
+                               Converter converter = cons.next();
+                               if (!converter.isFiltered()) {
+                                       cons.remove();
+                               } else {
+                                       Arrays.sort(converter.viewToModel);
+                               }
+                       }
+               }
+       }
+       
+       /**
+        * Sort root and expanded descendants.
+        * @return list of paths that were sorted
+        */
+       private ArrayList<TreePath> sort() {
+               if (converters == null)
+                       converters = createConvertersMap(); //new IdentityHashMap<Object,Converter>();
+               return sortHierarchy(new TreePath(model.getRoot()));
+       }
+
+       /**
+        * Sort path and expanded descendants.
+        * @param path
+        * @return list of paths that were sorted
+        */
+       private ArrayList<TreePath> sortHierarchy(TreePath path) {
+               ValueIndexPair<N>[] pairs = createValueIndexPairArray(20);
+               ArrayList<TreePath> list = new ArrayList<TreePath>();
+               pairs = sort(path.getLastPathComponent(), pairs);
+               list.add(path);
+               Enumeration<TreePath> paths = tree.getExpandedDescendants(path);
+               if (paths != null)
+                       while (paths.hasMoreElements()) {
+                               path = paths.nextElement();
+                               pairs = sort(path.getLastPathComponent(), pairs);
+                               list.add(path);
+                       }
+               return list;
+       }
+       
+       private ValueIndexPair<N>[] sort(Object node, ValueIndexPair<N>[] pairs) {
+               Converter converter = getConverter(node);
+               TreeModel mdl = model;
+               int[] vtm;
+               if (converter != null) {
+                       vtm = converter.viewToModel;
+                       if (pairs.length < vtm.length)
+                               pairs = createValueIndexPairArray(vtm.length);
+                       for (int i=vtm.length; --i>=0;) {
+                               int idx = vtm[i];
+                               pairs[i].index = idx;
+                               pairs[i].value = (N)mdl.getChild(node, idx);
+                       }
+               } else {
+                       int count = mdl.getChildCount(node);
+                       if (count <= 0)
+                               return pairs;
+                       if (pairs.length < count)
+                               pairs = createValueIndexPairArray(count);
+                       for (int i=count; --i>=0;) {
+                               pairs[i].index = i;
+                               pairs[i].value = (N)mdl.getChild(node, i);
+                       }
+                       vtm = new int[count];
+               }
+               Arrays.sort(pairs, 0, vtm.length, handler);
+               for (int i=vtm.length; --i>=0;)
+                       vtm[i] = pairs[i].index;
+               if (converter == null) {
+                       converters.put(node, new Converter(vtm, false));
+               }
+               if (sortOrder == SortOrder.DESCENDING)
+                       flip(vtm);
+               return pairs;
+       }
+       
+       private ValueIndexPair<N>[] createValueIndexPairArray(int len) {
+               ValueIndexPair<N>[] pairs = new ValueIndexPair[len];
+               for (int i=len; --i>=0;)
+                       pairs[i] = new ValueIndexPair<N>();
+               return pairs;
+       }
+       
+       public void setFilter(Filter<N> filter) {
+               setFilter(filter, null);
+       }
+       
+       public void setFilter(Filter<N> filter, TreePath startingPath) {
+               setFilter(filter, null, -1);
+       }
+       
+       public void setFilter(Filter<N> filter, TreePath startingPath, int depthLimit) {
+               if (filter == null && startingPath != null)
+                       throw new IllegalArgumentException();
+               if (startingPath != null && startingPath.getPathCount() == 1)
+                       startingPath = null;
+               Filter<N> oldFilter = this.filter;
+               TreePath oldStartPath = filterStartPath;
+               this.filter = filter;
+               filterStartPath = startingPath;
+               filterDepthLimit = depthLimit;
+               applyFilter(oldFilter, oldStartPath, null, true);
+       }
+       
+       public Filter<N> getFilter() {
+               return filter;
+       }
+       
+       public TreePath getFilterStartPath() {
+               return filterStartPath;
+       }
+       
+       private void applyFilter(Filter<N> oldFilter, TreePath oldStartPath, Collection<TreePath> expanded, boolean sort) {
+               TreePath startingPath = filterStartPath;
+               ArrayList<TreePath> expand = null;
+               if (filter == null) {
+                       converters = null;
+               } else {
+                       if (converters == null || startingPath == null) {
+                               converters = createConvertersMap();
+                       } else if (oldFilter != null) {
+                               // unfilter the oldStartPath if oldStartPath isn't descendant of startingPath
+                               if (oldStartPath == null) {
+                                       converters = createConvertersMap();
+                                       fireTreeStructureChangedAndExpand(new TreePath(getRoot()), null, true);
+                               } else if (!startingPath.isDescendant(oldStartPath)) {
+                                       Object node = oldStartPath.getLastPathComponent();
+                                       handler.removeConverter(getConverter(node), node);
+                                       fireTreeStructureChangedAndExpand(oldStartPath, null, true);
+                               }
+                       }
+                       expand = new ArrayList<TreePath>();
+                       TreePath path = startingPath != null ? startingPath : new TreePath(getRoot());
+                       if (!applyFilter(filter, path, expand, filterDepthLimit)) {
+                               converters.put(path.getLastPathComponent(), new Converter(Converter.EMPTY, true));
+                       }
+               }
+               if (startingPath == null)
+                       startingPath = new TreePath(getRoot());
+               fireTreeStructureChanged(startingPath);
+               if (expanded != null)
+                       expand.retainAll(expanded);
+               expandPaths(expand);
+               if (sort && sortOrder != SortOrder.UNSORTED) {
+                       if (filter == null)
+                               converters = createConvertersMap();
+                       if (startingPath.getPathCount() > 1 && oldFilter != null) {
+                               // upgrade startingPath or sort oldStartPath
+                               if (oldStartPath == null) {
+                                       startingPath = new TreePath(getRoot());
+                               } else if (oldStartPath.isDescendant(startingPath)) {
+                                       startingPath = oldStartPath;
+                               } else if (!startingPath.isDescendant(oldStartPath)) {
+                                       expand = sortHierarchy(oldStartPath);
+                                       fireTreeStructureChanged(oldStartPath);
+                                       expandPaths(expand);
+                               }
+                       }
+                       expand = sortHierarchy(startingPath);
+                       fireTreeStructureChanged(startingPath);
+                       expandPaths(expand);
+               }
+
+       }
+       
+       private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand) {
+               int depthLimit = filterDepthLimit;
+               if (depthLimit >= 0) {
+                       depthLimit -= filterStartPath.getPathCount() - path.getPathCount();
+                       if (depthLimit <= 0)
+                               return false;
+               }
+               return applyFilter(filter, path, expand, depthLimit);
+       }
+       
+       private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand, int depthLimit) {
+               Object node = path.getLastPathComponent();
+               int count = model.getChildCount(node);
+               int[] viewToModel = null;
+               int viewIndex = 0;
+               boolean needsExpand = false;
+               boolean isExpanded = false;
+               if (depthLimit > 0)
+                       depthLimit--;
+               for (int i=0; i<count; i++) {
+                       Object child = model.getChild(node, i);
+                       boolean leaf = model.isLeaf(child);
+                       if (filter.acceptNode(path, (N)child, leaf)) {
+                               if (viewToModel == null)
+                                       viewToModel = new int[count-i];
+                               viewToModel[viewIndex++] = i;
+                               needsExpand = true;
+                       } else if (depthLimit != 0 && !leaf) {
+                               if (applyFilter(filter, path.pathByAddingChild(child), expand, depthLimit)) {
+                                       if (viewToModel == null)
+                                               viewToModel = new int[count-i];
+                                       viewToModel[viewIndex++] = i;
+                                       isExpanded = true;
+                               }
+                       }
+               }
+               if (needsExpand && expand != null && !isExpanded && path.getPathCount() > 1) {
+                       expand.add(path);
+               }
+               if (viewToModel != null) {
+                       if (viewIndex < viewToModel.length)
+                               viewToModel = Arrays.copyOf(viewToModel, viewIndex);
+                       // a node must have a converter to signify that tree modifications
+                       // need to query the filter, so have to put in converter
+                       // even if viewIndex == viewToModel.length
+                       converters.put(node, new Converter(viewToModel, true));
+                       return true;
+               }
+               return false;
+       }
+
+       
+       private void expandPaths(ArrayList<TreePath> paths) {
+               if (paths == null || paths.isEmpty())
+                       return;
+               JTree tre = tree;
+               for (TreePath path : paths)
+                       tre.expandPath(path);
+       }
+       
+       
+       private void fireTreeStructureChangedAndExpand(TreePath path, ArrayList<TreePath> list, boolean retainSelection) {
+               Enumeration<TreePath> paths = list != null ?
+                               Collections.enumeration(list) : tree.getExpandedDescendants(path);
+               TreePath[] sel = retainSelection ? tree.getSelectionPaths() : null;
+               fireTreeStructureChanged(path);
+               if (paths != null)
+                       while (paths.hasMoreElements())
+                               tree.expandPath(paths.nextElement());
+               if (sel != null)
+                       tree.setSelectionPaths(sel);
+       }
+
+       
+       
+       protected void fireTreeStructureChanged(TreePath path) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, null, null);
+                               ((TreeModelListener)listeners[i+1]).treeStructureChanged(e);
+                       }
+               }
+       }
+       
+       protected void fireTreeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesChanged(e);
+                       }
+               }
+       }
+       
+       protected void fireTreeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesInserted(e);
+                       }
+               }
+       }
+
+       protected void fireTreeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesRemoved(e);
+                       }
+               }
+       }
+       
+       
+       protected class Handler implements Comparator<ValueIndexPair<N>>,
+                       TreeModelListener, TreeExpansionListener {
+               
+               private Comparator<N> comparator;
+               
+               private Collator collator = Collator.getInstance();
+               
+               void setComparator(Comparator<N> cmp) {
+                       comparator = cmp;
+                       collator = cmp == null ? Collator.getInstance() : null;
+               }
+               
+               Comparator<N> getComparator() {
+                       return comparator;
+               }
+               
+               // TODO, maybe switch to TreeWillExpandListener?
+               // TreeExpansionListener was used in case an expanded node
+               // had children that would also be expanded, but it is impossible
+               // for hidden nodes' expansion state to survive a SortOrder change
+               // since they are all erased when the tree structure change event
+               // is fired after changing the SortOrder.
+               
+               @Override
+               public void treeCollapsed(TreeExpansionEvent e) {}
+
+               @Override
+               public void treeExpanded(TreeExpansionEvent e) {
+                       if (sortOrder != SortOrder.UNSORTED) {
+                               TreePath path = e.getPath();
+                               Converter converter = getConverter(path.getLastPathComponent());
+                               if (converter == null) {
+                                       ArrayList<TreePath> paths = sortHierarchy(path);
+                                       fireTreeStructureChangedAndExpand(path, paths, false);
+                               }
+                       }
+               }
+               
+               private boolean isFiltered(Object node) {
+                       Converter c = getConverter(node);
+                       return c == null ? false : c.isFiltered();
+               }
+               
+               private boolean acceptable(TreePath path, Object[] childNodes, int index, ArrayList<TreePath> expand) {
+                       return acceptable(path, childNodes, index) ||
+                                       applyFilter(filter, path.pathByAddingChild(childNodes[index]), expand);
+               }
+               
+               @Override
+               public void treeNodesChanged(TreeModelEvent e) {
+                       treeNodesChanged(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+               
+               protected void treeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+                       if (childIndices == null) {
+                               // path should be root path
+                               // reapply filter
+                               if (filter != null)
+                                       applyFilter(null, null, null, true);
+                               return;
+                       }
+                       Converter converter = getConverter(path.getLastPathComponent());
+                       ArrayList<TreePath> expand = null;
+                       if (converter != null) {
+                               expand = new ArrayList<TreePath>();
+                               int childIndex = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       int idx = converter.convertRowIndexToView(childIndices[i]);
+                                       if (idx >= 0) {
+                                               // see if the filter dislikes the nodes new state
+                                               if (converter.isFiltered() &&
+                                                               !isFiltered(childNodes[i]) &&
+                                                               !acceptable(path, childNodes, i)) {
+                                                       // maybe it likes a child nodes state
+                                                       if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+                                                               remove(path, childNodes[i]);
+                                                       continue;
+                                               }
+                                               childNodes[childIndex] = childNodes[i];
+                                               childIndices[childIndex++] = idx;
+                                       } else if (acceptable(path, childNodes, i, expand)) {
+                                               int viewIndex = insert(path.getLastPathComponent(),
+                                                               childNodes[i], childIndices[i], converter);
+                                               fireTreeNodesInserted(path, indices(viewIndex), nodes(childNodes[i]));
+                                       }
+                               }
+                               if (childIndex == 0) {
+                                       maybeFireStructureChange(path, expand);
+                                       return;
+                               }
+                               if (sortOrder != SortOrder.UNSORTED && converter.getChildCount() > 1) {
+                                       sort(path.getLastPathComponent(), createValueIndexPairArray(converter.getChildCount()));
+                                       fireTreeStructureChangedAndExpand(path, null, true);
+                                       expandPaths(expand);
+                                       return;
+                               }
+                               if (childIndex != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, childIndex);
+                                       childNodes = Arrays.copyOf(childNodes, childIndex);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               // see if the filter likes the nodes new states
+                               expand = new ArrayList<TreePath>();
+                               int[] vtm = null;
+                               int idx = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (acceptable(path, childNodes, i, expand)) {
+                                               if (vtm == null)
+                                                       vtm = new int[childIndices.length-i];
+                                               vtm[idx++] = childIndices[i];
+                                       }
+                               }
+                               // filter in path if appropriate
+                               if (vtm != null)
+                                       filterIn(vtm, idx, path, expand);
+                               return;
+                       }
+                       // must fire tree nodes changed even if a
+                       // structure change will be fired because the
+                       // expanded paths need to be updated first
+                       fireTreeNodesChanged(path, childIndices, childNodes);
+                       maybeFireStructureChange(path, expand);
+               }
+               
+               /**
+                * Helper method for treeNodesChanged...
+                * @param path
+                * @param expand
+                */
+               private void maybeFireStructureChange(TreePath path, ArrayList<TreePath> expand) {
+                       if (expand != null && !expand.isEmpty()) {
+                               Enumeration<TreePath> expanded = tree.getExpandedDescendants(path);
+                               fireTreeStructureChanged(path);
+                               if (expanded != null)
+                                       while (expanded.hasMoreElements())
+                                               tree.expandPath(expanded.nextElement());
+                               expandPaths(expand);
+                       }
+               }
+               
+               @Override
+               public void treeNodesInserted(TreeModelEvent e) {
+                       treeNodesInserted(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+
+               protected void treeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = getConverter(parent);
+                       ArrayList<TreePath> expand = null;
+                       if (converter != null) {
+//                             if (childIndices.length > 3 && !converter.isFiltered()
+//                                             && childIndices.length > converter.getChildCount()/10) {
+//                                     TreePath expand = sortHierarchy(path);
+//                                     fireTreeStructureChangedAndExpand(expand);
+//                                     return;
+//                             }
+                               int childIndex = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (converter.isFiltered()) {
+                                               // path hasn't met the filter criteria, so childNodes must be filtered
+                                               if (expand == null)
+                                                       expand = new ArrayList<TreePath>();
+                                               if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+                                                       continue;
+                                       }
+                                       // shift the appropriate cached modelIndices
+                                       int[] vtm = converter.viewToModel;
+                                       int modelIndex = childIndices[i];
+                                       for (int j=vtm.length; --j>=0;) {
+                                               if (vtm[j] >= modelIndex)
+                                                       vtm[j] += 1;
+                                       }
+                                       // insert modelIndex to converter
+                                       int viewIndex = insert(parent, childNodes[i], modelIndex, converter);
+                                       childNodes[childIndex] = childNodes[i];
+                                       childIndices[childIndex++] = viewIndex;
+                               }
+                               if (childIndex == 0)
+                                       return;
+                               if (childIndex != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, childIndex);
+                                       childNodes = Arrays.copyOf(childNodes, childIndex);
+                               }
+                               if (childIndex > 1 && sortOrder != SortOrder.UNSORTED) {
+                                       sort(childIndices, childNodes);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               // apply filter to inserted nodes
+                               int[] vtm = null;
+                               int idx = 0;
+                               expand = new ArrayList<TreePath>();
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (acceptable(path, childNodes, i, expand)) {
+                                               if (vtm == null)
+                                                       vtm = new int[childIndices.length-i];
+                                               vtm[idx++] = childIndices[i];
+                                       }
+                               }
+                               // filter in path if appropriate
+                               if (vtm != null)
+                                       filterIn(vtm, idx, path, expand);
+                               return;
+                       }
+                       fireTreeNodesInserted(path, childIndices, childNodes);
+                       expandPaths(expand);
+               }
+               
+               @Override
+               public void treeNodesRemoved(TreeModelEvent e) {
+                       treeNodesRemoved(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+               
+
+               private boolean isFilterStartPath(TreePath path) {
+                       if (filterStartPath == null)
+                               return path.getPathCount() == 1;
+                       return filterStartPath.equals(path);
+               }
+               
+               protected void treeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = getConverter(parent);
+                       if (converter != null) {
+                               int len = 0;
+                               for (int i=0; i<childNodes.length; i++) {
+                                       removeConverter(childNodes[i]);
+                                       int viewIndex = converter.convertRowIndexToView(childIndices[i]);
+                                       if (viewIndex >= 0) {
+                                               childNodes[len] = childNodes[i];
+                                               childIndices[len++] = viewIndex;
+                                       }
+                               }
+                               if (len == 0)
+                                       return;
+                               if (converter.isFiltered() && converter.getChildCount() == len) {
+                                       ArrayList<TreePath> expand = new ArrayList<TreePath>();
+                                       if (applyFilter(filter, path, expand)) {
+                                               expand.retainAll(getExpandedPaths(path));
+                                               if (sortOrder != SortOrder.UNSORTED)
+                                                       sortHierarchy(path);
+                                               fireTreeStructureChangedAndExpand(path, expand, true);
+                                       } else if (isFilterStartPath(path)) {
+                                               converters.put(parent, new Converter(Converter.EMPTY, true));
+                                               fireTreeStructureChanged(path);
+                                       } else {
+                                               remove(path.getParentPath(), parent);
+                                       }
+                                       return;
+                               }
+                               if (len != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, len);
+                                       childNodes = Arrays.copyOf(childNodes, len);
+                               }
+                               if (len > 1 && sortOrder != SortOrder.UNSORTED) {
+                                       sort(childIndices, childNodes);
+                               }
+                               if (childIndices.length == 1) {
+                                       converter.remove(converter.convertRowIndexToModel(childIndices[0]));
+                               } else {
+                                       converter.remove(childIndices);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               return;
+                       }
+                       fireTreeNodesRemoved(path, childIndices, childNodes);
+               }
+               
+               private Collection<TreePath> getExpandedPaths(TreePath path) {
+                       Enumeration<TreePath> en = tree.getExpandedDescendants(path);
+                       if (en == null)
+                               return Collections.emptySet();
+                       HashSet<TreePath> expanded = new HashSet<TreePath>();
+                       while (en.hasMoreElements())
+                               expanded.add(en.nextElement());
+                       return expanded;
+               }
+
+               @Override
+               public void treeStructureChanged(TreeModelEvent e) {
+                       if (converters != null) {
+                               // not enough information to properly clean up
+                               // reapply filter/sort
+                               converters = createConvertersMap();
+                               TreePath[] sel = tree.getSelectionPaths();
+                               if (filter != null) {
+                                       applyFilter(null, null, getExpandedPaths(new TreePath(getRoot())), false);
+                               }
+                               if (sortOrder != SortOrder.UNSORTED) {
+                                       TreePath path = new TreePath(getRoot());
+                                       ArrayList<TreePath> expand = sortHierarchy(path);
+                                       fireTreeStructureChangedAndExpand(path, expand, false);
+                               }
+                               if (sel != null) {
+                                       tree.clearSelection();
+                                       TreePath changedPath = e.getTreePath();
+                                       for (TreePath path : sel) {
+                                               if (!changedPath.isDescendant(path))
+                                                       tree.addSelectionPath(path);
+                                       }
+                               }
+                       } else {
+                               fireTreeStructureChanged(e.getTreePath());
+                       }
+               }
+               
+
+               @Override
+               public final int compare(ValueIndexPair<N> a, ValueIndexPair<N> b) {
+                       return compareNodes(a.value, b.value);
+               }
+
+               
+               protected int compareNodes(N a, N b) {
+                       if (comparator != null)
+                               return comparator.compare(a, b);
+                       return collator.compare(a.toString(), b.toString());
+               }
+
+               private void removeConverter(Object node) {
+                       Converter c = getConverter(node);
+                       if (c != null)
+                               removeConverter(c, node);
+               }
+               
+               private void removeConverter(Converter converter, Object node) {
+                       for (int i=converter.getChildCount(); --i>=0;) {
+                               int index = converter.convertRowIndexToModel(i);
+                               Object child = model.getChild(node, index);
+                               Converter c = getConverter(child);
+                               if (c != null)
+                                       removeConverter(c, child);
+                       }
+                       converters.remove(node);
+               }
+               
+               private boolean isFilteredOut(TreePath path) {
+                       if (filterStartPath != null && !filterStartPath.isDescendant(path))
+                               return false;
+                       TreePath parent = path.getParentPath();
+                       // root should always have a converter if filter is non-null,
+                       // so if parent is ever null, there is a bug somewhere else
+                       Converter c = getConverter(parent.getLastPathComponent());
+                       if (c != null) {
+                               return getIndexOfChild(
+                                               parent.getLastPathComponent(),
+                                               path.getLastPathComponent()) < 0;
+                       }
+                       return isFilteredOut(parent);
+               }
+               
+               private void filterIn(int[] vtm, int vtmLength, TreePath path, ArrayList<TreePath> expand) {
+                       Object node = path.getLastPathComponent();
+                       if (vtmLength != vtm.length)
+                               vtm = Arrays.copyOf(vtm, vtmLength);
+                       Converter converter = new Converter(vtm, true);
+                       converters.put(node, converter);
+                       insert(path.getParentPath(), node);
+                       tree.expandPath(path);
+                       expandPaths(expand);
+               }
+
+               private boolean acceptable(TreePath path, Object[] nodes, int index) {
+                       Object node = nodes[index];
+                       return filter.acceptNode(path, (N)node, model.isLeaf(node));
+               }
+               
+               private int ascInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+                       for (int i=vtm.length; --i>=0;) {
+                               int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+                               if (cmp > 0 || (cmp == 0 && idx > vtm[i])) {
+                                       return i+1;
+                               }
+                       }
+                       return 0;
+               }
+               
+               
+               private int dscInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+                       for (int i=vtm.length; --i>=0;) {
+                               int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+                               if (cmp < 0) {
+                                       return i+1;
+                               } else if (cmp == 0 && idx < vtm[i]) {
+                                       return i;
+                               }
+                       }
+                       return 0;
+               }
+
+               
+               /**
+                * Inserts the specified path and node and any parent paths as necessary.
+                * <p>
+                * Fires appropriate event.
+                * @param path
+                * @param node
+                */
+               private void insert(TreePath path, Object node) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = converters.get(parent);
+                       int modelIndex = model.getIndexOfChild(parent, node);
+                       if (converter == null) {
+                               converter = new Converter(indices(modelIndex), true);
+                               converters.put(parent, converter);
+                               insert(path.getParentPath(), parent);
+                       } else {
+                               int viewIndex = insert(parent, node, modelIndex, converter);
+                               fireTreeNodesInserted(path, indices(viewIndex), nodes(node));
+                       }
+               }
+               
+               /**
+                * Inserts node into parent in correct sort order.
+                * <p>
+                * Responsibility of caller to fire appropriate event with the returned viewIndex.
+                * @param path
+                * @param node
+                * @param modelIndex
+                * @param converter
+                * @return viewIndex
+                */
+               private int insert(Object parent, Object node, int modelIndex, Converter converter) {
+                       int[] vtm = converter.viewToModel;
+                       int viewIndex;
+                       switch (sortOrder) {
+                       case ASCENDING:
+                               viewIndex = ascInsertionIndex(vtm, parent, (N)node, modelIndex);
+                               break;
+                       case DESCENDING:
+                               viewIndex = dscInsertionIndex(vtm, parent, (N)node, modelIndex);
+                               break;
+                       default: case UNSORTED:
+                               viewIndex = unsortedInsertionIndex(vtm, modelIndex);
+                               break;
+                       }
+                       int[] a = new int[vtm.length+1];
+                       System.arraycopy(vtm, 0, a, 0, viewIndex);
+                       System.arraycopy(vtm, viewIndex, a, viewIndex+1, vtm.length-viewIndex);
+                       a[viewIndex] = modelIndex;
+                       converter.viewToModel = a;
+                       return viewIndex;
+               }
+               
+               private void remove(TreePath path, Object node) {
+                       Object parent = path.getLastPathComponent();
+                       if (path.getPathCount() == 1 || (filterStartPath != null && filterStartPath.equals(path))) {
+                               removeConverter(node);
+                               converters.put(parent, new Converter(Converter.EMPTY, true));
+                               fireTreeNodesRemoved(path, indices(0), nodes(node));
+                               return;
+                       }
+                       Converter converter = converters.get(parent);
+                       int modelIndex = model.getIndexOfChild(parent, node);
+                       int viewIndex = converter.remove(modelIndex);
+                       switch (viewIndex) {
+                       default:
+                               removeConverter(node);
+                               fireTreeNodesRemoved(path, indices(viewIndex), nodes(node));
+                               break;
+                       case Converter.ONLY_INDEX:
+//                             if (path.getParentPath() == null) {
+//                                     // reached filter root
+//                                     removeConverter(node);
+//                                     converters.put(parent, new Converter(Converter.EMPTY, true));
+//                                     fireTreeNodesRemoved(path, indices(0), nodes(node));
+//                                     return;
+//                             }
+                               remove(path.getParentPath(), parent);
+                               break;
+                       case Converter.INDEX_NOT_FOUND:
+                               removeConverter(node);
+                       }
+               }
+               
+               
+               
+       }
+       
+       
+
+       private static int unsortedInsertionIndex(int[] vtm, int idx) {
+               for (int i=vtm.length; --i>=0;)
+                       if (vtm[i] < idx)
+                               return i+1;
+               return 0;
+       }
+       
+       private static void sort(int[] childIndices, Object[] childNodes) {
+               int len = childIndices.length;
+               ValueIndexPair[] pairs = new ValueIndexPair[len];
+               for (int i=len; --i>=0;)
+                       pairs[i] = new ValueIndexPair<Object>(childIndices[i], childNodes[i]);
+               Arrays.sort(pairs);
+               for (int i=len; --i>=0;) {
+                       childIndices[i] = pairs[i].index;
+                       childNodes[i] = pairs[i].value;
+               }
+       }
+       
+       private static int[] indices(int...indices) {
+               return indices;
+       }
+       
+       private static Object[] nodes(Object...nodes) {
+               return nodes;
+       }
+       
+
+
+       
+       /**
+        * This class has a dual purpose, both related to comparing/sorting.
+        * <p>
+        * The Handler class sorts an array of ValueIndexPair based on the value.
+        * Used for sorting the view.
+        * <p>
+        * ValueIndexPair sorts itself based on the index.
+        * Used for sorting childIndices for fire* methods.
+        */
+       private static class ValueIndexPair<N> implements Comparable<ValueIndexPair<N>> {
+               ValueIndexPair() {}
+               
+               ValueIndexPair(int idx, N val) {
+                       index = idx;
+                       value = val;
+               }
+               
+               N value;
+               
+               int index;
+               
+               public int compareTo(ValueIndexPair<N> o) {
+                       return index - o.index;
+               }
+       }
+       
+       private static class Converter {
+               
+               static final int[] EMPTY = new int[0];
+               
+               static final int ONLY_INDEX = -2;
+               
+               static final int INDEX_NOT_FOUND = -1;
+               
+               Converter(int[] vtm, boolean filtered) {
+                       viewToModel = vtm;
+                       isFiltered = filtered;
+               }
+               
+               private int[] viewToModel;
+               
+               private boolean isFiltered;
+               
+//             public boolean equals(Converter conv) {
+//                     if (conv == null)
+//                             return false;
+//                     if (isFiltered != conv.isFiltered)
+//                             return false;
+//                     return Arrays.equals(viewToModel, conv.viewToModel);
+//             }
+               
+               boolean isFiltered() {
+                       return isFiltered;
+               }
+               
+               void remove(int viewIndices[]) {
+                       int len = viewToModel.length - viewIndices.length;
+                       if (len == 0) {
+                               viewToModel = EMPTY;
+                       } else {
+                               int[] oldVTM = viewToModel;
+                               int[] newVTM = new int[len];
+                               for (int oldIndex=0, newIndex=0, removeIndex=0;
+                                               newIndex<newVTM.length;
+                                               newIndex++, oldIndex++) {
+                                       while (removeIndex < viewIndices.length && oldIndex == viewIndices[removeIndex]) {
+                                               int idx = oldVTM[oldIndex];
+                                               removeIndex++;
+                                               oldIndex++;
+                                               for (int i=newIndex; --i>=0;)
+                                                       if (newVTM[i] > idx)
+                                                               newVTM[i]--;
+                                               for (int i=oldIndex; i<oldVTM.length; i++)
+                                                       if (oldVTM[i] > idx)
+                                                               oldVTM[i]--;
+                                       }
+                                       newVTM[newIndex] = oldVTM[oldIndex];
+                               }
+                               viewToModel = newVTM;
+                       }
+               }
+               
+               /**
+                * @param modelIndex
+                * @return viewIndex that was removed<br>
+                *              or <code>ONLY_INDEX</code> if the modelIndex is the only one in the view<br>
+                *              or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+                */
+               int remove(int modelIndex) {
+                       int[] vtm = viewToModel;
+                       for (int i=vtm.length; --i>=0;) {
+                               if (vtm[i] > modelIndex) {
+                                       vtm[i] -= 1;
+                               } else if (vtm[i] == modelIndex) {
+                                       if (vtm.length == 1) {
+                                               viewToModel = EMPTY;
+                                               return ONLY_INDEX;
+                                       }
+                                       int viewIndex = i;
+                                       while (--i>=0) {
+                                               if (vtm[i] > modelIndex)
+                                                       vtm[i] -= 1;
+                                       }
+                                       int[] a = new int[vtm.length-1];
+                                       if (viewIndex > 0)
+                                               System.arraycopy(vtm, 0, a, 0, viewIndex);
+                                       int len = a.length-viewIndex;
+                                       if (len > 0)
+                                               System.arraycopy(vtm, viewIndex+1, a, viewIndex, len);
+                                       viewToModel = a;
+                                       return viewIndex;
+                               }
+                       }
+                       return INDEX_NOT_FOUND;
+               }
+               
+               
+               int getChildCount() {
+                       return viewToModel.length;
+               }
+               
+               /**
+                * @param modelIndex
+                * @return viewIndex corresponding to modelIndex<br>
+                *              or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+                */
+               int convertRowIndexToView(int modelIndex) {
+                       int[] vtm = viewToModel;
+                       for (int i=vtm.length; --i>=0;) {
+                               if (vtm[i] == modelIndex)
+                                       return i;
+                       }
+                       return INDEX_NOT_FOUND;
+               }
+               
+               int convertRowIndexToModel(int viewIndex) {
+                       return viewToModel[viewIndex];
+               }
+       }
+
+       public interface Filter<N> {
+               boolean acceptNode(TreePath parent, N node, boolean leaf);
+       }
+
+       public static class RegexFilter<N> implements Filter<N> {
+               
+               public RegexFilter(Pattern pattern, boolean leaf) {
+                       matcher = pattern.matcher("");
+                       leafOnly = leaf;
+               }
+               
+               private Matcher matcher;
+               
+               private boolean leafOnly;
+               
+               public boolean acceptNode(TreePath parent, N node, boolean leaf) {
+                       if (leafOnly && !leaf)
+                               return false;
+                       matcher.reset(getStringValue(node));
+                       return matcher.find();
+               }
+               
+               protected String getStringValue(N node) {
+                       return node.toString();
+               }
+       }
+       
+
+       private static Map<Object,Converter> createConvertersMap() {
+               return new HashMap<Object,Converter>();
+       }
+}
diff --git a/ui/TreeSnapshot.java b/ui/TreeSnapshot.java
new file mode 100644 (file)
index 0000000..ef9a6fb
--- /dev/null
@@ -0,0 +1,127 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import javax.swing.JTree;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreeNode;
+import javax.swing.tree.TreePath;
+
+public class TreeSnapshot {
+       private interface NodeAction {
+               public void run(TreeNode node);
+       }
+
+       private JTree tree;
+       private TreePath[] selectionPaths;
+       private List<TreePath> expanded;
+
+       public TreeSnapshot(JTree tree) {
+               this.tree = tree;
+
+               selectionPaths = tree.getSelectionPaths();
+               if (selectionPaths == null) {
+                       selectionPaths = new TreePath[0];
+               }
+
+               expanded = new ArrayList<TreePath>();
+               forEach(tree, new NodeAction() {
+                       @Override
+                       public void run(TreeNode node) {
+                               TreePath path = nodeToPath(node);
+                               if (path != null) {
+                                       if (TreeSnapshot.this.tree.isExpanded(path)) {
+                                               expanded.add(path);
+                                       }
+                               }
+                       }
+               });
+       }
+
+       public void apply() {
+               applyTo(tree);
+       }
+
+       public void applyTo(JTree tree) {
+               final List<TreePath> newExpanded = new ArrayList<TreePath>();
+               final List<TreePath> newSlectionPaths = new ArrayList<TreePath>();
+
+               forEach(tree, new NodeAction() {
+                       @Override
+                       public void run(TreeNode newNode) {
+                               TreePath newPath = nodeToPath(newNode);
+                               if (newPath != null) {
+                                       for (TreePath path : selectionPaths) {
+                                               if (isSamePath(path, newPath)) {
+                                                       newSlectionPaths.add(newPath);
+                                                       if (expanded.contains(path)) {
+                                                               newExpanded.add(newPath);
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               });
+
+               for (TreePath newPath : newExpanded) {
+                       tree.expandPath(newPath);
+               }
+
+               tree.setSelectionPaths(newSlectionPaths.toArray(new TreePath[0]));
+       }
+
+       // You can override this
+       protected boolean isSamePath(TreePath oldPath, TreePath newPath) {
+               return newPath.toString().equals(oldPath.toString());
+       }
+
+       private void forEach(JTree tree, NodeAction action) {
+               forEach(tree.getModel(), tree.getModel().getRoot(), action);
+       }
+
+       private void forEach(TreeModel model, Object parent, NodeAction action) {
+               if (!(parent instanceof TreeNode))
+                       return;
+
+               TreeNode node = (TreeNode) parent;
+
+               action.run(node);
+               int count = model.getChildCount(node);
+               for (int i = 0; i < count; i++) {
+                       Object child = model.getChild(node, i);
+                       forEach(model, child, action);
+               }
+       }
+
+       private static TreePath nodeToPath(TreeNode node) {
+               List<Object> nodes = new LinkedList<Object>();
+               if (node != null) {
+                       nodes.add(node);
+                       node = node.getParent();
+                       while (node != null) {
+                               nodes.add(0, node);
+                               node = node.getParent();
+                       }
+               }
+
+               return nodes.isEmpty() ? null : new TreePath(nodes.toArray());
+       }
+
+       @Override
+       public String toString() {
+               StringBuilder builder = new StringBuilder();
+               builder.append("Tree Snapshot of: ").append(tree).append("\n");
+               builder.append("Selected paths:\n");
+               for (TreePath path : selectionPaths) {
+                       builder.append("\t").append(path).append("\n");
+               }
+               builder.append("Expanded paths:\n");
+               for (TreePath epath : expanded) {
+                       builder.append("\t").append(epath).append("\n");
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/ui/UIUtils.java b/ui/UIUtils.java
new file mode 100644 (file)
index 0000000..6c40389
--- /dev/null
@@ -0,0 +1,335 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Desktop;
+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 java.io.IOException;
+import java.net.URISyntaxException;
+
+import javax.swing.JComponent;
+import javax.swing.JEditorPane;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JScrollPane;
+import javax.swing.UIManager;
+import javax.swing.UnsupportedLookAndFeelException;
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkListener;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.VersionCheck;
+
+/**
+ * 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.
+        * 
+        * @return TRUE if it succeeded
+        */
+       static public boolean setLookAndFeel() {
+               // native look & feel
+               String noLF = "javax.swing.plaf.metal.MetalLookAndFeel";
+               String lf = UIManager.getSystemLookAndFeelClassName();
+               if (lf.equals(noLF))
+                       lf = "com.sun.java.swing.plaf.gtk.GTKLookAndFeel";
+               
+               return setLookAndFeel(lf);
+       }
+
+       /**
+        * Switch to the given 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.
+        * 
+        * @param laf
+        *            the Look &amp; Feel to use
+        * 
+        * @return TRUE if it succeeded
+        */
+       static public boolean setLookAndFeel(String laf) {
+               try {
+                       UIManager.setLookAndFeel(laf);
+                       return true;
+               } catch (InstantiationException e) {
+               } catch (ClassNotFoundException e) {
+               } catch (UnsupportedLookAndFeelException e) {
+               } catch (IllegalAccessException e) {
+               }
+
+               return false;
+       }
+
+       /**
+        * 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) {
+               drawEllipse3D(g, color, x, y, width, height, true);
+       }
+
+       /**
+        * 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 of the upper left corner
+        * @param y
+        *            the Y coordinate of the upper left corner
+        * @param width
+        *            the width radius
+        * @param height
+        *            the height radius
+        * @param fill
+        *            fill the content of the ellipse
+        */
+       static public void drawEllipse3D(Graphics g, Color color, int x, int y,
+                       int width, int height, boolean fill) {
+               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);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(x, y, width, height);
+                       }
+
+                       // Compute dark/bright colours
+                       Paint p = null;
+                       Color dark = color.darker().darker();
+                       Color bright = color.brighter().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);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(x, y, width, height);
+                       }
+                       // Adds highlights at the top right
+                       p = new GradientPaint(width, 0, bright, 0, height, brightEnd);
+                       g2.setPaint(p);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(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);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(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);
+                       if (fill) {
+                               g2.fillOval(x * 2, y, width, height);
+                       } else {
+                               g2.drawOval(x * 2, y, width, height);
+                       }
+
+                       // Reset original paint
+                       g2.setPaint(oldPaint);
+               } else {
+                       g.setColor(color);
+                       if (fill) {
+                               g.fillOval(x, y, width, height);
+                       } else {
+                               g.drawOval(x, y, width, height);
+                       }
+               }
+       }
+
+       /**
+        * Add a {@link JScrollPane} around the given panel and use a sensible (for
+        * me) increment for the mouse wheel.
+        * 
+        * @param pane
+        *            the panel to wrap in a {@link JScrollPane}
+        * @param allowHorizontal
+        *            allow horizontal scrolling (not always desired)
+        * 
+        * @return the {@link JScrollPane}
+        */
+       static public JScrollPane scroll(JComponent pane, boolean allowHorizontal) {
+               return scroll(pane, allowHorizontal, true);
+       }
+
+       /**
+        * Add a {@link JScrollPane} around the given panel and use a sensible (for
+        * me) increment for the mouse wheel.
+        * 
+        * @param pane
+        *            the panel to wrap in a {@link JScrollPane}
+        * @param allowHorizontal
+        *            allow horizontal scrolling (not always desired)
+        * @param allowVertical
+        *            allow vertical scrolling (usually yes, but sometimes you only
+        *            want horizontal)
+        * 
+        * @return the {@link JScrollPane}
+        */
+       static public JScrollPane scroll(JComponent pane, boolean allowHorizontal,
+                       boolean allowVertical) {
+               JScrollPane scroll = new JScrollPane(pane);
+
+               scroll.getVerticalScrollBar().setUnitIncrement(16);
+               scroll.getHorizontalScrollBar().setUnitIncrement(16);
+
+               if (!allowHorizontal) {
+                       scroll.setHorizontalScrollBarPolicy(
+                                       JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+               }
+               if (!allowVertical) {
+                       scroll.setVerticalScrollBarPolicy(
+                                       JScrollPane.VERTICAL_SCROLLBAR_NEVER);
+               }
+
+               return scroll;
+       }
+
+       /**
+        * Show a confirmation message to the user to show him the changes since
+        * last version.
+        * <p>
+        * HTML 3.2 supported, links included (the user browser will be launched if
+        * possible).
+        * <p>
+        * If this is already the latest version, a message will still be displayed.
+        * 
+        * @param parentComponent
+        *            determines the {@link java.awt.Frame} in which the dialog is
+        *            displayed; if <code>null</code>, or if the
+        *            <code>parentComponent</code> has no {@link java.awt.Frame}, a
+        *            default {@link java.awt.Frame} is used
+        * @param updates
+        *            the new version
+        * @param introText
+        *            an introduction text before the list of changes
+        * @param title
+        *            the title of the dialog
+        * 
+        * @return TRUE if the user clicked on OK, false if the dialog was dismissed
+        */
+       static public boolean showUpdatedDialog(Component parentComponent,
+                       VersionCheck updates, String introText, String title) {
+               
+               StringBuilder builder = new StringBuilder();
+               final JEditorPane updateMessage = new JEditorPane("text/html", "");
+               if (introText != null && !introText.isEmpty()) {
+                       builder.append(introText);
+                       builder.append("<br>");
+                       builder.append("<br>");
+               }
+               for (Version v : updates.getNewer()) {
+                       builder.append("\t<b>" //
+                                       + "Version " + v.toString() //
+                                       + "</b>");
+                       builder.append("<br>");
+                       builder.append("<ul>");
+                       for (String item : updates.getChanges().get(v)) {
+                               builder.append("<li>" + item + "</li>");
+                       }
+                       builder.append("</ul>");
+               }
+
+               // html content
+               updateMessage.setText("<html><body>" //
+                               + builder//
+                               + "</body></html>");
+
+               // handle link events
+               updateMessage.addHyperlinkListener(new HyperlinkListener() {
+                       @Override
+                       public void hyperlinkUpdate(HyperlinkEvent e) {
+                               if (e.getEventType().equals(HyperlinkEvent.EventType.ACTIVATED))
+                                       try {
+                                               Desktop.getDesktop().browse(e.getURL().toURI());
+                                       } catch (IOException ee) {
+                                               Instance.getInstance().getTraceHandler().error(ee);
+                                       } catch (URISyntaxException ee) {
+                                               Instance.getInstance().getTraceHandler().error(ee);
+                                       }
+                       }
+               });
+               updateMessage.setEditable(false);
+               updateMessage.setBackground(new JLabel().getBackground());
+               updateMessage.addHyperlinkListener(new HyperlinkListener() {
+                       @Override
+                       public void hyperlinkUpdate(HyperlinkEvent evn) {
+                               if (evn.getEventType() == HyperlinkEvent.EventType.ACTIVATED) {
+                                       if (Desktop.isDesktopSupported()) {
+                                               try {
+                                                       Desktop.getDesktop().browse(evn.getURL().toURI());
+                                               } catch (IOException e) {
+                                               } catch (URISyntaxException e) {
+                                               }
+                                       }
+                               }
+                       }
+               });
+
+               return JOptionPane.showConfirmDialog(parentComponent, updateMessage,
+                               title, JOptionPane.DEFAULT_OPTION) == JOptionPane.OK_OPTION;
+       }
+}
diff --git a/ui/WaitingDialog.java b/ui/WaitingDialog.java
new file mode 100644 (file)
index 0000000..0fd4574
--- /dev/null
@@ -0,0 +1,176 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Window;
+
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.SwingWorker;
+import javax.swing.border.EmptyBorder;
+
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Progress.ProgressListener;
+
+/**
+ * A small waiting dialog that will show only if more than X milliseconds passed
+ * before we dismiss it.
+ * 
+ * @author niki
+ */
+public class WaitingDialog extends JDialog {
+       private static final long serialVersionUID = 1L;
+
+       private boolean waitScreen;
+       private Object waitLock = new Object();
+
+       private Progress pg;
+       private ProgressListener pgl;
+
+       /**
+        * Create a new {@link WaitingDialog}.
+        * 
+        * @param parent
+        *            the parent/owner of this {@link WaitingDialog}
+        * @param delayMs
+        *            the delay after which to show the dialog if it is still not
+        *            dismiss (see {@link WaitingDialog#dismiss()})
+        */
+       public WaitingDialog(Window parent, long delayMs) {
+               this(parent, delayMs, null, null);
+       }
+
+       /**
+        * Create a new {@link WaitingDialog}.
+        * 
+        * @param parent
+        *            the parent/owner of this {@link WaitingDialog}
+        * @param delayMs
+        *            the delay after which to show the dialog if it is still not
+        *            dismiss (see {@link WaitingDialog#dismiss()})
+        * @param pg
+        *            the {@link Progress} to listen on -- when it is
+        *            {@link Progress#done()}, this {@link WaitingDialog} will
+        *            automatically be dismissed as if
+        *            {@link WaitingDialog#dismiss()} was called
+        */
+       public WaitingDialog(Window parent, long delayMs, Progress pg) {
+               this(parent, delayMs, pg, null);
+       }
+
+       /**
+        * Create a new {@link WaitingDialog}.
+        * 
+        * @param parent
+        *            the parent/owner of this {@link WaitingDialog}
+        * @param delayMs
+        *            the delay after which to show the dialog if it is still not
+        *            dismiss (see {@link WaitingDialog#dismiss()})
+        * @param waitingText
+        *            a waiting text to display (note: you may want to subclass it
+        *            for nicer UI)
+        */
+       public WaitingDialog(Window parent, long delayMs, String waitingText) {
+               this(parent, delayMs, null, waitingText);
+       }
+
+       /**
+        * Create a new {@link WaitingDialog}.
+        * 
+        * @param parent
+        *            the parent/owner of this {@link WaitingDialog}
+        * @param delayMs
+        *            the delay after which to show the dialog if it is still not
+        *            dismiss (see {@link WaitingDialog#dismiss()})
+        * @param pg
+        *            the {@link Progress} to listen on -- when it is
+        *            {@link Progress#done()}, this {@link WaitingDialog} will
+        *            automatically be dismissed as if
+        *            {@link WaitingDialog#dismiss()} was called
+        * @param waitingText
+        *            a waiting text to display (note: you may want to subclass it
+        *            for nicer UI)
+        */
+       public WaitingDialog(Window parent, long delayMs, Progress pg,
+                       String waitingText) {
+               super(parent);
+
+               this.setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
+
+               this.pg = pg;
+
+               if (waitingText != null) {
+                       JLabel waitingTextLabel = new JLabel(waitingText);
+                       this.add(waitingTextLabel);
+                       waitingTextLabel.setBorder(new EmptyBorder(10, 10, 10, 10));
+                       this.pack();
+               }
+
+               if (pg != null) {
+                       pgl = new ProgressListener() {
+                               @Override
+                               public void progress(Progress progress, String name) {
+                                       if (WaitingDialog.this.pg.isDone()) {
+                                               // Must be done out of this thread (cannot remove a pgl
+                                               // from a running pgl)
+                                               new SwingWorker<Void, Void>() {
+                                                       @Override
+                                                       protected Void doInBackground() throws Exception {
+                                                               return null;
+                                                       }
+
+                                                       @Override
+                                                       public void done() {
+                                                               dismiss();
+                                                       }
+                                               }.execute();
+                                       }
+                               }
+                       };
+
+                       pg.addProgressListener(pgl);
+
+                       if (pg.isDone()) {
+                               dismiss();
+                               return;
+                       }
+               }
+
+               final long delay = delayMs;
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       Thread.sleep(delay);
+                               } catch (InterruptedException e) {
+                               }
+
+                               synchronized (waitLock) {
+                                       if (!waitScreen) {
+                                               waitScreen = true;
+                                               setVisible(true);
+                                       }
+                               }
+                       }
+               }).start();
+       }
+
+       /**
+        * Notify this {@link WaitingDialog} that the job is done, and dismiss it if
+        * it was already showing on screen (or never show it if it was not).
+        * <p>
+        * Will also dispose the {@link WaitingDialog}.
+        */
+       public void dismiss() {
+               synchronized (waitLock) {
+                       if (waitScreen) {
+                               setVisible(false);
+                       }
+                       waitScreen = true;
+               }
+
+               if (pg != null) {
+                       pg.removeProgressListener(pgl);
+               }
+
+               dispose();
+       }
+}
diff --git a/ui/WrapLayout.java b/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/ui/ZoomBox.java b/ui/ZoomBox.java
new file mode 100644 (file)
index 0000000..a8f9609
--- /dev/null
@@ -0,0 +1,477 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.DefaultComboBoxModel;
+import javax.swing.Icon;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
+
+/**
+ * A small panel that let you choose a zoom level or an actual zoom value (when
+ * there is enough space to allow that).
+ * 
+ * @author niki
+ */
+public class ZoomBox extends ListenerPanel {
+       private static final long serialVersionUID = 1L;
+
+       /** The event that is fired on zoom change. */
+       public static final String ZOOM_CHANGED = "zoom_changed";
+
+       private enum ZoomLevel {
+               FIT_TO_WIDTH(0, true), //
+               FIT_TO_HEIGHT(0, false), //
+               ACTUAL_SIZE(1, null), //
+               HALF_SIZE(0.5, null), //
+               DOUBLE_SIZE(2, null),//
+               ;
+
+               private final double zoom;
+               private final Boolean snapMode;
+
+               private ZoomLevel(double zoom, Boolean snapMode) {
+                       this.zoom = zoom;
+                       this.snapMode = snapMode;
+               }
+
+               public double getZoom() {
+                       return zoom;
+               }
+
+               public Boolean getSnapToWidth() {
+                       return snapMode;
+               }
+
+               /**
+                * Use default values that can be understood by a human.
+                */
+               @Override
+               public String toString() {
+                       switch (this) {
+                       case FIT_TO_WIDTH:
+                               return "Fit to width";
+                       case FIT_TO_HEIGHT:
+                               return "Fit to height";
+                       case ACTUAL_SIZE:
+                               return "Actual size";
+                       case HALF_SIZE:
+                               return "Half size";
+                       case DOUBLE_SIZE:
+                               return "Double size";
+                       }
+                       return super.toString();
+               }
+
+               static ZoomLevel[] values(boolean orderedSelection) {
+                       if (orderedSelection) {
+                               return new ZoomLevel[] { //
+                                               FIT_TO_WIDTH, //
+                                               FIT_TO_HEIGHT, //
+                                               ACTUAL_SIZE, //
+                                               HALF_SIZE, //
+                                               DOUBLE_SIZE,//
+                               };
+                       }
+
+                       return values();
+               }
+       }
+
+       private boolean vertical;
+       private boolean small;
+
+       private JButton zoomIn;
+       private JButton zoomOut;
+       private JButton snapWidth;
+       private JButton snapHeight;
+       private JLabel zoomLabel;
+
+       @SuppressWarnings("rawtypes") // JComboBox<?> is not java 1.6 compatible
+       private JComboBox zoombox;
+
+       private double zoom = 1;
+       private Boolean snapMode = true;
+
+       @SuppressWarnings("rawtypes") // JComboBox<?> not compatible java 1.6
+       private DefaultComboBoxModel zoomBoxModel;
+
+       /**
+        * Create a new {@link ZoomBox}.
+        */
+       @SuppressWarnings({ "unchecked", "rawtypes" }) // JComboBox<?> not
+                                                                                                       // compatible java 1.6
+       public ZoomBox() {
+               zoomIn = new JButton();
+               zoomIn.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               zoomIn(1);
+                       }
+               });
+
+               zoomBoxModel = new DefaultComboBoxModel(ZoomLevel.values(true));
+               zoombox = new JComboBox(zoomBoxModel);
+               zoombox.setEditable(true);
+               zoombox.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               Object selected = zoomBoxModel.getSelectedItem();
+
+                               if (selected == null) {
+                                       return;
+                               }
+
+                               if (selected instanceof ZoomLevel) {
+                                       ZoomLevel selectedZoomLevel = (ZoomLevel) selected;
+                                       setZoomSnapMode(selectedZoomLevel.getZoom(),
+                                                       selectedZoomLevel.getSnapToWidth());
+                               } else {
+                                       String selectedString = selected.toString();
+                                       selectedString = selectedString.trim();
+                                       if (selectedString.endsWith("%")) {
+                                               selectedString = selectedString
+                                                               .substring(0, selectedString.length() - 1)
+                                                               .trim();
+                                       }
+
+                                       try {
+                                               int pc = Integer.parseInt(selectedString);
+                                               if (pc <= 0) {
+                                                       throw new NumberFormatException("invalid");
+                                               }
+
+                                               setZoomSnapMode(pc / 100.0, null);
+                                       } catch (NumberFormatException nfe) {
+                                       }
+                               }
+
+                               fireActionPerformed(ZOOM_CHANGED);
+                       }
+               });
+
+               zoomOut = new JButton();
+               zoomOut.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               zoomOut(1);
+                       }
+               });
+
+               snapWidth = new JButton();
+               snapWidth.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               setSnapMode(true);
+                       }
+               });
+
+               snapHeight = new JButton();
+               snapHeight.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               setSnapMode(false);
+                       }
+               });
+
+               zoomLabel = new JLabel();
+               zoomLabel.setBorder(BorderFactory.createEmptyBorder(10, 10, 0, 0));
+
+               setIcons(null, null, null, null);
+               setOrientation(vertical);
+       }
+
+       /**
+        * The zoom level.
+        * <p>
+        * It usually returns 1 (default value), the value you passed yourself or 1
+        * (a snap to width or snap to height was asked by the user).
+        * <p>
+        * Will cause a fire event if needed.
+        * 
+        * @param zoom
+        *            the zoom level
+        */
+       public void setZoom(double zoom) {
+               if (this.zoom != zoom) {
+                       doSetZoom(zoom);
+                       fireActionPerformed(ZOOM_CHANGED);
+               }
+       }
+
+       /**
+        * The snap mode (NULL means no snap mode, TRUE for snap to width, FALSE for
+        * snap to height).
+        * <p>
+        * Will cause a fire event if needed.
+        * 
+        * @param snapToWidth
+        *            the snap mode
+        */
+       public void setSnapMode(Boolean snapToWidth) {
+               if (this.snapMode != snapToWidth) {
+                       doSetSnapMode(snapToWidth);
+                       fireActionPerformed(ZOOM_CHANGED);
+               }
+       }
+
+       /**
+        * Set both {@link ZoomBox#setZoom(double)} and
+        * {@link ZoomBox#setSnapMode(Boolean)} but fire only one change event.
+        * <p>
+        * Will cause a fire event if needed.
+        * 
+        * @param zoom
+        *            the zoom level
+        * @param snapMode
+        *            the snap mode
+        */
+       public void setZoomSnapMode(double zoom, Boolean snapMode) {
+               if (this.zoom != zoom || this.snapMode != snapMode) {
+                       doSetZoom(zoom);
+                       doSetSnapMode(snapMode);
+                       fireActionPerformed(ZOOM_CHANGED);
+               }
+       }
+
+       /**
+        * The zoom level.
+        * <p>
+        * It usually returns 1 (default value), the value you passed yourself or 0
+        * (a snap to width or snap to height was asked by the user).
+        * 
+        * @return the zoom level
+        */
+       public double getZoom() {
+               return zoom;
+       }
+
+       /**
+        * The snap mode (NULL means no snap mode, TRUE for snap to width, FALSE for
+        * snap to height).
+        * 
+        * @return the snap mode
+        */
+       public Boolean getSnapMode() {
+               return snapMode;
+       }
+
+       /**
+        * Zoom in, by a certain amount in "steps".
+        * <p>
+        * Note that zoomIn(-1) is the same as zoomOut(1).
+        * 
+        * @param steps
+        *            the number of zoom steps to make, can be negative
+        */
+       public void zoomIn(int steps) {
+               // TODO: redo zoomIn/zoomOut correctly
+               if (steps < 0) {
+                       zoomOut(-steps);
+                       return;
+               }
+
+               double newZoom = zoom;
+               for (int i = 0; i < steps; i++) {
+                       newZoom = newZoom + (newZoom < 0.1 ? 0.01 : 0.1);
+                       if (newZoom > 0.1) {
+                               newZoom = Math.round(newZoom * 10.0) / 10.0; // snap to 10%
+                       } else {
+                               newZoom = Math.round(newZoom * 100.0) / 100.0; // snap to 1%
+                       }
+               }
+
+               setZoomSnapMode(newZoom, null);
+               fireActionPerformed(ZOOM_CHANGED);
+       }
+
+       /**
+        * Zoom out, by a certain amount in "steps".
+        * <p>
+        * Note that zoomOut(-1) is the same as zoomIn(1).
+        * 
+        * @param steps
+        *            the number of zoom steps to make, can be negative
+        */
+       public void zoomOut(int steps) {
+               if (steps < 0) {
+                       zoomIn(-steps);
+                       return;
+               }
+
+               double newZoom = zoom;
+               for (int i = 0; i < steps; i++) {
+                       newZoom = newZoom - (newZoom > 0.19 ? 0.1 : 0.01);
+                       if (newZoom < 0.01) {
+                               newZoom = 0.01;
+                               break;
+                       }
+
+                       if (newZoom > 0.1) {
+                               newZoom = Math.round(newZoom * 10.0) / 10.0; // snap to 10%
+                       } else {
+                               newZoom = Math.round(newZoom * 100.0) / 100.0; // snap to 1%
+                       }
+               }
+
+               setZoomSnapMode(newZoom, null);
+               fireActionPerformed(ZOOM_CHANGED);
+       }
+
+       /**
+        * Set icons for the buttons instead of square brackets.
+        * <p>
+        * Any NULL value will make the button use square brackets again.
+        * 
+        * @param zoomIn
+        *            the icon of the button "go to first page"
+        * @param zoomOut
+        *            the icon of the button "go to previous page"
+        * @param snapWidth
+        *            the icon of the button "go to next page"
+        * @param snapHeight
+        *            the icon of the button "go to last page"
+        */
+       public void setIcons(Icon zoomIn, Icon zoomOut, Icon snapWidth,
+                       Icon snapHeight) {
+               this.zoomIn.setIcon(zoomIn);
+               this.zoomIn.setText(zoomIn == null ? "+" : "");
+               this.zoomOut.setIcon(zoomOut);
+               this.zoomOut.setText(zoomOut == null ? "-" : "");
+               this.snapWidth.setIcon(snapWidth);
+               this.snapWidth.setText(snapWidth == null ? "W" : "");
+               this.snapHeight.setIcon(snapHeight);
+               this.snapHeight.setText(snapHeight == null ? "H" : "");
+       }
+
+       /**
+        * A smaller {@link ZoomBox} that uses buttons instead of a big combo box
+        * for the zoom modes.
+        * <p>
+        * Always small in vertical orientation.
+        * 
+        * @return TRUE if it is small
+        */
+       public boolean getSmall() {
+               return small;
+       }
+
+       /**
+        * A smaller {@link ZoomBox} that uses buttons instead of a big combo box
+        * for the zoom modes.
+        * <p>
+        * Always small in vertical orientation.
+        * 
+        * @param small
+        *            TRUE to set it small
+        * 
+        * @return TRUE if it changed something
+        */
+       public boolean setSmall(boolean small) {
+               return setUi(small, vertical);
+       }
+
+       /**
+        * The general orientation of the component.
+        * 
+        * @return TRUE for vertical orientation, FALSE for horisontal orientation
+        */
+       public boolean getOrientation() {
+               return vertical;
+       }
+
+       /**
+        * The general orientation of the component.
+        * 
+        * @param vertical
+        *            TRUE for vertical orientation, FALSE for horisontal
+        *            orientation
+        * 
+        * @return TRUE if it changed something
+        */
+       public boolean setOrientation(boolean vertical) {
+               return setUi(small, vertical);
+       }
+
+       /**
+        * Set the zoom level, no fire event.
+        * <p>
+        * It usually returns 1 (default value), the value you passed yourself or 0
+        * (a snap to width or snap to height was asked by the user).
+        * 
+        * @param zoom
+        *            the zoom level
+        */
+       private void doSetZoom(double zoom) {
+               if (zoom > 0) {
+                       String zoomStr = Integer.toString((int) Math.round(zoom * 100))
+                                       + " %";
+                       zoomLabel.setText(zoomStr);
+                       if (snapMode == null) {
+                               zoomBoxModel.setSelectedItem(zoomStr);
+                       }
+               }
+
+               this.zoom = zoom;
+       }
+
+       /**
+        * Set the snap mode, no fire event.
+        * 
+        * @param snapToWidth
+        *            the snap mode
+        */
+       private void doSetSnapMode(Boolean snapToWidth) {
+               if (snapToWidth == null) {
+                       String zoomStr = Integer.toString((int) Math.round(zoom * 100))
+                                       + " %";
+                       if (zoom > 0) {
+                               zoomBoxModel.setSelectedItem(zoomStr);
+                       }
+               } else {
+                       for (ZoomLevel level : ZoomLevel.values()) {
+                               if (level.getSnapToWidth() == snapToWidth) {
+                                       zoomBoxModel.setSelectedItem(level);
+                               }
+                       }
+               }
+
+               this.snapMode = snapToWidth;
+       }
+
+       private boolean setUi(boolean small, boolean vertical) {
+               if (getWidth() == 0 || this.small != small
+                               || this.vertical != vertical) {
+                       this.small = small;
+                       this.vertical = vertical;
+
+                       BoxLayout layout = new BoxLayout(this,
+                                       vertical ? BoxLayout.Y_AXIS : BoxLayout.X_AXIS);
+                       this.removeAll();
+                       setLayout(layout);
+
+                       if (vertical || small) {
+                               this.add(zoomIn);
+                               this.add(snapWidth);
+                               this.add(snapHeight);
+                               this.add(zoomOut);
+                               this.add(zoomLabel);
+                       } else {
+                               this.add(zoomIn);
+                               this.add(zoombox);
+                               this.add(zoomOut);
+                       }
+
+                       this.revalidate();
+                       this.repaint();
+
+                       return true;
+               }
+
+               return false;
+       }
+}
diff --git a/ui/compat/DefaultListModel6.java b/ui/compat/DefaultListModel6.java
new file mode 100644 (file)
index 0000000..3f7552f
--- /dev/null
@@ -0,0 +1,22 @@
+package be.nikiroo.utils.ui.compat;
+
+import javax.swing.DefaultListModel;
+import javax.swing.JList;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link DefaultListModel} that you can parametrise also
+ * in Java 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public class DefaultListModel6<E> extends DefaultListModel
+               implements ListModel6<E> {
+       private static final long serialVersionUID = 1L;
+}
diff --git a/ui/compat/JList6.java b/ui/compat/JList6.java
new file mode 100644 (file)
index 0000000..a504abb
--- /dev/null
@@ -0,0 +1,84 @@
+package be.nikiroo.utils.ui.compat;
+
+import javax.swing.JList;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListModel;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link JList} that you can parametrise also in Java
+ * 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public class JList6<E> extends JList {
+       private static final long serialVersionUID = 1L;
+
+       @Override
+       @Deprecated
+       /**
+        * @deprecated please use {@link JList6#setCellRenderer(ListCellRenderer6)}
+        *             instead
+        */
+       public void setCellRenderer(ListCellRenderer cellRenderer) {
+               super.setCellRenderer(cellRenderer);
+       }
+
+       /**
+        * Sets the delegate that is used to paint each cell in the list. The job of
+        * a cell renderer is discussed in detail in the <a href="#renderer">class
+        * level documentation</a>.
+        * <p>
+        * If the {@code prototypeCellValue} property is {@code non-null}, setting
+        * the cell renderer also causes the {@code fixedCellWidth} and
+        * {@code fixedCellHeight} properties to be re-calculated. Only one
+        * <code>PropertyChangeEvent</code> is generated however - for the
+        * <code>cellRenderer</code> property.
+        * <p>
+        * The default value of this property is provided by the {@code ListUI}
+        * delegate, i.e. by the look and feel implementation.
+        * <p>
+        * This is a JavaBeans bound property.
+        *
+        * @param cellRenderer
+        *            the <code>ListCellRenderer</code> that paints list cells
+        * @see #getCellRenderer
+        * @beaninfo bound: true attribute: visualUpdate true description: The
+        *           component used to draw the cells.
+        */
+       public void setCellRenderer(ListCellRenderer6<E> cellRenderer) {
+               super.setCellRenderer(cellRenderer);
+       }
+
+       @Override
+       @Deprecated
+       public void setModel(ListModel model) {
+               super.setModel(model);
+       }
+
+       /**
+        * Sets the model that represents the contents or "value" of the list,
+        * notifies property change listeners, and then clears the list's selection.
+        * <p>
+        * This is a JavaBeans bound property.
+        *
+        * @param model
+        *            the <code>ListModel</code> that provides the list of items for
+        *            display
+        * @exception IllegalArgumentException
+        *                if <code>model</code> is <code>null</code>
+        * @see #getModel
+        * @see #clearSelection
+        * @beaninfo bound: true attribute: visualUpdate true description: The
+        *           object that contains the data to be drawn by this JList.
+        */
+       public void setModel(ListModel6<E> model) {
+               super.setModel(model);
+       }
+}
diff --git a/ui/compat/ListCellRenderer6.java b/ui/compat/ListCellRenderer6.java
new file mode 100644 (file)
index 0000000..bc76e80
--- /dev/null
@@ -0,0 +1,65 @@
+package be.nikiroo.utils.ui.compat;
+
+import java.awt.Component;
+
+import javax.swing.JList;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListModel;
+import javax.swing.ListSelectionModel;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link ListCellRenderer} that you can parametrise also
+ * in Java 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public abstract class ListCellRenderer6<E> implements ListCellRenderer {
+       @Override
+       @Deprecated
+       /**
+        * @deprecated please use@deprecated please use
+        *             {@link ListCellRenderer6#getListCellRendererComponent(JList6, Object, int, boolean, boolean)}
+        *             instead
+        *             {@link ListCellRenderer6#getListCellRendererComponent(JList6, Object, int, boolean, boolean)}
+        *             instead
+        */
+       public Component getListCellRendererComponent(JList list, Object value,
+                       int index, boolean isSelected, boolean cellHasFocus) {
+               return getListCellRendererComponent((JList6<E>) list, (E) value, index,
+                               isSelected, cellHasFocus);
+       }
+
+       /**
+        * Return a component that has been configured to display the specified
+        * value. That component's <code>paint</code> method is then called to
+        * "render" the cell. If it is necessary to compute the dimensions of a list
+        * because the list cells do not have a fixed size, this method is called to
+        * generate a component on which <code>getPreferredSize</code> can be
+        * invoked.
+        *
+        * @param list
+        *            The JList we're painting.
+        * @param value
+        *            The value returned by list.getModel().getElementAt(index).
+        * @param index
+        *            The cells index.
+        * @param isSelected
+        *            True if the specified cell was selected.
+        * @param cellHasFocus
+        *            True if the specified cell has the focus.
+        * @return A component whose paint() method will render the specified value.
+        *
+        * @see JList
+        * @see ListSelectionModel
+        * @see ListModel
+        */
+       public abstract Component getListCellRendererComponent(JList6<E> list,
+                       E value, int index, boolean isSelected, boolean cellHasFocus);
+}
diff --git a/ui/compat/ListModel6.java b/ui/compat/ListModel6.java
new file mode 100644 (file)
index 0000000..938da14
--- /dev/null
@@ -0,0 +1,19 @@
+package be.nikiroo.utils.ui.compat;
+
+import javax.swing.JList;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link javax.swing.ListModel} that you can parametrise
+ * also in Java 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public interface ListModel6<E> extends javax.swing.ListModel {
+}
diff --git a/ui/test/ProgressBarManualTest.java b/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/ui/test/TestUI.java b/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();
+       }
+}