2",
+ null);
+ assertEquals(3, paras.size());
+ assertEquals(ParagraphType.BREAK, paras.get(1)
+ .getType());
+
+ paras = support.makeParagraphs(null,
+ "1
2", null);
+ assertEquals(3, paras.size());
+ assertEquals(ParagraphType.BREAK, paras.get(1)
+ .getType());
+
+ paras = support.makeParagraphs(null,
+ "1
2", null);
+ assertEquals(3, paras.size());
+ assertEquals(ParagraphType.BREAK, paras.get(1)
+ .getType());
+
+ paras = support.makeParagraphs(null,
+ "1
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 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> 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 makeParagraphs(URL source, String content,
+ Progress pg) throws IOException {
+ return super.makeParagraphs(source, content, pg);
+ }
+
+ @Override
+ // and make it public!
+ public void fixBlanksBreaks(List 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 requotify(Paragraph para) {
+ return super.requotify(para);
+ }
+ }
+}
diff --git a/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java b/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java
new file mode 100644
index 0000000..9cec220
--- /dev/null
+++ b/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java
@@ -0,0 +1,397 @@
+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 paras) {
+ }
+
+ @Override
+ public List requotify(Paragraph para, boolean html) {
+ List paras = new ArrayList(
+ 1);
+ paras.add(para);
+ return paras;
+ }
+ };
+
+ List 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
paras = null;
+
+ paras = support
+ .makeParagraphs(
+ null,
+ null,
+ "Line1
Line2
Line3
",
+ true,
+ null);
+ assertEquals(5, paras.size());
+
+ paras = support
+ .makeParagraphs(
+ null,
+ null,
+ "Line1
Line2
Line3
* * *",
+ true,
+ null);
+ assertEquals(5, paras.size());
+
+ paras = support.makeParagraphs(null, null, "1* * *
2",
+ true, null);
+ assertEquals(3, paras.size());
+ assertEquals(ParagraphType.BREAK, paras.get(1)
+ .getType());
+
+ paras = support.makeParagraphs(null, null,
+ "1
* * *
2", true, null);
+ assertEquals(3, paras.size());
+ assertEquals(ParagraphType.BREAK, paras.get(1)
+ .getType());
+
+ paras = support.makeParagraphs(null, null,
+ "1
* * *
2", true, null);
+ assertEquals(3, paras.size());
+ assertEquals(ParagraphType.BREAK, paras.get(1)
+ .getType());
+
+ paras = support.makeParagraphs(null, null,
+ "1
* * *
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 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 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 requotify(Paragraph para, boolean html) {
+ return super.requotify(para, html);
+ }
+ }
+}
diff --git a/src/be/nikiroo/fanfix/test/ConversionTest.java b/src/be/nikiroo/fanfix/test/ConversionTest.java
new file mode 100644
index 0000000..d730b3b
--- /dev/null
+++ b/src/be/nikiroo/fanfix/test/ConversionTest.java
@@ -0,0 +1,284 @@
+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.utils.IOUtils;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class ConversionTest extends TestLauncher {
+ private String testUri;
+ private String expectedDir;
+ private String resultDir;
+ private List realTypes;
+ private Map> skipCompare;
+ private Map> 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();
+ 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>();
+ skipCompareCross = new HashMap>();
+
+ skipCompare.put("epb.ncx", Arrays.asList(
+ " ",
+ " "));
+ 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(" "));
+ skipCompareCross.put("index.html",
+ Arrays.asList(" "));
+ 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
errors = new ArrayList();
+
+ 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 = Main.convert(testUri, type.toString(),
+ 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> 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 resultFiles;
+ List 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 expectedLines = Arrays.asList(IOUtils
+ .readSmallFile(expected).split("\n"));
+ List 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> 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/src/be/nikiroo/fanfix/test/LibraryTest.java b/src/be/nikiroo/fanfix/test/LibraryTest.java
new file mode 100644
index 0000000..da44438
--- /dev/null
+++ b/src/be/nikiroo/fanfix/test/LibraryTest.java
@@ -0,0 +1,258 @@
+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 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 metas = lib.getList().getMetas();
+ assertEquals(errMess, Arrays.asList(name1),
+ titlesAsList(metas));
+ }
+ });
+
+ addTest(new TestCase("save more") {
+ @Override
+ public void test() throws Exception {
+ List 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 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 metas = lib.getList().getMetas();
+ assertEquals(3, metas.size());
+ }
+ });
+
+ addTest(new TestCase("getList by source") {
+ @Override
+ public void test() throws Exception {
+ List 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 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 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 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 titlesAsList(List metas) {
+ List list = new ArrayList();
+ 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 chapters = new ArrayList();
+ 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 paragraphs = new ArrayList();
+ 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/src/be/nikiroo/fanfix/test/Test.java b/src/be/nikiroo/fanfix/test/Test.java
new file mode 100644
index 0000000..5ec24a4
--- /dev/null
+++ b/src/be/nikiroo/fanfix/test/Test.java
@@ -0,0 +1,177 @@
+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 (.url 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).
+ *
+ * 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/src/be/nikiroo/jexer/TBrowsableWidget.java b/src/be/nikiroo/jexer/TBrowsableWidget.java
new file mode 100644
index 0000000..44fa710
--- /dev/null
+++ b/src/be/nikiroo/jexer/TBrowsableWidget.java
@@ -0,0 +1,418 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 2
+ */
+package be.nikiroo.jexer;
+
+import static jexer.TKeypress.kbBackTab;
+import static jexer.TKeypress.kbDown;
+import static jexer.TKeypress.kbEnd;
+import static jexer.TKeypress.kbEnter;
+import static jexer.TKeypress.kbHome;
+import static jexer.TKeypress.kbLeft;
+import static jexer.TKeypress.kbPgDn;
+import static jexer.TKeypress.kbPgUp;
+import static jexer.TKeypress.kbRight;
+import static jexer.TKeypress.kbShiftTab;
+import static jexer.TKeypress.kbTab;
+import static jexer.TKeypress.kbUp;
+import jexer.THScroller;
+import jexer.TScrollableWidget;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+/**
+ * This class represents a browsable {@link TWidget}, that is, a {@link TWidget}
+ * where you can use the keyboard or mouse to browse to one line to the next, or
+ * from left t right.
+ *
+ * @author niki
+ */
+abstract public class TBrowsableWidget extends TScrollableWidget {
+ private int selectedRow;
+ private int selectedColumn;
+ private int yOffset;
+
+ /**
+ * The number of rows in this {@link TWidget}.
+ *
+ * @return the number of rows
+ */
+ abstract protected int getRowCount();
+
+ /**
+ * The number of columns in this {@link TWidget}.
+ *
+ * @return the number of columns
+ */
+ abstract protected int getColumnCount();
+
+ /**
+ * The virtual width of this {@link TWidget}, that is, the total width it
+ * can take to display all the data.
+ *
+ * @return the width
+ */
+ abstract int getVirtualWidth();
+
+ /**
+ * The virtual height of this {@link TWidget}, that is, the total width it
+ * can take to display all the data.
+ *
+ * @return the height
+ */
+ abstract int getVirtualHeight();
+
+ /**
+ * Basic setup of this class (called by all constructors)
+ */
+ private void setup() {
+ vScroller = new TVScroller(this, 0, 0, 1);
+ hScroller = new THScroller(this, 0, 0, 1);
+ fixScrollers();
+ }
+
+ /**
+ * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+ * parent.
+ *
+ * @param parent
+ * parent widget
+ */
+ protected TBrowsableWidget(final TWidget parent) {
+ super(parent);
+ setup();
+ }
+
+ /**
+ * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+ * parent.
+ *
+ * @param parent
+ * parent widget
+ * @param x
+ * column relative to parent
+ * @param y
+ * row relative to parent
+ * @param width
+ * width of widget
+ * @param height
+ * height of widget
+ */
+ protected TBrowsableWidget(final TWidget parent, final int x, final int y,
+ final int width, final int height) {
+ super(parent, x, y, width, height);
+ setup();
+ }
+
+ /**
+ * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+ * parent.
+ *
+ * @param parent
+ * parent widget
+ * @param enabled
+ * if true assume enabled
+ */
+ protected TBrowsableWidget(final TWidget parent, final boolean enabled) {
+ super(parent, enabled);
+ setup();
+ }
+
+ /**
+ * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+ * parent.
+ *
+ * @param parent
+ * parent widget
+ * @param enabled
+ * if true assume enabled
+ * @param x
+ * column relative to parent
+ * @param y
+ * row relative to parent
+ * @param width
+ * width of widget
+ * @param height
+ * height of widget
+ */
+ protected TBrowsableWidget(final TWidget parent, final boolean enabled,
+ final int x, final int y, final int width, final int height) {
+ super(parent, enabled, x, y, width, height);
+ setup();
+ }
+
+ /**
+ * The currently selected row (or -1 if no row is selected).
+ *
+ * @return the selected row
+ */
+ public int getSelectedRow() {
+ return selectedRow;
+ }
+
+ /**
+ * The currently selected row (or -1 if no row is selected).
+ *
+ * You may want to call {@link TBrowsableWidget#reflowData()} when done to
+ * see the changes.
+ *
+ * @param selectedRow
+ * the new selected row
+ *
+ * @throws IndexOutOfBoundsException
+ * when the index is out of bounds
+ */
+ public void setSelectedRow(int selectedRow) {
+ if (selectedRow < -1 || selectedRow >= getRowCount()) {
+ throw new IndexOutOfBoundsException(String.format(
+ "Cannot set row %d on a table with %d rows", selectedRow,
+ getRowCount()));
+ }
+
+ this.selectedRow = selectedRow;
+ }
+
+ /**
+ * The currently selected column (or -1 if no column is selected).
+ *
+ * @return the new selected column
+ */
+ public int getSelectedColumn() {
+ return selectedColumn;
+ }
+
+ /**
+ * The currently selected column (or -1 if no column is selected).
+ *
+ * You may want to call {@link TBrowsableWidget#reflowData()} when done to
+ * see the changes.
+ *
+ * @param selectedColumn
+ * the new selected column
+ *
+ * @throws IndexOutOfBoundsException
+ * when the index is out of bounds
+ */
+ public void setSelectedColumn(int selectedColumn) {
+ if (selectedColumn < -1 || selectedColumn >= getColumnCount()) {
+ throw new IndexOutOfBoundsException(String.format(
+ "Cannot set column %d on a table with %d columns",
+ selectedColumn, getColumnCount()));
+ }
+
+ this.selectedColumn = selectedColumn;
+ }
+
+ /**
+ * An offset on the Y position of the table, i.e., the number of rows to
+ * skip so the control can draw that many rows always on top.
+ *
+ * @return the offset
+ */
+ public int getYOffset() {
+ return yOffset;
+ }
+
+ /**
+ * An offset on the Y position of the table, i.e., the number of rows that
+ * should always stay on top.
+ *
+ * @param yOffset
+ * the new offset
+ */
+ public void setYOffset(int yOffset) {
+ this.yOffset = yOffset;
+ }
+
+ @SuppressWarnings("unused")
+ public void dispatchMove(int fromRow, int toRow) {
+ reflowData();
+ }
+
+ @SuppressWarnings("unused")
+ public void dispatchEnter(int selectedRow) {
+ reflowData();
+ }
+
+ @Override
+ public void onMouseDown(final TMouseEvent mouse) {
+ if (mouse.isMouseWheelUp()) {
+ vScroller.decrement();
+ return;
+ }
+ if (mouse.isMouseWheelDown()) {
+ vScroller.increment();
+ return;
+ }
+
+ if ((mouse.getX() < getWidth() - 1) && (mouse.getY() < getHeight() - 1)) {
+ if (vScroller.getValue() + mouse.getY() < getRowCount()) {
+ selectedRow = vScroller.getValue() + mouse.getY()
+ - getYOffset();
+ }
+ dispatchEnter(selectedRow);
+ return;
+ }
+
+ // Pass to children
+ super.onMouseDown(mouse);
+ }
+
+ @Override
+ public void onKeypress(final TKeypressEvent keypress) {
+ int maxX = getRowCount();
+ int prevSelectedRow = selectedRow;
+
+ int firstLineIndex = vScroller.getValue() - getYOffset() + 2;
+ int lastLineIndex = firstLineIndex - hScroller.getHeight()
+ + getHeight() - 2 - 2;
+
+ if (keypress.equals(kbLeft)) {
+ hScroller.decrement();
+ } else if (keypress.equals(kbRight)) {
+ hScroller.increment();
+ } else if (keypress.equals(kbUp)) {
+ if (maxX > 0 && selectedRow < maxX) {
+ if (selectedRow > 0) {
+ if (selectedRow <= firstLineIndex) {
+ vScroller.decrement();
+ }
+ selectedRow--;
+ } else {
+ selectedRow = 0;
+ }
+
+ dispatchMove(prevSelectedRow, selectedRow);
+ }
+ } else if (keypress.equals(kbDown)) {
+ if (maxX > 0) {
+ if (selectedRow >= 0) {
+ if (selectedRow < maxX - 1) {
+ selectedRow++;
+ if (selectedRow >= lastLineIndex) {
+ vScroller.increment();
+ }
+ }
+ } else {
+ selectedRow = 0;
+ }
+
+ dispatchMove(prevSelectedRow, selectedRow);
+ }
+ } else if (keypress.equals(kbPgUp)) {
+ if (selectedRow >= 0) {
+ vScroller.bigDecrement();
+ selectedRow -= getHeight() - 1;
+ if (selectedRow < 0) {
+ selectedRow = 0;
+ }
+
+ dispatchMove(prevSelectedRow, selectedRow);
+ }
+ } else if (keypress.equals(kbPgDn)) {
+ if (selectedRow >= 0) {
+ vScroller.bigIncrement();
+ selectedRow += getHeight() - 1;
+ if (selectedRow > getRowCount() - 1) {
+ selectedRow = getRowCount() - 1;
+ }
+
+ dispatchMove(prevSelectedRow, selectedRow);
+ }
+ } else if (keypress.equals(kbHome)) {
+ if (getRowCount() > 0) {
+ vScroller.toTop();
+ selectedRow = 0;
+ dispatchMove(prevSelectedRow, selectedRow);
+ }
+ } else if (keypress.equals(kbEnd)) {
+ if (getRowCount() > 0) {
+ vScroller.toBottom();
+ selectedRow = getRowCount() - 1;
+ dispatchMove(prevSelectedRow, selectedRow);
+ }
+ } else if (keypress.equals(kbTab)) {
+ getParent().switchWidget(true);
+ } else if (keypress.equals(kbShiftTab) || keypress.equals(kbBackTab)) {
+ getParent().switchWidget(false);
+ } else if (keypress.equals(kbEnter)) {
+ if (selectedRow >= 0) {
+ dispatchEnter(selectedRow);
+ }
+ } else {
+ // Pass other keys (tab etc.) on
+ super.onKeypress(keypress);
+ }
+ }
+
+ @Override
+ public void onResize(TResizeEvent event) {
+ super.onResize(event);
+ reflowData();
+ }
+
+ @Override
+ public void reflowData() {
+ super.reflowData();
+ fixScrollers();
+ }
+
+ private void fixScrollers() {
+ int width = getWidth() - 1; // vertical prio
+ int height = getHeight();
+
+ // TODO: why did we do that before?
+ if (false) {
+ width -= 2;
+ height = -1;
+ }
+
+ int x = Math.max(0, width);
+ int y = Math.max(0, height - 1);
+
+ vScroller.setX(x);
+ vScroller.setHeight(height);
+ hScroller.setY(y);
+ hScroller.setWidth(width);
+
+ // TODO why did we use to add 2?
+ // + 2 (for the border of the window)
+
+ // virtual_size
+ // + the other scroll bar size
+ vScroller.setTopValue(0);
+ vScroller.setBottomValue(Math.max(0, getVirtualHeight() - getHeight()
+ + hScroller.getHeight()));
+ hScroller.setLeftValue(0);
+ hScroller.setRightValue(Math.max(0, getVirtualWidth() - getWidth()
+ + vScroller.getWidth()));
+ }
+}
diff --git a/src/be/nikiroo/jexer/TSizeConstraint.java b/src/be/nikiroo/jexer/TSizeConstraint.java
new file mode 100644
index 0000000..bfdbb3a
--- /dev/null
+++ b/src/be/nikiroo/jexer/TSizeConstraint.java
@@ -0,0 +1,92 @@
+package be.nikiroo.jexer;
+
+import java.util.List;
+
+import jexer.TScrollableWidget;
+import jexer.TWidget;
+import jexer.event.TResizeEvent;
+import jexer.event.TResizeEvent.Type;
+
+public class TSizeConstraint {
+ private TWidget widget;
+ private Integer x1;
+ private Integer y1;
+ private Integer x2;
+ private Integer y2;
+
+ // TODO: include in the window classes I use?
+
+ public TSizeConstraint(TWidget widget, Integer x1, Integer y1, Integer x2,
+ Integer y2) {
+ this.widget = widget;
+ this.x1 = x1;
+ this.y1 = y1;
+ this.x2 = x2;
+ this.y2 = y2;
+ }
+
+ public TWidget getWidget() {
+ return widget;
+ }
+
+ public Integer getX1() {
+ if (x1 != null && x1 < 0)
+ return widget.getParent().getWidth() + x1;
+ return x1;
+ }
+
+ public Integer getY1() {
+ if (y1 != null && y1 < 0)
+ return widget.getParent().getHeight() + y1;
+ return y1;
+ }
+
+ public Integer getX2() {
+ if (x2 != null && x2 <= 0)
+ return widget.getParent().getWidth() - 2 + x2;
+ return x2;
+ }
+
+ public Integer getY2() {
+ if (y2 != null && y2 <= 0)
+ return widget.getParent().getHeight() - 2 + y2;
+ return y2;
+ }
+
+ // coordinates < 0 = from the other side
+ // x2 or y2 = 0 = max size
+ // coordinate NULL = do not work on that side at all
+ static public void setSize(List sizeConstraints, TWidget child,
+ Integer x1, Integer y1, Integer x2, Integer y2) {
+ sizeConstraints.add(new TSizeConstraint(child, x1, y1, x2, y2));
+ }
+
+ static public void resize(List sizeConstraints) {
+ for (TSizeConstraint sizeConstraint : sizeConstraints) {
+ TWidget widget = sizeConstraint.getWidget();
+ Integer x1 = sizeConstraint.getX1();
+ Integer y1 = sizeConstraint.getY1();
+ Integer x2 = sizeConstraint.getX2();
+ Integer y2 = sizeConstraint.getY2();
+
+ if (x1 != null)
+ widget.setX(x1);
+ if (y1 != null)
+ widget.setY(y1);
+
+ if (x2 != null)
+ widget.setWidth(x2 - widget.getX());
+ if (y2 != null)
+ widget.setHeight(y2 - widget.getY());
+
+ // Resize the text field
+ // TODO: why setW/setH/reflow not enough for the scrollbars?
+ widget.onResize(new TResizeEvent(Type.WIDGET, widget.getWidth(),
+ widget.getHeight()));
+
+ if (widget instanceof TScrollableWidget) {
+ ((TScrollableWidget) widget).reflowData();
+ }
+ }
+ }
+}
diff --git a/src/be/nikiroo/jexer/TTable.java b/src/be/nikiroo/jexer/TTable.java
new file mode 100644
index 0000000..45e5df2
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTable.java
@@ -0,0 +1,516 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.swing.table.TableModel;
+
+import be.nikiroo.jexer.TTableCellRenderer.CellRendererMode;
+import jexer.TAction;
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+
+/**
+ * A table widget to display and browse through tabular data.
+ *
+ * Currently, you can only select a line (a row) at a time, but the data you
+ * present is still tabular. You also access the data in a tabular way (by
+ * (raw,column) ).
+ *
+ * @author niki
+ */
+public class TTable extends TBrowsableWidget {
+ // Default renderers use text mode
+ static private TTableCellRenderer defaultSeparatorRenderer = new TTableCellRendererText(
+ CellRendererMode.SEPARATOR);
+ static private TTableCellRenderer defaultHeaderRenderer = new TTableCellRendererText(
+ CellRendererMode.HEADER);
+ static private TTableCellRenderer defaultHeaderSeparatorRenderer = new TTableCellRendererText(
+ CellRendererMode.HEADER_SEPARATOR);
+
+ private boolean showHeader;
+
+ private List columns = new ArrayList();
+ private TableModel model;
+
+ private int selectedColumn;
+
+ private TTableCellRenderer separatorRenderer;
+ private TTableCellRenderer headerRenderer;
+ private TTableCellRenderer headerSeparatorRenderer;
+
+ /**
+ * The action to perform when the user selects an item (clicks or enter).
+ */
+ private TAction enterAction = null;
+
+ /**
+ * The action to perform when the user navigates with keyboard.
+ */
+ private TAction moveAction = null;
+
+ /**
+ * Create a new {@link TTable}.
+ *
+ * @param parent
+ * the parent widget
+ * @param x
+ * the X position
+ * @param y
+ * the Y position
+ * @param width
+ * the width of the {@link TTable}
+ * @param height
+ * the height of the {@link TTable}
+ * @param enterAction
+ * an action to call when a cell is selected
+ * @param moveAction
+ * an action to call when the currently active cell is changed
+ */
+ public TTable(TWidget parent, int x, int y, int width, int height,
+ final TAction enterAction, final TAction moveAction) {
+ this(parent, x, y, width, height, enterAction, moveAction, null, false);
+ }
+
+ /**
+ * Create a new {@link TTable}.
+ *
+ * @param parent
+ * the parent widget
+ * @param x
+ * the X position
+ * @param y
+ * the Y position
+ * @param width
+ * the width of the {@link TTable}
+ * @param height
+ * the height of the {@link TTable}
+ * @param enterAction
+ * an action to call when a cell is selected
+ * @param moveAction
+ * an action to call when the currently active cell is changed
+ * @param headers
+ * the headers of the {@link TTable}
+ * @param showHeaders
+ * TRUE to show the headers on screen
+ */
+ public TTable(TWidget parent, int x, int y, int width, int height,
+ final TAction enterAction, final TAction moveAction,
+ List extends Object> headers, boolean showHeaders) {
+ super(parent, x, y, width, height);
+
+ this.model = new TTableModel(new Object[][] {});
+ setSelectedRow(-1);
+ this.selectedColumn = -1;
+
+ setHeaders(headers, showHeaders);
+
+ this.enterAction = enterAction;
+ this.moveAction = moveAction;
+
+ reflowData();
+ }
+
+ /**
+ * The data model (containing the actual data) used by this {@link TTable},
+ * as with the usual Swing tables.
+ *
+ * @return the model
+ */
+ public TableModel getModel() {
+ return model;
+ }
+
+ /**
+ * The data model (containing the actual data) used by this {@link TTable},
+ * as with the usual Swing tables.
+ *
+ * Will reset all the rendering cells.
+ *
+ * @param model
+ * the new model
+ */
+ public void setModel(TableModel model) {
+ this.model = model;
+ reflowData();
+ }
+
+ /**
+ * The columns used by this {@link TTable} (you need to access them if you
+ * want to change the way they are rendered, for instance, or their size).
+ *
+ * @return the columns
+ */
+ public List getColumns() {
+ return columns;
+ }
+
+ /**
+ * The {@link TTableCellRenderer} used by the separators (one separator
+ * between two data columns).
+ *
+ * @return the renderer, or the default one if none is set (never NULL)
+ */
+ public TTableCellRenderer getSeparatorRenderer() {
+ return separatorRenderer != null ? separatorRenderer
+ : defaultSeparatorRenderer;
+ }
+
+ /**
+ * The {@link TTableCellRenderer} used by the separators (one separator
+ * between two data columns).
+ *
+ * @param separatorRenderer
+ * the new renderer, or NULL to use the default renderer
+ */
+ public void setSeparatorRenderer(TTableCellRenderer separatorRenderer) {
+ this.separatorRenderer = separatorRenderer;
+ }
+
+ /**
+ * The {@link TTableCellRenderer} used by the headers (if
+ * {@link TTable#isShowHeader()} is enabled, the first line represents the
+ * headers with the column names).
+ *
+ * @return the renderer, or the default one if none is set (never NULL)
+ */
+ public TTableCellRenderer getHeaderRenderer() {
+ return headerRenderer != null ? headerRenderer : defaultHeaderRenderer;
+ }
+
+ /**
+ * The {@link TTableCellRenderer} used by the headers (if
+ * {@link TTable#isShowHeader()} is enabled, the first line represents the
+ * headers with the column names).
+ *
+ * @param headerRenderer
+ * the new renderer, or NULL to use the default renderer
+ */
+ public void setHeaderRenderer(TTableCellRenderer headerRenderer) {
+ this.headerRenderer = headerRenderer;
+ }
+
+ /**
+ * The {@link TTableCellRenderer} to use on separators in header lines (see
+ * the related methods to understand what each of them is).
+ *
+ * @return the renderer, or the default one if none is set (never NULL)
+ */
+ public TTableCellRenderer getHeaderSeparatorRenderer() {
+ return headerSeparatorRenderer != null ? headerSeparatorRenderer
+ : defaultHeaderSeparatorRenderer;
+ }
+
+ /**
+ * The {@link TTableCellRenderer} to use on separators in header lines (see
+ * the related methods to understand what each of them is).
+ *
+ * @param headerSeparatorRenderer
+ * the new renderer, or NULL to use the default renderer
+ */
+ public void setHeaderSeparatorRenderer(
+ TTableCellRenderer headerSeparatorRenderer) {
+ this.headerSeparatorRenderer = headerSeparatorRenderer;
+ }
+
+ /**
+ * Show the header row on this {@link TTable}.
+ *
+ * @return TRUE if we show them
+ */
+ public boolean isShowHeader() {
+ return showHeader;
+ }
+
+ /**
+ * Show the header row on this {@link TTable}.
+ *
+ * @param showHeader
+ * TRUE to show them
+ */
+ public void setShowHeader(boolean showHeader) {
+ this.showHeader = showHeader;
+ setYOffset(showHeader ? 2 : 0);
+ reflowData();
+ }
+
+ /**
+ * Change the headers of the table.
+ *
+ * Note that this method is a convenience method that will create columns of
+ * the corresponding names and set them. As such, the previous columns if
+ * any will be replaced.
+ *
+ * @param headers
+ * the new headers
+ */
+ public void setHeaders(List extends Object> headers) {
+ setHeaders(headers, showHeader);
+ }
+
+ /**
+ * Change the headers of the table.
+ *
+ * Note that this method is a convenience method that will create columns of
+ * the corresponding names and set them in the same order. As such, the
+ * previous columns if any will be replaced.
+ *
+ * @param headers
+ * the new headers
+ * @param showHeader
+ * TRUE to show them on screen
+ */
+ public void setHeaders(List extends Object> headers, boolean showHeader) {
+ if (headers == null) {
+ headers = new ArrayList();
+ }
+
+ int i = 0;
+ this.columns = new ArrayList();
+ for (Object header : headers) {
+ this.columns.add(new TTableColumn(i++, header, getModel()));
+ }
+
+ setShowHeader(showHeader);
+ }
+
+ /**
+ * Set the data and create a new {@link TTableModel} for them.
+ *
+ * @param data
+ * the data to set into this table, as an array of rows, that is,
+ * an array of arrays of values
+ */
+
+ public void setRowData(Object[][] data) {
+ setRowData(TTableModel.convert(data));
+ }
+
+ /**
+ * Set the data and create a new {@link TTableModel} for them.
+ *
+ * @param data
+ * the data to set into this table, as a collection of rows, that
+ * is, a collection of collections of values
+ */
+ public void setRowData(
+ final Collection extends Collection extends Object>> data) {
+ setModel(new TTableModel(data));
+ }
+
+ /**
+ * The currently selected cell.
+ *
+ * @return the cell
+ */
+ public Object getSelectedCell() {
+ int selectedRow = getSelectedRow();
+ if (selectedRow >= 0 && selectedColumn >= 0) {
+ return model.getValueAt(selectedRow, selectedColumn);
+ }
+
+ return null;
+ }
+
+ @Override
+ public int getRowCount() {
+ if (model == null) {
+ return 0;
+ }
+ return model.getRowCount();
+ }
+
+ @Override
+ public int getColumnCount() {
+ if (model == null) {
+ return 0;
+ }
+ return model.getColumnCount();
+ }
+
+ @Override
+ public void dispatchEnter(int selectedRow) {
+ super.dispatchEnter(selectedRow);
+ if (enterAction != null) {
+ enterAction.DO();
+ }
+ }
+
+ @Override
+ public void dispatchMove(int fromRow, int toRow) {
+ super.dispatchMove(fromRow, toRow);
+ if (moveAction != null) {
+ moveAction.DO();
+ }
+ }
+
+ /**
+ * Clear the content of the {@link TTable}.
+ *
+ * It will not affect the headers.
+ *
+ * You may want to call {@link TTable#reflowData()} when done to see the
+ * changes.
+ */
+ public void clear() {
+ setSelectedRow(-1);
+ selectedColumn = -1;
+ setModel(new TTableModel(new Object[][] {}));
+ }
+
+ @Override
+ public void reflowData() {
+ super.reflowData();
+
+ int lastAutoColumn = -1;
+ int rowWidth = 0;
+
+ int i = 0;
+ for (TTableColumn tcol : columns) {
+ tcol.reflowData();
+
+ if (!tcol.isForcedWidth()) {
+ lastAutoColumn = i;
+ }
+
+ rowWidth += tcol.getWidth();
+
+ i++;
+ }
+
+ if (!columns.isEmpty()) {
+ rowWidth += (i - 1) * getSeparatorRenderer().getWidthOf(null);
+
+ int extraWidth = getWidth() - rowWidth;
+ if (extraWidth > 0) {
+ if (lastAutoColumn < 0) {
+ lastAutoColumn = columns.size() - 1;
+ }
+ TTableColumn tcol = columns.get(lastAutoColumn);
+ tcol.expandWidthTo(tcol.getWidth() + extraWidth);
+ rowWidth += extraWidth;
+ }
+ }
+ }
+
+ @Override
+ public void draw() {
+ int begin = vScroller.getValue();
+ int y = this.showHeader ? 2 : 0;
+
+ if (showHeader) {
+ CellAttributes colorHeaders = getHeaderRenderer()
+ .getCellAttributes(getTheme(), false, isAbsoluteActive());
+ drawRow(-1, 0);
+ String formatString = "%-" + Integer.toString(getWidth()) + "s";
+ String data = String.format(formatString, "");
+ getScreen().putStringXY(0, 1, data, colorHeaders);
+ }
+
+ // draw the actual rows until no more,
+ // then pad the rest with blank rows
+ for (int i = begin; i < getRowCount(); i++) {
+ drawRow(i, y);
+ y++;
+
+ // -2: window borders
+ if (y >= getHeight() - 2 - getHorizontalScroller().getHeight()) {
+ break;
+ }
+ }
+
+ CellAttributes emptyRowColor = getSeparatorRenderer()
+ .getCellAttributes(getTheme(), false, isAbsoluteActive());
+ for (int i = getRowCount(); i < getHeight(); i++) {
+ getScreen().hLineXY(0, y, getWidth() - 1, ' ', emptyRowColor);
+ y++;
+ }
+ }
+
+ @Override
+ protected int getVirtualWidth() {
+ int width = 0;
+
+ if (getColumns() != null) {
+ for (TTableColumn tcol : getColumns()) {
+ width += tcol.getWidth();
+ }
+
+ if (getColumnCount() > 0) {
+ width += (getColumnCount() - 1)
+ * getSeparatorRenderer().getWidthOf(null);
+ }
+ }
+
+ return width;
+ }
+
+ @Override
+ protected int getVirtualHeight() {
+ // TODO: allow changing the height of one row
+ return (showHeader ? 2 : 0) + (getRowCount() * 1);
+ }
+
+ /**
+ * Draw the given row (it MUST exist) at the specified index and
+ * offset.
+ *
+ * @param rowIndex
+ * the index of the row to draw or -1 for the headers
+ * @param y
+ * the Y position
+ */
+ private void drawRow(int rowIndex, int y) {
+ for (int i = 0; i < getColumnCount(); i++) {
+ TTableColumn tcol = columns.get(i);
+ Object value;
+ if (rowIndex < 0) {
+ value = tcol.getHeaderValue();
+ } else {
+ value = model.getValueAt(rowIndex, tcol.getModelIndex());
+ }
+
+ if (i > 0) {
+ TTableCellRenderer sep = rowIndex < 0 ? getHeaderSeparatorRenderer()
+ : getSeparatorRenderer();
+ sep.renderTableCell(this, null, rowIndex, i - 1, y);
+ }
+
+ if (rowIndex < 0) {
+ getHeaderRenderer()
+ .renderTableCell(this, value, rowIndex, i, y);
+ } else {
+ tcol.getRenderer().renderTableCell(this, value, rowIndex, i, y);
+ }
+ }
+ }
+}
diff --git a/src/be/nikiroo/jexer/TTableCellRenderer.java b/src/be/nikiroo/jexer/TTableCellRenderer.java
new file mode 100644
index 0000000..6d7b3b3
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTableCellRenderer.java
@@ -0,0 +1,240 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.ColorTheme;
+
+/**
+ * A {@link TTable} cell renderer allows you to customize the way a single cell
+ * will be displayed on screen.
+ *
+ * It can be used in a {@link TTable} for the haeders or the separators or in a
+ * {@link TTableColumn} for the data.
+ *
+ * @author niki
+ */
+abstract public class TTableCellRenderer {
+ private CellRendererMode mode;
+
+ /**
+ * The simple renderer mode.
+ *
+ * @author niki
+ */
+ public enum CellRendererMode {
+ /** Normal text mode */
+ NORMAL,
+ /** Only display a separator */
+ SEPARATOR,
+ /** Header text mode */
+ HEADER,
+ /** Both HEADER and SEPARATOR at once */
+ HEADER_SEPARATOR;
+
+ /**
+ * This mode represents a separator.
+ *
+ * @return TRUE for separators
+ */
+ public boolean isSeparator() {
+ return this == SEPARATOR || this == HEADER_SEPARATOR;
+ }
+
+ /**
+ * This mode represents a header.
+ *
+ * @return TRUE for headers
+ */
+ public boolean isHeader() {
+ return this == HEADER || this == HEADER_SEPARATOR;
+ }
+ }
+
+ /**
+ * Create a new renderer of the given mode.
+ *
+ * @param mode
+ * the renderer mode, cannot be NULL
+ */
+ public TTableCellRenderer(CellRendererMode mode) {
+ if (mode == null) {
+ throw new IllegalArgumentException(
+ "Cannot create a renderer of type NULL");
+ }
+
+ this.mode = mode;
+ }
+
+ /**
+ * Render the given value.
+ *
+ * @param table
+ * the table to write on
+ * @param value
+ * the value to write
+ * @param rowIndex
+ * the row index in the table
+ * @param colIndex
+ * the column index in the table
+ * @param y
+ * the Y position at which to draw this row
+ */
+ abstract public void renderTableCell(TTable table, Object value,
+ int rowIndex, int colIndex, int y);
+
+ /**
+ * The mode of this {@link TTableCellRenderer}.
+ *
+ * @return the mode
+ */
+ public CellRendererMode getMode() {
+ return mode;
+ }
+
+ /**
+ * The cell attributes to use for the given state.
+ *
+ * @param theme
+ * the color theme to use
+ * @param isSelected
+ * TRUE if the cell is selected
+ * @param hasFocus
+ * TRUE if the cell has focus
+ *
+ * @return the attributes
+ */
+ public CellAttributes getCellAttributes(ColorTheme theme,
+ boolean isSelected, boolean hasFocus) {
+ return theme.getColor(getColorKey(isSelected, hasFocus));
+ }
+
+ /**
+ * Measure the width of the value.
+ *
+ * @param value
+ * the value to measure
+ *
+ * @return its width
+ */
+ public int getWidthOf(Object value) {
+ if (getMode().isSeparator()) {
+ return asText(null, 0, false).length();
+ }
+ return ("" + value).length();
+ }
+
+ /**
+ * The colour to use for the given state, specified as a Jexer colour key.
+ *
+ * @param isSelected
+ * TRUE if the cell is selected
+ * @param hasFocus
+ * TRUE if the cell has focus
+ *
+ * @return the colour key
+ */
+ protected String getColorKey(boolean isSelected, boolean hasFocus) {
+ if (mode.isHeader()) {
+ return "tlabel";
+ }
+
+ String colorKey = "tlist";
+ if (isSelected) {
+ colorKey += ".selected";
+ } else if (!hasFocus) {
+ colorKey += ".inactive";
+ }
+
+ return colorKey;
+ }
+
+ /**
+ * Return the X offset to use to draw a column at the given index.
+ *
+ * @param table
+ * the table to draw into
+ * @param colIndex
+ * the column index
+ *
+ * @return the offset
+ */
+ protected int getXOffset(TTable table, int colIndex) {
+ int xOffset = -table.getHorizontalValue();
+ for (int i = 0; i <= colIndex; i++) {
+ TTableColumn tcol = table.getColumns().get(i);
+ xOffset += tcol.getWidth();
+ if (i > 0) {
+ xOffset += table.getSeparatorRenderer().getWidthOf(null);
+ }
+ }
+
+ TTableColumn tcol = table.getColumns().get(colIndex);
+ if (!getMode().isSeparator()) {
+ xOffset -= tcol.getWidth();
+ }
+
+ return xOffset;
+ }
+
+ /**
+ * Return the text to use (usually the converted-to-text value, except for
+ * the special separator mode).
+ *
+ * @param value
+ * the value to get the text of
+ * @param width
+ * the width we should tale
+ * @param align
+ * the text to the right
+ *
+ * @return the {@link String} to display
+ */
+ protected String asText(Object value, int width, boolean rightAlign) {
+ if (getMode().isSeparator()) {
+ // some nice characters for the separator: â â |
+ return " â ";
+ }
+
+ if (width <= 0) {
+ return "";
+ }
+
+ String format;
+ if (!rightAlign) {
+ // Left align
+ format = "%-" + width + "s";
+ } else {
+ // right align
+ format = "%" + width + "s";
+ }
+
+ return String.format(format, value);
+ }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/jexer/TTableCellRendererText.java b/src/be/nikiroo/jexer/TTableCellRendererText.java
new file mode 100644
index 0000000..8f81883
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTableCellRendererText.java
@@ -0,0 +1,91 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import jexer.bits.CellAttributes;
+
+/**
+ * A simple {@link TTableCellRenderer} that display the values within a
+ * {@link TLabel}.
+ *
+ * It supports a few different modes, see
+ * {@link TTableOldSimpleTextCellRenderer.CellRendererMode}.
+ *
+ * @author niki
+ */
+public class TTableCellRendererText extends TTableCellRenderer {
+ private boolean rightAlign;
+
+ /**
+ * Create a new renderer for normal text mode.
+ */
+ public TTableCellRendererText() {
+ this(CellRendererMode.NORMAL);
+ }
+
+ /**
+ * Create a new renderer of the given mode.
+ *
+ * @param mode
+ * the renderer mode
+ */
+ public TTableCellRendererText(CellRendererMode mode) {
+ this(mode, false);
+ }
+
+ /**
+ * Create a new renderer of the given mode.
+ *
+ * @param mode
+ * the renderer mode, cannot be NULL
+ */
+ public TTableCellRendererText(CellRendererMode mode,
+ boolean rightAlign) {
+ super(mode);
+
+ this.rightAlign = rightAlign;
+ }
+
+ @Override
+ public void renderTableCell(TTable table, Object value, int rowIndex,
+ int colIndex, int y) {
+
+ int xOffset = getXOffset(table, colIndex);
+ TTableColumn tcol = table.getColumns().get(colIndex);
+ String data = asText(value, tcol.getWidth(), rightAlign);
+
+ if (!data.isEmpty()) {
+ boolean isSelected = table.getSelectedRow() == rowIndex;
+ boolean hasFocus = table.isAbsoluteActive();
+ CellAttributes color = getCellAttributes(table.getWindow()
+ .getApplication().getTheme(), isSelected, hasFocus);
+ table.getScreen().putStringXY(xOffset, y, data, color);
+ }
+ }
+}
diff --git a/src/be/nikiroo/jexer/TTableCellRendererWidget.java b/src/be/nikiroo/jexer/TTableCellRendererWidget.java
new file mode 100644
index 0000000..22c6f47
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTableCellRendererWidget.java
@@ -0,0 +1,170 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jexer.TLabel;
+import jexer.TWidget;
+
+/**
+ * A simple {@link TTableCellRenderer} that display the values within a
+ * {@link TLabel}.
+ *
+ * It supports a few different modes, see
+ * {@link TTableSimpleTextCellRenderer.CellRendererMode}.
+ *
+ * @author niki
+ */
+public class TTableCellRendererWidget extends TTableCellRenderer {
+ private boolean rightAlign;
+ private Map widgets = new HashMap();
+
+ /**
+ * Create a new renderer for normal text mode.
+ */
+ public TTableCellRendererWidget() {
+ this(CellRendererMode.NORMAL);
+ }
+
+ /**
+ * Create a new renderer of the given mode.
+ *
+ * @param mode
+ * the renderer mode
+ */
+ public TTableCellRendererWidget(CellRendererMode mode) {
+ this(mode, false);
+ }
+
+ /**
+ * Create a new renderer of the given mode.
+ *
+ * @param mode
+ * the renderer mode, cannot be NULL
+ */
+ public TTableCellRendererWidget(CellRendererMode mode, boolean rightAlign) {
+ super(mode);
+
+ this.rightAlign = rightAlign;
+ }
+
+ @Override
+ public void renderTableCell(TTable table, Object value, int rowIndex,
+ int colIndex, int y) {
+
+ String wkey = "[Row " + y + " " + getMode() + "]";
+ TWidget widget = widgets.get(wkey);
+
+ TTableColumn tcol = table.getColumns().get(colIndex);
+ boolean isSelected = table.getSelectedRow() == rowIndex;
+ boolean hasFocus = table.isAbsoluteActive();
+ int width = tcol.getWidth();
+
+ int xOffset = getXOffset(table, colIndex);
+
+ if (widget != null
+ && !updateTableCellRendererComponent(widget, value, isSelected,
+ hasFocus, y, xOffset, width)) {
+ table.removeChild(widget);
+ widget = null;
+ }
+
+ if (widget == null) {
+ widget = getTableCellRendererComponent(table, value, isSelected,
+ hasFocus, y, xOffset, width);
+ }
+
+ widgets.put(wkey, widget);
+ }
+
+ /**
+ * Create a new {@link TWidget} to represent the given value.
+ *
+ * @param table
+ * the parent {@link TTable}
+ * @param value
+ * the value to represent
+ * @param isSelected
+ * TRUE if selected
+ * @param hasFocus
+ * TRUE if focused
+ * @param row
+ * the row to draw it at
+ * @param column
+ * the column to draw it at
+ * @param width
+ * the width of the control
+ *
+ * @return the widget
+ */
+ protected TWidget getTableCellRendererComponent(TTable table, Object value,
+ boolean isSelected, boolean hasFocus, int row, int column, int width) {
+ return new TLabel(table, asText(value, width, rightAlign), column, row,
+ getColorKey(isSelected, hasFocus), false);
+ }
+
+ /**
+ * Update the content of the widget if at all possible.
+ *
+ * @param component
+ * the component to update
+ * @param value
+ * the value to represent
+ * @param isSelected
+ * TRUE if selected
+ * @param hasFocus
+ * TRUE if focused
+ * @param row
+ * the row to draw it at
+ * @param column
+ * the column to draw it at
+ * @param width
+ * the width of the control
+ *
+ * @return TRUE if the operation was possible, FALSE if it failed
+ */
+ protected boolean updateTableCellRendererComponent(TWidget component,
+ Object value, boolean isSelected, boolean hasFocus, int row,
+ int column, int width) {
+
+ if (component instanceof TLabel) {
+ TLabel widget = (TLabel) component;
+ widget.setLabel(asText(value, width, rightAlign));
+ widget.setColorKey(getColorKey(isSelected, hasFocus));
+ widget.setWidth(width);
+ widget.setX(column);
+ widget.setY(row);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/be/nikiroo/jexer/TTableColumn.java b/src/be/nikiroo/jexer/TTableColumn.java
new file mode 100644
index 0000000..3eea230
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTableColumn.java
@@ -0,0 +1,129 @@
+package be.nikiroo.jexer;
+
+import javax.swing.table.TableModel;
+
+import be.nikiroo.jexer.TTableCellRenderer.CellRendererMode;
+
+public class TTableColumn {
+ static private TTableCellRenderer defaultrenderer = new TTableCellRendererText(
+ CellRendererMode.NORMAL);
+
+ private TableModel model;
+ private int modelIndex;
+ private int width;
+ private boolean forcedWidth;
+
+ private TTableCellRenderer renderer;
+
+ /** The auto-computed width of the column (the width of the largest value) */
+ private int autoWidth;
+
+ private Object headerValue;
+
+ public TTableColumn(int modelIndex) {
+ this(modelIndex, null);
+ }
+
+ public TTableColumn(int modelIndex, String colName) {
+ this(modelIndex, colName, null);
+ }
+
+ // set the width and preferred with the the max data size
+ public TTableColumn(int modelIndex, Object colValue, TableModel model) {
+ this.model = model;
+ this.modelIndex = modelIndex;
+
+ reflowData();
+
+ if (colValue != null) {
+ setHeaderValue(colValue);
+ }
+ }
+
+ // never null
+ public TTableCellRenderer getRenderer() {
+ return renderer != null ? renderer : defaultrenderer;
+ }
+
+ public void setCellRenderer(TTableCellRenderer renderer) {
+ this.renderer = renderer;
+ }
+
+ /**
+ * Recompute whatever data is displayed by this widget.
+ *
+ * Will just update the sizes in this case.
+ */
+ public void reflowData() {
+ if (model != null) {
+ int maxDataSize = 0;
+ for (int i = 0; i < model.getRowCount(); i++) {
+ maxDataSize = Math.max(
+ maxDataSize,
+ getRenderer().getWidthOf(
+ model.getValueAt(i, modelIndex)));
+ }
+
+ autoWidth = maxDataSize;
+ if (!forcedWidth) {
+ setWidth(maxDataSize);
+ }
+ } else {
+ autoWidth = 0;
+ forcedWidth = false;
+ width = 0;
+ }
+ }
+
+ public int getModelIndex() {
+ return modelIndex;
+ }
+
+ /**
+ * The actual size of the column. This can be auto-computed in some cases.
+ *
+ * @return the width (never < 0)
+ */
+ public int getWidth() {
+ return width;
+ }
+
+ /**
+ * Set the actual size of the column or -1 for auto size.
+ *
+ * @param width
+ * the width (or -1 for auto)
+ */
+ public void setWidth(int width) {
+ forcedWidth = width >= 0;
+
+ if (forcedWidth) {
+ this.width = width;
+ } else {
+ this.width = autoWidth;
+ }
+ }
+
+ /**
+ * The width was forced by the user (using
+ * {@link TTableColumn#setWidth(int)} with a positive value).
+ *
+ * @return TRUE if it was
+ */
+ public boolean isForcedWidth() {
+ return forcedWidth;
+ }
+
+ // not an actual forced width, but does change the width return
+ void expandWidthTo(int width) {
+ this.width = width;
+ }
+
+ public Object getHeaderValue() {
+ return headerValue;
+ }
+
+ public void setHeaderValue(Object headerValue) {
+ this.headerValue = headerValue;
+ }
+}
diff --git a/src/be/nikiroo/jexer/TTableLine.java b/src/be/nikiroo/jexer/TTableLine.java
new file mode 100644
index 0000000..f393621
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTableLine.java
@@ -0,0 +1,135 @@
+package be.nikiroo.jexer;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+
+public class TTableLine implements List {
+ //TODO: in TTable: default to header of size 1
+ private List list;
+
+ public TTableLine(List list) {
+ this.list = list;
+ }
+
+ // TODO: override this and the rest shall follow
+ protected List getList() {
+ return list;
+ }
+
+ @Override
+ public int size() {
+ return getList().size();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return getList().isEmpty();
+ }
+
+ @Override
+ public boolean contains(Object o) {
+ return getList().contains(o);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return getList().iterator();
+ }
+
+ @Override
+ public Object[] toArray() {
+ return getList().toArray();
+ }
+
+ @Override
+ public T[] toArray(T[] a) {
+ return getList().toArray(a);
+ }
+
+ @Override
+ public boolean containsAll(Collection> c) {
+ return getList().containsAll(c);
+ }
+
+ @Override
+ public String get(int index) {
+ return getList().get(index);
+ }
+
+ @Override
+ public int indexOf(Object o) {
+ return getList().indexOf(o);
+ }
+
+ @Override
+ public int lastIndexOf(Object o) {
+ return getList().lastIndexOf(o);
+ }
+
+ @Override
+ public List subList(int fromIndex, int toIndex) {
+ return getList().subList(fromIndex, toIndex);
+ }
+
+ @Override
+ public ListIterator listIterator() {
+ return getList().listIterator();
+ }
+
+ @Override
+ public ListIterator listIterator(int index) {
+ return getList().listIterator(index);
+ }
+
+ @Override
+ public boolean add(String e) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public boolean remove(Object o) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public boolean addAll(Collection extends String> c) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public boolean addAll(int index, Collection extends String> c) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public boolean removeAll(Collection> c) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public boolean retainAll(Collection> c) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public void clear() {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public String set(int index, String element) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public void add(int index, String element) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+
+ @Override
+ public String remove(int index) {
+ throw new UnsupportedOperationException("Read-only collection");
+ }
+}
diff --git a/src/be/nikiroo/jexer/TTableModel.java b/src/be/nikiroo/jexer/TTableModel.java
new file mode 100644
index 0000000..cd86d35
--- /dev/null
+++ b/src/be/nikiroo/jexer/TTableModel.java
@@ -0,0 +1,176 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+
+import javax.swing.event.TableModelListener;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.table.TableModel;
+
+/**
+ * The model of a {@link TTable}. It contains the data of the table and allows
+ * you access to it.
+ *
+ * Note that you don't need to send it the representation of the data, but the
+ * data itself; {@link TTableCellRenderer} is the class responsible of
+ * representing that data (you can change the headers renderer on a
+ * {@link TTable} and the cells renderer on each of its {@link TTableColumn}).
+ *
+ * It works in a similar way to the Java Swing version of it.
+ *
+ * @author niki
+ */
+public class TTableModel implements TableModel {
+ private TableModel model;
+
+ /**
+ * Create a new {@link TTableModel} with the given data inside.
+ *
+ * @param data
+ * the data
+ */
+ public TTableModel(Object[][] data) {
+ this(convert(data));
+ }
+
+ /**
+ * Create a new {@link TTableModel} with the given data inside.
+ *
+ * @param data
+ * the data
+ */
+ public TTableModel(
+ final Collection extends Collection extends Object>> data) {
+
+ int maxItemsPerRow = 0;
+ for (Collection extends Object> rowOfData : data) {
+ maxItemsPerRow = Math.max(maxItemsPerRow, rowOfData.size());
+ }
+
+ int i = 0;
+ final Object[][] odata = new Object[data.size()][maxItemsPerRow];
+ for (Collection extends Object> rowOfData : data) {
+ odata[i] = new String[maxItemsPerRow];
+ int j = 0;
+ for (Object pieceOfData : rowOfData) {
+ odata[i][j] = pieceOfData;
+ j++;
+ }
+ i++;
+ }
+
+ final int maxItemsPerRowFinal = maxItemsPerRow;
+ this.model = new AbstractTableModel() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public Object getValueAt(int rowIndex, int columnIndex) {
+ return odata[rowIndex][columnIndex];
+ }
+
+ @Override
+ public int getRowCount() {
+ return odata.length;
+ }
+
+ @Override
+ public int getColumnCount() {
+ return maxItemsPerRowFinal;
+ }
+ };
+ }
+
+ @Override
+ public int getRowCount() {
+ return model.getRowCount();
+ }
+
+ @Override
+ public int getColumnCount() {
+ return model.getColumnCount();
+ }
+
+ @Override
+ public String getColumnName(int columnIndex) {
+ return model.getColumnName(columnIndex);
+ }
+
+ @Override
+ public Class> getColumnClass(int columnIndex) {
+ return model.getColumnClass(columnIndex);
+ }
+
+ @Override
+ public boolean isCellEditable(int rowIndex, int columnIndex) {
+ return model.isCellEditable(rowIndex, columnIndex);
+ }
+
+ @Override
+ public Object getValueAt(int rowIndex, int columnIndex) {
+ return model.getValueAt(rowIndex, columnIndex);
+ }
+
+ @Override
+ public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
+ model.setValueAt(aValue, rowIndex, columnIndex);
+ }
+
+ @Override
+ public void addTableModelListener(TableModelListener l) {
+ model.addTableModelListener(l);
+ }
+
+ @Override
+ public void removeTableModelListener(TableModelListener l) {
+ model.removeTableModelListener(l);
+ }
+
+ /**
+ * Helper method to convert an array to a collection.
+ *
+ * @param
+ *
+ * @param data
+ * the data
+ *
+ * @return the data in another format
+ */
+ static Collection> convert(T[][] data) {
+ Collection> dataCollection = new ArrayList>(
+ data.length);
+ for (T pieceOfData[] : data) {
+ dataCollection.add(Arrays.asList(pieceOfData));
+ }
+
+ return dataCollection;
+ }
+}
diff --git a/src/be/nikiroo/utils/Cache.java b/src/be/nikiroo/utils/Cache.java
new file mode 100644
index 0000000..6233082
--- /dev/null
+++ b/src/be/nikiroo/utils/Cache.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * Note that you may need to add a sub-directory in some cases.
+ *
+ * @param uniqueID
+ * the id
+ *
+ * @return the cached version if present, NULL if not
+ */
+ private File getCached(String uniqueID) {
+ File file = new File(dir, allowedChars(uniqueID));
+ File subdir = new File(file.getParentFile(), "_");
+ return new File(subdir, file.getName());
+ }
+
+ /**
+ * Replace not allowed chars (in a {@link File}) by "_".
+ *
+ * @param raw
+ * the raw {@link String}
+ *
+ * @return the sanitised {@link String}
+ */
+ private String allowedChars(String raw) {
+ return raw.replace('/', '_').replace(':', '_').replace("\\", "_");
+ }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/CacheMemory.java b/src/be/nikiroo/utils/CacheMemory.java
new file mode 100644
index 0000000..de4fae3
--- /dev/null
+++ b/src/be/nikiroo/utils/CacheMemory.java
@@ -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 data;
+
+ /**
+ * Create a new {@link CacheMemory}.
+ */
+ public CacheMemory() {
+ data = new HashMap();
+ }
+
+ @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/src/be/nikiroo/utils/CryptUtils.java b/src/be/nikiroo/utils/CryptUtils.java
new file mode 100644
index 0000000..638f82f
--- /dev/null
+++ b/src/be/nikiroo/utils/CryptUtils.java
@@ -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.
+ *
+ * It is multi-thread compatible, but beware:
+ *
+ * The encrypt/decrypt calls are serialized
+ * The streams are independent (and thus parallel)
+ *
+ *
+ * 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}.
+ *
+ * Some part of the key will be used to generate a 128 bits key and
+ * initialize the {@link CryptUtils}; even NULL will generate something.
+ *
+ * This is most probably not secure. Do not use if you actually care
+ * about security.
+ *
+ * @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.
+ *
+ * The key must 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 must 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 was 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 NOT secure!
+ *
+ * @param input
+ * some {@link String} input
+ *
+ * @return a 128 bits key computed from the given input
+ */
+ static private byte[] key2key(String input) {
+ return StringUtils.getMd5Hash("" + input).getBytes();
+ }
+}
diff --git a/src/be/nikiroo/utils/Downloader.java b/src/be/nikiroo/utils/Downloader.java
new file mode 100644
index 0000000..0487933
--- /dev/null
+++ b/src/be/nikiroo/utils/Downloader.java
@@ -0,0 +1,478 @@
+package be.nikiroo.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.net.CookieStore;
+import java.net.HttpCookie;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLEncoder;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * This class will help you download content from Internet Sites ({@link URL}
+ * based).
+ *
+ * It allows you to control some options often required on web sites that do not
+ * want to simply serve HTML, but actively makes your life difficult with stupid
+ * checks.
+ *
+ * @author niki
+ */
+public class Downloader {
+ private String UA;
+ private CookieManager cookies;
+ private TraceHandler tracer = new TraceHandler();
+ private Cache cache;
+ private boolean offline;
+
+ /**
+ * Create a new {@link Downloader}.
+ *
+ * @param UA
+ * the User-Agent to use to download the resources -- note that
+ * some websites require one, some actively blacklist real UAs
+ * like the one from wget, some whitelist a couple of browsers
+ * only (!)
+ */
+ public Downloader(String UA) {
+ this(UA, null);
+ }
+
+ /**
+ * Create a new {@link Downloader}.
+ *
+ * @param UA
+ * the User-Agent to use to download the resources -- note that
+ * some websites require one, some actively blacklist real UAs
+ * like the one from wget, some whitelist a couple of browsers
+ * only (!)
+ * @param cache
+ * the {@link Cache} to use for all access (can be NULL)
+ */
+ public Downloader(String UA, Cache cache) {
+ this.UA = UA;
+
+ cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
+ CookieHandler.setDefault(cookies);
+
+ setCache(cache);
+ }
+
+ /**
+ * This {@link Downloader} is forbidden to try and connect to the network.
+ *
+ * If TRUE, it will only check the cache if any.
+ *
+ * Default is FALSE.
+ *
+ * @return TRUE if offline
+ */
+ public boolean isOffline() {
+ return offline;
+ }
+
+ /**
+ * This {@link Downloader} is forbidden to try and connect to the network.
+ *
+ * If TRUE, it will only check the cache if any.
+ *
+ * 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.
+ *
+ * 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 cookiesValues, Map postParams,
+ Map 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 cookiesValues, Map postParams,
+ Map 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 cookiesValues, Map postParams,
+ Map 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 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 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 cookiesValues)
+ throws IOException {
+ URLConnection conn = url.openConnection();
+
+ String cookies = generateCookies(cookiesValues);
+ if (cookies != null && !cookies.isEmpty()) {
+ conn.setRequestProperty("Cookie", cookies);
+ }
+
+ conn.setRequestProperty("User-Agent", UA);
+ conn.setRequestProperty("Accept-Encoding", "gzip");
+ conn.setRequestProperty("Accept", "*/*");
+ conn.setRequestProperty("Charset", "utf-8");
+
+ if (currentReferer != null) {
+ conn.setRequestProperty("Referer", currentReferer.toString());
+ conn.setRequestProperty("Host", currentReferer.getHost());
+ }
+
+ return conn;
+ }
+
+ /**
+ * Generate the cookie {@link String} from the local {@link CookieStore} so
+ * it is ready to be passed.
+ *
+ * @return the cookie
+ */
+ private String generateCookies(Map 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 set : cookiesValues.entrySet()) {
+ if (builder.length() > 0) {
+ builder.append(';');
+ }
+ builder.append(set.getKey());
+ builder.append('=');
+ builder.append(set.getValue());
+ }
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/src/be/nikiroo/utils/IOUtils.java b/src/be/nikiroo/utils/IOUtils.java
new file mode 100644
index 0000000..3d252ea
--- /dev/null
+++ b/src/be/nikiroo/utils/IOUtils.java
@@ -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 .zip 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.
+ *
+ * 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 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.
+ *
+ * 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.
+ *
+ * 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 deltree(File target, List errorAcc) {
+ if (errorAcc == null) {
+ errorAcc = new ArrayList();
+ }
+
+ 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 may be the same
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static InputStream forceResetableStream(InputStream in)
+ throws IOException {
+ boolean resetable = in.markSupported();
+ if (resetable) {
+ try {
+ in.reset();
+ } catch (IOException e) {
+ resetable = false;
+ }
+ }
+
+ if (resetable) {
+ return in;
+ }
+
+ final File tmp = File.createTempFile(".tmp-stream.", ".tmp");
+ try {
+ write(in, tmp);
+ in.close();
+
+ return new MarkableFileInputStream(tmp) {
+ @Override
+ public void close() throws IOException {
+ try {
+ super.close();
+ } finally {
+ tmp.delete();
+ }
+ }
+ };
+ } catch (IOException e) {
+ tmp.delete();
+ throw e;
+ }
+ }
+
+ /**
+ * Convert the {@link InputStream} into a byte array.
+ *
+ * @param in
+ * the input stream
+ *
+ * @return the array
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static byte[] toByteArray(InputStream in) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try {
+ write(in, out);
+ return out.toByteArray();
+ } finally {
+ out.close();
+ }
+ }
+
+ /**
+ * Convert the {@link File} into a byte array.
+ *
+ * @param file
+ * the input {@link File}
+ *
+ * @return the array
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static byte[] toByteArray(File file) throws IOException {
+ FileInputStream fis = new FileInputStream(file);
+ try {
+ return toByteArray(fis);
+ } finally {
+ fis.close();
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/Image.java b/src/be/nikiroo/utils/Image.java
new file mode 100644
index 0000000..4518577
--- /dev/null
+++ b/src/be/nikiroo/utils/Image.java
@@ -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.
+ *
+ *
+ * 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}.
+ *
+ * This {@link InputStream} will (always) be a new one, and you are
+ * responsible for it.
+ *
+ * Note: take care that the {@link InputStream} must not 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);
+ }
+
+ /**
+ * Read 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.
+ *
+ * Note that even if you don't, the program will still try 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.
+ *
+ * Caution: the directory will be owned by the system, all its files
+ * now belong to us (and will most probably be deleted).
+ *
+ * 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 owned by the
+ * system
+ */
+ public static void setTemporaryFilesRoot(File root) {
+ tempRoot = root;
+ }
+}
diff --git a/src/be/nikiroo/utils/ImageUtils.java b/src/be/nikiroo/utils/ImageUtils.java
new file mode 100644
index 0000000..fb86929
--- /dev/null
+++ b/src/be/nikiroo/utils/ImageUtils.java
@@ -0,0 +1,220 @@
+package be.nikiroo.utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+import be.nikiroo.utils.serial.SerialUtils;
+
+/**
+ * This class offer some utilities based around images.
+ *
+ * @author niki
+ */
+public abstract class ImageUtils {
+ private static ImageUtils instance = newObject();
+
+ /**
+ * Get a (unique) instance of an {@link ImageUtils} compatible with your
+ * system.
+ *
+ * @return an {@link ImageUtils}
+ */
+ public static ImageUtils getInstance() {
+ return instance;
+ }
+
+ /**
+ * Save the given resource as an image on disk using the given image format
+ * for content, or with "png" format if it fails.
+ *
+ * @param img
+ * the resource
+ * @param target
+ * the target file
+ * @param format
+ * the file format ("png", "jpeg", "bmp"...)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public abstract void saveAsImage(Image img, File target, String format)
+ throws IOException;
+
+ /**
+ * Return the EXIF transformation flag of this image if any.
+ *
+ *
+ * Note: this code has been found on internet; thank you anonymous coder.
+ *
+ *
+ * @param in
+ * the data {@link InputStream}
+ *
+ * @return the transformation flag if any
+ *
+ * @throws IOException
+ * in case of IO error
+ */
+ protected static int getExifTransorm(InputStream in) throws IOException {
+ int[] exif_data = new int[100];
+ int set_flag = 0;
+ int is_motorola = 0;
+
+ /* Read File head, check for JPEG SOI + Exif APP1 */
+ for (int i = 0; i < 4; i++)
+ exif_data[i] = in.read();
+
+ if (exif_data[0] != 0xFF || exif_data[1] != 0xD8
+ || exif_data[2] != 0xFF || exif_data[3] != 0xE1)
+ return -2;
+
+ /* Get the marker parameter length count */
+ int length = (in.read() << 8 | in.read());
+
+ /* Length includes itself, so must be at least 2 */
+ /* Following Exif data length must be at least 6 */
+ if (length < 8)
+ return -1;
+ length -= 8;
+ /* Read Exif head, check for "Exif" */
+ for (int i = 0; i < 6; i++)
+ exif_data[i] = in.read();
+
+ if (exif_data[0] != 0x45 || exif_data[1] != 0x78
+ || exif_data[2] != 0x69 || exif_data[3] != 0x66
+ || exif_data[4] != 0 || exif_data[5] != 0)
+ return -1;
+
+ /* Read Exif body */
+ length = length > exif_data.length ? exif_data.length : length;
+ for (int i = 0; i < length; i++)
+ exif_data[i] = in.read();
+
+ if (length < 12)
+ return -1; /* Length of an IFD entry */
+
+ /* Discover byte order */
+ if (exif_data[0] == 0x49 && exif_data[1] == 0x49)
+ is_motorola = 0;
+ else if (exif_data[0] == 0x4D && exif_data[1] == 0x4D)
+ is_motorola = 1;
+ else
+ return -1;
+
+ /* Check Tag Mark */
+ if (is_motorola == 1) {
+ if (exif_data[2] != 0)
+ return -1;
+ if (exif_data[3] != 0x2A)
+ return -1;
+ } else {
+ if (exif_data[3] != 0)
+ return -1;
+ if (exif_data[2] != 0x2A)
+ return -1;
+ }
+
+ /* Get first IFD offset (offset to IFD0) */
+ int offset;
+ if (is_motorola == 1) {
+ if (exif_data[4] != 0)
+ return -1;
+ if (exif_data[5] != 0)
+ return -1;
+ offset = exif_data[6];
+ offset <<= 8;
+ offset += exif_data[7];
+ } else {
+ if (exif_data[7] != 0)
+ return -1;
+ if (exif_data[6] != 0)
+ return -1;
+ offset = exif_data[5];
+ offset <<= 8;
+ offset += exif_data[4];
+ }
+ if (offset > length - 2)
+ return -1; /* check end of data segment */
+
+ /* Get the number of directory entries contained in this IFD */
+ int number_of_tags;
+ if (is_motorola == 1) {
+ number_of_tags = exif_data[offset];
+ number_of_tags <<= 8;
+ number_of_tags += exif_data[offset + 1];
+ } else {
+ number_of_tags = exif_data[offset + 1];
+ number_of_tags <<= 8;
+ number_of_tags += exif_data[offset];
+ }
+ if (number_of_tags == 0)
+ return -1;
+ offset += 2;
+
+ /* Search for Orientation Tag in IFD0 */
+ for (;;) {
+ if (offset > length - 12)
+ return -1; /* check end of data segment */
+ /* Get Tag number */
+ int tagnum;
+ if (is_motorola == 1) {
+ tagnum = exif_data[offset];
+ tagnum <<= 8;
+ tagnum += exif_data[offset + 1];
+ } else {
+ tagnum = exif_data[offset + 1];
+ tagnum <<= 8;
+ tagnum += exif_data[offset];
+ }
+ if (tagnum == 0x0112)
+ break; /* found Orientation Tag */
+ if (--number_of_tags == 0)
+ return -1;
+ offset += 12;
+ }
+
+ /* Get the Orientation value */
+ if (is_motorola == 1) {
+ if (exif_data[offset + 8] != 0)
+ return -1;
+ set_flag = exif_data[offset + 9];
+ } else {
+ if (exif_data[offset + 9] != 0)
+ return -1;
+ set_flag = exif_data[offset + 8];
+ }
+ if (set_flag > 8)
+ return -1;
+
+ return set_flag;
+ }
+
+ /**
+ * Check that the class can operate (for instance, that all the required
+ * libraries or frameworks are present).
+ *
+ * @return TRUE if it works
+ */
+ abstract protected boolean check();
+
+ /**
+ * Create a new {@link ImageUtils}.
+ *
+ * @return the {@link ImageUtils}
+ */
+ private static ImageUtils newObject() {
+ for (String clazz : new String[] { "be.nikiroo.utils.ui.ImageUtilsAwt",
+ "be.nikiroo.utils.android.ImageUtilsAndroid" }) {
+ try {
+ ImageUtils obj = (ImageUtils) SerialUtils.createObject(clazz);
+ if (obj.check()) {
+ return obj;
+ }
+ } catch (Throwable e) {
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/be/nikiroo/utils/MarkableFileInputStream.java b/src/be/nikiroo/utils/MarkableFileInputStream.java
new file mode 100644
index 0000000..3f28064
--- /dev/null
+++ b/src/be/nikiroo/utils/MarkableFileInputStream.java
@@ -0,0 +1,22 @@
+package be.nikiroo.utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+
+/**
+ * Class was moved to {@link be.nikiroo.utils.streams.MarkableFileInputStream}.
+ *
+ * @author niki
+ */
+@Deprecated
+public class MarkableFileInputStream extends
+ be.nikiroo.utils.streams.MarkableFileInputStream {
+ public MarkableFileInputStream(File file) throws FileNotFoundException {
+ super(file);
+ }
+
+ public MarkableFileInputStream(FileInputStream fis) {
+ super(fis);
+ }
+}
diff --git a/src/be/nikiroo/utils/Progress.java b/src/be/nikiroo/utils/Progress.java
new file mode 100644
index 0000000..748d4a6
--- /dev/null
+++ b/src/be/nikiroo/utils/Progress.java
@@ -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.
+ *
+ * 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).
+ *
+ * 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 map = new HashMap();
+ private Progress parent = null;
+ private Object lock = new Object();
+ private String name;
+ private Map children;
+ private List 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();
+ this.listeners = new ArrayList();
+ 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 < 0 or if min > 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 < 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 < 0 or if min > 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 getChildren() {
+ synchronized (lock) {
+ return new ArrayList(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 this
+ * @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.
+ *
+ * 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 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.
+ *
+ * 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/src/be/nikiroo/utils/Proxy.java b/src/be/nikiroo/utils/Proxy.java
new file mode 100644
index 0000000..750b3ee
--- /dev/null
+++ b/src/be/nikiroo/utils/Proxy.java
@@ -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:
+ *
+ * ((user(:pass)@)proxy:port)
+ * System proxy is noted :
+ *
+ * Some examples:
+ *
+ * â do not use any proxy
+ * : â use the system proxy
+ * user@prox.com â use the proxy "prox.com" with default port
+ * and user "user"
+ * prox.com:8080 â use the proxy "prox.com" on port 8080
+ * user:pass@prox.com:8080 â use "prox.com" on port 8080
+ * authenticated as "user" with password "pass"
+ * user:pass@: â use the system proxy authenticated as user
+ * "user" with password "pass"
+ *
+ *
+ * @param proxy
+ * the proxy
+ */
+ static public void use(String proxy) {
+ if (proxy != null && !proxy.isEmpty()) {
+ String user = null;
+ String password = null;
+ int port = 8080;
+
+ if (proxy.contains("@")) {
+ int pos = proxy.indexOf("@");
+ user = proxy.substring(0, pos);
+ proxy = proxy.substring(pos + 1);
+ if (user.contains(":")) {
+ pos = user.indexOf(":");
+ password = user.substring(pos + 1);
+ user = user.substring(0, pos);
+ }
+ }
+
+ if (proxy.equals(":")) {
+ proxy = null;
+ } else if (proxy.contains(":")) {
+ int pos = proxy.indexOf(":");
+ try {
+ port = Integer.parseInt(proxy.substring(0, pos));
+ proxy = proxy.substring(pos + 1);
+ } catch (Exception e) {
+ }
+ }
+
+ if (proxy == null) {
+ Proxy.useSystemProxy(user, password);
+ } else {
+ Proxy.useProxy(proxy, port, user, password);
+ }
+ }
+ }
+
+ /**
+ * Use the system proxy.
+ */
+ static public void useSystemProxy() {
+ useSystemProxy(null, null);
+ }
+
+ /**
+ * Use the system proxy with the given login/password, for authenticated
+ * proxies.
+ *
+ * @param user
+ * the user name or login
+ * @param password
+ * the password
+ */
+ static public void useSystemProxy(String user, String password) {
+ System.setProperty("java.net.useSystemProxies", "true");
+ auth(user, password);
+ }
+
+ /**
+ * Use the give proxy.
+ *
+ * @param host
+ * the proxy host name or IP address
+ * @param port
+ * the port to use
+ */
+ static public void useProxy(String host, int port) {
+ useProxy(host, port, null, null);
+ }
+
+ /**
+ * Use the given proxy with the given login/password, for authenticated
+ * proxies.
+ *
+ * @param user
+ * the user name or login
+ * @param password
+ * the password
+ * @param host
+ * the proxy host name or IP address
+ * @param port
+ * the port to use
+ * @param user
+ * the user name or login
+ * @param password
+ * the password
+ */
+ static public void useProxy(String host, int port, String user,
+ String password) {
+ System.setProperty("http.proxyHost", host);
+ System.setProperty("http.proxyPort", Integer.toString(port));
+ auth(user, password);
+ }
+
+ /**
+ * Select the default authenticator for proxy requests.
+ *
+ * @param user
+ * the user name or login
+ * @param password
+ * the password
+ */
+ static private void auth(final String user, final String password) {
+ if (user != null && password != null) {
+ Authenticator proxy = new Authenticator() {
+ @Override
+ protected PasswordAuthentication getPasswordAuthentication() {
+ if (getRequestorType() == RequestorType.PROXY) {
+ return new PasswordAuthentication(user,
+ password.toCharArray());
+ }
+ return null;
+ }
+ };
+ Authenticator.setDefault(proxy);
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/StringJustifier.java b/src/be/nikiroo/utils/StringJustifier.java
new file mode 100644
index 0000000..ed20291
--- /dev/null
+++ b/src/be/nikiroo/utils/StringJustifier.java
@@ -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 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 right(final String str, final int n) {
+ List result = new LinkedList();
+
+ /*
+ * Same as left(), but preceed each line with spaces to make it n chars
+ * long.
+ */
+ List 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 center(final String str, final int n) {
+ List result = new LinkedList();
+
+ /*
+ * Same as left(), but preceed/succeed each line with spaces to make it
+ * n chars long.
+ */
+ List 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 full(final String str, final int n) {
+ List result = new LinkedList();
+
+ /*
+ * 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 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 left(final String data, final int width,
+ boolean minTwoWords) {
+ List lines = new LinkedList();
+
+ for (String dataLine : data.split("\n")) {
+ String line = rightTrim(dataLine.replace("\t", " "));
+
+ if (width > 0 && line.length() > width) {
+ while (line.length() > 0) {
+ int i = Math.min(line.length(), width - 1); // -1 for "-"
+
+ boolean needDash = true;
+ // find the best space if any and if needed
+ int prevSpace = 0;
+ if (i < line.length()) {
+ prevSpace = -1;
+ int space = line.indexOf(' ');
+ int numOfSpaces = 0;
+
+ while (space > -1 && space <= i) {
+ prevSpace = space;
+ space = line.indexOf(' ', space + 1);
+ numOfSpaces++;
+ }
+
+ if (prevSpace > 0 && (!minTwoWords || numOfSpaces >= 2)) {
+ i = prevSpace;
+ needDash = false;
+ }
+ }
+ //
+
+ // no dash before space/dash
+ if ((i + 1) < line.length()) {
+ char car = line.charAt(i);
+ char nextCar = line.charAt(i + 1);
+ if (car == ' ' || car == '-' || nextCar == ' ') {
+ needDash = false;
+ } else if (i > 0) {
+ char prevCar = line.charAt(i - 1);
+ if (prevCar == ' ' || prevCar == '-') {
+ needDash = false;
+ i--;
+ }
+ }
+ }
+
+ // if the space freed by the removed dash allows it, or if
+ // it is the last char, add the next char
+ if (!needDash || i >= line.length() - 1) {
+ int checkI = Math.min(i + 1, line.length());
+ if (checkI == i || checkI <= width) {
+ needDash = false;
+ i = checkI;
+ }
+ }
+
+ // no dash before parenthesis (but cannot add one more
+ // after)
+ if ((i + 1) < line.length()) {
+ char nextCar = line.charAt(i + 1);
+ if (nextCar == '(' || nextCar == ')') {
+ needDash = false;
+ }
+ }
+
+ if (needDash) {
+ lines.add(rightTrim(line.substring(0, i)) + "-");
+ } else {
+ lines.add(rightTrim(line.substring(0, i)));
+ }
+
+ // full trim (remove spaces when cutting)
+ line = line.substring(i).trim();
+ }
+ } else {
+ lines.add(line);
+ }
+ }
+
+ return lines;
+ }
+
+ /**
+ * Trim the given {@link String} on the right only.
+ *
+ * @param data
+ * the source {@link String}
+ * @return the right-trimmed String or Empty if it was NULL
+ */
+ static private String rightTrim(String data) {
+ if (data == null)
+ return "";
+
+ return ("|" + data).trim().substring(1);
+ }
+}
diff --git a/src/be/nikiroo/utils/StringUtils.java b/src/be/nikiroo/utils/StringUtils.java
new file mode 100644
index 0000000..b3c1071
--- /dev/null
+++ b/src/be/nikiroo/utils/StringUtils.java
@@ -0,0 +1,1162 @@
+package be.nikiroo.utils;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.Normalizer;
+import java.text.Normalizer.Form;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+import org.unbescape.html.HtmlEscape;
+import org.unbescape.html.HtmlEscapeLevel;
+import org.unbescape.html.HtmlEscapeType;
+
+import be.nikiroo.utils.streams.Base64InputStream;
+import be.nikiroo.utils.streams.Base64OutputStream;
+
+/**
+ * This class offer some utilities based around {@link String}s.
+ *
+ * @author niki
+ */
+public class StringUtils {
+ /**
+ * This enum type will decide the alignment of a {@link String} when padding
+ * or justification is applied (if there is enough horizontal space for it
+ * to be aligned).
+ */
+ public enum Alignment {
+ /** Aligned at left. */
+ LEFT,
+ /** Centered. */
+ CENTER,
+ /** Aligned at right. */
+ RIGHT,
+ /** Full justified (to both left and right). */
+ JUSTIFY,
+
+ // Old Deprecated values:
+
+ /** DEPRECATED: please use LEFT. */
+ @Deprecated
+ Beginning,
+ /** DEPRECATED: please use CENTER. */
+ @Deprecated
+ Center,
+ /** DEPRECATED: please use RIGHT. */
+ @Deprecated
+ End;
+
+ /**
+ * Return the non-deprecated version of this enum if needed (or return
+ * self if not).
+ *
+ * @return the non-deprecated value
+ */
+ Alignment undeprecate() {
+ if (this == Beginning)
+ return LEFT;
+ if (this == Center)
+ return CENTER;
+ if (this == End)
+ return RIGHT;
+ return this;
+ }
+ }
+
+ static private Pattern marks = getMarks();
+
+ /**
+ * Fix the size of the given {@link String} either with space-padding or by
+ * shortening it.
+ *
+ * @param text
+ * the {@link String} to fix
+ * @param width
+ * the size of the resulting {@link String} or -1 for a noop
+ *
+ * @return the resulting {@link String} of size size
+ */
+ 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 size 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 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 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 justifyText(List 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 justifyText(List text, int width,
+ Alignment align) {
+ List result = new ArrayList();
+
+ // Content <-> Bullet spacing (null = no spacing)
+ List> lines = new ArrayList>();
+ 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(
+ previous.toString(), previousItemBulletSpacing));
+ previous.setLength(0);
+ previousItemBulletSpacing = itemBulletSpacing;
+ } else {
+ previous.append(' ');
+ }
+ }
+
+ previous.append(current);
+
+ }
+
+ if (previous != null) {
+ lines.add(new AbstractMap.SimpleEntry(previous
+ .toString(), previousItemBulletSpacing));
+ }
+
+ for (Entry 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).
+ *
+ * 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).
+ *
+ * Precise to the second.
+ *
+ * @param displayTime
+ * the time as a {@link String}
+ *
+ * @return the number of milliseconds since the standard base time known as
+ * "the epoch", namely January 1, 1970, 00:00:00 GMT, or -1 in case
+ * of error
+ *
+ * @throws ParseException
+ * in case of parse error
+ */
+ static public long toTime(String displayTime) throws ParseException {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ return sdf.parse(displayTime).getTime();
+ }
+
+ /**
+ * Return a hash of the given {@link String}.
+ *
+ * @param input
+ * the input data
+ *
+ * @return the hash
+ */
+ static public String getMd5Hash(String input) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ md.update(getBytes(input));
+ byte byteData[] = md.digest();
+
+ StringBuffer hexString = new StringBuffer();
+ for (int i = 0; i < byteData.length; i++) {
+ String hex = Integer.toHexString(0xff & byteData[i]);
+ if (hex.length() == 1)
+ hexString.append('0');
+ hexString.append(hex);
+ }
+
+ return hexString.toString();
+ } catch (NoSuchAlgorithmException e) {
+ return input;
+ }
+ }
+
+ /**
+ * Remove the HTML content from the given input, and un-html-ize the rest.
+ *
+ * @param html
+ * the HTML-encoded content
+ *
+ * @return the HTML-free equivalent content
+ */
+ public static String unhtml(String html) {
+ StringBuilder builder = new StringBuilder();
+
+ int inTag = 0;
+ for (char car : html.toCharArray()) {
+ if (car == '<') {
+ inTag++;
+ } else if (car == '>') {
+ inTag--;
+ } else if (inTag <= 0) {
+ builder.append(car);
+ }
+ }
+
+ char nbsp = 'Â '; // non-breakable space (a special char)
+ char space = ' ';
+ return HtmlEscape.unescapeHtml(builder.toString()).replace(nbsp, space);
+ }
+
+ /**
+ * Escape the given {@link String} so it can be used in XML, as content.
+ *
+ * @param input
+ * the input {@link String}
+ *
+ * @return the escaped {@link String}
+ */
+ public static String xmlEscape(String input) {
+ if (input == null) {
+ return "";
+ }
+
+ return HtmlEscape.escapeHtml(input,
+ HtmlEscapeType.HTML4_NAMED_REFERENCES_DEFAULT_TO_HEXA,
+ HtmlEscapeLevel.LEVEL_1_ONLY_MARKUP_SIGNIFICANT);
+ }
+
+ /**
+ * Escape the given {@link String} so it can be used in XML, as text content
+ * inside double-quotes.
+ *
+ * @param input
+ * the input {@link String}
+ *
+ * @return the escaped {@link String}
+ */
+ public static String xmlEscapeQuote(String input) {
+ if (input == null) {
+ return "";
+ }
+
+ return HtmlEscape.escapeHtml(input,
+ HtmlEscapeType.HTML4_NAMED_REFERENCES_DEFAULT_TO_HEXA,
+ HtmlEscapeLevel.LEVEL_1_ONLY_MARKUP_SIGNIFICANT);
+ }
+
+ /**
+ * Zip the data and then encode it into Base64.
+ *
+ * @param data
+ * the data
+ *
+ * @return the Base64 zipped version
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static String zip64(String data) throws IOException {
+ try {
+ return zip64(getBytes(data));
+ } catch (UnsupportedEncodingException e) {
+ // All conforming JVM are required to support UTF-8
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ /**
+ * Zip the data and then encode it into Base64.
+ *
+ * @param data
+ * the data
+ *
+ * @return the Base64 zipped version
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static String zip64(byte[] data) throws IOException {
+ // 1. compress
+ ByteArrayOutputStream bout = new ByteArrayOutputStream();
+ try {
+ OutputStream out = new GZIPOutputStream(bout);
+ try {
+ out.write(data);
+ } finally {
+ out.close();
+ }
+ } finally {
+ data = bout.toByteArray();
+ bout.close();
+ }
+
+ // 2. base64
+ InputStream in = new ByteArrayInputStream(data);
+ try {
+ in = new Base64InputStream(in, true);
+ return new String(IOUtils.toByteArray(in), "UTF-8");
+ } finally {
+ in.close();
+ }
+ }
+
+ /**
+ * Unconvert from Base64 then unzip the content, which is assumed to be a
+ * String.
+ *
+ * @param data
+ * the data in Base64 format
+ *
+ * @return the raw data
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static String unzip64s(String data) throws IOException {
+ return new String(unzip64(data), "UTF-8");
+ }
+
+ /**
+ * Unconvert from Base64 then unzip the content.
+ *
+ * @param data
+ * the data in Base64 format
+ *
+ * @return the raw data
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static byte[] unzip64(String data) throws IOException {
+ InputStream in = new Base64InputStream(new ByteArrayInputStream(
+ getBytes(data)), false);
+ try {
+ in = new GZIPInputStream(in);
+ return IOUtils.toByteArray(in);
+ } finally {
+ in.close();
+ }
+ }
+
+ /**
+ * Convert the given data to Base64 format.
+ *
+ * @param data
+ * the data to convert
+ *
+ * @return the Base64 {@link String} representation of the data
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ public static String base64(String data) throws IOException {
+ return base64(getBytes(data));
+ }
+
+ /**
+ * Convert the given data to Base64 format.
+ *
+ * @param data
+ * the data to convert
+ *
+ * @return the Base64 {@link String} representation of the data
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ public static String base64(byte[] data) throws IOException {
+ Base64InputStream in = new Base64InputStream(new ByteArrayInputStream(
+ data), true);
+ try {
+ return new String(IOUtils.toByteArray(in), "UTF-8");
+ } finally {
+ in.close();
+ }
+ }
+
+ /**
+ * Unconvert the given data from Base64 format back to a raw array of bytes.
+ *
+ * @param data
+ * the data to unconvert
+ *
+ * @return the raw data represented by the given Base64 {@link String},
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ public static byte[] unbase64(String data) throws IOException {
+ Base64InputStream in = new Base64InputStream(new ByteArrayInputStream(
+ getBytes(data)), false);
+ try {
+ return IOUtils.toByteArray(in);
+ } finally {
+ in.close();
+ }
+ }
+
+ /**
+ * Unonvert the given data from Base64 format back to a {@link String}.
+ *
+ * @param data
+ * the data to unconvert
+ *
+ * @return the {@link String} represented by the given Base64 {@link String}
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ public static String unbase64s(String data) throws IOException {
+ return new String(unbase64(data), "UTF-8");
+ }
+
+ /**
+ * Return a display {@link String} for the given value, which can be
+ * suffixed with "k" or "M" depending upon the number, if it is big enough.
+ *
+ *
+ * Examples:
+ *
+ * 8 765 becomes "8 k"
+ * 998 765 becomes "998 k"
+ * 12 987 364 becomes "12 M"
+ * 5 534 333 221 becomes "5 G"
+ *
+ *
+ * @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.
+ *
+ * Examples (assuming decimalPositions = 1):
+ *
+ * 8 765 becomes "8.7 k"
+ * 998 765 becomes "998.7 k"
+ * 12 987 364 becomes "12.9 M"
+ * 5 534 333 221 becomes "5.5 G"
+ *
+ *
+ * @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.
+ *
+ * Of course, the conversion to and from display form is lossy (example:
+ * 6870 to "6.5k" to 6500 ).
+ *
+ * @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.
+ *
+ * Of course, the conversion to and from display form is lossy (example:
+ * 6870 to "6.5k" to 6500 ).
+ *
+ * @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 related:
+ //
+
+ /**
+ * Check if this line ends as a complete line (ends with a "." or similar).
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * Will automatically detect zipped data and also uncompress it before
+ * returning, unless ZIP is false.
+ *
+ * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+ *
+ * @param data
+ * the data to unconvert
+ * @param zip
+ * TRUE to also uncompress the data from a GZIP format
+ * automatically; if set to FALSE, zipped data can be returned
+ *
+ * @return the raw data represented by the given Base64 {@link String},
+ * optionally compressed with GZIP
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ @Deprecated
+ public static InputStream unbase64(InputStream data, boolean zip)
+ throws IOException {
+ return new ByteArrayInputStream(unbase64(IOUtils.readSmallStream(data),
+ zip));
+ }
+
+ /**
+ * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+ */
+ @Deprecated
+ public static byte[] unbase64(byte[] data, int offset, int count,
+ boolean zip) throws IOException {
+ byte[] dataPart = Arrays.copyOfRange(data, offset, offset + count);
+ return unbase64(new String(dataPart, "UTF-8"), zip);
+ }
+
+ /**
+ * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+ */
+ @Deprecated
+ public static String unbase64s(String data, boolean zip) throws IOException {
+ return new String(unbase64(data, zip), "UTF-8");
+ }
+
+ /**
+ * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped)
+ */
+ @Deprecated
+ public static String unbase64s(byte[] data, int offset, int count,
+ boolean zip) throws IOException {
+ return new String(unbase64(data, offset, count, zip), "UTF-8");
+ }
+}
diff --git a/src/be/nikiroo/utils/TempFiles.java b/src/be/nikiroo/utils/TempFiles.java
new file mode 100644
index 0000000..b54f0bc
--- /dev/null
+++ b/src/be/nikiroo/utils/TempFiles.java
@@ -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.
+ *
+ * The whole repository will be deleted on close (if you fail to call it,
+ * the program will try to call it on JVM termination).
+ *
+ * @param name
+ * the instance name (will be part 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.
+ *
+ * The whole repository will be deleted on close (if you fail to call it,
+ * the program will try to call it on JVM termination).
+ *
+ * Be careful, this instance will own 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 part 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 part 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.
+ *
+ * Note that creating 2 temporary directories with the same name will result
+ * in two different directories, even if the final name is the same
+ * (the absolute path will be different).
+ *
+ * @param name
+ * the actual directory name (not path)
+ *
+ * @return the newly created file
+ *
+ * @throws IOException
+ * in case of I/O errors, or if the name was a path instead of a
+ * name
+ */
+ public synchronized File createTempDir(String name) throws IOException {
+ File localRoot = createTempFile(name);
+ IOUtils.deltree(localRoot, true);
+
+ localRoot.mkdir();
+ if (!localRoot.exists()) {
+ throw new IOException("Cannot create subdirectory: " + localRoot);
+ }
+
+ File dir = new File(localRoot, name);
+ if (!dir.getName().equals(name)) {
+ throw new IOException(
+ "Cannot create temporary directory with a path, only names are allowed: "
+ + dir);
+ }
+
+ dir.mkdir();
+ dir.deleteOnExit();
+
+ if (!dir.exists()) {
+ throw new IOException("Cannot create subdirectory: " + dir);
+ }
+
+ return dir;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ File root = this.root;
+ this.root = null;
+
+ if (root != null) {
+ IOUtils.deltree(root);
+
+ // Since we allocate temp directories from a base point,
+ // try and remove that base point
+ root.getParentFile().delete(); // (only works if empty)
+ }
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ try {
+ close();
+ } finally {
+ super.finalize();
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/TraceHandler.java b/src/be/nikiroo/utils/TraceHandler.java
new file mode 100644
index 0000000..0a09712
--- /dev/null
+++ b/src/be/nikiroo/utils/TraceHandler.java
@@ -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.
+ *
+ * By default, will only be effective if {@link TraceHandler#traceLevel} is
+ * not 0.
+ *
+ * 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.
+ *
+ * By default, will only be effective if {@link TraceHandler#traceLevel} is
+ * not 0 and the level is lower or equal to it.
+ *
+ * @param message
+ * the trace message
+ * @param level
+ * the trace level
+ */
+ public void trace(String message, int level) {
+ if (traceLevel > 0 && level <= traceLevel) {
+ long now = System.currentTimeMillis();
+ System.err.print(StringUtils.fromTime(now) + ": ");
+ if (maxPrintSize > 0 && message.length() > maxPrintSize) {
+
+ System.err
+ .println(message.substring(0, maxPrintSize) + "[...]");
+ } else {
+ System.err.println(message);
+ }
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/Version.java b/src/be/nikiroo/utils/Version.java
new file mode 100644
index 0000000..269edb6
--- /dev/null
+++ b/src/be/nikiroo/utils/Version.java
@@ -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 {
+ 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 MAJOR.MINOR.PATCH(-TAG(TAG_VERSION)) .
+ *
+ * @param version
+ * the version (MAJOR.MINOR.PATCH ,
+ * MAJOR.MINOR.PATCH-TAG or
+ * MAJOR.MINOR.PATCH-TAGVERSIONTAG )
+ */
+ 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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}.
+ *
+ * Note that a tagged version is considered newer than a non-tagged version,
+ * but two tagged versions with different tags are not comparable.
+ *
+ * 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}.
+ *
+ * Note that a tagged version is considered newer than a non-tagged version,
+ * but two tagged versions with different tags are not comparable.
+ *
+ * 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).
+ *
+ * If it does not, return an empty {@link Version} object.
+ *
+ * @return the {@link Version} of the program, or an empty {@link Version}
+ * (does not return NULL)
+ */
+ public static Version getCurrentVersion() {
+ String version = null;
+
+ InputStream in = IOUtils.openResource("VERSION");
+ if (in != null) {
+ try {
+ ByteArrayOutputStream ba = new ByteArrayOutputStream();
+ IOUtils.write(in, ba);
+ in.close();
+
+ version = ba.toString("UTF-8").trim();
+ } catch (IOException e) {
+ }
+ }
+
+ return new Version(version);
+ }
+
+ @Override
+ public int compareTo(Version o) {
+ if (equals(o)) {
+ return 0;
+ } else if (isNewerThan(o)) {
+ return 1;
+ } else {
+ return -1;
+ }
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof Version) {
+ Version o = (Version) obj;
+ if (isEmpty()) {
+ return o.isEmpty();
+ }
+
+ boolean sameTag = (tag == null && o.tag == null)
+ || (tag != null && tag.equals(o.tag));
+ return o.major == major && o.minor == minor && o.patch == patch
+ && sameTag && o.tagVersion == tagVersion;
+ }
+
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return version == null ? 0 : version.hashCode();
+ }
+
+ /**
+ * Return a user-readable form of this {@link Version}.
+ */
+ @Override
+ public String toString() {
+ return version == null ? "[unknown]" : version;
+ }
+
+ /**
+ * Generate the clean version {@link String} from the current values.
+ *
+ * @return the clean version string
+ */
+ private String generateVersion() {
+ String tagSuffix = "";
+ if (tag != null) {
+ tagSuffix = "-" + tag
+ + (tagVersion >= 0 ? Integer.toString(tagVersion) : "");
+ }
+
+ return String.format("%d.%d.%d%s", major, minor, patch, tagSuffix);
+ }
+}
diff --git a/src/be/nikiroo/utils/android/ImageUtilsAndroid.class b/src/be/nikiroo/utils/android/ImageUtilsAndroid.class
new file mode 100644
index 0000000..844712a
Binary files /dev/null and b/src/be/nikiroo/utils/android/ImageUtilsAndroid.class differ
diff --git a/src/be/nikiroo/utils/android/ImageUtilsAndroid.java b/src/be/nikiroo/utils/android/ImageUtilsAndroid.java
new file mode 100644
index 0000000..c2e269c
--- /dev/null
+++ b/src/be/nikiroo/utils/android/ImageUtilsAndroid.java
@@ -0,0 +1,99 @@
+package be.nikiroo.utils.android;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.stream.Stream;
+
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class offer some utilities based around images and uses the Android
+ * framework.
+ *
+ * @author niki
+ */
+public class ImageUtilsAndroid extends ImageUtils {
+ @Override
+ protected boolean check() {
+ // If we can get the class, it means we have access to it
+ Config c = Config.ALPHA_8;
+ return true;
+ }
+
+ @Override
+ public void saveAsImage(Image img, File target, String format)
+ throws IOException {
+ FileOutputStream fos = new FileOutputStream(target);
+ try {
+ Bitmap image = fromImage(img);
+
+ boolean ok = false;
+ try {
+ ok = image.compress(
+ Bitmap.CompressFormat.valueOf(format.toUpperCase()),
+ 90, fos);
+ } catch (Exception e) {
+ ok = false;
+ }
+
+ // Some formats are not reliable
+ // Second chance: PNG
+ if (!ok && !format.equals("png")) {
+ ok = image.compress(Bitmap.CompressFormat.PNG, 90, fos);
+ }
+
+ if (!ok) {
+ throw new IOException(
+ "Cannot find a writer for this image and format: "
+ + format);
+ }
+ } catch (IOException e) {
+ throw new IOException("Cannot write image to " + target, e);
+ } finally {
+ fos.close();
+ }
+ }
+
+ /**
+ * Convert the given {@link Image} into a {@link Bitmap} object.
+ *
+ * @param img
+ * the {@link Image}
+ * @return the {@link Image} object
+ * @throws IOException
+ * in case of IO error
+ */
+ static public Bitmap fromImage(Image img) throws IOException {
+ InputStream stream = img.newInputStream();
+ try {
+ Bitmap image = BitmapFactory.decodeStream(stream);
+ if (image == null) {
+ String extra = "";
+ if (img.getSize() <= 2048) {
+ try {
+ extra = ", content: "
+ + new String(img.getData(), "UTF-8");
+ } catch (Exception e) {
+ extra = ", content unavailable";
+ }
+ }
+ String ssize = StringUtils.formatNumber(img.getSize());
+ throw new IOException(
+ "Failed to convert input to image, size was: " + ssize
+ + extra);
+ }
+
+ return image;
+ } finally {
+ stream.close();
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/android/test/TestAndroid.class b/src/be/nikiroo/utils/android/test/TestAndroid.class
new file mode 100644
index 0000000..216aa20
Binary files /dev/null and b/src/be/nikiroo/utils/android/test/TestAndroid.class differ
diff --git a/src/be/nikiroo/utils/android/test/TestAndroid.java b/src/be/nikiroo/utils/android/test/TestAndroid.java
new file mode 100644
index 0000000..2ded4e1
--- /dev/null
+++ b/src/be/nikiroo/utils/android/test/TestAndroid.java
@@ -0,0 +1,7 @@
+package be.nikiroo.utils.android.test;
+
+import be.nikiroo.utils.android.ImageUtilsAndroid;
+
+public class TestAndroid {
+ ImageUtilsAndroid a = new ImageUtilsAndroid();
+}
diff --git a/src/be/nikiroo/utils/compat/DefaultListModel6.java b/src/be/nikiroo/utils/compat/DefaultListModel6.java
new file mode 100644
index 0000000..114ac42
--- /dev/null
+++ b/src/be/nikiroo/utils/compat/DefaultListModel6.java
@@ -0,0 +1,22 @@
+package be.nikiroo.utils.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).
+ *
+ * This class is merely a {@link DefaultListModel} that you can parametrise also
+ * in Java 1.6.
+ *
+ * @author niki
+ *
+ * @param
+ * the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public class DefaultListModel6 extends DefaultListModel
+ implements ListModel6 {
+ private static final long serialVersionUID = 1L;
+}
diff --git a/src/be/nikiroo/utils/compat/JList6.java b/src/be/nikiroo/utils/compat/JList6.java
new file mode 100644
index 0000000..ca44165
--- /dev/null
+++ b/src/be/nikiroo/utils/compat/JList6.java
@@ -0,0 +1,84 @@
+package be.nikiroo.utils.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).
+ *
+ * This class is merely a {@link JList} that you can parametrise also in Java
+ * 1.6.
+ *
+ * @author niki
+ *
+ * @param
+ * the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public class JList6 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 class
+ * level documentation .
+ *
+ * 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
+ * PropertyChangeEvent
is generated however - for the
+ * cellRenderer
property.
+ *
+ * The default value of this property is provided by the {@code ListUI}
+ * delegate, i.e. by the look and feel implementation.
+ *
+ * This is a JavaBeans bound property.
+ *
+ * @param cellRenderer
+ * the ListCellRenderer
that paints list cells
+ * @see #getCellRenderer
+ * @beaninfo bound: true attribute: visualUpdate true description: The
+ * component used to draw the cells.
+ */
+ public void setCellRenderer(ListCellRenderer6 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.
+ *
+ * This is a JavaBeans bound property.
+ *
+ * @param model
+ * the ListModel
that provides the list of items for
+ * display
+ * @exception IllegalArgumentException
+ * if model
is null
+ * @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 model) {
+ super.setModel(model);
+ }
+}
diff --git a/src/be/nikiroo/utils/compat/ListCellRenderer6.java b/src/be/nikiroo/utils/compat/ListCellRenderer6.java
new file mode 100644
index 0000000..d004849
--- /dev/null
+++ b/src/be/nikiroo/utils/compat/ListCellRenderer6.java
@@ -0,0 +1,65 @@
+package be.nikiroo.utils.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).
+ *
+ * This class is merely a {@link ListCellRenderer} that you can parametrise also
+ * in Java 1.6.
+ *
+ * @author niki
+ *
+ * @param
+ * the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public abstract class ListCellRenderer6 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) list, (E) value, index,
+ isSelected, cellHasFocus);
+ }
+
+ /**
+ * Return a component that has been configured to display the specified
+ * value. That component's paint
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 getPreferredSize
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 list,
+ E value, int index, boolean isSelected, boolean cellHasFocus);
+}
diff --git a/src/be/nikiroo/utils/compat/ListModel6.java b/src/be/nikiroo/utils/compat/ListModel6.java
new file mode 100644
index 0000000..a1f8c60
--- /dev/null
+++ b/src/be/nikiroo/utils/compat/ListModel6.java
@@ -0,0 +1,19 @@
+package be.nikiroo.utils.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).
+ *
+ * This class is merely a {@link javax.swing.ListModel} that you can parametrise
+ * also in Java 1.6.
+ *
+ * @author niki
+ *
+ * @param
+ * the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public interface ListModel6 extends javax.swing.ListModel {
+}
diff --git a/src/be/nikiroo/utils/main/bridge.java b/src/be/nikiroo/utils/main/bridge.java
new file mode 100644
index 0000000..1b7ab85
--- /dev/null
+++ b/src/be/nikiroo/utils/main/bridge.java
@@ -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 which is different (it will then use an empty encryption
+ * key)).
+ */
+ KEY,
+ /**
+ * The encryption key for the output data (optional, but can also be
+ * empty which is different (it will then use an empty encryption
+ * key)).
+ */
+ FORWARD_KEY,
+ /** The trace level (1, 2, 3.. default is 1). */
+ TRACE_LEVEL,
+ /**
+ * The maximum length after which to truncate data to display (the whole
+ * data will still be sent).
+ */
+ MAX_DISPLAY_SIZE,
+ /** The help message. */
+ HELP,
+ }
+
+ static private String getSyntax() {
+ return "Syntax: (--options) (--) [NAME] [PORT] [FORWARD_HOST] [FORWARD_PORT]\n"//
+ + "\tNAME : the bridge name for display/debug purposes\n"//
+ + "\tPORT : the port to listen on\n"//
+ + "\tFORWARD_HOST : the host to connect to\n"//
+ + "\tFORWARD_PORT : the port to connect to\n"//
+ + "\n" //
+ + "\tOptions: \n" //
+ + "\t-- : no more options in the rest of the parameters\n" //
+ + "\t--help : this help message\n" //
+ + "\t--key : the INCOMING encryption key\n" //
+ + "\t--forward-key : the OUTGOING encryption key\n" //
+ + "\t--trace-level : the trace level (1, 2, 3... default is 1)\n" //
+ + "\t--max-display-size : the maximum size after which to \n"//
+ + "\t truncate the messages to display (the full message will still be sent)\n" //
+ ;
+ }
+
+ /**
+ * Start a bridge between 2 servers.
+ *
+ * @param args
+ * the parameters, which can be seen by passing "--help" or just
+ * calling the program without parameters
+ */
+ public static void main(String[] args) {
+ final TraceHandler tracer = new TraceHandler(true, false, 0);
+ try {
+ if (args.length == 0) {
+ tracer.error(getSyntax());
+ System.exit(0);
+ }
+
+ String key = null;
+ String fkey = null;
+ int traceLevel = 1;
+ int maxPrintSize = 0;
+
+ int i = 0;
+ while (args[i].startsWith("--")) {
+ String arg = args[i];
+ i++;
+
+ if (arg.equals("--")) {
+ break;
+ }
+
+ arg = arg.substring(2).toUpperCase().replace("-", "_");
+ try {
+ Option opt = Enum.valueOf(Option.class, arg);
+ switch (opt) {
+ case HELP:
+ tracer.trace(getSyntax());
+ System.exit(0);
+ break;
+ case FORWARD_KEY:
+ fkey = args[i++];
+ break;
+ case KEY:
+ key = args[i++];
+ break;
+ case MAX_DISPLAY_SIZE:
+ maxPrintSize = Integer.parseInt(args[i++]);
+ break;
+ case TRACE_LEVEL:
+ traceLevel = Integer.parseInt(args[i++]);
+ break;
+ }
+ } catch (Exception e) {
+ tracer.error(getSyntax());
+ System.exit(1);
+ }
+ }
+
+ if ((args.length - i) != 4) {
+ tracer.error(getSyntax());
+ System.exit(2);
+ }
+
+ String name = args[i++];
+ int port = Integer.parseInt(args[i++]);
+ String fhost = args[i++];
+ int fport = Integer.parseInt(args[i++]);
+
+ ServerBridge bridge = new ServerBridge(name, port, key, fhost,
+ fport, fkey);
+ bridge.setTraceHandler(new TraceHandler(true, true, traceLevel,
+ maxPrintSize));
+ bridge.run();
+ } catch (Exception e) {
+ tracer.error(e);
+ System.exit(42);
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/main/img2aa.java b/src/be/nikiroo/utils/main/img2aa.java
new file mode 100644
index 0000000..9cc6f0c
--- /dev/null
+++ b/src/be/nikiroo/utils/main/img2aa.java
@@ -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)
+ *
+ * See "--help".
+ *
+ * @param args
+ */
+ public static void main(String[] args) {
+ Dimension size = null;
+ Mode mode = null;
+ boolean invert = false;
+ List inputs = new ArrayList();
+ File output = null;
+
+ String lastArg = "";
+ try {
+ int height = -1;
+ int width = -1;
+
+ for (String arg : args) {
+ lastArg = arg;
+
+ if (arg.startsWith("--mode=")) {
+ mode = Mode.valueOf(arg.substring("--mode=".length()));
+ } else if (arg.startsWith("--width=")) {
+ width = Integer
+ .parseInt(arg.substring("--width=".length()));
+ } else if (arg.startsWith("--height=")) {
+ height = Integer.parseInt(arg.substring("--height="
+ .length()));
+ } else if (arg.startsWith("--size=")) {
+ String content = arg.substring("--size=".length()).replace(
+ "X", "x");
+ width = Integer.parseInt(content.split("x")[0]);
+ height = Integer.parseInt(content.split("x")[1]);
+ } else if (arg.startsWith("--ouput=")) {
+ if (!arg.equals("--output=-")) {
+ output = new File(arg.substring("--output=".length()));
+ }
+ } else if (arg.equals("--invert")) {
+ invert = true;
+ } else if (arg.equals("--help")) {
+ System.out
+ .println("Syntax: (--mode=MODE) (--width=WIDTH) (--height=HEIGHT) (--size=SIZE) (--output=OUTPUT) (--invert) (--help)");
+ System.out.println("\t --help: will show this screen");
+ System.out
+ .println("\t --invert: will invert the 'colours'");
+ System.out
+ .println("\t --mode: will select the rendering mode (default: ASCII):");
+ System.out
+ .println("\t\t ASCII: ASCI output mode, that is, characters \" .-+=o8#\"");
+ System.out
+ .println("\t\t DITHERING: Use 5 different \"colours\" which are actually"
+ + "\n\t\t Unicode characters \" ââââ\"");
+ System.out
+ .println("\t\t DOUBLE_RESOLUTION: Use \"block\" Unicode characters up to quarter"
+ + "\n\t\t blocks, thus in effect doubling the resolution both in vertical"
+ + "\n\t\t and horizontal space."
+ + "\n\t\t Note that since 2 characters next to each other are square,"
+ + "\n\t\t 4 blocks per 2 blocks for w/h resolution.");
+ System.out
+ .println("\t\t DOUBLE_DITHERING: Use characters from both DOUBLE_RESOLUTION"
+ + "\n\t\t and DITHERING");
+ return;
+ } else {
+ inputs.add(arg);
+ }
+ }
+
+ size = new Dimension(width, height);
+ if (inputs.size() == 0) {
+ inputs.add("-"); // by default, stdin
+ }
+ } catch (Exception e) {
+ System.err.println("Syntax error: \"" + lastArg + "\" is invalid");
+ System.exit(1);
+ }
+
+ try {
+ if (mode == null) {
+ mode = Mode.ASCII;
+ }
+
+ for (String input : inputs) {
+ InputStream in = null;
+
+ try {
+ if (input.equals("-")) {
+ in = System.in;
+ } else {
+ in = new FileInputStream(input);
+ }
+ BufferedImage image = ImageUtilsAwt
+ .fromImage(new Image(in));
+ ImageTextAwt img = new ImageTextAwt(image, size, mode,
+ invert);
+ if (output == null) {
+ System.out.println(img.getText());
+ } else {
+ IOUtils.writeSmallFile(output, img.getText());
+ }
+ } finally {
+ if (!input.equals("-")) {
+ in.close();
+ }
+ }
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ System.exit(2);
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/main/justify.java b/src/be/nikiroo/utils/main/justify.java
new file mode 100644
index 0000000..2a83389
--- /dev/null
+++ b/src/be/nikiroo/utils/main/justify.java
@@ -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)
+ *
+ *
+ * mode: left, right, center or full justification (defaults to left)
+ * max width: the maximum width of a line, or "" for "no maximum"
+ * (defaults to "no maximum")
+ *
+ *
+ * @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 lines = new ArrayList();
+ while (scan.hasNext()) {
+ lines.add(scan.next());
+ }
+
+ for (String line : StringUtils.justifyText(lines, width, align)) {
+ System.out.println(line);
+ }
+ } finally {
+ scan.close();
+ }
+ }
+}
diff --git a/src/be/nikiroo/utils/resources/Bundle.java b/src/be/nikiroo/utils/resources/Bundle.java
new file mode 100644
index 0000000..c757e2b
--- /dev/null
+++ b/src/be/nikiroo/utils/resources/Bundle.java
@@ -0,0 +1,1306 @@
+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.
+ *
+ * It also sports a writable change map, and you can save back the
+ * {@link Bundle} to file with {@link Bundle#updateFile(String)}.
+ *
+ * @param
+ * the enum to use to get values out of this class
+ *
+ * @author niki
+ */
+
+public class Bundle> {
+ /** The type of E. */
+ protected Class 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 descriptionBundle;
+
+ /** R/O map */
+ private Map map;
+ /** R/W map */
+ private Map 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 type, Enum> name,
+ TransBundle descriptionBundle) {
+ this.type = type;
+ this.keyType = name;
+ this.descriptionBundle = descriptionBundle;
+
+ this.map = new HashMap();
+ this.changeMap = new HashMap();
+ 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}.
+ *
+ * If no value is associated, take the default one if any.
+ *
+ * @param id
+ * the id of the value to get
+ * @param def
+ * the default value when it is not present in the config file
+ *
+ * @return the associated value, or NULL if not found (not present in the
+ * resource file)
+ */
+ public String getString(E id, String def) {
+ return getString(id, def, -1);
+ }
+
+ /**
+ * Return the value associated to the given id as a {@link String}.
+ *
+ * If no value is associated (or if it is empty!), take the default one if
+ * any.
+ *
+ * @param id
+ * the id of the value to get
+ * @param def
+ * the default value when it is not present in the config file
+ * @param item
+ * the item number to get for an array of values, or -1 for
+ * non-arrays
+ *
+ * @return the associated value, or NULL if not found (not present in the
+ * resource file)
+ */
+ public String getString(E id, String def, int item) {
+ String rep = getString(id.name(), null);
+ if (rep == null) {
+ rep = getMetaDef(id.name());
+ }
+
+ if (rep == null || rep.isEmpty()) {
+ return def;
+ }
+
+ if (item >= 0) {
+ List 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 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).
+ *
+ * Will only accept suffixes that form an existing id.
+ *
+ * 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).
+ *
+ * Will only accept suffixes that form an existing id.
+ *
+ * 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).
+ *
+ * Will only accept suffixes that form an existing id.
+ *
+ * If no value is associated, take the default one if any.
+ *
+ * @param id
+ * the id of the value to get
+ * @param suffix
+ * the runtime suffix
+ * @param def
+ * the default value when it is not present in the config file
+ * @param item
+ * the item number to get for an array of values, or -1 for
+ * non-arrays
+ *
+ * @return the associated value, or NULL if not found (not present in the
+ * resource file)
+ */
+ public String getStringX(E id, String suffix, String def, int item) {
+ String key = id.name()
+ + (suffix == null ? "" : "_" + suffix.toUpperCase());
+
+ try {
+ id = Enum.valueOf(type, key);
+ return getString(id, def, item);
+ } catch (IllegalArgumentException e) {
+ }
+
+ return null;
+ }
+
+ /**
+ * Set the value associated to the given id as a {@link String} suffixed
+ * with the runtime value "_suffix" (that is, "_" and suffix).
+ *
+ * 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).
+ *
+ * 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}.
+ *
+ * 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}.
+ *
+ * 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}.
+ *
+ * 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}.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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}.
+ *
+ * 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}.
+ *
+ * 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}.
+ *
+ * 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.
+ *
+ * The returned value is an ARGB value.
+ *
+ * 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.
+ *
+ * The returned value is an ARGB value.
+ *
+ * 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.
+ *
+ * The returned value is an ARGB value.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 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.
+ *
+ * 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 getList(E id, List def) {
+ List 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.
+ *
+ * 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 getList(E id, List 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 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 value, int item) {
+ setString(id, BundleHelper.fromList(value), item);
+ }
+
+ /**
+ * Create/update the .properties file.
+ *
+ * Will use the most likely candidate as base if the file does not already
+ * exists and this resource is translatable (for instance, "en_US" will use
+ * "en" as a base if the resource is a translation file).
+ *
+ * Will update the files in {@link Bundles#getDirectory()}; it MUST
+ * be set.
+ *
+ * @throws IOException
+ * in case of IO errors
+ */
+ public void updateFile() throws IOException {
+ updateFile(Bundles.getDirectory());
+ }
+
+ /**
+ * Create/update the .properties file.
+ *
+ * Will use the most likely candidate as base if the file does not already
+ * exists and this resource is translatable (for instance, "en_US" will use
+ * "en" as a base if the resource is a translation file).
+ *
+ * @param path
+ * the path where the .properties files are, MUST NOT 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.
+ *
+ * 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).
+ *
+ * Will delete the files in {@link Bundles#getDirectory()}; it MUST
+ * be set.
+ *
+ * @return TRUE if the file was deleted
+ */
+ public boolean deleteFile() {
+ return deleteFile(Bundles.getDirectory());
+ }
+
+ /**
+ * Delete the .properties file.
+ *
+ * Will use the most likely candidate as base if the file does not already
+ * exists and this resource is translatable (for instance, "en_US" will use
+ * "en" as a base if the resource is a translation file).
+ *
+ * @param path
+ * the path where the .properties files are, MUST NOT 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 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 MetaInfo.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
+ * def if not.
+ *
+ * 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 def 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 .properties file .
+ *
+ * @return the name
+ */
+ protected String getBundleDisplayName() {
+ return keyType.toString();
+ }
+
+ /**
+ * Write the header found in the configuration .properties 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.
+ *
+ * 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.
+ *
+ * Will prepend a # sign if the is is not set.
+ *
+ * @param writer
+ * the {@link Writer} to write into
+ * @param id
+ * the id to write
+ * @param value
+ * the id's value
+ * @param set
+ * the value is set in this {@link Bundle}
+ *
+ * @throws IOException
+ * in case of IO error
+ */
+ protected void writeValue(Writer writer, String id, String value,
+ boolean set) throws IOException {
+
+ if (!set) {
+ writer.write('#');
+ }
+
+ writer.write(id);
+ writer.write(" = ");
+
+ if (value == null) {
+ value = "";
+ }
+
+ String[] lines = value.replaceAll("\t", "\\\\\\t").split("\n");
+ for (int i = 0; i < lines.length; i++) {
+ writer.write(lines[i]);
+ if (i < lines.length - 1) {
+ writer.write("\\n\\");
+ }
+ writer.write("\n");
+ }
+ }
+
+ /**
+ * Return the source file for this {@link Bundles} from the given path.
+ *
+ * @param path
+ * the path where the .properties files are
+ *
+ * @return the source {@link File}
+ */
+ protected File getUpdateFile(String path) {
+ return new File(path, keyType.name() + ".properties");
+ }
+
+ /**
+ * Change the currently used bundle, and reset all changes.
+ *
+ * @param name
+ * the name of the bundle to load
+ * @param locale
+ * the {@link Locale} to use
+ * @param resetToDefault
+ * reset to the default configuration (do not look into the
+ * possible user configuration files, only take the original
+ * configuration)
+ */
+ protected void setBundle(Enum> name, Locale locale, boolean resetToDefault) {
+ changeMap.clear();
+ String dir = Bundles.getDirectory();
+ String bname = type.getPackage().getName() + "." + name.name();
+
+ boolean found = false;
+ if (!resetToDefault && dir != null) {
+ // Look into Bundles.getDirectory() for .properties files
+ try {
+ File file = getPropertyFile(dir, name.name(), locale);
+ if (file != null) {
+ Reader reader = new InputStreamReader(new FileInputStream(
+ file), "UTF-8");
+ resetMap(new PropertyResourceBundle(reader));
+ found = true;
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ if (!found) {
+ // Look into the package itself for resources
+ try {
+ resetMap(ResourceBundle
+ .getBundle(bname, locale, type.getClassLoader(),
+ new FixedResourceBundleControl()));
+ found = true;
+ } catch (MissingResourceException e) {
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ if (!found) {
+ // We have no bundle for this Bundle
+ System.err.println("No bundle found for: " + bname);
+ resetMap(null);
+ }
+ }
+
+ /**
+ * Reset the backing map to the content of the given bundle, or with NULL
+ * values if bundle is NULL.
+ *
+ * @param bundle
+ * the bundle to copy
+ */
+ protected void resetMap(ResourceBundle bundle) {
+ this.map.clear();
+ for (Field field : type.getDeclaredFields()) {
+ try {
+ Meta meta = field.getAnnotation(Meta.class);
+ if (meta != null) {
+ E id = Enum.valueOf(type, field.getName());
+
+ String value;
+ if (bundle != null) {
+ value = bundle.getString(id.name());
+ } else {
+ value = null;
+ }
+
+ this.map.put(id.name(), value == null ? null : value.trim());
+ }
+ } catch (MissingResourceException e) {
+ }
+ }
+ }
+
+ /**
+ * Take a snapshot of the changes in memory in this {@link Bundle} made by
+ * the "set" methods ( {@link Bundle#setString(Enum, String)}...) at the
+ * current time.
+ *
+ * @return a snapshot to use with {@link Bundle#restoreSnapshot(Object)}
+ */
+ public Object takeSnapshot() {
+ return new HashMap(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) 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 .properties )
+ * @param locale
+ * the {@link Locale}
+ *
+ * @return the closest match or NULL if none
+ */
+ private File getPropertyFile(String dir, String name, Locale locale) {
+ List locales = new ArrayList();
+ if (locale != null) {
+ String country = locale.getCountry() == null ? "" : locale
+ .getCountry();
+ String language = locale.getLanguage() == null ? "" : locale
+ .getLanguage();
+ if (!language.isEmpty() && !country.isEmpty()) {
+ locales.add("_" + language + "-" + country);
+ }
+ if (!language.isEmpty()) {
+ locales.add("_" + language);
+ }
+ }
+
+ locales.add("");
+
+ File file = null;
+ for (String loc : locales) {
+ file = new File(dir, name + loc + ".properties");
+ if (file.exists()) {
+ break;
+ }
+
+ file = null;
+ }
+
+ return file;
+ }
+}
diff --git a/src/be/nikiroo/utils/resources/BundleHelper.java b/src/be/nikiroo/utils/resources/BundleHelper.java
new file mode 100644
index 0000000..c6b26c7
--- /dev/null
+++ b/src/be/nikiroo/utils/resources/BundleHelper.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * Note: null, "strange text", ""... will all be converted to NULL
+ * (remember: any {@link String} whose length is not 1 is not 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.
+ *
+ * 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.
+ *
+ * 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 list = parseList(raw, -1);
+ if (list == null) {
+ return -1;
+ }
+
+ return list.size();
+ }
+
+ /**
+ * Return a {@link String} representation of the given list of values.
+ *
+ * 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 parseList(String str, int item) {
+ if (str == null) {
+ return null;
+ }
+
+ if (item >= 0) {
+ str = getItem(str, item);
+ }
+
+ List list = new ArrayList();
+ 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.
+ *
+ * NULL will be assimilated to an empty {@link String} if later non-null
+ * values exist, or just ignored if not.
+ *
+ * Example:
+ *
+ * 1 ,NULL , 3 will become 1 ,
+ * "" , 3
+ * 1 ,NULL , NULL will become 1
+ * NULL , NULL , NULL will become an empty list
+ *
+ *
+ *
+ * @param list
+ * the input value
+ *
+ * @return the raw {@link String} value that correspond to it
+ */
+ static public String fromList(List list) {
+ if (list == null) {
+ list = new ArrayList();
+ }
+
+ 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.
+ *
+ * NULL will be assimilated to an empty {@link String} if later non-null
+ * values exist, or just ignored if not.
+ *
+ * Example:
+ *
+ * 1 ,NULL , 3 will become 1 ,
+ * "" , 3
+ * 1 ,NULL , NULL will become 1
+ * NULL , NULL , NULL will become an empty list
+ *
+ *
+ *
+ * @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 list, String value, int item) {
+ if (list == null) {
+ list = new ArrayList();
+ }
+
+ 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.
+ *
+ * NULL will be assimilated to an empty {@link String} if later non-null
+ * values exist, or just ignored if not.
+ *
+ * Example:
+ *
+ * 1 ,NULL , 3 will become 1 ,
+ * "" , 3
+ * 1 ,NULL , NULL will become 1
+ * NULL , NULL , NULL will become an empty list
+ *
+ *
+ *
+ * @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...).
+ *
+ * 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).
+ *
+ * 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 item if found,
+ * NULL if not
+ */
+ static private String getItem(String value, int item) {
+ if (item >= 0) {
+ value = null;
+ List values = parseList(value, -1);
+ if (values != null && item < values.size()) {
+ value = values.get(item);
+ }
+ }
+
+ return value;
+ }
+}
diff --git a/src/be/nikiroo/utils/resources/Bundles.java b/src/be/nikiroo/utils/resources/Bundles.java
new file mode 100644
index 0000000..ad7b99d
--- /dev/null
+++ b/src/be/nikiroo/utils/resources/Bundles.java
@@ -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 .properties
+ * 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 .properties
+ * 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 .properties
+ * files in.
+ *
+ * @return the directory
+ */
+ static public String getDirectory() {
+ return Bundles.confDir;
+ }
+}
diff --git a/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java b/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java
new file mode 100644
index 0000000..b53da9d
--- /dev/null
+++ b/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java
@@ -0,0 +1,60 @@
+package be.nikiroo.utils.resources;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Locale;
+import java.util.PropertyResourceBundle;
+import java.util.ResourceBundle;
+import java.util.ResourceBundle.Control;
+
+/**
+ * Fixed ResourceBundle.Control class. It will use UTF-8 for the files to load.
+ *
+ * Also support an option to first check into the given path before looking into
+ * the resources.
+ *
+ * @author niki
+ *
+ */
+class FixedResourceBundleControl extends Control {
+ @Override
+ public ResourceBundle newBundle(String baseName, Locale locale,
+ String format, ClassLoader loader, boolean reload)
+ throws IllegalAccessException, InstantiationException, IOException {
+ // The below is a copy of the default implementation.
+ String bundleName = toBundleName(baseName, locale);
+ String resourceName = toResourceName(bundleName, "properties");
+
+ ResourceBundle bundle = null;
+ InputStream stream = null;
+ if (reload) {
+ URL url = loader.getResource(resourceName);
+ if (url != null) {
+ URLConnection connection = url.openConnection();
+ if (connection != null) {
+ connection.setUseCaches(false);
+ stream = connection.getInputStream();
+ }
+ }
+ } else {
+ stream = loader.getResourceAsStream(resourceName);
+ }
+
+ if (stream != null) {
+ try {
+ // This line is changed to make it to read properties files
+ // as UTF-8.
+ // How can someone use an archaic encoding such as ISO 8859-1 by
+ // *DEFAULT* is beyond me...
+ bundle = new PropertyResourceBundle(new InputStreamReader(
+ stream, "UTF-8"));
+ } finally {
+ stream.close();
+ }
+ }
+ return bundle;
+ }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/resources/Meta.java b/src/be/nikiroo/utils/resources/Meta.java
new file mode 100644
index 0000000..fb4d491
--- /dev/null
+++ b/src/be/nikiroo/utils/resources/Meta.java
@@ -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).
+ *
+ * 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...).
+ *
+ * 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).
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * The list items are separated by a comma, each surrounded by
+ * double-quotes, with backslashes and double-quotes escaped by a backslash.
+ *
+ * Example: "un", "deux"
+ *
+ * @return TRUE if it is
+ */
+ boolean array() default false;
+
+ /**
+ * @deprecated add the info into the description, as only the description
+ * will be translated.
+ */
+ @Deprecated
+ String info() default "";
+}
diff --git a/src/be/nikiroo/utils/resources/MetaInfo.java b/src/be/nikiroo/utils/resources/MetaInfo.java
new file mode 100644
index 0000000..70c6c43
--- /dev/null
+++ b/src/be/nikiroo/utils/resources/MetaInfo.java
@@ -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
+ * the type of {@link Bundle} to edit
+ */
+public class MetaInfo> implements Iterable> {
+ private final Bundle bundle;
+ private final E id;
+
+ private Meta meta;
+ private List> children = new ArrayList>();
+
+ private String value;
+ private List reloadedListeners = new ArrayList();
+ private List saveListeners = new ArrayList();
+
+ private String name;
+ private boolean hidden;
+ private String description;
+
+ private boolean dirty;
+
+ /**
+ * Create a new {@link MetaInfo} from a value (without children).
+ *
+ * For instance, you can call
+ * new MetaInfo(Config.class, configBundle, Config.MY_VALUE) .
+ *
+ * @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 type, Bundle 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).
+ *
+ * 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).
+ *
+ * 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...).
+ *
+ * For group, the first line ('\\n'-separated) will be used as a title while
+ * the rest will be the description.
+ *
+ * If a default value is known, it will be specified here, too.
+ *
+ * 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}.
+ *
+ * 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.
+ *
+ * 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 getKnownLanguages() {
+ if (bundle instanceof TransBundle) {
+ return ((TransBundle) bundle).getKnownLanguages();
+ }
+
+ return new ArrayList();
+ }
+
+ /**
+ * This item is a comma-separated list of values instead of a single value.
+ *
+ * The list items are separated by a comma, each surrounded by
+ * double-quotes, with backslashes and double-quotes escaped by a backslash.
+ *
+ * Example: "un", "deux"
+ *
+ * @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.
+ *
+ * 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 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 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 getList(int item, boolean useDefaultIfEmpty) {
+ return BundleHelper.parseList(getString(item, useDefaultIfEmpty), -1);
+ }
+
+ /**
+ * A {@link String} representation of the default list of values.
+ *
+ * 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 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.
+ *
+ * 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.
+ *
+ * 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 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(reloadedListeners)) {
+ try {
+ listener.run();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ /**
+ * Add a listener that will be called after a reload operation.
+ *
+ * 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}.
+ *
+ * Note that listeners will be called before the dirty check and
+ * before 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(saveListeners)) {
+ try {
+ listener.run();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ if (!onlyIfDirty || isDirty()) {
+ bundle.setString(id, value);
+ }
+ }
+
+ /**
+ * Add a listener that will be called before a save operation.
+ *
+ * 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).
+ *
+ * Sub-items are declared when a {@link Meta} has an ID that starts with the
+ * ID of a {@link Meta#group()} {@link MetaInfo}.
+ *
+ * For instance:
+ *
+ * {@link Meta} MY_PREFIX is a {@link Meta#group()}
+ * {@link Meta} MY_PREFIX_DESCRIPTION is another {@link Meta}
+ * MY_PREFIX_DESCRIPTION will be a child of MY_PREFIX
+ *
+ *
+ * @return the sub-items if any
+ */
+ public List> getChildren() {
+ return children;
+ }
+
+ /**
+ * The number of sub-items, if any.
+ *
+ * @return the number or 0
+ */
+ public int size() {
+ return children.size();
+ }
+
+ @Override
+ public Iterator> iterator() {
+ return children.iterator();
+ }
+
+ /**
+ * Create a list of {@link MetaInfo}, one for each of the item in the given
+ * {@link Bundle}.
+ *
+ * @param
+ * 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 > List> getItems(Class type,
+ Bundle bundle) {
+ List> list = new ArrayList>();
+ List> shadow = new ArrayList>();
+ for (E id : type.getEnumConstants()) {
+ MetaInfo info = new MetaInfo(type, bundle, id);
+ if (!info.hidden) {
+ list.add(info);
+ shadow.add(info);
+ }
+ }
+
+ for (int i = 0; i < list.size(); i++) {
+ MetaInfo info = list.get(i);
+
+ MetaInfo 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:
+ *
+ * the parent is a {@link Meta#group()}
+ * the parent Id is a substring of the Id of the given {@link MetaInfo}
+ * there is no other parent sharing a substring for this
+ * {@link MetaInfo} with a longer Id
+ *
+ *
+ * @param
+ * 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 > MetaInfo findParent(MetaInfo info,
+ List