Initial commit (working)
authorNiki Roo <niki@nikiroo.be>
Sat, 11 Feb 2017 21:24:09 +0000 (22:24 +0100)
committerNiki Roo <niki@nikiroo.be>
Sat, 11 Feb 2017 21:24:09 +0000 (22:24 +0100)
This is not the actual first commit (obviously), but the first
commit since it was exported to GitHub.
It works correctly as of today, but I still have a few websites
I want to add to replace my old BASH scripts.

48 files changed:
Makefile.base [new file with mode: 0644]
README.md
configure.sh [new file with mode: 0755]
libs/nikiroo-utils-0.9.1-sources.jar [new file with mode: 0644]
libs/unbescape-1.1.4-sources.jar [new file with mode: 0644]
libs/unbescape-1.1.4_ChangeLog.txt [new file with mode: 0644]
libs/unbescape-1.1.4_LICENSE.txt [new file with mode: 0644]
src/.gitattributes [new file with mode: 0644]
src/.gitignore [new file with mode: 0644]
src/be/nikiroo/fanfix/Cache.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Instance.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Library.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Main.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/Config.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/ConfigBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringId.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/Target.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/config.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Chapter.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/MetaData.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Paragraph.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Story.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/BasicOutput.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Cbz.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Epub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/InfoCover.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/InfoText.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/LaTeX.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Sysout.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Text.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/epub.style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/output/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/CliReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupport.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Cbz.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/E621.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Epub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Fanfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Fimfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/InfoText.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/MangaFox.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Text.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/package-info.java [new file with mode: 0644]

diff --git a/Makefile.base b/Makefile.base
new file mode 100644 (file)
index 0000000..300db50
--- /dev/null
@@ -0,0 +1,136 @@
+# Required parameters (the commented out ones are supposed to change per project):
+
+#MAIN = path to main java source to compile
+#MORE = path to supplementary needed resources not linked from MAIN
+#NAME = name of project (used for jar output file)
+#PREFIX = usually /usr/local (where to install the program)
+#TEST = path to main test source to compile
+#JAR_FLAGS += a list of things to pack, each usually prefixed with "-C bin/"
+
+JAVAC = javac
+JAVAC_FLAGS += -encoding UTF-8 -d ./bin/ -cp ./src/ -Xdiags:verbose
+JAVA = java
+JAVA_FLAGS += -cp ./bin/
+JAR = jar
+RJAR = java
+RJAR_FLAGS += -jar
+
+# Usual options:
+#      make            : to build the jar file
+#      make libs       : to update the libraries into src/
+#      make build      : to update the binaries (not the jar)
+#      make test       : to update the test binaries
+#      make build jar  : to update the binaries and jar file
+#      make clean      : to clean the directory of intermediate files
+#      make mrpropre   : to clean the directory of all outputs
+#      make run        : to run the program from the binaries
+#      make run-test   : to run the test program from the binaries
+#      make jrun       : to run the program from the jar file
+#      make install    : to install the application into $PREFIX
+
+# Note: build is actually slower than rebuild in most cases except when
+# small changes only are detected ; so we use rebuild by default
+
+all: build jar
+
+.PHONY: all clean mrproper mrpropre build run jrun jar resources install libs love 
+
+bin:
+       @mkdir -p bin
+
+jar: $(NAME).jar
+
+build: resources
+       @echo Compiling program...
+       @echo " src/$(MAIN)"
+       @$(JAVAC) $(JAVAC_FLAGS) "src/$(MAIN).java"
+       @[ "$(MORE)" = "" ] || for sup in $(MORE); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+test: 
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Compiling test program...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       @[ "$(TEST)"  = "" ] || for sup in $(TEST); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+clean:
+       rm -rf bin/
+       @echo Removing sources taken from libs...
+       @for lib in libs/*.jar; do \
+               basename "$$lib"; \
+               jar tf "$$lib" | while read -r ln; do \
+                       [ -f "src/$$ln" ] && rm "src/$$ln"; \
+               done; \
+               jar tf "$$lib" | tac | while read -r ln; do \
+                       [ -d "src/$$ln" ] && rmdir "src/$$ln" 2>/dev/null || true; \
+               done; \
+       done
+
+mrproper: mrpropre
+
+mrpropre: clean
+       rm -f $(NAME).jar
+
+love:
+       @echo " ...not war."
+
+resources: libs
+       @echo Copying resources into bin/...
+       @cd src && find . | grep -v '\.java$$' | while read -r ln; do \
+               if [ -f "$$ln" ]; then \
+                       dir="`dirname "$$ln"`"; \
+                       mkdir -p "../bin/$$dir" ; \
+                       cp "$$ln" "../bin/$$ln" ; \
+               fi ; \
+       done
+
+libs: bin
+       @[ -e bin/libs -o ! -d libs ] || echo Extracting sources from libs...
+       @[ -e bin/libs -o ! -d libs ] || (cd src&& for lib in ../libs/*.jar;do \
+               basename "$$lib"; \
+               jar xf "$$lib"; \
+       done )
+       @[ ! -d libs ] || touch bin/libs
+
+$(NAME).jar: resources
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Making JAR file...
+       @echo "Main-Class: `echo "$(MAIN)" | sed 's:/:.:g'`" > bin/manifest
+       @echo >> bin/manifest
+       $(JAR) cfm $(NAME).jar bin/manifest $(JAR_FLAGS)
+
+run: 
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Running "$(NAME)"...
+       $(JAVA) $(JAVA_FLAGS) $(MAIN)
+
+jrun:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       @echo Running "$(NAME).jar"...
+       $(RJAR) $(RJAR_FLAGS) $(NAME).jar
+
+run-test: 
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ] || echo You need to build the test sources
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ]
+       @echo Running tests for "$(NAME)"...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       [ "$(TEST)"  = "" ] || $(JAVA) $(JAVA_FLAGS) $(TEST)
+
+install:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       mkdir -p "$(PREFIX)/lib" "$(PREFIX)/bin"
+       cp $(NAME).jar "$(PREFIX)/lib/"
+       echo "#!/bin/sh" > "$(PREFIX)/bin/$(NAME)"
+       echo "$(RJAR) $(RJAR_FLAGS) \"$(PREFIX)/lib/$(NAME).jar\" \"$$@\"" >> "$(PREFIX)/bin/$(NAME)"
+       chmod a+rx "$(PREFIX)/bin/$(NAME)"
+
index 83ca081392e0903699618a466dfab247c91922f3..39f7fb43f1e611d662aaf8c0651989b2d4c93bee 100644 (file)
--- a/README.md
+++ b/README.md
@@ -1,2 +1,71 @@
-# fanfix
-A small program to download and convert fanfictions and comics from supported websites into offline files (epu, cbz...)
+# Fanfix
+
+A small program to download and convert fanfictions and comics from supported websites into offline files (epu, cbz...).
+
+It can either "convert" a website into a file, or import/export to/from its library.
+
+## Supported platforms
+
+Any platform with at lest Java 1.5 on it should be ok.
+
+It was only tested on Linux up until now, though.
+
+If you have any problems to compile it on another platform or with a supported Java version (1.4 won't work, but you may try to cross-compile; 1.8 had been tested and works), please contact me.
+
+## Usage
+
+- java -jar fanfix.jar --convert http://SOME_SUPPORTE_URL/ epub /home/niki/my-story.epub: will convert the story to EPUB
+- java -jar fanfix.jar --import http://SOME_SUPPORTED_URL/ : will import the story into the local library
+- java -jar fanfix.jar --export LUID CBZ /tmp/comix.cbz : will export the story from the local library
+- java -jar fanfix.jar --list: will list the known stories and their LUIDs from the local library
+- ... (calling the program without parameters will display the syntax)
+
+### Environment variables
+
+- LANG=en java -jar fanfix.jar: force the language to English (the only one for now...)
+- CONFIG_DIR=$HOME/.fanfix java -jar fanfix.jar: use the given directory as a config directory (and copy the default configuration if needed)
+- NOUTF=1 java -jar fanfix.jar: try to fallback to non-unicode values when possible (can have an impact on the resulting files, not only on user messages)
+
+## Compilation
+
+./configure.sh && make
+
+You can also import the java sources into, say, Eclipse, and create a runnable JAR file from there.
+Just remember to unpak the 2 dependant libraries before (or "make libs" can do it).
+
+### Dependant libraries (included)
+
+- libs/nikiroo-utils-sources-0.9.1.jar: some shared utility functions I also use elsewhere
+- libs/unbescape-1.1.4-sources.jar: a nice library to escape/unescape a lot of text formats; I only use it for HTML
+
+Nothing else but Java 1.5+.
+
+Note that calling "make libs" will export the libraries into the src/ directory.
+
+## Supported websites
+
+Currently, the following websites are supported:
+- http://FimFiction.net/: Fanfictions devoted to the My Little Pony show
+- http://Fanfiction.net/: Fan fictions of many, many different universes, from TV shows to novels to games.
+- http://mangafox.me/: A well filled repository of mangas, or, as their website states: Most popular manga scanlations read online for free at mangafox, as well as a close-knit community to chat and make friends.
+- https://e621.net/: Furry website supporting comics, including MLP
+
+We also support some other (file) types:
+- epub: EPUB files created by this program (we do not support "all" EPUB files)
+- text: Support class for local stories encoded in textual format, with a few rules :
+  - the title must be on the first line, 
+  - the author (preceded by nothing, "by " or "©") must be on the second line, possibly with the publication date in parenthesis (i.e., "By Unknown (3rd October 1998)"), 
+  - chapters must be declared with "Chapter x" or "Chapter x: NAME OF THE CHAPTER", where "x" is the chapter number,
+  - a description of the story must be given as chapter number 0,
+  - a cover image may be present with the same filename but a PNG, JPEG or JPG extension.
+- info_text: Contains the same information as the TEXT format, but with a companion ".info" file to store some metadata
+- cbz: CBZ (collection of images) files created with this program
+
+## TODO
+
+- A nice README file
+- A binary JAR release (and thus, versions)
+- Improve the CLI reader
+- Offer some other readers (TUI, GUI)
+- Check if it can work on Android
+
diff --git a/configure.sh b/configure.sh
new file mode 100755 (executable)
index 0000000..2eab91b
--- /dev/null
@@ -0,0 +1,42 @@
+#!/bin/sh
+
+# default:
+PREFIX=/usr/local
+PROGS="java javac jar make sed"
+
+valid=true
+while [ "$*" != "" ]; do
+       key=`echo "$1" | cut -c1-9`
+       val=`echo "$1" | cut -c10-`
+       case "$key" in
+       --prefix=)
+               PREFIX="$val"
+       ;;
+       *)
+               echo "Unsupported parameter: '$1'" >&2
+               valid=false
+       ;;
+       esac
+       shift
+done
+
+[ $valid = false ] && exit 1
+
+MESS="A required program cannot be found:"
+for prog in $PROGS; do
+       out="`whereis -b "$prog" 2>/dev/null`"
+       if [ "$out" = "$prog:" ]; then
+               echo "$MESS $prog" >&2
+               valid=false
+       fi
+done
+
+[ $valid = false ] && exit 2
+
+echo "MAIN = be/nikiroo/fanfix/Main" > Makefile
+echo "NAME = fanfix" >> Makefile
+echo "PREFIX = $PREFIX" >> Makefile
+echo "JAR_FLAGS += -C bin/ org -C bin/ be" >> Makefile
+
+cat Makefile.base >> Makefile
+
diff --git a/libs/nikiroo-utils-0.9.1-sources.jar b/libs/nikiroo-utils-0.9.1-sources.jar
new file mode 100644 (file)
index 0000000..7a9468a
Binary files /dev/null and b/libs/nikiroo-utils-0.9.1-sources.jar differ
diff --git a/libs/unbescape-1.1.4-sources.jar b/libs/unbescape-1.1.4-sources.jar
new file mode 100644 (file)
index 0000000..01ddb56
Binary files /dev/null and b/libs/unbescape-1.1.4-sources.jar differ
diff --git a/libs/unbescape-1.1.4_ChangeLog.txt b/libs/unbescape-1.1.4_ChangeLog.txt
new file mode 100644 (file)
index 0000000..9cec6ec
--- /dev/null
@@ -0,0 +1,32 @@
+1.1.4.RELEASE
+=============
+- Added ampersand (&) to the list of characters to be escaped in LEVEL 1 for JSON, JavaScript and CSS literals
+  in order to make escaped code safe against code injection attacks in XHTML scenarios (browsers using XHTML
+  processing mode) performed by means of including XHTML escape codes in literals.
+
+1.1.3.RELEASE
+=============
+- Improved performance of String-based unescape methods for HTML, XML, JS, JSON and others when the
+  text to be unescaped actually needs no unescaping.
+
+1.1.2.RELEASE
+=============
+- Added support for stream-based (String-to-Writer and Reader-to-Writer) escape and unescape operations.
+
+1.1.1.RELEASE
+=============
+- Fixed HTML unescape for codepoints > U+10FFFF (was throwing IllegalArgumentException).
+- Fixed HTML unescape for codepoints > Integer.MAX_VALUE (was throwing ArrayIndexOutOfBounds).
+- Simplified and improved performance of codepoint-computing code by using Character.codePointAt(...) instead
+  of a complex conditional structure based on Character.isHighSurrogate(...) and Character.isLowSurrogate(...).
+- [doc] Fixed description of MSExcel-compatible CSV files.
+
+
+1.1.0.RELEASE
+=============
+- Added URI/URL escape and unescape operations.
+
+
+1.0
+===
+- First release of unbescape.
diff --git a/libs/unbescape-1.1.4_LICENSE.txt b/libs/unbescape-1.1.4_LICENSE.txt
new file mode 100644 (file)
index 0000000..d645695
--- /dev/null
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/src/.gitattributes b/src/.gitattributes
new file mode 100644 (file)
index 0000000..409851f
--- /dev/null
@@ -0,0 +1,49 @@
+# Auto detect text files and perform LF normalization
+*            text=auto
+
+*.java       text diff=java
+*.properties text
+*.js         text
+*.css        text
+*.less       text
+*.html       text diff=html
+*.jsp        text diff=html
+*.jspx       text diff=html
+*.tag        text diff=html
+*.tagx       text diff=html
+*.tld        text
+*.xml        text
+*.gradle     text
+
+*.sql        text
+
+*.xsd        text
+*.dtd        text
+*.mod        text
+*.ent        text
+
+*.txt        text
+*.md         text
+*.markdown   text
+
+*.thtest     text
+*.thindex    text
+*.common     text
+
+*.odt        binary
+*.pdf        binary
+
+*.sh         text eol=lf
+*.bat        text eol=crlf
+
+*.ico        binary
+*.png        binary
+*.svg        binary
+*.woff       binary
+
+*.rar        binary
+*.zargo      binary
+*.zip        binary
+
+CNAME        text
+*.MF         text
diff --git a/src/.gitignore b/src/.gitignore
new file mode 100644 (file)
index 0000000..5c79834
--- /dev/null
@@ -0,0 +1,8 @@
+.classpath
+.project
+target/
+bin/
+.settings/
+.idea/
+*.iml
+
diff --git a/src/be/nikiroo/fanfix/Cache.java b/src/be/nikiroo/fanfix/Cache.java
new file mode 100644 (file)
index 0000000..75a0f5d
--- /dev/null
@@ -0,0 +1,427 @@
+package be.nikiroo.fanfix;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+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.URISyntaxException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.file.FileAlreadyExistsException;
+import java.util.Date;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+
+import javax.imageio.ImageIO;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.MarkableFileInputStream;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This cache will manage Internet (and local) downloads, as well as put the
+ * downloaded files into a cache.
+ * <p>
+ * As long the cached resource is not too old, it will use it instead of
+ * retrieving the file again.
+ * 
+ * @author niki
+ */
+public class Cache {
+       private File dir;
+       private String UA;
+       private long tooOldChanging;
+       private long tooOldStable;
+       private CookieManager cookies;
+
+       /**
+        * Create a new {@link Cache} object.
+        * 
+        * @param dir
+        *            the directory to use as cache
+        * @param UA
+        *            the User-Agent to use to download the resources
+        * @param hoursChanging
+        *            the number of hours after which a cached file that is thought
+        *            to change ~often is considered too old (or -1 for
+        *            "never too old")
+        * @param hoursStable
+        *            the number of hours after which a LARGE cached file that is
+        *            thought to change rarely is considered too old (or -1 for
+        *            "never too old")
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Cache(File dir, String UA, int hoursChanging, int hoursStable)
+                       throws IOException {
+               this.dir = dir;
+               this.UA = UA;
+               this.tooOldChanging = 1000 * 60 * 60 * hoursChanging;
+               this.tooOldStable = 1000 * 60 * 60 * hoursStable;
+
+               if (dir != null) {
+                       if (!dir.exists()) {
+                               dir.mkdirs();
+                       }
+               }
+
+               if (dir == null || !dir.exists()) {
+                       throw new IOException("Cannot create the cache directory: "
+                                       + (dir == null ? "null" : dir.getAbsolutePath()));
+               }
+
+               cookies = new CookieManager();
+               cookies.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
+               CookieHandler.setDefault(cookies);
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the opened resource
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, BasicSupport support, boolean stable)
+                       throws IOException {
+               return open(url, support, stable, url);
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * @param originalUrl
+        *            the original {@link URL} used to locate the cached resource
+        * 
+        * @return the opened resource
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, BasicSupport support, boolean stable,
+                       URL originalUrl) throws IOException {
+               try {
+                       InputStream in = load(originalUrl, false, stable);
+                       if (in == null) {
+                               try {
+                                       save(url, support, originalUrl);
+                               } catch (IOException e) {
+                                       throw new IOException("Cannot save the url: "
+                                                       + (url == null ? "null" : url.toString()), e);
+                               }
+
+                               in = load(originalUrl, true, stable);
+                       }
+
+                       return in;
+               } catch (IOException e) {
+                       throw new IOException("Cannot open the url: "
+                                       + (url == null ? "null" : url.toString()), e);
+               }
+       }
+
+       /**
+        * Refresh the resource into cache if needed.
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return TRUE if it was pre-downloaded
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void refresh(URL url, BasicSupport support, boolean stable)
+                       throws IOException {
+               File cached = getCached(url);
+               if (cached.exists() && !isOld(cached, stable)) {
+                       return;
+               }
+
+               open(url, support, stable).close();
+       }
+
+       /**
+        * Check the resource to see if it is in the cache.
+        * 
+        * @param url
+        *            the resource to check
+        * 
+        * @return TRUE if it is
+        * 
+        */
+       public boolean check(URL url) {
+               return getCached(url).exists();
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not) as an Image, then save it where
+        * requested.
+        * <p>
+        * This version will not always work properly if the original file was not
+        * downloaded before.
+        * 
+        * @param url
+        *            the resource to open
+        * 
+        * @return the opened resource image
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void saveAsImage(URL url, File target) throws IOException {
+               URL cachedUrl = new URL(url.toString()
+                               + "."
+                               + Instance.getConfig().getString(Config.IMAGE_FORMAT_CONTENT)
+                                               .toLowerCase());
+               File cached = getCached(cachedUrl);
+
+               if (!cached.exists() || isOld(cached, true)) {
+                       InputStream imageIn = Instance.getCache().open(url, null, true);
+                       ImageIO.write(StringUtils.toImage(imageIn), Instance.getConfig()
+                                       .getString(Config.IMAGE_FORMAT_CONTENT).toLowerCase(),
+                                       cached);
+               }
+
+               IOUtils.write(new FileInputStream(cached), target);
+       }
+
+       /**
+        * Manually add this item to the cache.
+        * 
+        * @param in
+        *            the input data
+        * @param uniqueID
+        *            a unique ID for this resource
+        * 
+        * @return the resulting {@link FileAlreadyExistsException}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File addToCache(InputStream in, String uniqueID) throws IOException {
+               File file = getCached(new File(uniqueID).toURI().toURL());
+               IOUtils.write(in, file);
+               return file;
+       }
+
+       /**
+        * Clean the cache (delete the cached items).
+        * 
+        * @param onlyOld
+        *            only clean the files that are considered too old
+        * 
+        * @return the number of cleaned items
+        */
+       public int cleanCache(boolean onlyOld) {
+               int num = 0;
+               for (File file : dir.listFiles()) {
+                       if (!onlyOld || isOld(file, true)) {
+                               if (file.delete()) {
+                                       num++;
+                               } else {
+                                       System.err.println("Cannot delete temporary file: "
+                                                       + file.getAbsolutePath());
+                               }
+                       }
+               }
+               return num;
+       }
+
+       /**
+        * Open a resource from the cache if it exists.
+        * 
+        * @param url
+        *            the resource to open
+        * @return the opened resource
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private InputStream load(URL url, boolean allowOld, boolean stable)
+                       throws IOException {
+               File cached = getCached(url);
+               if (cached.exists() && !isOld(cached, stable)) {
+                       return new MarkableFileInputStream(new FileInputStream(cached));
+               }
+
+               return null;
+       }
+
+       /**
+        * Save the given resource to the cache.
+        * 
+        * @param url
+        *            the resource
+        * @param support
+        *            the {@link BasicSupport} used to download it
+        * @param originalUrl
+        *            the original {@link URL} used to locate the cached resource
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * @throws URISyntaxException
+        */
+       private void save(URL url, BasicSupport support, URL originalUrl)
+                       throws IOException {
+               URLConnection conn = url.openConnection();
+
+               conn.setRequestProperty("User-Agent", UA);
+               conn.setRequestProperty("Cookie", generateCookies(support));
+               conn.setRequestProperty("Accept-Encoding", "gzip");
+               if (support != null) {
+                       conn.setRequestProperty("Referer", support.getCurrentReferer()
+                                       .toString());
+                       conn.setRequestProperty("Host", support.getCurrentReferer()
+                                       .getHost());
+               }
+
+               conn.connect();
+
+               // Check if redirect
+               if (conn instanceof HttpURLConnection
+                               && ((HttpURLConnection) conn).getResponseCode() / 100 == 3) {
+                       String newUrl = conn.getHeaderField("Location");
+                       save(new URL(newUrl), support, originalUrl);
+                       return;
+               }
+
+               InputStream in = conn.getInputStream();
+               if ("gzip".equals(conn.getContentEncoding())) {
+                       in = new GZIPInputStream(in);
+               }
+
+               try {
+                       File cached = getCached(originalUrl);
+                       BufferedOutputStream out = new BufferedOutputStream(
+                                       new FileOutputStream(cached));
+                       try {
+                               byte[] buf = new byte[4096];
+                               int len;
+                               while ((len = in.read(buf)) > 0) {
+                                       out.write(buf, 0, len);
+                               }
+                       } finally {
+                               out.close();
+                       }
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Check if the {@link File} is too old according to
+        * {@link Cache#tooOldChanging}.
+        * 
+        * @param file
+        *            the file to check
+        * @param stable
+        *            TRUE to denote 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) {
+                       System.err.println("Timestamp in the future for file: "
+                                       + file.getAbsolutePath());
+               }
+
+               return time < 0 || time > max;
+       }
+
+       /**
+        * Get the cache resource from the cache if it is present for this
+        * {@link URL}.
+        * 
+        * @param url
+        *            the url
+        * @return the cached version if present, NULL if not
+        */
+       private File getCached(URL url) {
+               String name = url.getHost();
+               if (name == null || name.length() == 0) {
+                       name = url.getFile();
+               } else {
+                       name = url.toString();
+               }
+
+               name = name.replace('/', '_').replace(':', '_');
+
+               return new File(dir, name);
+       }
+
+       /**
+        * Generate the cookie {@link String} from the local {@link CookieStore} so
+        * it is ready to be passed.
+        * 
+        * @return the cookie
+        */
+       private String generateCookies(BasicSupport support) {
+               StringBuilder builder = new StringBuilder();
+               for (HttpCookie cookie : cookies.getCookieStore().getCookies()) {
+                       if (builder.length() > 0) {
+                               builder.append(';');
+                       }
+
+                       // TODO: check if format is ok
+                       builder.append(cookie.toString());
+               }
+
+               if (support != null) {
+                       for (Map.Entry<String, String> set : support.getCookies()
+                                       .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/fanfix/Instance.java b/src/be/nikiroo/fanfix/Instance.java
new file mode 100644 (file)
index 0000000..5c198ee
--- /dev/null
@@ -0,0 +1,195 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.fanfix.bundles.StringIdBundle;
+import be.nikiroo.utils.resources.Bundles;
+
+/**
+ * Global state for the program (services and singletons).
+ * 
+ * @author niki
+ */
+public class Instance {
+       private static ConfigBundle config;
+       private static StringIdBundle trans;
+       private static Cache cache;
+       private static Library lib;
+       private static boolean debug;
+       private static File coverDir;
+
+       static {
+               config = new ConfigBundle();
+
+               // config dependent:
+               trans = new StringIdBundle(getLang());
+               lib = new Library(getFile(Config.LIBRARY_DIR));
+               debug = Instance.getConfig().getBoolean(Config.DEBUG_ERR, false);
+               coverDir = getFile(Config.DEFAULT_COVERS_DIR);
+
+               if (coverDir != null && !coverDir.exists()) {
+                       syserr(new IOException(
+                                       "The 'default covers' directory does not exists: "
+                                                       + coverDir));
+                       coverDir = null;
+               }
+               //
+
+               String noutf = System.getenv("NOUTF");
+               if (noutf != null) {
+                       noutf = noutf.trim().toLowerCase();
+                       if ("yes".equals(noutf) || "true".equals(noutf)
+                                       || "on".equals(noutf) || "1".equals(noutf)
+                                       || "y".equals(noutf)) {
+                               trans.setUnicode(false);
+                       }
+               }
+
+               String configDir = System.getenv("CONFIG_DIR");
+               if (configDir != null) {
+                       if (new File(configDir).isDirectory()) {
+                               Bundles.setDirectory(configDir);
+                               try {
+                                       config = new ConfigBundle();
+                                       config.updateFile(configDir);
+                               } catch (IOException e) {
+                                       syserr(e);
+                               }
+                               try {
+                                       trans = new StringIdBundle(getLang());
+                                       trans.updateFile(configDir);
+                               } catch (IOException e) {
+                                       syserr(e);
+                               }
+                       } else {
+                               syserr(new IOException("Configuration directory not found: "
+                                               + configDir));
+                       }
+               }
+
+               try {
+                       File tmp = getFile(Config.CACHE_DIR);
+                       String ua = config.getString(Config.USER_AGENT);
+                       int hours = config.getInteger(Config.CACHE_MAX_TIME_CHANGING, -1);
+                       int hoursLarge = config
+                                       .getInteger(Config.CACHE_MAX_TIME_STABLE, -1);
+
+                       if (tmp == null) {
+                               String tmpDir = System.getProperty("java.io.tmpdir");
+                               if (tmpDir != null) {
+                                       tmp = new File(tmpDir, "fanfic-tmp");
+                               } else {
+                                       syserr(new IOException(
+                                                       "The system does not have a default temporary directory"));
+                               }
+                       }
+
+                       cache = new Cache(tmp, ua, hours, hoursLarge);
+               } catch (IOException e) {
+                       syserr(new IOException(
+                                       "Cannot create cache (will continue without cache)", e));
+               }
+       }
+
+       /**
+        * Get the (unique) configuration service for the program.
+        * 
+        * @return the configuration service
+        */
+       public static ConfigBundle getConfig() {
+               return config;
+       }
+
+       /**
+        * Get the (unique) {@link Cache} for the program.
+        * 
+        * @return the {@link Cache}
+        */
+       public static Cache getCache() {
+               return cache;
+       }
+
+       /**
+        * Get the (unique) {link StringIdBundle} for the program.
+        *
+        * @return the {link StringIdBundle}
+        */
+       public static StringIdBundle getTrans() {
+               return trans;
+       }
+
+       /**
+        * Get the (unique) {@link Library} for the program.
+        * 
+        * @return the {@link Library}
+        */
+       public static Library getLibrary() {
+               return lib;
+       }
+
+       /**
+        * Return the directory where to look for default cover pages.
+        * 
+        * @return the default covers directory
+        */
+       public static File getCoverDir() {
+               return coverDir;
+       }
+
+       /**
+        * Report an error to the user
+        * 
+        * @param e
+        *            the {@link Exception} to report
+        */
+       public static void syserr(Exception e) {
+               if (debug) {
+                       e.printStackTrace();
+               } else {
+                       System.err.println(e.getMessage());
+               }
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @return the path
+        */
+       private static File getFile(Config id) {
+               File file = null;
+               String path = config.getString(id);
+               if (path != null && !path.isEmpty()) {
+                       path = path.replace('/', File.separatorChar);
+                       if (path.contains("$HOME")) {
+                               path = path.replace("$HOME",
+                                               "" + System.getProperty("user.home"));
+                       }
+
+                       file = new File(path);
+               }
+
+               return file;
+       }
+
+       /**
+        * The language to use for the application (NULL = default system language).
+        * 
+        * @return the language
+        */
+       private static String getLang() {
+               String lang = config.getString(Config.LANG);
+
+               if (System.getenv("LANG") != null && !System.getenv("LANG").isEmpty()) {
+                       lang = System.getenv("LANG");
+               }
+
+               if (lang != null && lang.isEmpty()) {
+                       lang = null;
+               }
+
+               return lang;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/Library.java b/src/be/nikiroo/fanfix/Library.java
new file mode 100644 (file)
index 0000000..9864ad7
--- /dev/null
@@ -0,0 +1,261 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupport.SupportType;
+
+/**
+ * Manage a library of Stories: import, export, list.
+ * <p>
+ * Each {@link Story} object will be associated with a (local to the library)
+ * unique ID, the LUID, which will be used to identify the {@link Story}.
+ * 
+ * @author niki
+ */
+public class Library {
+       private File baseDir;
+       private Map<MetaData, File> stories;
+       private BasicSupport itSupport = BasicSupport
+                       .getSupport(SupportType.INFO_TEXT);
+       private int lastId;
+
+       /**
+        * Create a new {@link Library} with the given backend directory.
+        * 
+        * @param dir
+        *            the directoy where to find the {@link Story} objects
+        */
+       public Library(File dir) {
+               this.baseDir = dir;
+               this.stories = new HashMap<MetaData, File>();
+               this.lastId = 0;
+
+               dir.mkdirs();
+       }
+
+       /**
+        * List all the stories of the given source type in the {@link Library}, or
+        * all the stories if NULL is passed as a type.
+        * 
+        * @param type
+        *            the type of story to retrieve, or NULL for all
+        * 
+        * @return the stories
+        */
+       public List<MetaData> getList(SupportType type) {
+               String typeString = type == null ? null : type.getSourceName();
+
+               List<MetaData> list = new ArrayList<MetaData>();
+               for (Entry<MetaData, File> entry : getStories().entrySet()) {
+                       String storyType = entry.getValue().getParentFile().getName();
+                       if (typeString == null || typeString.equalsIgnoreCase(storyType)) {
+                               list.add(entry.getKey());
+                       }
+               }
+
+               return list;
+       }
+
+       /**
+        * Retrieve a specific {@link Story}.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * 
+        * @return the corresponding {@link Story}
+        */
+       public Story getStory(String luid) {
+               if (luid != null) {
+                       for (Entry<MetaData, File> entry : getStories().entrySet()) {
+                               if (luid.equals(entry.getKey().getLuid())) {
+                                       try {
+                                               return itSupport.process(entry.getValue().toURI()
+                                                               .toURL());
+                                       } catch (IOException e) {
+                                               // We should not have not-supported files in the
+                                               // library
+                                               Instance.syserr(new IOException(
+                                                               "Cannot load file from library: "
+                                                                               + entry.getValue().getPath(), e));
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Import the {@link Story} at the given {@link URL} into the
+        * {@link Library}.
+        * 
+        * @param url
+        *            the {@link URL} to import
+        * 
+        * @return the imported {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story imprt(URL url) throws IOException {
+               BasicSupport support = BasicSupport.getSupport(url);
+               if (support == null) {
+                       throw new IOException("URL not supported: " + url.toString());
+               }
+
+               getStories(); // refresh lastId
+               Story story = support.process(url);
+               story.getMeta().setLuid(String.format("%03d", (++lastId)));
+               save(story);
+
+               return story;
+       }
+
+       /**
+        * Export the {@link Story} to the given target in the given format.
+        * 
+        * @param luid
+        *            the {@link Story} ID
+        * @param type
+        *            the {@link OutputType} to transform it to
+        * @param target
+        *            the target to save to
+        * 
+        * @return the saved resource (the main saved {@link File})
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File export(String luid, OutputType type, String target)
+                       throws IOException {
+               BasicOutput out = BasicOutput.getOutput(type, true);
+               if (out == null) {
+                       throw new IOException("Output type not supported: " + type);
+               }
+
+               return out.process(getStory(luid), target);
+       }
+
+       /**
+        * Save a story as-is to the {@link Library} -- the LUID <b>must</b> be
+        * correct.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private void save(Story story) throws IOException {
+               MetaData key = story.getMeta();
+
+               getDir(key).mkdirs();
+               if (!getDir(key).exists()) {
+                       throw new IOException("Cannot create library dir");
+               }
+
+               OutputType out;
+               SupportType in;
+               if (key != null && key.isImageDocument()) {
+                       in = SupportType.CBZ;
+                       out = OutputType.CBZ;
+               } else {
+                       in = SupportType.INFO_TEXT;
+                       out = OutputType.INFO_TEXT;
+               }
+               BasicOutput it = BasicOutput.getOutput(out, true);
+               File file = it.process(story, getFile(key).getPath());
+               getStories().put(
+                               BasicSupport.getSupport(in).processMeta(file.toURI().toURL())
+                                               .getMeta(), file);
+       }
+
+       /**
+        * The directory (full path) where the {@link Story} related to this
+        * {@link MetaData} should be located on disk.
+        * 
+        * @param key
+        *            the {@link Story} {@link MetaData}
+        * 
+        * @return the target directory
+        */
+       private File getDir(MetaData key) {
+               String source = key.getSource().replaceAll("[^a-zA-Z0-9._+-]", "_");
+               return new File(baseDir, source);
+       }
+
+       /**
+        * The target (full path) where the {@link Story} related to this
+        * {@link MetaData} should be located on disk.
+        * 
+        * @param key
+        *            the {@link Story} {@link MetaData}
+        * 
+        * @return the target
+        */
+       private File getFile(MetaData key) {
+               String title = key.getTitle().replaceAll("[^a-zA-Z0-9._+-]", "_");
+               return new File(getDir(key), key.getLuid() + "_" + title);
+       }
+
+       /**
+        * Return all the known stories in this {@link Library} object.
+        * 
+        * @return the stories
+        */
+       private Map<MetaData, File> getStories() {
+               if (stories.isEmpty()) {
+                       lastId = 0;
+                       String format = Instance.getConfig()
+                                       .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
+                       for (File dir : baseDir.listFiles()) {
+                               if (dir.isDirectory()) {
+                                       for (File file : dir.listFiles()) {
+                                               try {
+                                                       String path = file.getPath().toLowerCase();
+                                                       if (!path.endsWith(".info")
+                                                                       && !path.endsWith(format)) {
+                                                               MetaData meta = itSupport.processMeta(
+                                                                               file.toURI().toURL()).getMeta();
+                                                               stories.put(meta, file);
+
+                                                               try {
+                                                                       int id = Integer.parseInt(meta.getLuid());
+                                                                       if (id > lastId) {
+                                                                               lastId = id;
+                                                                       }
+                                                               } catch (Exception e) {
+                                                                       // not normal!!
+                                                                       Instance.syserr(new IOException(
+                                                                                       "Cannot read the LUID of: "
+                                                                                                       + file.getPath(), e));
+                                                               }
+                                                       }
+                                               } catch (IOException e) {
+                                                       // We should not have not-supported files in the
+                                                       // library
+                                                       Instance.syserr(new IOException(
+                                                                       "Cannot load file from library: "
+                                                                                       + file.getPath(), e));
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return stories;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/Main.java b/src/be/nikiroo/fanfix/Main.java
new file mode 100644 (file)
index 0000000..45b87c4
--- /dev/null
@@ -0,0 +1,341 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.reader.CliReader;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupport.SupportType;
+
+/**
+ * Main program entry point.
+ * 
+ * @author niki
+ */
+public class Main {
+       /**
+        * Main program entry point.
+        * <p>
+        * Known environment variables:
+        * <ul>
+        * <li>NOUTF: if set to 1, the program will prefer non-unicode
+        * {@link String}s when possible</li>
+        * <li>CONFIG_DIR: a path where to look for the <tt>.properties</tt> files
+        * before taking the included ones; they will also be saved/updated into
+        * this path when the program starts</li>
+        * </ul>
+        * 
+        * @param args
+        *            <ol>
+        *            <li>--import [URL]: import into library</li> <li>--export [id]
+        *            [output_type] [target]: export story to target</li> <li>
+        *            --convert [URL] [output_type] [target]: convert URL into
+        *            target</li> <li>--read [id]: read the given story from the
+        *            library</li> <li>--read-url [URL]: convert on the fly and read
+        *            the story, without saving it</li> <li>--list: list the stories
+        *            present in the library</li>
+        *            </ol>
+        */
+       public static void main(String[] args) {
+               int exitCode = 255;
+
+               if (args.length > 0) {
+                       String action = args[0];
+                       if (action.equals("--import")) {
+                               if (args.length > 1) {
+                                       exitCode = imprt(args[1]);
+                               }
+                       } else if (action.equals("--export")) {
+                               if (args.length > 3) {
+                                       exitCode = export(args[1], args[2], args[3]);
+                               }
+                       } else if (action.equals("--convert")) {
+                               if (args.length > 3) {
+                                       exitCode = convert(
+                                                       args[1],
+                                                       args[2],
+                                                       args[3],
+                                                       args.length > 4 ? args[4].toLowerCase().equals(
+                                                                       "+info") : false);
+                               }
+                       } else if (action.equals("--list")) {
+                               exitCode = list(args.length > 1 ? args[1] : null);
+                       } else if (action.equals("--read-url")) {
+                               if (args.length > 1) {
+                                       exitCode = read(args[1], args.length > 2 ? args[2] : null,
+                                                       false);
+                               }
+                       } else if (action.equals("--read")) {
+                               if (args.length > 1) {
+                                       exitCode = read(args[1], args.length > 2 ? args[2] : null,
+                                                       true);
+                               }
+                       }
+               }
+
+               if (exitCode == 255) {
+                       syntax();
+               }
+
+               if (exitCode != 0) {
+                       System.exit(exitCode);
+               }
+       }
+
+       /**
+        * Return an {@link URL} from this {@link String}, be it a file path or an
+        * actual {@link URL}.
+        * 
+        * @param sourceString
+        *            the source
+        * 
+        * @return the corresponding {@link URL}
+        * 
+        * @throws MalformedURLException
+        *             if this is neither a file nor a conventional {@link URL}
+        */
+       private static URL getUrl(String sourceString) throws MalformedURLException {
+               if (sourceString == null || sourceString.isEmpty()) {
+                       throw new MalformedURLException("Empty url");
+               }
+
+               URL source = null;
+               try {
+                       source = new URL(sourceString);
+               } catch (MalformedURLException e) {
+                       File sourceFile = new File(sourceString);
+                       source = sourceFile.toURI().toURL();
+               }
+
+               return source;
+       }
+
+       /**
+        * Import the given resource into the {@link Library}.
+        * 
+        * @param sourceString
+        *            the resource to import
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int imprt(String sourceString) {
+               try {
+                       Story story = Instance.getLibrary().imprt(getUrl(sourceString));
+                       System.out.println(story.getMeta().getLuid() + ": \""
+                                       + story.getMeta().getTitle() + "\" imported.");
+               } catch (IOException e) {
+                       Instance.syserr(e);
+                       return 1;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Export the {@link Story} from the {@link Library} to the given target.
+        * 
+        * @param sourceString
+        *            the story LUID
+        * @param typeString
+        *            the {@link OutputType} to use
+        * @param target
+        *            the target
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int export(String sourceString, String typeString,
+                       String target) {
+               OutputType type = OutputType.valueOfNullOkUC(typeString);
+               if (type == null) {
+                       Instance.syserr(new Exception(trans(StringId.OUTPUT_DESC,
+                                       typeString)));
+                       return 1;
+               }
+
+               try {
+                       Story story = Instance.getLibrary().imprt(new URL(sourceString));
+                       Instance.getLibrary().export(story.getMeta().getLuid(), type,
+                                       target);
+               } catch (IOException e) {
+                       Instance.syserr(e);
+                       return 4;
+               }
+
+               return 0;
+       }
+
+       /**
+        * List the stories of the given type from the {@link Library} (unless NULL
+        * is passed, in which case all stories will be listed).
+        * 
+        * @param typeString
+        *            the {@link SupportType} to list the known stories of, or NULL
+        *            to list all stories
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int list(String typeString) {
+               SupportType type = null;
+               try {
+                       type = SupportType.valueOfNullOkUC(typeString);
+               } catch (Exception e) {
+                       Instance.syserr(new Exception(
+                                       trans(StringId.INPUT_DESC, typeString), e));
+                       return 1;
+               }
+
+               CliReader.list(type);
+
+               return 0;
+       }
+
+       /**
+        * Start the CLI reader for this {@link Story}.
+        * 
+        * @param story
+        *            the LUID of the {@link Story} in the {@link Library} <b>or</b>
+        *            the {@link Story} {@link URL}
+        * @param chap
+        *            which {@link Chapter} to read (starting at 1), or NULL to get
+        *            the {@link Story} description
+        * @param library
+        *            TRUE if the source is the {@link Story} LUID, FALSE if it is a
+        *            {@link URL}
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int read(String story, String chap, boolean library) {
+               try {
+                       CliReader reader;
+                       if (library) {
+                               reader = new CliReader(story);
+                       } else {
+                               reader = new CliReader(getUrl(story));
+                       }
+
+                       if (chap != null) {
+                               reader.read(Integer.parseInt(chap));
+                       } else {
+                               reader.read();
+                       }
+               } catch (IOException e) {
+                       Instance.syserr(e);
+                       return 1;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Convert the {@link Story} into another format.
+        * 
+        * @param sourceString
+        *            the source {@link Story} to convert
+        * @param typeString
+        *            the {@link OutputType} to convert to
+        * @param filename
+        *            the target file
+        * @param infoCover
+        *            TRUE to also export the cover and info file, even if the given
+        *            {@link OutputType} does not usually save them
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int convert(String sourceString, String typeString,
+                       String filename, boolean infoCover) {
+               int exitCode = 0;
+
+               String sourceName = sourceString;
+               try {
+                       URL source = getUrl(sourceString);
+                       sourceName = source.toString();
+                       if (source.toString().startsWith("file://")) {
+                               sourceName = sourceName.substring("file://".length());
+                       }
+
+                       OutputType type = OutputType.valueOfAllOkUC(typeString);
+                       if (type == null) {
+                               Instance.syserr(new IOException(trans(
+                                               StringId.ERR_BAD_OUTPUT_TYPE, typeString)));
+
+                               exitCode = 2;
+                       } else {
+                               try {
+                                       BasicSupport support = BasicSupport.getSupport(source);
+                                       if (support != null) {
+                                               Story story = support.process(source);
+
+                                               try {
+                                                       filename = new File(filename).getAbsolutePath();
+                                                       BasicOutput.getOutput(type, infoCover).process(
+                                                                       story, filename);
+                                               } catch (IOException e) {
+                                                       Instance.syserr(new IOException(trans(
+                                                                       StringId.ERR_SAVING, filename), e));
+                                                       exitCode = 5;
+                                               }
+                                       } else {
+                                               Instance.syserr(new IOException(trans(
+                                                               StringId.ERR_NOT_SUPPORTED, source)));
+
+                                               exitCode = 4;
+                                       }
+                               } catch (IOException e) {
+                                       Instance.syserr(new IOException(trans(StringId.ERR_LOADING,
+                                                       sourceName), e));
+                                       exitCode = 3;
+                               }
+                       }
+               } catch (MalformedURLException e) {
+                       Instance.syserr(new IOException(trans(StringId.ERR_BAD_URL,
+                                       sourceName), e));
+                       exitCode = 1;
+               }
+
+               return exitCode;
+       }
+
+       /**
+        * Simple shortcut method to call {link Instance#getTrans()#getString()}.
+        * 
+        * @param id
+        *            the ID to translate
+        * 
+        * @return the translated result
+        */
+       private static String trans(StringId id, Object... params) {
+               return Instance.getTrans().getString(id, params);
+       }
+
+       /**
+        * Display the correct syntax of the program to the user.
+        */
+       private static void syntax() {
+               StringBuilder builder = new StringBuilder();
+               for (SupportType type : SupportType.values()) {
+                       builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+                                       type.getDesc()));
+                       builder.append('\n');
+               }
+
+               String typesIn = builder.toString();
+               builder.setLength(0);
+
+               for (OutputType type : OutputType.values()) {
+                       builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+                                       type.getDesc()));
+                       builder.append('\n');
+               }
+
+               String typesOut = builder.toString();
+
+               System.err.println(trans(StringId.ERR_SYNTAX, typesIn, typesOut));
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Config.java b/src/be/nikiroo/fanfix/bundles/Config.java
new file mode 100644 (file)
index 0000000..969eb27
--- /dev/null
@@ -0,0 +1,45 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Meta;
+
+/**
+ * The configuration options.
+ * 
+ * @author niki
+ */
+public enum Config {
+       @Meta(what = "language", where = "", format = "language (example: en-GB) or nothing for default system language", info = "Force the language (can be overwritten again with the env variable $LANG)")
+       LANG, //
+       @Meta(what = "directory", where = "", format = "absolute path, $HOME variable supported, / is always accepted as dir separator", info = "The directory where to store temporary files, defaults to a directory 'fanfic-tmp' in the system default temporary directory")
+       CACHE_DIR, //
+       @Meta(what = "delay in hours", where = "", format = "integer | 0: no cache | -1: infinite time cache which is default", info = "The delay after which a cached resource that is thought to change ~often is considered too old and triggers a refresh")
+       CACHE_MAX_TIME_CHANGING, //
+       @Meta(what = "delay in hours", where = "", format = "integer | 0: no cache | -1: infinite time cache which is default", info = "The delay after which a cached resource that is thought to change rarely is considered too old and triggers a refresh")
+       CACHE_MAX_TIME_STABLE, //
+       @Meta(what = "string", where = "", format = "", info = "The user-agent to use to download files")
+       USER_AGENT, //
+       @Meta(what = "directory", where = "", format = "absolute path, $HOME variable supported, / is always accepted as dir separator", info = "The directory where to get the default story covers")
+       DEFAULT_COVERS_DIR, //
+       @Meta(what = "directory", where = "", format = "absolute path, $HOME variable supported, / is always accepted as dir separator", info = "The directory where to store the library")
+       LIBRARY_DIR, //
+       @Meta(what = "boolean", where = "", format = "'true' or 'false'", info = "Show debug information on errors")
+       DEBUG_ERR, //
+       @Meta(what = "image format", where = "", format = "PNG, JPG, BMP...", info = "Image format to use for cover images")
+       IMAGE_FORMAT_COVER, //
+       @Meta(what = "image format", where = "", format = "PNG, JPG, BMP...", info = "Image format to use for content images")
+       IMAGE_FORMAT_CONTENT, //
+       @Meta(what = "", where = "", format = "not used", info = "This key is only present to allow access to suffixes")
+       LATEX_LANG, //
+       @Meta(what = "LaTeX output language", where = "LaTeX", format = "", info = "LaTeX full name for English")
+       LATEX_LANG_EN, //
+       @Meta(what = "LaTeX output language", where = "LaTeX", format = "", info = "LaTeX full name for French")
+       LATEX_LANG_FR, //
+       @Meta(what = "other 'by' prefixes before author name", where = "", format = "coma-separated list", info = "used to identify the author")
+       BYS, //
+       @Meta(what = "Chapter identification languages", where = "", format = "coma-separated list", info = "used to identify a starting chapter in text mode")
+       CHAPTER, //
+       @Meta(what = "Chapter identification string", where = "", format = "", info = "used to identify a starting chapter in text mode")
+       CHAPTER_EN, //
+       @Meta(what = "Chapter identification string", where = "", format = "", info = "used to identify a starting chapter in text mode")
+       CHAPTER_FR, //
+}
diff --git a/src/be/nikiroo/fanfix/bundles/ConfigBundle.java b/src/be/nikiroo/fanfix/bundles/ConfigBundle.java
new file mode 100644 (file)
index 0000000..4f2303e
--- /dev/null
@@ -0,0 +1,38 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.bundles.Bundle;
+
+/**
+ * This class manages the configuration of the application.
+ * 
+ * @author niki
+ */
+public class ConfigBundle extends Bundle<Config> {
+       public ConfigBundle() {
+               super(Config.class, Target.config);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new ConfigBundle().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "Configuration options";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringId.java b/src/be/nikiroo/fanfix/bundles/StringId.java
new file mode 100644 (file)
index 0000000..39cb0b2
--- /dev/null
@@ -0,0 +1,122 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.bundles.Bundle;
+
+/**
+ * The {@link Enum} representing textual information to be translated to the
+ * user as a key.
+ * 
+ * Note that each key that should be translated <b>must</b> be annotated with a
+ * {@link Meta} annotation.
+ * 
+ * @author niki
+ */
+public enum StringId {
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need NO translation.
+        */
+       NULL, //
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need a real translation but still don't have a key.
+        */
+       DUMMY, //
+       @Meta(what = "error message", where = "cli", format = "%s = supported input, %s = supported output", info = "syntax error message")
+       ERR_SYNTAX, //
+       @Meta(what = "error message", where = "cli", format = "%s = support name, %s = support desc", info = "an input or output support type description")
+       ERR_SYNTAX_TYPE, //
+       @Meta(what = "error message", where = "cli", format = "%s = input string", info = "Error when retrieving data")
+       ERR_LOADING, //
+       @Meta(what = "error message", where = "cli", format = "%s = save target", info = "Error when saving to given target")
+       ERR_SAVING, //
+       @Meta(what = "error message", where = "cli", format = "%s = bad output format", info = "Error when unknown output format")
+       ERR_BAD_OUTPUT_TYPE, //
+       @Meta(what = "error message", where = "cli", format = "%s = input string", info = "Error when converting input to URL/File")
+       ERR_BAD_URL, //
+       @Meta(what = "error message", where = "cli", format = "%s = input url", info = "URL/File not supported")
+       ERR_NOT_SUPPORTED, //
+       @Meta(what = "error message", where = "BasicSupport", format = "%s = cover URL", info = "Failed to download cover : %s")
+       ERR_BS_NO_COVER, //
+       @Meta(what = "char", where = "LaTeX/BasicSupport", format = "single char", info = "Canonical OPEN SINGLE QUOTE char (for instance: `)")
+       OPEN_SINGLE_QUOTE, //
+       @Meta(what = "char", where = "LaTeX/BasicSupport", format = "single char", info = "Canonical CLOSE SINGLE QUOTE char (for instance: ‘)")
+       CLOSE_SINGLE_QUOTE, //
+       @Meta(what = "char", where = "LaTeX/BasicSupport", format = "single char", info = "Canonical OPEN DOUBLE QUOTE char (for instance: “)")
+       OPEN_DOUBLE_QUOTE, //
+       @Meta(what = "char", where = "LaTeX/BasicSupport", format = "single char", info = "Canonical CLOSE DOUBLE QUOTE char (for instance: ”)")
+       CLOSE_DOUBLE_QUOTE, //
+       @Meta(what = "chapter name", where = "BasicSupport", format = "", info = "Name of the description fake chapter")
+       DESCRIPTION, //
+       @Meta(what = "chapter name", where = "", format = "%d = number, %s = name", info = "Name of a chapter with a name")
+       CHAPTER_NAMED, //
+       @Meta(what = "chapter name", where = "", format = "%d = number, %s = name", info = "Name of a chapter without name")
+       CHAPTER_UNNAMED, //
+       @Meta(what = "input format description", where = "SupportType", format = "%s = type", info = "Default description when the type is not known by i18n")
+       INPUT_DESC, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_EPUB, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_TEXT, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_INFO_TEXT, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_FANFICTION, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_FIMFICTION, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_MANGAFOX, //
+       @Meta(what = "input format description", where = "SupportType", format = "", info = "Description of this input type")
+       INPUT_DESC_E621, //
+       @Meta(what = "output format description", where = "OutputType", format = "%s = type", info = "Default description when the type is not known by i18n")
+       OUTPUT_DESC, //
+       @Meta(what = "output format description", where = "OutputType", format = "", info = "Description of this output type")
+       OUTPUT_DESC_EPUB, //
+       @Meta(what = "output format description", where = "OutputType", format = "", info = "Description of this output type")
+       OUTPUT_DESC_TEXT, //
+       @Meta(what = "output format description", where = "OutputType", format = "", info = "Description of this output type")
+       OUTPUT_DESC_INFO_TEXT, //
+       @Meta(what = "output format description", where = "OutputType", format = "", info = "Description of this output type")
+       OUTPUT_DESC_CBZ, //
+       @Meta(what = "output format description", where = "OutputType", format = "", info = "Description of this output type")
+       OUTPUT_DESC_LATEX, //
+       @Meta(what = "output format description", where = "OutputType", format = "", info = "Description of this output type")
+       OUTPUT_DESC_SYSOUT, //
+       @Meta(what = "error message", where = "LaTeX", format = "%s = the unknown 2-code language", info = "Error message for unknown 2-letter LaTeX language code")
+       LATEX_LANG_UNKNOWN, //
+       @Meta(what = "'by' prefix before author name", where = "", format = "", info = "used to output the author, make sure it is covered by Config.BYS for input detection")
+       BY, //
+
+       ;
+
+       /**
+        * Write the header found in the configuration <tt>.properties</tt> file of
+        * this {@link Bundle}.
+        * 
+        * @param writer
+        *            the {@link Writer} to write the header in
+        * @param name
+        *            the file name
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public void writeHeader(Writer writer, String name)
+                       throws IOException {
+               writer.write("# " + name + " translation file (UTF-8)\n");
+               writer.write("# \n");
+               writer.write("# Note that any key can be doubled with a _NOUTF suffix\n");
+               writer.write("# to use when the NOUTF env variable is set to 1\n");
+               writer.write("# \n");
+               writer.write("# Also, the comments always refer to the key below them.\n");
+               writer.write("# \n");
+       }
+};
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdBundle.java b/src/be/nikiroo/fanfix/bundles/StringIdBundle.java
new file mode 100644 (file)
index 0000000..3456b67
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.bundles.TransBundle;
+
+/**
+ * This class manages the translation resources of the application.
+ * 
+ * @author niki
+ */
+public class StringIdBundle extends TransBundle<StringId> {
+       /**
+        * Create a translation service for the given language (will fall back to
+        * the default one i not found).
+        * 
+        * @param lang
+        *            the language to use
+        */
+       public StringIdBundle(String lang) {
+               super(StringId.class, Target.resources, lang);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new StringIdBundle(null).updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Target.java b/src/be/nikiroo/fanfix/bundles/Target.java
new file mode 100644 (file)
index 0000000..212f8a7
--- /dev/null
@@ -0,0 +1,19 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.bundles.Bundle;
+
+/**
+ * The type of configuration information the associated {@link Bundle} will
+ * convey.
+ * 
+ * @author niki
+ */
+public enum Target {
+       /**
+        * Configuration options that the user can change in the
+        * <tt>.properties</tt> file.
+        */
+       config,
+       /** Translation resources. */
+       resources,
+}
diff --git a/src/be/nikiroo/fanfix/bundles/config.properties b/src/be/nikiroo/fanfix/bundles/config.properties
new file mode 100644 (file)
index 0000000..8a1d6c1
--- /dev/null
@@ -0,0 +1,55 @@
+# Configuration options
+#
+
+
+# (WHAT: language, FORMAT: language (example: en-GB) or nothing for default system language)
+# Force the language (can be overwritten again with the env variable $LANG)
+LANG = 
+# (WHAT: directory, FORMAT: absolute path, $HOME variable supported, / is always accepted as dir separator)
+# The directory where to store temporary files, defaults to a directory 'fanfic-tmp' in the system default temporary directory
+CACHE_DIR = 
+# (WHAT: delay in hours, FORMAT: integer | 0: no cache | -1: infinite time cache which is default)
+# The delay after which a cached resource that is thought to change ~often is considered too old and triggers a refresh
+CACHE_MAX_TIME_CHANGING = 24
+# (WHAT: delay in hours, FORMAT: integer | 0: no cache | -1: infinite time cache which is default)
+# The delay after which a cached resource that is thought to change rarely is considered too old and triggers a refresh
+CACHE_MAX_TIME_STABLE = 
+# (WHAT: string)
+# The user-agent to use to download files
+USER_AGENT = Mozilla/5.0 (X11; Linux x86_64; rv:44.0) Gecko/20100101 Firefox/44.0 -- ELinks/0.9.3 (Linux 2.6.11 i686; 79x24)
+# (WHAT: directory, FORMAT: absolute path, $HOME variable supported, / is always accepted as dir separator)
+# The directory where to get the default story covers
+DEFAULT_COVERS_DIR = $HOME/bin/epub/
+# (WHAT: directory, FORMAT: absolute path, $HOME variable supported, / is always accepted as dir separator)
+# The directory where to store the library
+LIBRARY_DIR = $HOME/Books
+# (WHAT: boolean, FORMAT: 'true' or 'false')
+# Show debug information on errors
+DEBUG_ERR = true
+# (WHAT: image format, FORMAT: PNG, JPG, BMP...)
+# Image format to use for cover images
+IMAGE_FORMAT_COVER = png
+# (WHAT: image format, FORMAT: PNG, JPG, BMP...)
+# Image format to use for content images
+IMAGE_FORMAT_CONTENT = png
+# (FORMAT: not used)
+# This key is only present to allow access to suffixes
+LATEX_LANG = 
+# (WHAT: LaTeX output language, WHERE: LaTeX)
+# LaTeX full name for English
+LATEX_LANG_EN = english
+# (WHAT: LaTeX output language, WHERE: LaTeX)
+# LaTeX full name for French
+LATEX_LANG_FR = french
+# (WHAT: other 'by' prefixes before author name, FORMAT: coma-separated list)
+# used to identify the author
+BYS = by,par,de,©,(c)
+# (WHAT: Chapter identification languages, FORMAT: coma-separated list)
+# used to identify a starting chapter in text mode
+CHAPTER = EN,FR
+# (WHAT: Chapter identification string)
+# used to identify a starting chapter in text mode
+CHAPTER_EN = Chapter
+# (WHAT: Chapter identification string)
+# used to identify a starting chapter in text mode
+CHAPTER_FR = Chapitre
diff --git a/src/be/nikiroo/fanfix/bundles/package-info.java b/src/be/nikiroo/fanfix/bundles/package-info.java
new file mode 100644 (file)
index 0000000..50db011
--- /dev/null
@@ -0,0 +1,8 @@
+/**
+ * This package encloses the different 
+ * {@link be.nikiroo.utils.resources.bundles.Bundle} and their associated 
+ * {@link java.lang.Enum}s used by the application.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.bundles;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/bundles/resources.properties b/src/be/nikiroo/fanfix/bundles/resources.properties
new file mode 100644 (file)
index 0000000..13f6c2f
--- /dev/null
@@ -0,0 +1,134 @@
+# United Kingdom (en_GB) resources translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# (WHAT: error message, WHERE: cli, FORMAT: %s = supported input, %s = supported output)
+# syntax error message
+ERR_SYNTAX = Syntax error\n\
+\n\
+Valid options:\n\
+\t--import [URL]: import into library\n\
+\t--export [id] [output_type] [target]: export story to target\n\
+\t--convert [URL] [output_type] [target]: convert URL into target\n\
+\t--read [id]: read the given story from the library\n\
+\t--read-url [URL]: convert on the fly and read the story, without saving it\n\
+\t--list: list the stories present in the library\n\
+\n\
+Supported input types:\n\
+%s\n\
+\n\
+Supported output types:\n\
+%s
+# (WHAT: error message, WHERE: cli, FORMAT: %s = support name, %s = support desc)
+# an input or output support type description
+ERR_SYNTAX_TYPE = %s: %s
+# (WHAT: error message, WHERE: cli, FORMAT: %s = input string)
+# Error when retrieving data
+ERR_LOADING = Error when retrieving data from: %s
+# (WHAT: error message, WHERE: cli, FORMAT: %s = save target)
+# Error when saving to given target
+ERR_SAVING = Error when saving to target: %s
+# (WHAT: error message, WHERE: cli, FORMAT: %s = bad output format)
+# Error when unknown output format
+ERR_BAD_OUTPUT_TYPE = Unknown output type: %s
+# (WHAT: error message, WHERE: cli, FORMAT: %s = input string)
+# Error when converting input to URL/File
+ERR_BAD_URL = Cannot understand file or protocol: %s
+# (WHAT: error message, WHERE: cli, FORMAT: %s = input url)
+# URL/File not supported
+ERR_NOT_SUPPORTED = URL not supported: %s
+# (WHAT: error message, WHERE: BasicSupport, FORMAT: %s = cover URL)
+# Failed to download cover : %s
+ERR_BS_NO_COVER = Failed to download cover: %s
+# (WHAT: char, WHERE: LaTeX/BasicSupport, FORMAT: single char)
+# Canonical OPEN SINGLE QUOTE char (for instance: `)
+OPEN_SINGLE_QUOTE = `
+OPEN_SINGLE_QUOTE_NOUTF = '
+# (WHAT: char, WHERE: LaTeX/BasicSupport, FORMAT: single char)
+# Canonical CLOSE SINGLE QUOTE char (for instance: ‘)
+CLOSE_SINGLE_QUOTE = ‘
+CLOSE_SINGLE_QUOTE_NOUTF = '
+# (WHAT: char, WHERE: LaTeX/BasicSupport, FORMAT: single char)
+# Canonical OPEN DOUBLE QUOTE char (for instance: “)
+OPEN_DOUBLE_QUOTE = “
+OPEN_DOUBLE_QUOTE_NOUTF = "
+# (WHAT: char, WHERE: LaTeX/BasicSupport, FORMAT: single char)
+# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
+CLOSE_DOUBLE_QUOTE = ”
+CLOSE_DOUBLE_QUOTE_NOUTF = "
+# (WHAT: chapter name, WHERE: BasicSupport)
+# Name of the description fake chapter
+DESCRIPTION = Description
+# (WHAT: chapter name, FORMAT: %d = number, %s = name)
+# Name of a chapter with a name
+CHAPTER_NAMED = Chapter %d: %s
+# (WHAT: chapter name, FORMAT: %d = number, %s = name)
+# Name of a chapter without name
+CHAPTER_UNNAMED = Chapter %d
+# (WHAT: input format description, WHERE: SupportType, FORMAT: %s = type)
+# Default description when the type is not known by i18n
+INPUT_DESC = Unknown type: %s
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_EPUB = EPUB files created by this program (we do not support "all" EPUB files)
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_TEXT = Support class for local stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second line, possibly with the publication date in parenthesis (i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE CHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, JPEG or JPG extension.
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a companion ".info" file to store some metadata
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_FANFICTION = Fan fictions of many, many different universes, from TV shows to novels to games.
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_FIMFICTION = Fanfictions devoted to the My Little Pony show
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_MANGAFOX = A well filled repository of mangas, or, as their website states: Most popular manga scanlations read online for free at mangafox, as well as a close-knit community to chat and make friends.
+# (WHAT: input format description, WHERE: SupportType)
+# Description of this input type
+INPUT_DESC_E621 = Furry website supporting comics, including MLP
+# (WHAT: output format description, WHERE: OutputType, FORMAT: %s = type)
+# Default description when the type is not known by i18n
+OUTPUT_DESC = Unknown type: %s
+# (WHAT: output format description, WHERE: OutputType)
+# Description of this output type
+OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
+# (WHAT: output format description, WHERE: OutputType)
+# Description of this output type
+OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second line, possibly with the publication date in parenthesis (i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE CHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, JPEG or JPG extension.
+# (WHAT: output format description, WHERE: OutputType)
+# Description of this output type
+OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a companion ".info" file to store some metadata
+# (WHAT: output format description, WHERE: OutputType)
+# Description of this output type
+OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images in PNG format)
+# (WHAT: output format description, WHERE: OutputType)
+# Description of this output type
+OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
+# (WHAT: output format description, WHERE: OutputType)
+# Description of this output type
+OUTPUT_DESC_SYSOUT = A simple DEBUG console output
+# (WHAT: error message, WHERE: LaTeX, FORMAT: %s = the unknown 2-code language)
+# Error message for unknown 2-letter LaTeX language code
+LATEX_LANG_UNKNOWN = Unknown language: %s
+# (WHAT: 'by' prefix before author name)
+# used to output the author, make sure it is covered by Config.BYS for input detection
+BY = ©
+BY_NOUTF = (c)
diff --git a/src/be/nikiroo/fanfix/data/Chapter.java b/src/be/nikiroo/fanfix/data/Chapter.java
new file mode 100644 (file)
index 0000000..5516063
--- /dev/null
@@ -0,0 +1,102 @@
+package be.nikiroo.fanfix.data;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * A chapter in the story (or the resume/description).
+ * 
+ * @author niki
+ */
+public class Chapter implements Iterable<Paragraph> {
+       private String name;
+       private int number;
+       private List<Paragraph> paragraphs = new ArrayList<Paragraph>();
+       private List<Paragraph> empty = new ArrayList<Paragraph>();
+
+       /**
+        * Create a new {@link Chapter} with the given information.
+        * 
+        * @param number
+        *            the chapter number, or 0 for the description/resume.
+        * @param name
+        *            the chapter name
+        */
+       public Chapter(int number, String name) {
+               this.number = number;
+               this.name = name;
+       }
+
+       /**
+        * The chapter name.
+        * 
+        * @return the name
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * The chapter name.
+        * 
+        * @param name
+        *            the name to set
+        */
+       public void setName(String name) {
+               this.name = name;
+       }
+
+       /**
+        * The chapter number, or 0 for the description/resume.
+        * 
+        * @return the number
+        */
+       public int getNumber() {
+               return number;
+       }
+
+       /**
+        * The chapter number, or 0 for the description/resume.
+        * 
+        * @param number
+        *            the number to set
+        */
+       public void setNumber(int number) {
+               this.number = number;
+       }
+
+       /**
+        * The included paragraphs.
+        * 
+        * @return the paragraphs
+        */
+       public List<Paragraph> getParagraphs() {
+               return paragraphs;
+       }
+
+       /**
+        * The included paragraphs.
+        * 
+        * @param paragraphes
+        *            the paragraphs to set
+        */
+       public void setParagraphs(List<Paragraph> paragraphs) {
+               this.paragraphs = paragraphs;
+       }
+
+       /**
+        * Get an iterator on the {@link Paragraph}s.
+        */
+       public Iterator<Paragraph> iterator() {
+               return paragraphs == null ? empty.iterator() : paragraphs.iterator();
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               return "Chapter " + number + ": " + name;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/MetaData.java b/src/be/nikiroo/fanfix/data/MetaData.java
new file mode 100644 (file)
index 0000000..3980e96
--- /dev/null
@@ -0,0 +1,276 @@
+package be.nikiroo.fanfix.data;
+
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+/**
+ * The meta data associated to a {@link Story} object.
+ * 
+ * @author niki
+ */
+public class MetaData {
+       private String title;
+       private String author;
+       private String date;
+       private Chapter resume;
+       private List<String> tags;
+       private BufferedImage cover;
+       private String subject;
+       private String source;
+       private String uuid;
+       private String luid;
+       private String lang;
+       private String publisher;
+       private boolean imageDocument;
+
+       /**
+        * The title of the story.
+        * 
+        * @return the title
+        */
+       public String getTitle() {
+               return title;
+       }
+
+       /**
+        * The title of the story.
+        * 
+        * @param title
+        *            the title to set
+        */
+       public void setTitle(String title) {
+               this.title = title;
+       }
+
+       /**
+        * The author of the story.
+        * 
+        * @return the author
+        */
+       public String getAuthor() {
+               return author;
+       }
+
+       /**
+        * The author of the story.
+        * 
+        * @param author
+        *            the author to set
+        */
+       public void setAuthor(String author) {
+               this.author = author;
+       }
+
+       /**
+        * The story publication date.
+        * 
+        * @return the date
+        */
+       public String getDate() {
+               return date;
+       }
+
+       /**
+        * The story publication date.
+        * 
+        * @param date
+        *            the date to set
+        */
+       public void setDate(String date) {
+               this.date = date;
+       }
+
+       /**
+        * The tags associated with this story.
+        * 
+        * @return the tags
+        */
+       public List<String> getTags() {
+               return tags;
+       }
+
+       /**
+        * The tags associated with this story.
+        * 
+        * @param tags
+        *            the tags to set
+        */
+       public void setTags(List<String> tags) {
+               this.tags = tags;
+       }
+
+       /**
+        * The story resume (a.k.a. description).
+        * 
+        * @return the resume
+        */
+       public Chapter getResume() {
+               return resume;
+       }
+
+       /**
+        * The story resume (a.k.a. description).
+        * 
+        * @param resume
+        *            the resume to set
+        */
+       public void setResume(Chapter resume) {
+               this.resume = resume;
+       }
+
+       /**
+        * The cover image of the story if any (can be NULL).
+        * 
+        * @return the cover
+        */
+       public BufferedImage getCover() {
+               return cover;
+       }
+
+       /**
+        * The cover image of the story if any (can be NULL).
+        * 
+        * @param cover
+        *            the cover to set
+        */
+       public void setCover(BufferedImage cover) {
+               this.cover = cover;
+       }
+
+       /**
+        * The subject of the story (or instance, if it is a fanfiction, what is the
+        * original work; if it is a technical text, what is the technical
+        * subject...).
+        * 
+        * @return the subject
+        */
+       public String getSubject() {
+               return subject;
+       }
+
+       /**
+        * The subject of the story (for instance, if it is a fanfiction, what is
+        * the original work; if it is a technical text, what is the technical
+        * subject...).
+        * 
+        * @param subject
+        *            the subject to set
+        */
+       public void setSubject(String subject) {
+               this.subject = subject;
+       }
+
+       /**
+        * The source of this story (where it was downloaded from).
+        * 
+        * @return the source
+        */
+       public String getSource() {
+               return source;
+       }
+
+       /**
+        * The source of this story (where it was downloaded from).
+        * 
+        * @param source
+        *            the source to set
+        */
+       public void setSource(String source) {
+               this.source = source;
+       }
+
+       /**
+        * A unique value representing the story (it is often an URL).
+        * 
+        * @return the uuid
+        */
+       public String getUuid() {
+               return uuid;
+       }
+
+       /**
+        * A unique value representing the story (it is often an URL).
+        * 
+        * @param uuid
+        *            the uuid to set
+        */
+       public void setUuid(String uuid) {
+               this.uuid = uuid;
+       }
+
+       /**
+        * A unique value representing the story in the local library.
+        * 
+        * @return the luid
+        */
+       public String getLuid() {
+               return luid;
+       }
+
+       /**
+        * A unique value representing the story in the local library.
+        * 
+        * @param uuid
+        *            the luid to set
+        */
+       public void setLuid(String luid) {
+               this.luid = luid;
+       }
+
+       /**
+        * The 2-letter code language of this story.
+        * 
+        * @return the lang
+        */
+       public String getLang() {
+               return lang;
+       }
+
+       /**
+        * The 2-letter code language of this story.
+        * 
+        * @param lang
+        *            the lang to set
+        */
+       public void setLang(String lang) {
+               this.lang = lang;
+       }
+
+       /**
+        * The story publisher (other the same as the source).
+        * 
+        * @return the publisher
+        */
+       public String getPublisher() {
+               return publisher;
+       }
+
+       /**
+        * The story publisher (other the same as the source).
+        * 
+        * @param publisher
+        *            the publisher to set
+        */
+       public void setPublisher(String publisher) {
+               this.publisher = publisher;
+       }
+
+       /**
+        * Document catering mostly to image files.
+        * 
+        * @return the imageDocument state
+        */
+       public boolean isImageDocument() {
+               return imageDocument;
+       }
+
+       /**
+        * Document catering mostly to image files.
+        * 
+        * @param imageDocument
+        *            the imageDocument state to set
+        */
+       public void setImageDocument(boolean imageDocument) {
+               this.imageDocument = imageDocument;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/Paragraph.java b/src/be/nikiroo/fanfix/data/Paragraph.java
new file mode 100644 (file)
index 0000000..feb949c
--- /dev/null
@@ -0,0 +1,104 @@
+package be.nikiroo.fanfix.data;
+
+import java.net.URL;
+
+/**
+ * A paragraph in a chapter of the story.
+ * 
+ * @author niki
+ */
+public class Paragraph {
+       /**
+        * A paragraph type, that will dictate how the paragraph will be handled.
+        * 
+        * @author niki
+        */
+       public enum ParagraphType {
+               /** Normal paragraph (text) */
+               NORMAL,
+               /** Blank line */
+               BLANK,
+               /** A Break paragraph, i.e.: HR (Horizontal Line) or '* * *' or whatever */
+               BREAK,
+               /** Quotation (dialogue) */
+               QUOTE,
+               /** An image (no text) */
+               IMAGE,
+       }
+
+       private ParagraphType type;
+       private String content;
+
+       /**
+        * Create a new {@link Paragraph} with the given values.
+        * 
+        * @param type
+        *            the {@link ParagraphType}
+        * @param content
+        *            the content of this paragraph
+        */
+       public Paragraph(ParagraphType type, String content) {
+               this.type = type;
+               this.content = content;
+       }
+
+       /**
+        * Create a new {@link Paragraph} with the given image.
+        * 
+        * @param support
+        *            the support that will be used to fetch the image via
+        *            {@link Paragraph#getContentImage()}.
+        * @param content
+        *            the content image of this paragraph
+        */
+       public Paragraph(URL imageUrl) {
+               this.type = ParagraphType.IMAGE;
+               this.content = imageUrl.toString();
+       }
+
+       /**
+        * The {@link ParagraphType}.
+        * 
+        * @return the type
+        */
+       public ParagraphType getType() {
+               return type;
+       }
+
+       /**
+        * The {@link ParagraphType}.
+        * 
+        * @param type
+        *            the type to set
+        */
+       public void setType(ParagraphType type) {
+               this.type = type;
+       }
+
+       /**
+        * The content of this {@link Paragraph}.
+        * 
+        * @return the content
+        */
+       public String getContent() {
+               return content;
+       }
+
+       /**
+        * The content of this {@link Paragraph}.
+        * 
+        * @param content
+        *            the content to set
+        */
+       public void setContent(String content) {
+               this.content = content;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               return String.format("%s: [%s]", "" + type, "" + content);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/Story.java b/src/be/nikiroo/fanfix/data/Story.java
new file mode 100644 (file)
index 0000000..cb65119
--- /dev/null
@@ -0,0 +1,103 @@
+package be.nikiroo.fanfix.data;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * The main data class, where the whole story resides.
+ * 
+ * @author niki
+ */
+public class Story implements Iterable<Chapter> {
+       private MetaData meta;
+       private List<Chapter> chapters = new ArrayList<Chapter>();
+       private List<Chapter> empty = new ArrayList<Chapter>();
+
+       /**
+        * The metadata about this {@link Story}.
+        * 
+        * @return the meta
+        */
+       public MetaData getMeta() {
+               return meta;
+       }
+
+       /**
+        * The metadata about this {@link Story}.
+        * 
+        * @param meta
+        *            the meta to set
+        */
+       public void setMeta(MetaData meta) {
+               this.meta = meta;
+       }
+
+       /**
+        * The chapters of the story.
+        * 
+        * @return the chapters
+        */
+       public List<Chapter> getChapters() {
+               return chapters;
+       }
+
+       /**
+        * The chapters of the story.
+        * 
+        * @param chapters
+        *            the chapters to set
+        */
+       public void setChapters(List<Chapter> chapters) {
+               this.chapters = chapters;
+       }
+
+       /**
+        * Get an iterator on the {@link Chapter}s.
+        */
+       public Iterator<Chapter> iterator() {
+               return chapters == null ? empty.iterator() : chapters.iterator();
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        * <p>
+        * This is not efficient, nor intended to be.
+        */
+       @Override
+       public String toString() {
+               String title = "";
+               if (meta != null && meta.getTitle() != null) {
+                       title = meta.getTitle();
+               }
+
+               String tags = "";
+               if (meta != null && meta.getTags() != null) {
+                       for (String tag : meta.getTags()) {
+                               if (!tags.isEmpty()) {
+                                       tags += ", ";
+                               }
+                               tags += tag;
+                       }
+               }
+
+               String resume = "";
+               if (meta != null && meta.getResume() != null) {
+                       for (Paragraph para : meta.getResume()) {
+                               resume += "\n\t";
+                               resume += para.toString().substring(0,
+                                               Math.min(para.toString().length(), 120));
+                       }
+                       resume += "\n";
+               }
+
+               String cover = (meta == null || meta.getCover() == null) ? "none"
+                               : meta.getCover().getWidth() + "x"
+                                               + meta.getCover().getHeight();
+               return String.format(
+                               "Title: [%s]\nAuthor: [%s]\nDate: [%s]\nTags: [%s]\n"
+                                               + "Resume: [%s]\nCover: [%s]", title, meta == null ? ""
+                                               : meta.getAuthor(), meta == null ? "" : meta.getDate(),
+                               tags, resume, cover);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/package-info.java b/src/be/nikiroo/fanfix/data/package-info.java
new file mode 100644 (file)
index 0000000..aaa02c3
--- /dev/null
@@ -0,0 +1,7 @@
+/**
+ * This package contains the data structure used by the program, without the 
+ * logic behind them.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.data;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/output/BasicOutput.java b/src/be/nikiroo/fanfix/output/BasicOutput.java
new file mode 100644 (file)
index 0000000..2e77eae
--- /dev/null
@@ -0,0 +1,427 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+/**
+ * This class is the base class used by the other output classes. It can be used
+ * outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * 
+ * @author niki
+ */
+public abstract class BasicOutput {
+       /**
+        * The supported output types for which we can get a {@link BasicOutput}
+        * object.
+        * 
+        * @author niki
+        */
+       public enum OutputType {
+               /** EPUB files created with this program */
+               EPUB,
+               /** Pure text file with some rules */
+               TEXT,
+               /** TEXT but with associated .info file */
+               INFO_TEXT,
+               /** DEBUG output to console */
+               SYSOUT,
+               /** ZIP with (PNG) images */
+               CBZ,
+               /** LaTeX file with "book" template */
+               LATEX;
+               public String toString() {
+                       return super.toString().toLowerCase();
+               }
+
+               /**
+                * A description of this output type.
+                * 
+                * @return the description
+                */
+               public String getDesc() {
+                       String desc = Instance.getTrans().getStringX(StringId.OUTPUT_DESC,
+                                       this.name());
+
+                       if (desc == null) {
+                               desc = Instance.getTrans()
+                                               .getString(StringId.OUTPUT_DESC, this);
+                       }
+
+                       return desc;
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String.toUpperCase())}.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfUC(String typeName) {
+                       return OutputType.valueOf(typeName == null ? null : typeName
+                                       .toUpperCase());
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String.toUpperCase())} but return NULL
+                * for NULL instead of raising exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfNullOkUC(String typeName) {
+                       if (typeName == null) {
+                               return null;
+                       }
+
+                       return OutputType.valueOfUC(typeName);
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String.toUpperCase())} but return NULL
+                * in case of error instead of raising an exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfAllOkUC(String typeName) {
+                       try {
+                               return OutputType.valueOfUC(typeName);
+                       } catch (Exception e) {
+                               return null;
+                       }
+               }
+       }
+
+       /** The creator name (this program, by me!) */
+       static final String EPUB_CREATOR = "Fanfix (by Niki)";
+
+       /** The current best name for an image */
+       private String imageName;
+       private File targetDir;
+       private String targetName;
+       private OutputType type;
+       private boolean writeCover;
+       private boolean writeInfo;
+
+       /**
+        * Process the {@link Story} into the given target.
+        * 
+        * @param story
+        *            the {@link Story} to export
+        * @param target
+        *            the target where to save to (will not necessary be taken as is
+        *            by the processor, for instance an extension can be added)
+        * 
+        * @return the actual main target saved, which can be slightly different
+        *         that the input one
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File process(Story story, String target) throws IOException {
+               target = new File(target).getAbsolutePath();
+               File targetDir = new File(target).getParentFile();
+               String targetName = new File(target).getName();
+
+               String ext = getDefaultExtension();
+               if (ext != null && !ext.isEmpty()) {
+                       if (targetName.toLowerCase().endsWith(ext)) {
+                               targetName = targetName.substring(0,
+                                               targetName.length() - ext.length());
+                       }
+               }
+
+               return process(story, targetDir, targetName);
+       }
+
+       /**
+        * Process the {@link Story} into the given target.
+        * <p>
+        * This method is expected to be overridden in most cases.
+        * 
+        * @param story
+        *            the {@link Story} to export
+        * @param targetDir
+        *            the target dir where to save to
+        * @param targetName
+        *            the target filename (will not necessary be taken as is by the
+        *            processor, for instance an extension can be added)
+        * 
+        * @return the actual main target saved, which can be slightly different
+        *         that the input one
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               this.targetDir = targetDir;
+               this.targetName = targetName;
+
+               writeStory(story);
+
+               return null;
+       }
+
+       /**
+        * The output type.
+        * 
+        * @return the type
+        */
+       public OutputType getType() {
+               return type;
+       }
+
+       /**
+        * The output type.
+        * 
+        * @param type
+        *            the new type
+        * @param infoCover
+        *            TRUE to enable the creation of a .info file and a cover if
+        *            possible
+        * 
+        * @return this
+        */
+       protected BasicOutput setType(OutputType type, boolean writeCover,
+                       boolean writeInfo) {
+               this.type = type;
+               this.writeCover = writeCover;
+               this.writeInfo = writeInfo;
+
+               return this;
+       }
+
+       /**
+        * The default extension to add to the output files.
+        * <p>
+        * Cannot be NULL!
+        * 
+        * @return the extension
+        */
+       protected String getDefaultExtension() {
+               return "";
+       }
+
+       protected void writeStoryHeader(Story story) throws IOException {
+       }
+
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+       }
+
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+       }
+
+       protected void writeStoryFooter(Story story) throws IOException {
+       }
+
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+       }
+
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+       }
+
+       protected void writeStory(Story story) throws IOException {
+               String chapterNameNum = String.format("%03d", 0);
+               String paragraphNumber = String.format("%04d", 0);
+               imageName = paragraphNumber + "_" + chapterNameNum + ".png";
+
+               if (writeCover) {
+                       InfoCover.writeCover(targetDir, targetName, story.getMeta());
+               }
+               if (writeInfo) {
+                       InfoCover.writeInfo(targetDir, targetName, story.getMeta());
+               }
+
+               writeStoryHeader(story);
+               for (Chapter chap : story) {
+                       writeChapter(chap);
+               }
+               writeStoryFooter(story);
+       }
+
+       protected void writeChapter(Chapter chap) throws IOException {
+               String chapterNameNum;
+               if (chap.getName() == null || chap.getName().isEmpty()) {
+                       chapterNameNum = String.format("%03d", chap.getNumber());
+               } else {
+                       chapterNameNum = String.format("%03d", chap.getNumber()) + "_"
+                                       + chap.getName().replace(" ", "_");
+               }
+
+               int num = 0;
+               String paragraphNumber = String.format("%04d", num++);
+               imageName = chapterNameNum + "_" + paragraphNumber + ".png";
+
+               writeChapterHeader(chap);
+               for (Paragraph para : chap) {
+                       paragraphNumber = String.format("%04d", num++);
+                       imageName = chapterNameNum + "_" + paragraphNumber + ".png";
+                       writeParagraph(para);
+               }
+               writeChapterFooter(chap);
+       }
+
+       protected void writeParagraph(Paragraph para) throws IOException {
+               writeParagraphHeader(para);
+               writeTextLine(para.getType(), para.getContent());
+               writeParagraphFooter(para);
+       }
+
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+       }
+
+       /**
+        * Return the current best guess for an image name, based upon the current
+        * {@link Chapter} and {@link Paragraph}.
+        * 
+        * @param prefix
+        *            add the original target name as a prefix
+        * 
+        * @return the guessed name
+        */
+       protected String getCurrentImageBestName(boolean prefix) {
+               if (prefix) {
+                       return targetName + "_" + imageName;
+               }
+
+               return imageName;
+       }
+
+       /**
+        * Return the given word or sentence as <b>bold</b>.
+        * 
+        * @param word
+        *            the input
+        * 
+        * @return the bold output
+        */
+       protected String enbold(String word) {
+               return word;
+       }
+
+       /**
+        * Return the given word or sentence as <i>italic</i>.
+        * 
+        * @param word
+        *            the input
+        * 
+        * @return the italic output
+        */
+       protected String italize(String word) {
+               return word;
+       }
+
+       /**
+        * Decorate the given text with <b>bold</b> and <i>italic</i> words,
+        * according to {@link BasicOutput#enbold(String)} and
+        * {@link BasicOutput#italize(String)}.
+        * 
+        * @param text
+        *            the input
+        * 
+        * @return the decorated output
+        */
+       protected String decorateText(String text) {
+               StringBuilder builder = new StringBuilder();
+
+               int bold = -1;
+               int italic = -1;
+               char prev = '\0';
+               for (char car : text.toCharArray()) {
+                       switch (car) {
+                       case '*':
+                               if (bold >= 0 && prev != ' ') {
+                                       String data = builder.substring(bold);
+                                       builder.setLength(bold);
+                                       builder.append(enbold(data));
+                                       bold = -1;
+                               } else if (bold < 0
+                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
+                                       bold = builder.length();
+                               } else {
+                                       builder.append(car);
+                               }
+
+                               break;
+                       case '_':
+                               if (italic >= 0 && prev != ' ') {
+                                       String data = builder.substring(italic);
+                                       builder.setLength(italic);
+                                       builder.append(enbold(data));
+                                       italic = -1;
+                               } else if (italic < 0
+                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
+                                       italic = builder.length();
+                               } else {
+                                       builder.append(car);
+                               }
+
+                               break;
+                       default:
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (bold >= 0) {
+                       builder.insert(bold, '*');
+               }
+
+               if (italic >= 0) {
+                       builder.insert(italic, '_');
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Return a {@link BasicOutput} object compatible with the given
+        * {@link OutputType}.
+        * 
+        * @param type
+        *            the type
+        * @param infoCover
+        *            force the <tt>.info</tt> file and the cover to be saved next
+        *            to the main target file
+        * 
+        * @return the {@link BasicOutput}
+        */
+       public static BasicOutput getOutput(OutputType type, boolean infoCover) {
+               if (type != null) {
+                       switch (type) {
+                       case EPUB:
+                               return new Epub().setType(type, infoCover, infoCover);
+                       case TEXT:
+                               return new Text().setType(type, true, infoCover);
+                       case INFO_TEXT:
+                               return new InfoText().setType(type, true, true);
+                       case SYSOUT:
+                               return new Sysout().setType(type, false, false);
+                       case CBZ:
+                               return new Cbz().setType(type, infoCover, infoCover);
+                       case LATEX:
+                               return new LaTeX().setType(type, infoCover, infoCover);
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Cbz.java b/src/be/nikiroo/fanfix/output/Cbz.java
new file mode 100644 (file)
index 0000000..51cf732
--- /dev/null
@@ -0,0 +1,88 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+
+class Cbz extends BasicOutput {
+       private File dir;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension();
+
+               File target = new File(targetDir, targetName);
+
+               dir = File.createTempFile("fanfic-reader-cbz-dir", ".wip");
+               dir.delete();
+               dir.mkdir();
+
+               // will also save the images!
+               new InfoText().process(story, dir, targetNameOrig);
+               IOUtils.writeSmallFile(dir, "version", "3.0");
+
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+               }
+
+               IOUtils.zip(dir, target, true);
+               IOUtils.deltree(dir);
+
+               return target;
+       }
+
+       @Override
+       protected String getDefaultExtension() {
+               return ".cbz";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               MetaData meta = story.getMeta();
+
+               StringBuilder builder = new StringBuilder();
+               if (meta != null && meta.getResume() != null) {
+                       for (Paragraph para : story.getMeta().getResume()) {
+                               builder.append(para.getContent());
+                               builder.append("\n");
+                       }
+               }
+
+               FileWriter writer = new FileWriter(new File(dir, "URL"));
+               try {
+                       if (meta != null) {
+                               writer.write(meta.getUuid());
+                       }
+                       writer.write("\n\n");
+                       writer.write(builder.toString());
+               } finally {
+                       writer.close();
+               }
+
+               writer = new FileWriter(new File(dir, "SUMMARY"));
+               try {
+                       String title = "";
+                       if (meta != null && meta.getTitle() != null) {
+                               title = meta.getTitle();
+                       }
+
+                       writer.write(title);
+                       if (meta != null && meta.getAuthor() != null) {
+                               writer.write("\n©");
+                               writer.write(meta.getAuthor());
+                       }
+                       writer.write("\n\n");
+                       writer.write(builder.toString());
+               } finally {
+                       writer.close();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Epub.java b/src/be/nikiroo/fanfix/output/Epub.java
new file mode 100644 (file)
index 0000000..1706d8b
--- /dev/null
@@ -0,0 +1,471 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+
+import javax.imageio.ImageIO;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.StringUtils;
+
+class Epub extends BasicOutput {
+       private File tmpDir;
+       private FileWriter writer;
+       private boolean inDialogue = false;
+       private boolean inNormal = false;
+       private File images;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension();
+
+               tmpDir = File.createTempFile("fanfic-reader-epub_", ".wip");
+               tmpDir.delete();
+
+               if (!tmpDir.mkdir()) {
+                       throw new IOException(
+                                       "Cannot create a temporary directory: no space left on device?");
+               }
+
+               // "Originals"
+               File data = new File(tmpDir, "DATA");
+               data.mkdir();
+               new InfoText().process(story, data, targetNameOrig);
+               IOUtils.writeSmallFile(data, "version", "3.0");
+
+               super.process(story, targetDir, targetNameOrig);
+
+               // zip/epub
+               File epub = new File(targetDir, targetName);
+               IOUtils.zip(tmpDir, epub, true);
+               IOUtils.deltree(tmpDir);
+               tmpDir = null;
+
+               return epub;
+       }
+
+       @Override
+       protected String getDefaultExtension() {
+               return ".epub";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               File ops = new File(tmpDir, "OPS");
+               ops.mkdirs();
+               File css = new File(ops, "css");
+               css.mkdirs();
+               images = new File(ops, "images");
+               images.mkdirs();
+               File metaInf = new File(tmpDir, "META-INF");
+               metaInf.mkdirs();
+
+               // "root"
+               IOUtils.writeSmallFile(tmpDir, "mimetype", "application/epub+zip");
+
+               // META-INF
+               String containerContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+                               + "<container xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\" version=\"1.0\">\n"
+                               + "\t<rootfiles>\n"
+                               + "\t\t<rootfile full-path=\"OPS/epb.opf\" media-type=\"application/oebps-package+xml\"/>\n"
+                               + "\t</rootfiles>\n" + "</container>\n";
+
+               IOUtils.writeSmallFile(metaInf, "container.xml", containerContent);
+
+               // OPS/css
+               InputStream inStyle = getClass().getResourceAsStream("epub.style.css");
+               if (inStyle == null) {
+                       throw new IOException("Cannot find style.css resource");
+               }
+               try {
+                       IOUtils.write(inStyle, new File(css, "style.css"));
+               } finally {
+                       inStyle.close();
+               }
+
+               // OPS/images
+               if (story.getMeta() != null && story.getMeta().getCover() != null) {
+                       String format = Instance.getConfig()
+                                       .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
+                       File file = new File(images, "cover." + format);
+                       ImageIO.write(story.getMeta().getCover(), format, file);
+               }
+
+               // OPS/* except chapters
+               IOUtils.writeSmallFile(ops, "epb.ncx", generateNcx(story));
+               IOUtils.writeSmallFile(ops, "epb.opf", generateOpf(story));
+               IOUtils.writeSmallFile(ops, "title.xml", generateTitleXml(story));
+
+               // Resume
+               if (story.getMeta() != null && story.getMeta().getResume() != null) {
+                       writeChapter(story.getMeta().getResume());
+               }
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String filename = String.format("%s%03d%s", "chapter-",
+                               chap.getNumber(), ".xml");
+               writer = new FileWriter(new File(tmpDir + "/OPS", filename));
+               inDialogue = false;
+               inNormal = false;
+               try {
+                       String title = "Chapter " + chap.getNumber();
+                       String nameOrNum = Integer.toString(chap.getNumber());
+                       if (chap.getName() != null && !chap.getName().isEmpty()) {
+                               title += ": " + chap.getName();
+                               nameOrNum = chap.getName();
+                       }
+
+                       writer.write("<?xml version='1.0' encoding='UTF-8'?>");
+                       writer.write("\n<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Strict//EN' 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd'>");
+                       writer.write("\n<html xmlns='http://www.w3.org/1999/xhtml' lang='en' xml:lang='en'>");
+                       writer.write("\n<head>");
+                       writer.write("\n        <title>" + StringUtils.xmlEscape(title)
+                                       + "</title>");
+                       writer.write("\n        <link rel='stylesheet' href='css/style.css' type='text/css'/>");
+                       writer.write("\n</head>");
+                       writer.write("\n<body>");
+                       writer.write("\n        <h2>");
+                       writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
+                                       + chap.getNumber() + "</span>:</span> ");
+                       writer.write("\n                <span class='chaptitle'>"
+                                       + StringUtils.xmlEscape(nameOrNum) + "</span>");
+                       writer.write("\n        </h2>");
+                       writer.write("\n        ");
+                       writer.write("\n        <div class='chapter_content'>\n");
+               } catch (Exception e) {
+                       writer.close();
+                       throw new IOException(e);
+               }
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               try {
+                       if (inDialogue) {
+                               writer.write("          </div>\n");
+                               inDialogue = false;
+                       }
+                       if (inNormal) {
+                               writer.write("          </div>\n");
+                               inNormal = false;
+                       }
+                       writer.write("  </div>\n</body>\n</html>\n");
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
+                       writer.write("          <div class='dialogues'>\n");
+                       inDialogue = true;
+               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+
+               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
+                       writer.write("          <div class='normals'>\n");
+                       inNormal = true;
+               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               switch (para.getType()) {
+               case BLANK:
+                       writer.write("          <div class='blank'></div>");
+                       break;
+               case BREAK:
+                       writer.write("          <hr/>");
+                       break;
+               case NORMAL:
+                       writer.write("          <span class='normal'>");
+                       break;
+               case QUOTE:
+                       writer.write("                  <div class='dialogue'>&mdash; ");
+                       break;
+               case IMAGE:
+                       File file = new File(images, getCurrentImageBestName(false));
+                       Instance.getCache().saveAsImage(new URL(para.getContent()), file);
+                       writer.write("                  <img class='page-image' src='images/"
+                                       + getCurrentImageBestName(false) + "'/>");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               switch (para.getType()) {
+               case NORMAL:
+                       writer.write("</span>\n");
+                       break;
+               case QUOTE:
+                       writer.write("</div>\n");
+                       break;
+               default:
+                       writer.write("\n");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case QUOTE:
+               case NORMAL:
+                       writer.write(decorateText(StringUtils.xmlEscape(line)));
+                       break;
+               default:
+                       break;
+               }
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "<strong>" + word + "</strong>";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "<emph>" + word + "</emph>";
+       }
+
+       private String generateNcx(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String uuid = "";
+               String author = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       uuid = meta.getUuid();
+                       author = meta.getAuthor();
+                       title = meta.getTitle();
+               }
+
+               builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
+               builder.append("\n<!DOCTYPE ncx");
+               builder.append("\nPUBLIC \"-//NISO//DTD ncx 2005-1//EN\" \"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd\">");
+               builder.append("\n<ncx xmlns=\"http://www.daisy.org/z3986/2005/ncx/\" version=\"2005-1\">");
+               builder.append("\n      <head>");
+               builder.append("\n              <!--The following four metadata items are required for all");
+               builder.append("\n                  NCX documents, including those conforming to the relaxed");
+               builder.append("\n                  constraints of OPS 2.0-->");
+               builder.append("\n              <meta name=\"dtb:uid\" content=\""
+                               + StringUtils.xmlEscapeQuote(uuid) + "\"/>");
+               builder.append("\n              <meta name=\"dtb:depth\" content=\"1\"/>");
+               builder.append("\n              <meta name=\"dtb:totalPageCount\" content=\"0\"/>");
+               builder.append("\n              <meta name=\"dtb:maxPageNumber\" content=\"0\"/>");
+               builder.append("\n              <meta name=\"epub-creator\" content=\""
+                               + StringUtils.xmlEscapeQuote(EPUB_CREATOR) + "\"/>");
+               builder.append("\n      </head>");
+               builder.append("\n      <docTitle>");
+               builder.append("\n              <text>" + StringUtils.xmlEscape(title) + "</text>");
+               builder.append("\n      </docTitle>");
+               builder.append("\n      <docAuthor>");
+
+               builder.append("\n              <text>" + StringUtils.xmlEscape(author) + "</text>");
+               builder.append("\n      </docAuthor>");
+               builder.append("\n      <navMap>");
+               builder.append("\n              <navPoint id=\"navpoint-1\" playOrder=\"1\">");
+               builder.append("\n                      <navLabel>");
+               builder.append("\n                              <text>Title Page</text>");
+               builder.append("\n                      </navLabel>");
+               builder.append("\n                      <content src=\"title.xml\"/>");
+               builder.append("\n              </navPoint>");
+
+               int navPoint = 2; // 1 is above
+
+               if (story.getMeta() != null & story.getMeta().getResume() != null) {
+                       Chapter chap = story.getMeta().getResume();
+                       generateNcx(chap, builder, navPoint++);
+               }
+
+               for (Chapter chap : story) {
+                       generateNcx(chap, builder, navPoint++);
+               }
+
+               builder.append("\n      </navMap>");
+               builder.append("\n</ncx>\n");
+
+               return builder.toString();
+       }
+
+       private void generateNcx(Chapter chap, StringBuilder builder, int navPoint) {
+               String name;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       name = Instance.getTrans().getString(StringId.CHAPTER_NAMED,
+                                       chap.getNumber(), chap.getName());
+               } else {
+                       name = Instance.getTrans().getString(StringId.CHAPTER_UNNAMED,
+                                       chap.getNumber());
+               }
+
+               String nnn = String.format("%03d", (navPoint - 2));
+
+               builder.append("\n              <navPoint id=\"navpoint-" + navPoint
+                               + "\" playOrder=\"" + navPoint + "\">");
+               builder.append("\n                      <navLabel>");
+               builder.append("\n                              <text>" + name + "</text>");
+               builder.append("\n                      </navLabel>");
+               builder.append("\n                      <content src=\"chapter-" + nnn + ".xml\"/>");
+               builder.append("\n              </navPoint>\n");
+       }
+
+       private String generateOpf(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String uuid = "";
+               String author = "";
+               String date = "";
+               String publisher = "";
+               String subject = "";
+               String source = "";
+               String lang = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       uuid = meta.getUuid();
+                       author = meta.getAuthor();
+                       date = meta.getDate();
+                       publisher = meta.getPublisher();
+                       subject = meta.getSubject();
+                       source = meta.getSource();
+                       lang = meta.getLang();
+               }
+
+               builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
+               builder.append("\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\""
+                               + uuid + "\" version=\"2.0\">");
+               builder.append("\n   <metadata xmlns:opf=\"http://www.idpf.org/2007/opf\"");
+               builder.append("\n             xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
+               builder.append("\n      <dc:title>" + StringUtils.xmlEscape(title)
+                               + "</dc:title>");
+               builder.append("\n      <dc:creator opf:role=\"aut\" opf:file-as=\""
+                               + StringUtils.xmlEscapeQuote(author) + "\">"
+                               + StringUtils.xmlEscape(author) + "</dc:creator>");
+               builder.append("\n      <dc:date opf:event=\"original-publication\">"
+                               + StringUtils.xmlEscape(date) + "</dc:date>");
+               builder.append("\n      <dc:publisher>"
+                               + StringUtils.xmlEscape(publisher) + "</dc:publisher>");
+               builder.append("\n      <dc:date opf:event=\"epub-publication\"></dc:date>");
+               builder.append("\n      <dc:subject>" + StringUtils.xmlEscape(subject)
+                               + "</dc:subject>");
+               builder.append("\n      <dc:source>" + StringUtils.xmlEscape(source)
+                               + "</dc:source>");
+               builder.append("\n      <dc:rights>Not for commercial use.</dc:rights>");
+               builder.append("\n      <dc:identifier id=\"id\" opf:scheme=\"URI\">"
+                               + StringUtils.xmlEscape(uuid) + "</dc:identifier>");
+               builder.append("\n      <dc:language>" + StringUtils.xmlEscape(lang)
+                               + "</dc:language>");
+               builder.append("\n   </metadata>");
+               builder.append("\n   <manifest>");
+               builder.append("\n      <!-- Content Documents -->");
+               builder.append("\n      <item id=\"titlepage\" href=\"title.xml\" media-type=\"application/xhtml+xml\"/>");
+               for (int i = 0; i <= story.getChapters().size(); i++) {
+                       String name = String.format("%s%03d", "chapter-", i);
+                       builder.append("\n      <item id=\""
+                                       + StringUtils.xmlEscapeQuote(name) + "\" href=\""
+                                       + StringUtils.xmlEscapeQuote(name)
+                                       + ".xml\" media-type=\"application/xhtml+xml\"/>");
+               }
+
+               builder.append("\n      <!-- CSS Style Sheets -->");
+               builder.append("\n      <item id=\"style-css\" href=\"css/style.css\" media-type=\"text/css\"/>");
+
+               builder.append("\n      <!-- Images -->");
+
+               if (story.getMeta() != null && story.getMeta().getCover() != null) {
+                       String format = Instance.getConfig()
+                                       .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
+                       builder.append("\n      <item id=\"cover\" href=\"images/cover."
+                                       + format + "\" media-type=\"image/png\"/>");
+               }
+
+               builder.append("\n      <!-- NCX -->");
+               builder.append("\n      <item id=\"ncx\" href=\"epb.ncx\" media-type=\"application/x-dtbncx+xml\"/>");
+               builder.append("\n   </manifest>");
+               builder.append("\n   <spine toc=\"ncx\">");
+               builder.append("\n      <itemref idref=\"titlepage\" linear=\"yes\"/>");
+               for (int i = 0; i <= story.getChapters().size(); i++) {
+                       String name = String.format("%s%03d", "chapter-", i);
+                       builder.append("\n      <itemref idref=\""
+                                       + StringUtils.xmlEscapeQuote(name) + "\" linear=\"yes\"/>");
+               }
+               builder.append("\n   </spine>");
+               builder.append("\n</package>\n");
+
+               return builder.toString();
+       }
+
+       private String generateTitleXml(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String tags = "";
+               String author = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       if (meta.getTags() != null) {
+                               for (String tag : meta.getTags()) {
+                                       if (!tags.isEmpty()) {
+                                               tags += ", ";
+                                       }
+                                       tags += tag;
+                               }
+
+                               if (!tags.isEmpty()) {
+                                       tags = "(" + tags + ")";
+                               }
+                       }
+                       author = meta.getAuthor();
+               }
+
+               String format = Instance.getConfig()
+                               .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
+
+               builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
+               builder.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd \">");
+               builder.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\" xml:lang=\"en\">");
+               builder.append("\n<head>");
+               builder.append("\n      <title>" + StringUtils.xmlEscape(title) + "</title>");
+               builder.append("\n      <link rel=\"stylesheet\" href=\"css/style.css\" type=\"text/css\"/>");
+               builder.append("\n</head>");
+               builder.append("\n<body>");
+               builder.append("\n      <div class=\"titlepage\">");
+               builder.append("\n              <h1>" + StringUtils.xmlEscape(title) + "</h1>");
+               builder.append("\n                      <div class=\"type\">"
+                               + StringUtils.xmlEscape(tags) + "</div>");
+               builder.append("\n              <div class=\"cover\">");
+               builder.append("\n                      <img src=\"images/cover." + format + "\"></img>");
+               builder.append("\n              </div>");
+               builder.append("\n              <div class=\"author\">"
+                               + StringUtils.xmlEscape(author) + "</div>");
+               builder.append("\n      </div>");
+               builder.append("\n</body>");
+               builder.append("\n</html>\n");
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/InfoCover.java b/src/be/nikiroo/fanfix/output/InfoCover.java
new file mode 100644 (file)
index 0000000..2280e2d
--- /dev/null
@@ -0,0 +1,86 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+import javax.imageio.ImageIO;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+
+class InfoCover {
+       public static void writeInfo(File targetDir, String targetName,
+                       MetaData meta) throws IOException {
+               File info = new File(targetDir, targetName + ".info");
+               FileWriter infoWriter = new FileWriter(info);
+
+               if (meta != null) {
+                       try {
+                               String tags = "";
+                               if (meta.getTags() != null) {
+                                       for (String tag : meta.getTags()) {
+                                               if (!tags.isEmpty()) {
+                                                       tags += ", ";
+                                               }
+                                               tags += tag;
+                                       }
+                               }
+
+                               String lang = meta.getLang();
+                               if (lang != null) {
+                                       lang = lang.toLowerCase();
+                               }
+
+                               writeMeta(infoWriter, "TITLE", meta.getTitle());
+                               writeMeta(infoWriter, "AUTHOR", meta.getAuthor());
+                               writeMeta(infoWriter, "DATE", meta.getDate());
+                               writeMeta(infoWriter, "SUBJECT", meta.getSubject());
+                               writeMeta(infoWriter, "SOURCE", meta.getSource());
+                               writeMeta(infoWriter, "TAGS", tags);
+                               writeMeta(infoWriter, "UUID", meta.getUuid());
+                               writeMeta(infoWriter, "LUID", meta.getLuid());
+                               writeMeta(infoWriter, "LANG", lang);
+                               writeMeta(infoWriter, "IMAGES_DOCUMENT",
+                                               meta.isImageDocument() ? "true" : "false");
+                               if (meta.getCover() != null) {
+                                       String format = Instance.getConfig()
+                                                       .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
+                                       writeMeta(infoWriter, "COVER", targetName + "." + format);
+                               } else {
+                                       writeMeta(infoWriter, "COVER", "");
+                               }
+                               writeMeta(infoWriter, "EPUBCREATOR", BasicOutput.EPUB_CREATOR);
+                               writeMeta(infoWriter, "PUBLISHER", meta.getPublisher());
+                       } finally {
+                               infoWriter.close();
+                       }
+               }
+       }
+
+       public static void writeCover(File targetDir, String targetName,
+                       MetaData meta) {
+               if (meta != null && meta.getCover() != null) {
+                       try {
+                               String format = Instance.getConfig()
+                                               .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
+                               ImageIO.write(meta.getCover(), format, new File(targetDir,
+                                               targetName + "." + format));
+                       } catch (IOException e) {
+                               // Allow to continue without cover
+                               Instance.syserr(new IOException(
+                                               "Failed to save the cover image", e));
+                       }
+               }
+       }
+
+       private static void writeMeta(FileWriter writer, String key, String value)
+                       throws IOException {
+               if (value == null) {
+                       value = "";
+               }
+
+               writer.write(String.format("%s=\"%s\"\n", key, value.replace("\"", "'")));
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/InfoText.java b/src/be/nikiroo/fanfix/output/InfoText.java
new file mode 100644 (file)
index 0000000..6937685
--- /dev/null
@@ -0,0 +1,74 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+class InfoText extends Text {
+       // quote chars
+       private char openQuote = Instance.getTrans().getChar(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getChar(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getChar(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getChar(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       @Override
+       protected String getDefaultExtension() {
+               return "";
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               writer.write("\n");
+
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       writer.write(Instance.getTrans().getString(StringId.CHAPTER_NAMED,
+                                       chap.getNumber(), chap.getName()));
+               } else {
+                       writer.write(Instance.getTrans().getString(
+                                       StringId.CHAPTER_UNNAMED, chap.getNumber()));
+               }
+
+               writer.write("\n\n");
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case NORMAL:
+               case QUOTE:
+                       StringBuilder builder = new StringBuilder();
+                       for (char car : line.toCharArray()) {
+                               if (car == '—') {
+                                       builder.append("---");
+                               } else if (car == '–') {
+                                       builder.append("--");
+                               } else if (car == openDoubleQuote) {
+                                       builder.append("\"");
+                               } else if (car == closeDoubleQuote) {
+                                       builder.append("\"");
+                               } else if (car == openQuote) {
+                                       builder.append("'");
+                               } else if (car == closeQuote) {
+                                       builder.append("'");
+                               } else {
+                                       builder.append(car);
+                               }
+                       }
+
+                       line = builder.toString();
+                       break;
+               default:
+                       break;
+               }
+
+               super.writeTextLine(type, line);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/LaTeX.java b/src/be/nikiroo/fanfix/output/LaTeX.java
new file mode 100644 (file)
index 0000000..a8d6d37
--- /dev/null
@@ -0,0 +1,182 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+class LaTeX extends BasicOutput {
+       protected FileWriter writer;
+       private boolean lastWasQuote = false;
+
+       // quote chars
+       private char openQuote = Instance.getTrans().getChar(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getChar(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getChar(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getChar(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension();
+
+               File target = new File(targetDir, targetName);
+
+               writer = new FileWriter(target);
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               return target;
+       }
+
+       @Override
+       protected String getDefaultExtension() {
+               return ".tex";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String date = "";
+               String author = "";
+               String title = "\\title{}";
+               String lang = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = "\\title{" + latexEncode(meta.getTitle()) + "}";
+                       date = "\\date{" + latexEncode(meta.getDate()) + "}";
+                       author = "\\author{" + latexEncode(meta.getAuthor()) + "}";
+                       lang = meta.getLang().toLowerCase();
+                       if (lang != null && !lang.isEmpty()) {
+                               lang = Instance.getConfig().getStringX(Config.LATEX_LANG, lang);
+                               if (lang == null) {
+                                       System.err.println(Instance.getTrans().getString(
+                                                       StringId.LATEX_LANG_UNKNOWN, lang));
+                               }
+                       }
+               }
+
+               writer.append("%\n");
+               writer.append("% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.\n");
+               writer.append("%\n\n");
+               writer.append("\\documentclass[a4paper]{book}\n");
+               if (lang != null && !lang.isEmpty()) {
+                       writer.append("\\usepackage[" + lang + "]{babel}\n");
+               }
+               writer.append("\\usepackage[utf8]{inputenc}\n");
+               writer.append("\\usepackage[T1]{fontenc}\n");
+               writer.append("\\usepackage{lmodern}\n");
+               writer.append("\\newcommand{\\br}{\\vspace{10 mm}}\n");
+               writer.append("\\newcommand{\\say}{--- \\noindent\\emph}\n");
+               writer.append("\\hyphenpenalty=1000\n");
+               writer.append("\\tolerance=5000\n");
+               writer.append("\\begin{document}\n");
+               if (story.getMeta() != null && story.getMeta().getDate() != null)
+                       writer.append(date + "\n");
+               writer.append(title + "\n");
+               writer.append(author + "\n");
+               writer.append("\\maketitle\n");
+               writer.append("\n");
+
+               // TODO: cover
+       }
+
+       @Override
+       protected void writeStoryFooter(Story story) throws IOException {
+               writer.append("\\end{document}\n");
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               writer.append("\n\n\\chapter{" + latexEncode(chap.getName()) + "}"
+                               + "\n");
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               writer.write("\n");
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "\\textsc{" + word + "}";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "\\emph{" + word + "}";
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+
+               line = decorateText(latexEncode(line));
+
+               switch (type) {
+               case BLANK:
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case BREAK:
+                       writer.write("\n\\br");
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case NORMAL:
+                       writer.write(line);
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case QUOTE:
+                       writer.write("\n\\say{" + line + "}\n");
+                       if (lastWasQuote) {
+                               writer.write("\n\\noindent{}");
+                       }
+                       lastWasQuote = true;
+                       break;
+               case IMAGE:
+                       // TODO
+                       break;
+               }
+       }
+
+       private String latexEncode(String input) {
+               StringBuilder builder = new StringBuilder();
+               for (char car : input.toCharArray()) {
+                       // TODO: check restricted chars?
+                       if (car == '^' || car == '$' || car == '\\' || car == '#'
+                                       || car == '%') {
+                               builder.append('\\');
+                               builder.append(car);
+                       } else if (car == openQuote) {
+                               builder.append('`');
+                       } else if (car == closeQuote) {
+                               builder.append('\'');
+                       } else if (car == openDoubleQuote) {
+                               builder.append("``");
+                       } else if (car == closeDoubleQuote) {
+                               builder.append("''");
+                       } else {
+                               builder.append(car);
+                       }
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Sysout.java b/src/be/nikiroo/fanfix/output/Sysout.java
new file mode 100644 (file)
index 0000000..f6cd789
--- /dev/null
@@ -0,0 +1,22 @@
+package be.nikiroo.fanfix.output;
+
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+
+class Sysout extends BasicOutput {
+       @Override
+       protected void writeStoryHeader(Story story) {
+               System.out.println(story);
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) {
+               System.out.println(chap);
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) {
+               System.out.println(para);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Text.java b/src/be/nikiroo/fanfix/output/Text.java
new file mode 100644 (file)
index 0000000..22056ed
--- /dev/null
@@ -0,0 +1,126 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+class Text extends BasicOutput {
+       protected FileWriter writer;
+       protected File targetDir;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension();
+
+               this.targetDir = targetDir;
+
+               File target = new File(targetDir, targetName);
+
+               writer = new FileWriter(target);
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               return target;
+       }
+
+       @Override
+       protected String getDefaultExtension() {
+               return ".txt";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String title = "";
+               String author = null;
+               String date = null;
+
+               MetaData meta = story.getMeta();
+               if (meta != null) {
+                       title = meta.getTitle() == null ? "" : meta.getTitle();
+                       author = meta.getAuthor();
+                       date = meta.getDate();
+               }
+
+               writer.write(title);
+               writer.write("\n");
+               if (author != null && !author.isEmpty()) {
+                       writer.write("©" + author);
+               }
+               if (date != null && !date.isEmpty()) {
+                       writer.write(" (");
+                       writer.write(date);
+                       writer.write(")");
+               }
+               writer.write("\n");
+
+               // resume:
+               if (meta != null && meta.getResume() != null) {
+                       writeChapter(meta.getResume());
+               }
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String txt;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       txt = Instance.getTrans().getString(StringId.CHAPTER_NAMED,
+                                       chap.getNumber(), chap.getName());
+               } else {
+                       txt = Instance.getTrans().getString(StringId.CHAPTER_UNNAMED,
+                                       chap.getNumber());
+               }
+
+               writer.write("\n" + txt + "\n");
+               for (int i = 0; i < txt.length(); i++) {
+                       writer.write("—");
+               }
+               writer.write("\n\n");
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               writer.write("\n");
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.IMAGE) {
+                       File file = new File(targetDir, getCurrentImageBestName(true));
+                       Instance.getCache().saveAsImage(new URL(para.getContent()), file);
+               }
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case BLANK:
+                       break;
+               case BREAK:
+                       writer.write("\n* * *\n");
+                       break;
+               case NORMAL:
+               case QUOTE:
+                       writer.write(line);
+                       break;
+               case IMAGE:
+                       writer.write("[" + getCurrentImageBestName(true) + "]");
+                       break;
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/epub.style.css b/src/be/nikiroo/fanfix/output/epub.style.css
new file mode 100644 (file)
index 0000000..3999b9c
--- /dev/null
@@ -0,0 +1,103 @@
+html {
+       text-align: justify;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+       /* padding-bottom: 20px; */
+       
+}
+
+.normal {
+       /* padding-bottom: 20px; */
+       
+}
+
+.dialogues {
+       /* padding-top: 10px;
+       padding-bottom: 10px; */
+       
+}
+
+.dialogue {
+       font-style: italic;
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/output/package-info.java b/src/be/nikiroo/fanfix/output/package-info.java
new file mode 100644 (file)
index 0000000..6b7e490
--- /dev/null
@@ -0,0 +1,12 @@
+/**
+ * This package contains all the output processors.
+ * <p>
+ * Of those, only {@link be.nikiroo.fanfix.output.BasicOutput} is public,
+ * but it contains a method 
+ * ({@link be.nikiroo.fanfix.output.BasicOutput#getOutput(be.nikiroo.fanfix.output.BasicOutput.OutputType, boolean)})
+ * to get all the other 
+ * {@link be.nikiroo.fanfix.output.BasicOutput.OutputType}s.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.output;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/package-info.java b/src/be/nikiroo/fanfix/package-info.java
new file mode 100644 (file)
index 0000000..e104339
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * Fanfic Reader is a program that can support a few different websites from 
+ * which to retrieve stories, then process them into <tt>epub</tt> (or other)
+ * files that you can read anywhere.
+ * <p>
+ * It has support for a {@link be.nikiroo.fanfix.Library} system, too.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/reader/CliReader.java b/src/be/nikiroo/fanfix/reader/CliReader.java
new file mode 100644 (file)
index 0000000..52a5ea4
--- /dev/null
@@ -0,0 +1,146 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.Library;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupport.SupportType;
+
+/**
+ * Command line {@link Story} reader.
+ * <p>
+ * Will output stories to the console.
+ * 
+ * @author niki
+ */
+public class CliReader {
+       private Story story;
+
+       /**
+        * Create a new {@link CliReader} for a {@link Story} in the {@link Library}
+        * .
+        * 
+        * @param luid
+        *            the {@link Story} ID
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public CliReader(String luid) throws IOException {
+               story = Instance.getLibrary().getStory(luid);
+               if (story == null) {
+                       throw new IOException("Cannot retrieve story from library: " + luid);
+               }
+       }
+
+       /**
+        * Create a new {@link CliReader} for an external {@link Story}.
+        * 
+        * @param source
+        *            the {@link Story} {@link URL}
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public CliReader(URL source) throws IOException {
+               BasicSupport support = BasicSupport.getSupport(source);
+               if (support == null) {
+                       throw new IOException("URL not supported: " + source.toString());
+               }
+
+               story = support.process(source);
+               if (story == null) {
+                       throw new IOException(
+                                       "Cannot retrieve story from external source: "
+                                                       + source.toString());
+
+               }
+       }
+
+       /**
+        * Read the information about the {@link Story}.
+        */
+       public void read() {
+               String title = "";
+               String author = "";
+
+               MetaData meta = story.getMeta();
+               if (meta != null) {
+                       if (meta.getTitle() != null) {
+                               title = meta.getTitle();
+                       }
+
+                       if (meta.getAuthor() != null) {
+                               author = "©" + meta.getAuthor();
+                               if (meta.getDate() != null && !meta.getDate().isEmpty()) {
+                                       author = author + " (" + meta.getDate() + ")";
+                               }
+                       }
+               }
+
+               System.out.println(title);
+               System.out.println(author);
+               System.out.println("");
+
+               for (Chapter chap : story) {
+                       if (chap.getName() != null && !chap.getName().isEmpty()) {
+                               System.out.println(Instance.getTrans().getString(
+                                               StringId.CHAPTER_NAMED, chap.getNumber(),
+                                               chap.getName()));
+                       } else {
+                               System.out.println(Instance.getTrans().getString(
+                                               StringId.CHAPTER_UNNAMED, chap.getNumber()));
+                       }
+               }
+       }
+
+       /**
+        * Read the selected chapter (starting at 1).
+        * 
+        * @param chapter
+        *            the chapter
+        */
+       public void read(int chapter) {
+               if (chapter > story.getChapters().size()) {
+                       System.err.println("Chapter " + chapter + ": no such chapter");
+               } else {
+                       Chapter chap = story.getChapters().get(chapter - 1);
+                       System.out.println("Chapter " + chap.getNumber() + ": "
+                                       + chap.getName());
+
+                       for (Paragraph para : chap) {
+                               System.out.println(para.getContent());
+                               System.out.println("");
+                       }
+               }
+       }
+
+       /**
+        * List all the stories available in the {@link Library} by
+        * {@link OutputType} (or all of them if the given type is NULL)
+        * 
+        * @param type
+        *            the {@link OutputType} or NULL for all stories
+        */
+       public static void list(SupportType type) {
+               List<MetaData> stories;
+               stories = Instance.getLibrary().getList(type);
+
+               for (MetaData story : stories) {
+                       String author = "";
+                       if (story.getAuthor() != null && !story.getAuthor().isEmpty()) {
+                               author = " (" + story.getAuthor() + ")";
+                       }
+
+                       System.out.println(story.getLuid() + ": " + story.getTitle()
+                                       + author);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport.java b/src/be/nikiroo/fanfix/supported/BasicSupport.java
new file mode 100644 (file)
index 0000000..74f1115
--- /dev/null
@@ -0,0 +1,1292 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class is the base class used by the other support classes. It can be
+ * used outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * <p>
+ * It will be used with 'resources' (usually web pages or files).
+ * 
+ * @author niki
+ */
+public abstract class BasicSupport {
+       /**
+        * The supported input types for which we can get a {@link BasicSupport}
+        * object.
+        * 
+        * @author niki
+        */
+       public enum SupportType {
+               /** EPUB files created with this program */
+               EPUB,
+               /** Pure text file with some rules */
+               TEXT,
+               /** TEXT but with associated .info file */
+               INFO_TEXT,
+               /** My Little Pony fanfictions */
+               FIMFICTION,
+               /** Fanfictions from a lot of different universes */
+               FANFICTION,
+               /** Website with lots of Mangas */
+               MANGAFOX,
+               /** Furry website with comics support */
+               E621,
+               /** CBZ files */
+               CBZ;
+
+               /**
+                * A description of this support type (more information than the
+                * {@link BasicSupport#getSourceName()}).
+                * 
+                * @return the description
+                */
+               public String getDesc() {
+                       String desc = Instance.getTrans().getStringX(StringId.INPUT_DESC,
+                                       this.name());
+
+                       if (desc == null) {
+                               desc = Instance.getTrans().getString(StringId.INPUT_DESC, this);
+                       }
+
+                       return desc;
+               }
+
+               /**
+                * The name of this support type (a short version).
+                * 
+                * @return the name
+                */
+               public String getSourceName() {
+                       BasicSupport support = BasicSupport.getSupport(this);
+                       if (support != null) {
+                               return support.getSourceName();
+                       }
+
+                       return null;
+               }
+
+               @Override
+               public String toString() {
+                       return super.toString().toLowerCase();
+               }
+
+               /**
+                * Call {@link SupportType#valueOf(String.toUpperCase())}.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static SupportType valueOfUC(String typeName) {
+                       return SupportType.valueOf(typeName == null ? null : typeName
+                                       .toUpperCase());
+               }
+
+               /**
+                * Call {@link SupportType#valueOf(String.toUpperCase())} but return
+                * NULL for NULL instead of raising exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static SupportType valueOfNullOkUC(String typeName) {
+                       if (typeName == null) {
+                               return null;
+                       }
+
+                       return SupportType.valueOfUC(typeName);
+               }
+
+               /**
+                * Call {@link SupportType#valueOf(String.toUpperCase())} but return
+                * NULL in case of error instead of raising an exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static SupportType valueOfAllOkUC(String typeName) {
+                       try {
+                               return SupportType.valueOfUC(typeName);
+                       } catch (Exception e) {
+                               return null;
+                       }
+               }
+       }
+
+       /** Only used by {@link BasicSupport#getInput()} just so it is always reset. */
+       private InputStream in;
+       private SupportType type;
+       private URL currentReferer; // with on 'r', as in 'HTTP'...
+
+       // quote chars
+       private char openQuote = Instance.getTrans().getChar(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getChar(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getChar(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getChar(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       /**
+        * The name of this support class.
+        * 
+        * @return the name
+        */
+       protected abstract String getSourceName();
+
+       /**
+        * Check if the given resource is supported by this {@link BasicSupport}.
+        * 
+        * @param url
+        *            the resource to check for
+        * 
+        * @return TRUE if it is
+        */
+       protected abstract boolean supports(URL url);
+
+       /**
+        * Return TRUE if the support will return HTML encoded content values for
+        * the chapters content.
+        * 
+        * @return TRUE for HTML
+        */
+       protected abstract boolean isHtml();
+
+       /**
+        * Return the story title.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the title
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getTitle(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the story author.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the author
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getAuthor(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the story publication date.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the date
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getDate(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the subject of the story (for instance, if it is a fanfiction,
+        * what is the original work; if it is a technical text, what is the
+        * technical subject...).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the subject
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getSubject(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the story description.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the description
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getDesc(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the story cover resource if any, or NULL if none.
+        * <p>
+        * The default cover should not be checked for here.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the cover or NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract URL getCover(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the list of chapters (name and resource).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the chapters
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract List<Entry<String, URL>> getChapters(URL source,
+                       InputStream in) throws IOException;
+
+       /**
+        * Return the content of the chapter (possibly HTML encoded, if
+        * {@link BasicSupport#isHtml()} is TRUE).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * @param number
+        *            the chapter number
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getChapterContent(URL source, InputStream in,
+                       int number) throws IOException;
+
+       /**
+        * Check if this {@link BasicSupport} is mainly catered to image files.
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isImageDocument(URL source, InputStream in)
+                       throws IOException {
+               return false;
+       }
+
+       /**
+        * Return the list of cookies (values included) that must be used to
+        * correctly fetch the resources.
+        * <p>
+        * You are expected to call the super method implementation if you override
+        * it.
+        * 
+        * @return the cookies
+        */
+       public Map<String, String> getCookies() {
+               return new HashMap<String, String>();
+       }
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata, except for the description.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story processMeta(URL url) throws IOException {
+               return processMeta(url, true, false);
+       }
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @param close
+        *            close "this" and "in" when done
+        * 
+        * @return the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story processMeta(URL url, boolean close, boolean getDesc)
+                       throws IOException {
+               in = Instance.getCache().open(url, this, false);
+               if (in == null) {
+                       return null;
+               }
+
+               try {
+                       preprocess(getInput());
+
+                       Story story = new Story();
+                       story.setMeta(new MetaData());
+                       story.getMeta().setTitle(ifUnhtml(getTitle(url, getInput())));
+                       story.getMeta().setAuthor(
+                                       fixAuthor(ifUnhtml(getAuthor(url, getInput()))));
+                       story.getMeta().setDate(ifUnhtml(getDate(url, getInput())));
+                       story.getMeta().setTags(getTags(url, getInput()));
+                       story.getMeta().setSource(getSourceName());
+                       story.getMeta().setPublisher(
+                                       ifUnhtml(getPublisher(url, getInput())));
+                       story.getMeta().setUuid(getUuid(url, getInput()));
+                       story.getMeta().setLuid(getLuid(url, getInput()));
+                       story.getMeta().setLang(getLang(url, getInput()));
+                       story.getMeta().setSubject(ifUnhtml(getSubject(url, getInput())));
+                       story.getMeta().setImageDocument(isImageDocument(url, getInput()));
+
+                       if (getDesc) {
+                               String descChapterName = Instance.getTrans().getString(
+                                               StringId.DESCRIPTION);
+                               story.getMeta().setResume(
+                                               makeChapter(url, 0, descChapterName,
+                                                               getDesc(url, getInput())));
+                       }
+
+                       return story;
+               } finally {
+                       if (close) {
+                               try {
+                                       close();
+                               } catch (IOException e) {
+                                       Instance.syserr(e);
+                               }
+
+                               if (in != null) {
+                                       in.close();
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Process the given story resource into a fully filled {@link Story}
+        * object.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story process(URL url) throws IOException {
+               setCurrentReferer(url);
+
+               try {
+                       Story story = processMeta(url, false, true);
+                       if (story == null) {
+                               return null;
+                       }
+
+                       story.setChapters(new ArrayList<Chapter>());
+
+                       URL cover = getCover(url, getInput());
+                       if (cover == null) {
+                               String subject = story.getMeta() == null ? null : story
+                                               .getMeta().getSubject();
+                               if (subject != null && !subject.isEmpty()
+                                               && Instance.getCoverDir() != null) {
+                                       File fileCover = new File(Instance.getCoverDir(), subject);
+                                       cover = getImage(fileCover.toURI().toURL(), subject);
+                               }
+                       }
+
+                       if (cover != null) {
+                               InputStream coverIn = null;
+                               try {
+                                       coverIn = Instance.getCache().open(cover, this, true);
+                                       story.getMeta().setCover(StringUtils.toImage(coverIn));
+                               } catch (IOException e) {
+                                       Instance.syserr(new IOException(Instance.getTrans()
+                                                       .getString(StringId.ERR_BS_NO_COVER, cover), e));
+                               } finally {
+                                       if (coverIn != null)
+                                               coverIn.close();
+                               }
+                       }
+
+                       List<Entry<String, URL>> chapters = getChapters(url, getInput());
+                       int i = 1;
+                       if (chapters != null) {
+                               for (Entry<String, URL> chap : chapters) {
+                                       setCurrentReferer(chap.getValue());
+                                       InputStream chapIn = Instance.getCache().open(
+                                                       chap.getValue(), this, true);
+                                       try {
+                                               story.getChapters().add(
+                                                               makeChapter(url, i, chap.getKey(),
+                                                                               getChapterContent(url, chapIn, i)));
+                                       } finally {
+                                               chapIn.close();
+                                       }
+                                       i++;
+                               }
+                       }
+
+                       return story;
+
+               } finally {
+                       try {
+                               close();
+                       } catch (IOException e) {
+                               Instance.syserr(e);
+                       }
+
+                       if (in != null) {
+                               in.close();
+                       }
+
+                       currentReferer = null;
+               }
+       }
+
+       /**
+        * The support type.$
+        * 
+        * @return the type
+        */
+       public SupportType getType() {
+               return type;
+       }
+
+       /**
+        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+        * the current {@link URL} we work on.
+        * 
+        * @return the referer
+        */
+       public URL getCurrentReferer() {
+               return currentReferer;
+       }
+
+       /**
+        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+        * the current {@link URL} we work on.
+        * 
+        * @param currentReferer
+        *            the new referer
+        */
+       protected void setCurrentReferer(URL currentReferer) {
+               this.currentReferer = currentReferer;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @param type
+        *            the new type
+        * 
+        * @return this
+        */
+       protected BasicSupport setType(SupportType type) {
+               this.type = type;
+               return this;
+       }
+
+       /**
+        * Return the story publisher (by default,
+        * {@link BasicSupport#getSourceName()}).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the publisher
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected String getPublisher(URL source, InputStream in)
+                       throws IOException {
+               return getSourceName();
+       }
+
+       /**
+        * Return the story UUID, a unique value representing the story (it is often
+        * an URL).
+        * <p>
+        * By default, this is the {@link URL} of the resource.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the uuid
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected String getUuid(URL source, InputStream in) throws IOException {
+               return source.toString();
+       }
+
+       /**
+        * Return the story Library UID, a unique value representing the story (it
+        * is often a number) in the local library.
+        * <p>
+        * By default, this is empty.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the id
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected String getLuid(URL source, InputStream in) throws IOException {
+               return "";
+       }
+
+       /**
+        * Return the 2-letter language code of this story.
+        * <p>
+        * By default, this is 'EN'.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the language
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected String getLang(URL source, InputStream in) throws IOException {
+               return "EN";
+       }
+
+       /**
+        * Return the list of tags for this story.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the tags
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected List<String> getTags(URL source, InputStream in)
+                       throws IOException {
+               return new ArrayList<String>();
+       }
+
+       /**
+        * Return the first line from the given input which correspond to the given
+        * selectors.
+        * <p>
+        * Do not reset the input, which will be pointing at the line just after the
+        * result (input will be spent if no result is found).
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * 
+        * @return the line
+        */
+       protected String getLine(InputStream in, String needle, int relativeLine) {
+               return getLine(in, needle, relativeLine, true);
+       }
+
+       /**
+        * Return a line from the given input which correspond to the given
+        * selectors.
+        * <p>
+        * Do not reset the input, which will be pointing at the line just after the
+        * result (input will be spent if no result is found) when first is TRUE,
+        * and will always be spent if first is FALSE.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * @param first
+        *            takes the first result (as opposed to the last one, which will
+        *            also always spend the input)
+        * 
+        * @return the line
+        */
+       protected String getLine(InputStream in, String needle, int relativeLine,
+                       boolean first) {
+               String rep = null;
+
+               List<String> lines = new ArrayList<String>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               int index = -1;
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       lines.add(scan.next());
+
+                       if (index == -1 && lines.get(lines.size() - 1).contains(needle)) {
+                               index = lines.size() - 1;
+                       }
+
+                       if (index >= 0 && index + relativeLine < lines.size()) {
+                               rep = lines.get(index + relativeLine);
+                               if (first) {
+                                       break;
+                               }
+                       }
+               }
+
+               return rep;
+       }
+
+       /**
+        * Prepare the support if needed before processing.
+        * 
+        * @throws IOException
+        *             on I/O error
+        */
+       protected void preprocess(InputStream in) throws IOException {
+       }
+
+       /**
+        * Now that we have processed the {@link Story}, close the resources if any.
+        * 
+        * @throws IOException
+        *             on I/O error
+        */
+       protected void close() throws IOException {
+       }
+
+       /**
+        * Create a {@link Chapter} object from the given information, formatting
+        * the content as it should be.
+        * 
+        * @param number
+        *            the chapter number
+        * @param name
+        *            the chapter name
+        * @param content
+        *            the chapter content
+        * 
+        * @return the {@link Chapter}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Chapter makeChapter(URL source, int number, String name,
+                       String content) throws IOException {
+
+               // Chapter name: process it correctly, then remove the possible
+               // redundant "Chapter x: " in front of it
+               String chapterName = processPara(name).getContent().trim();
+               for (String lang : Instance.getConfig().getString(Config.CHAPTER)
+                               .split(",")) {
+                       String chapterWord = Instance.getConfig().getStringX(
+                                       Config.CHAPTER, lang);
+                       if (chapterName.startsWith(chapterWord)) {
+                               chapterName = chapterName.substring(chapterWord.length())
+                                               .trim();
+                               break;
+                       }
+               }
+
+               if (chapterName.startsWith(Integer.toString(number))) {
+                       chapterName = chapterName.substring(
+                                       Integer.toString(number).length()).trim();
+               }
+
+               if (chapterName.startsWith(":")) {
+                       chapterName = chapterName.substring(1).trim();
+               }
+               //
+
+               Chapter chap = new Chapter(number, chapterName);
+
+               if (content == null) {
+                       return chap;
+               }
+
+               if (isHtml()) {
+                       // Special <HR> processing:
+                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
+                                       "\n* * *\n");
+               }
+
+               InputStream in = new ByteArrayInputStream(
+                               content.getBytes(StandardCharsets.UTF_8));
+               try {
+                       @SuppressWarnings("resource")
+                       Scanner scan = new Scanner(in, "UTF-8");
+                       scan.useDelimiter("(\\n|</p>)"); // \n for test, </p> for html
+
+                       List<Paragraph> paras = new ArrayList<Paragraph>();
+                       while (scan.hasNext()) {
+                               String line = scan.next().trim();
+                               boolean image = false;
+                               if (line.startsWith("[") && line.endsWith("]")) {
+                                       URL url = getImage(source,
+                                                       line.substring(1, line.length() - 1).trim());
+                                       if (url != null) {
+                                               paras.add(new Paragraph(url));
+                                               image = true;
+                                       }
+                               }
+
+                               if (!image) {
+                                       paras.add(processPara(line));
+                               }
+                       }
+
+                       // Check quotes for "bad" format
+                       List<Paragraph> newParas = new ArrayList<Paragraph>();
+                       for (Paragraph para : paras) {
+                               newParas.addAll(requotify(para));
+                       }
+                       paras = newParas;
+
+                       // Remove double blanks/brks
+                       boolean space = false;
+                       boolean brk = true;
+                       for (int i = 0; i < paras.size(); i++) {
+                               Paragraph para = paras.get(i);
+                               boolean thisSpace = para.getType() == ParagraphType.BLANK;
+                               boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+                               if (space && thisBrk) {
+                                       paras.remove(i - 1);
+                                       i--;
+                               } else if ((space || brk) && (thisSpace || thisBrk)) {
+                                       paras.remove(i);
+                                       i--;
+                               }
+
+                               space = thisSpace;
+                               brk = thisBrk;
+                       }
+
+                       // Remove blank/brk at start
+                       if (paras.size() > 0
+                                       && (paras.get(0).getType() == ParagraphType.BLANK || paras
+                                                       .get(0).getType() == ParagraphType.BREAK)) {
+                               paras.remove(0);
+                       }
+
+                       // Remove blank/brk at end
+                       int last = paras.size() - 1;
+                       if (paras.size() > 0
+                                       && (paras.get(last).getType() == ParagraphType.BLANK || paras
+                                                       .get(last).getType() == ParagraphType.BREAK)) {
+                               paras.remove(last);
+                       }
+
+                       chap.setParagraphs(paras);
+
+                       return chap;
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @return the extensions
+        */
+       protected String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               } else {
+                       return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param source
+        *            the story source
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       protected URL getImage(URL source, String line) {
+               String path = new File(source.getFile()).getParent();
+               URL url = null;
+
+               // try for files
+               try {
+                       String urlBase = new File(new File(path), line.trim()).toURI()
+                                       .toURL().toString();
+                       for (String ext : getImageExt(true)) {
+                               if (new File(urlBase + ext).exists()) {
+                                       url = new File(urlBase + ext).toURI().toURL();
+                               }
+                       }
+               } catch (Exception e) {
+                       // Nothing to do here
+               }
+
+               if (url == null) {
+                       // try for URLs
+                       try {
+                               for (String ext : getImageExt(true)) {
+                                       if (Instance.getCache().check(new URL(line + ext))) {
+                                               url = new URL(line + ext);
+                                       }
+                               }
+
+                               // try out of cache
+                               if (url == null) {
+                                       for (String ext : getImageExt(true)) {
+                                               try {
+                                                       url = new URL(line + ext);
+                                                       Instance.getCache().refresh(url, this, true);
+                                                       break;
+                                               } catch (IOException e) {
+                                                       // no image with this ext
+                                                       url = null;
+                                               }
+                                       }
+                               }
+                       } catch (MalformedURLException e) {
+                               // Not an url
+                       }
+               }
+
+               // refresh the cached file
+               if (url != null) {
+                       try {
+                               Instance.getCache().refresh(url, this, true);
+                       } catch (IOException e) {
+                               // woops, broken image
+                               url = null;
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Reset then return {@link BasicSupport#in}.
+        * 
+        * @return {@link BasicSupport#in}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected InputStream getInput() throws IOException {
+               in.reset();
+               return in;
+       }
+
+       /**
+        * Fix the author name if it is prefixed with some "by" {@link String}.
+        * 
+        * @param author
+        *            the author with a possible prefix
+        * 
+        * @return the author without prefixes
+        */
+       private String fixAuthor(String author) {
+               if (author != null) {
+                       for (String suffix : new String[] { " ", ":" }) {
+                               for (String byString : Instance.getConfig()
+                                               .getString(Config.BYS).split(",")) {
+                                       byString += suffix;
+                                       if (author.toUpperCase().startsWith(byString.toUpperCase())) {
+                                               author = author.substring(byString.length()).trim();
+                                       }
+                               }
+                       }
+
+                       // Special case (without suffix):
+                       if (author.startsWith("©")) {
+                               author = author.substring(1);
+                       }
+               }
+
+               return author;
+       }
+
+       /**
+        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+        * and requotify them (i.e., separate them into QUOTE paragraphs and other
+        * paragraphs (quotes or not)).
+        * 
+        * @param para
+        *            the paragraph to requotify (not necessaraly a quote)
+        * 
+        * @return the correctly (or so we hope) quotified paragraphs
+        */
+       private List<Paragraph> requotify(Paragraph para) {
+               List<Paragraph> newParas = new ArrayList<Paragraph>();
+
+               if (para.getType() == ParagraphType.QUOTE) {
+                       String line = para.getContent();
+                       boolean singleQ = line.startsWith("" + openQuote);
+                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+                       if (!singleQ && !doubleQ) {
+                               line = openDoubleQuote + line + closeDoubleQuote;
+                               newParas.add(new Paragraph(ParagraphType.QUOTE, line));
+                       } else {
+                               char close = singleQ ? closeQuote : closeDoubleQuote;
+                               int posClose = line.indexOf(close);
+                               int posDot = line.indexOf(".");
+                               while (posDot >= 0 && posDot < posClose) {
+                                       posDot = line.indexOf(".", posDot + 1);
+                               }
+
+                               if (posDot >= 0) {
+                                       String rest = line.substring(posDot + 1).trim();
+                                       line = line.substring(0, posDot + 1).trim();
+                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line));
+                                       newParas.addAll(requotify(processPara(rest)));
+                               } else {
+                                       newParas.add(para);
+                               }
+                       }
+               } else {
+                       newParas.add(para);
+               }
+
+               return newParas;
+       }
+
+       /**
+        * Process a {@link Paragraph} from a raw line of text.
+        * <p>
+        * Will also fix quotes and HTML encoding if needed.
+        * 
+        * @param line
+        *            the raw line
+        * 
+        * @return the processed {@link Paragraph}
+        */
+       private Paragraph processPara(String line) {
+               line = ifUnhtml(line).trim();
+
+               boolean space = true;
+               boolean brk = true;
+               boolean quote = false;
+               boolean tentativeCloseQuote = false;
+               char prev = '\0';
+               int dashCount = 0;
+
+               StringBuilder builder = new StringBuilder();
+               for (char car : line.toCharArray()) {
+                       if (car != '-') {
+                               if (dashCount > 0) {
+                                       // dash, ndash and mdash: - – —
+                                       // currently: always use mdash
+                                       builder.append(dashCount == 1 ? '-' : '—');
+                               }
+                               dashCount = 0;
+                       }
+
+                       if (tentativeCloseQuote) {
+                               tentativeCloseQuote = false;
+                               if ((car >= 'a' && car <= 'z') || (car >= 'A' && car <= 'Z')
+                                               || (car >= '0' && car <= '9')) {
+                                       builder.append("'");
+                               } else {
+                                       builder.append(closeQuote);
+                               }
+                       }
+
+                       switch (car) {
+                       case ' ': // note: unbreakable space
+                       case ' ':
+                       case '\t':
+                       case '\n': // just in case
+                       case '\r': // just in case
+                               builder.append(' ');
+                               break;
+
+                       case '\'':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openQuote);
+                               } else if (prev == ' ') {
+                                       builder.append(openQuote);
+                               } else {
+                                       // it is a quote ("I'm off") or a 'quote' ("This
+                                       // 'good' restaurant"...)
+                                       tentativeCloseQuote = true;
+                               }
+                               break;
+
+                       case '"':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else if (prev == ' ') {
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(closeDoubleQuote);
+                               }
+                               break;
+
+                       case '-':
+                               if (space) {
+                                       quote = true;
+                               } else {
+                                       dashCount++;
+                               }
+                               space = false;
+                               break;
+
+                       case '*':
+                       case '~':
+                       case '/':
+                       case '\\':
+                       case '<':
+                       case '>':
+                       case '=':
+                       case '+':
+                       case '_':
+                       case '–':
+                       case '—':
+                               space = false;
+                               builder.append(car);
+                               break;
+
+                       case '‘':
+                       case '`':
+                       case '‹':
+                       case '﹁':
+                       case '〈':
+                       case '「':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openQuote);
+                               } else {
+                                       builder.append(openQuote);
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '’':
+                       case '›':
+                       case '﹂':
+                       case '〉':
+                       case '」':
+                               space = false;
+                               brk = false;
+                               builder.append(closeQuote);
+                               break;
+
+                       case '«':
+                       case '“':
+                       case '﹃':
+                       case '《':
+                       case '『':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(openDoubleQuote);
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '»':
+                       case '”':
+                       case '﹄':
+                       case '》':
+                       case '』':
+                               space = false;
+                               brk = false;
+                               builder.append(closeDoubleQuote);
+                               break;
+
+                       default:
+                               space = false;
+                               brk = false;
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (tentativeCloseQuote) {
+                       tentativeCloseQuote = false;
+                       builder.append(closeQuote);
+               }
+
+               line = builder.toString().trim();
+
+               ParagraphType type = ParagraphType.NORMAL;
+               if (space) {
+                       type = ParagraphType.BLANK;
+               } else if (brk) {
+                       type = ParagraphType.BREAK;
+               } else if (quote) {
+                       type = ParagraphType.QUOTE;
+               }
+
+               return new Paragraph(type, line);
+       }
+
+       /**
+        * Remove the HTML from the inpit <b>if</b> {@link BasicSupport#isHtml()} is
+        * true.
+        * 
+        * @param input
+        *            the input
+        * 
+        * @return the no html version if needed
+        */
+       private String ifUnhtml(String input) {
+               if (isHtml() && input != null) {
+                       return StringUtils.unhtml(input);
+               }
+
+               return input;
+       }
+
+       /**
+        * Return a {@link BasicSupport} implementation supporting the given
+        * resource if possible.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       public static BasicSupport getSupport(URL url) {
+               if (url == null) {
+                       return null;
+               }
+
+               // TEXT and INFO_TEXT always support files (not URLs though)
+               for (SupportType type : SupportType.values()) {
+                       if (type != SupportType.TEXT && type != SupportType.INFO_TEXT) {
+                               BasicSupport support = getSupport(type);
+                               if (support != null && support.supports(url)) {
+                                       return support;
+                               }
+                       }
+               }
+
+               for (SupportType type : new SupportType[] { SupportType.TEXT,
+                               SupportType.INFO_TEXT }) {
+                       BasicSupport support = getSupport(type);
+                       if (support != null && support.supports(url)) {
+                               return support;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return a {@link BasicSupport} implementation supporting the given type.
+        * 
+        * @param type
+        *            the type
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       public static BasicSupport getSupport(SupportType type) {
+               switch (type) {
+               case EPUB:
+                       return new Epub().setType(type);
+               case INFO_TEXT:
+                       return new InfoText().setType(type);
+               case FIMFICTION:
+                       return new Fimfiction().setType(type);
+               case FANFICTION:
+                       return new Fanfiction().setType(type);
+               case TEXT:
+                       return new Text().setType(type);
+               case MANGAFOX:
+                       return new MangaFox().setType(type);
+               case E621:
+                       return new E621().setType(type);
+               case CBZ:
+                       return new Cbz().setType(type);
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Cbz.java b/src/be/nikiroo/fanfix/supported/Cbz.java
new file mode 100644 (file)
index 0000000..012c047
--- /dev/null
@@ -0,0 +1,93 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+
+/**
+ * Support class for CBZ files (works better with CBZ created with this program,
+ * as they have some metadata available).
+ * 
+ * @author niki
+ */
+class Cbz extends Epub {
+       @Override
+       protected boolean supports(URL url) {
+               return url.toString().toLowerCase().endsWith(".cbz");
+       }
+
+       @Override
+       public String getSourceName() {
+               return "cbz";
+       }
+
+       @Override
+       protected String getDataPrefix() {
+               return "";
+       }
+
+       @Override
+       protected boolean requireInfo() {
+               return false;
+       }
+       
+       @Override
+       public boolean isImageDocument(URL source, InputStream in)
+                       throws IOException {
+               return true;
+       }
+
+       @Override
+       protected boolean getCover() {
+               return false;
+       }
+
+       @Override
+       public Story process(URL url) throws IOException {
+               Story story = processMeta(url, false, true);
+               story.setChapters(new ArrayList<Chapter>());
+               Chapter chap = new Chapter(1, null);
+               story.getChapters().add(chap);
+
+               ZipInputStream zipIn = new ZipInputStream(getInput());
+
+               for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
+                               .getNextEntry()) {
+                       if (!entry.isDirectory()
+                                       && entry.getName().startsWith(getDataPrefix())) {
+                               String entryLName = entry.getName().toLowerCase();
+                               boolean imageEntry = false;
+                               for (String ext : getImageExt(false)) {
+                                       if (entryLName.endsWith(ext)) {
+                                               imageEntry = true;
+                                       }
+                               }
+
+                               if (imageEntry) {
+                                       try {
+                                               // we assume that we can get the UUID without a stream
+                                               String uuid = getUuid(url, null) + "_"
+                                                               + entry.getName();
+
+                                               Instance.getCache().addToCache(zipIn, uuid);
+                                               chap.getParagraphs().add(
+                                                               new Paragraph(new File(uuid).toURI().toURL()));
+                                       } catch (Exception e) {
+                                               Instance.syserr(e);
+                                       }
+                               }
+                       }
+               }
+
+               return story;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/E621.java b/src/be/nikiroo/fanfix/supported/E621.java
new file mode 100644 (file)
index 0000000..2455c87
--- /dev/null
@@ -0,0 +1,257 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://e621.net/">e621.net</a> and <a
+ * href="http://e926.net/">e926.net</a>, a Furry website supporting comics,
+ * including some of MLP.
+ * <p>
+ * <a href="http://e926.net/">e926.net</a> only shows the "clean" images and
+ * comics, but it can be difficult to browse.
+ * 
+ * @author niki
+ */
+class E621 extends BasicSupport {
+       @Override
+       public String getSourceName() {
+               return "e621.net";
+       }
+
+       @Override
+       public boolean isImageDocument(URL source, InputStream in) {
+               return true;
+       }
+
+       @Override
+       public Story process(URL url) throws IOException {
+               // There is no chapters on e621, just pagination...
+               Story story = super.process(url);
+
+               Chapter only = new Chapter(1, null);
+               for (Chapter chap : story) {
+                       only.getParagraphs().addAll(chap.getParagraphs());
+               }
+
+               story.getChapters().clear();
+               story.getChapters().add(only);
+
+               return story;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               String host = url.getHost();
+               if (host.startsWith("www.")) {
+                       host = host.substring("www.".length());
+               }
+
+               return ("e621.net".equals(host) || "e926.net".equals(host))
+                               && url.getPath().startsWith("/pool/");
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected String getAuthor(URL source, InputStream in) throws IOException {
+               String author = getLine(in, "href=\"/post/show/", 0);
+               if (author != null) {
+                       String key = "href=\"";
+                       int pos = author.indexOf(key);
+                       if (pos >= 0) {
+                               author = author.substring(pos + key.length());
+                               pos = author.indexOf("\"");
+                               if (pos >= 0) {
+                                       author = author.substring(0, pos - 1);
+                                       String page = source.getProtocol() + "://"
+                                                       + source.getHost() + author;
+                                       InputStream pageIn = Instance.getCache().open(
+                                                       new URL(page), this, false);
+                                       try {
+                                               key = "class=\"tag-type-artist\"";
+                                               author = getLine(pageIn, key, 0);
+                                               if (author != null) {
+                                                       pos = author.indexOf("<a href=\"");
+                                                       if (pos >= 0) {
+                                                               author = author.substring(pos);
+                                                               pos = author.indexOf("</a>");
+                                                               if (pos >= 0) {
+                                                                       author = author.substring(0, pos);
+                                                                       return StringUtils.unhtml(author);
+                                                               }
+                                                       }
+                                               }
+                                       } finally {
+                                               pageIn.close();
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDate(URL source, InputStream in) throws IOException {
+               return null;
+       }
+
+       @Override
+       protected String getSubject(URL source, InputStream in) throws IOException {
+               return null;
+       }
+
+       @Override
+       protected URL getCover(URL source, InputStream in) throws IOException {
+               return null;
+       }
+
+       @Override
+       protected String getTitle(URL source, InputStream in) throws IOException {
+               String title = getLine(in, "<title>", 0);
+               if (title != null) {
+                       int pos = title.indexOf('>');
+                       if (pos >= 0) {
+                               title = title.substring(pos + 1);
+                               pos = title.indexOf('<');
+                               if (pos >= 0) {
+                                       title = title.substring(0, pos);
+                               }
+                       }
+
+                       if (title.startsWith("Pool:")) {
+                               title = title.substring("Pool:".length());
+                       }
+
+                       title = title.trim();
+               }
+
+               return title;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               String desc = getLine(in, "margin-bottom: 2em;", 0);
+
+               if (desc != null) {
+                       StringBuilder builder = new StringBuilder();
+
+                       boolean inTags = false;
+                       for (char car : desc.toCharArray()) {
+                               if ((inTags && car == '>') || (!inTags && car == '<')) {
+                                       inTags = !inTags;
+                               }
+
+                               if (inTags) {
+                                       builder.append(car);
+                               }
+                       }
+
+                       return builder.toString().trim();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in)
+                       throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               int last = 1; // no pool/show when only one page
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       for (int pos = line.indexOf(source.getPath()); pos >= 0; pos = line
+                                       .indexOf(source.getPath(), pos + source.getPath().length())) {
+                               int equalPos = line.indexOf("=", pos);
+                               int quotePos = line.indexOf("\"", pos);
+                               if (equalPos >= 0 && quotePos > equalPos) {
+                                       String snum = line.substring(equalPos + 1, quotePos);
+                                       try {
+                                               int num = Integer.parseInt(snum);
+                                               if (num > last) {
+                                                       last = num;
+                                               }
+                                       } catch (NumberFormatException e) {
+                                       }
+                               }
+                       }
+               }
+
+               for (int i = 1; i <= last; i++) {
+                       final String key = Integer.toString(i);
+                       final URL value = new URL(source.toString() + "?page=" + i);
+                       urls.add(new Entry<String, URL>() {
+                               public URL setValue(URL value) {
+                                       return null;
+                               }
+
+                               public URL getValue() {
+                                       return value;
+                               }
+
+                               public String getKey() {
+                                       return key;
+                               }
+                       });
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number)
+                       throws IOException {
+               StringBuilder builder = new StringBuilder();
+               String staticSite = "https://static1.e621.net";
+               if (source.getHost().contains("e926")) {
+                       staticSite = staticSite.replace("e621", "e926");
+               }
+
+               String key = staticSite + "/data/preview/";
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("class=\"preview\"")) {
+                               for (int pos = line.indexOf(key); pos >= 0; pos = line.indexOf(
+                                               key, pos + key.length())) {
+                                       int endPos = line.indexOf("\"", pos);
+                                       if (endPos >= 0) {
+                                               String id = line.substring(pos + key.length(), endPos);
+                                               id = staticSite + "/data/" + id;
+
+                                               int dotPos = id.lastIndexOf(".");
+                                               if (dotPos >= 0) {
+                                                       id = id.substring(0, dotPos);
+                                                       builder.append("[");
+                                                       builder.append(id);
+                                                       builder.append("]\n");
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Epub.java b/src/be/nikiroo/fanfix/supported/Epub.java
new file mode 100644 (file)
index 0000000..31bf725
--- /dev/null
@@ -0,0 +1,293 @@
+package be.nikiroo.fanfix.supported;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import javax.imageio.ImageIO;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.MarkableFileInputStream;
+
+/**
+ * Support class for EPUB files created with this program (as we need some
+ * metadata available in those we create).
+ * 
+ * @author niki
+ */
+class Epub extends BasicSupport {
+       private InfoText base;
+       private URL fakeSource;
+
+       private File tmpCover;
+       private File tmpInfo;
+       private File tmp;
+
+       /** Only used by {@link Epub#getInput()} so it is always reset. */
+       private InputStream in;
+
+       @Override
+       public String getSourceName() {
+               return "epub";
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               if (url.getPath().toLowerCase().endsWith(".epub")) {
+                       return true;
+               }
+
+               return false;
+       }
+
+       @Override
+       protected boolean isHtml() {
+               if (tmpInfo.exists()) {
+                       return base.isHtml();
+               }
+
+               return false;
+       }
+
+       @Override
+       protected String getTitle(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getTitle(fakeSource, getFakeInput());
+               }
+
+               return source.toString();
+       }
+
+       @Override
+       protected String getAuthor(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getAuthor(fakeSource, getFakeInput());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDate(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getDate(fakeSource, getFakeInput());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getSubject(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getSubject(fakeSource, getFakeInput());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getDesc(fakeSource, getFakeInput());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected URL getCover(URL source, InputStream in) throws IOException {
+               if (tmpCover.exists()) {
+                       return tmpCover.toURI().toURL();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in)
+                       throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getChapters(fakeSource, getFakeInput());
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number)
+                       throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getChapterContent(fakeSource, getFakeInput(), number);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getLang(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getLang(fakeSource, getFakeInput());
+               }
+
+               return super.getLang(source, in);
+       }
+
+       @Override
+       protected String getPublisher(URL source, InputStream in)
+                       throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getPublisher(fakeSource, getFakeInput());
+               }
+
+               return super.getPublisher(source, in);
+       }
+
+       @Override
+       protected List<String> getTags(URL source, InputStream in)
+                       throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getTags(fakeSource, getFakeInput());
+               }
+
+               return super.getTags(source, in);
+       }
+
+       @Override
+       protected String getUuid(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getUuid(fakeSource, getFakeInput());
+               }
+
+               return super.getUuid(source, in);
+       }
+
+       @Override
+       protected String getLuid(URL source, InputStream in) throws IOException {
+               if (tmpInfo.exists()) {
+                       return base.getLuid(fakeSource, getFakeInput());
+               }
+
+               return super.getLuid(source, in);
+       }
+
+       @Override
+       protected void preprocess(InputStream in) throws IOException {
+               // Note: do NOT close this stream, as it would also close "in"
+               ZipInputStream zipIn = new ZipInputStream(in);
+               tmp = File.createTempFile("fanfic-reader-parser_", ".tmp");
+               tmpInfo = new File(tmp + ".info");
+               tmpCover = File.createTempFile("fanfic-reader-parser_", ".tmp");
+
+               base = new InfoText();
+               fakeSource = tmp.toURI().toURL();
+
+               for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
+                               .getNextEntry()) {
+                       if (!entry.isDirectory()
+                                       && entry.getName().startsWith(getDataPrefix())) {
+                               String entryLName = entry.getName().toLowerCase();
+                               boolean imageEntry = false;
+                               for (String ext : getImageExt(false)) {
+                                       if (entryLName.endsWith(ext)) {
+                                               imageEntry = true;
+                                       }
+                               }
+
+                               if (entry.getName().equals(getDataPrefix() + "version")) {
+                                       // Nothing to do for now ("first"
+                                       // version is 3.0)
+                               } else if (entryLName.endsWith(".info")) {
+                                       // Info file
+                                       IOUtils.write(zipIn, tmpInfo);
+                               } else if (imageEntry) {
+                                       // Cover
+                                       if (getCover()) {
+                                               try {
+                                                       BufferedImage image = ImageIO.read(zipIn);
+                                                       ImageIO.write(image, Instance.getConfig()
+                                                                       .getString(Config.IMAGE_FORMAT_COVER)
+                                                                       .toLowerCase(), tmpCover);
+                                               } catch (Exception e) {
+                                                       Instance.syserr(e);
+                                               }
+                                       }
+                               } else if (entry.getName().equals(getDataPrefix() + "URL")) {
+                                       // Do nothing
+                               } else if (entry.getName().equals(getDataPrefix() + "SUMMARY")) {
+                                       // Do nothing
+                               } else {
+                                       // Hopefully the data file
+                                       IOUtils.write(zipIn, tmp);
+                               }
+                       }
+               }
+
+               if (requireInfo() && (!tmp.exists() || !tmpInfo.exists())) {
+                       throw new IOException(
+                                       "file not supported (maybe not created with this program or corrupt)");
+               }
+
+               if (tmp.exists()) {
+                       this.in = new MarkableFileInputStream(new FileInputStream(tmp));
+               }
+       }
+
+       @Override
+       protected void close() throws IOException {
+               for (File file : new File[] { tmp, tmpInfo, tmpCover }) {
+                       if (file != null && file.exists()) {
+                               if (!file.delete()) {
+                                       file.deleteOnExit();
+                               }
+                       }
+               }
+
+               tmp = null;
+               tmpInfo = null;
+               tmpCover = null;
+               fakeSource = null;
+
+               try {
+                       if (in != null) {
+                               in.close();
+                       }
+               } finally {
+                       in = null;
+                       base.close();
+               }
+       }
+
+       protected String getDataPrefix() {
+               return "DATA/";
+       }
+
+       protected boolean requireInfo() {
+               return true;
+       }
+
+       protected boolean getCover() {
+               return true;
+       }
+
+       /**
+        * Reset then return {@link Epub#in}.
+        * 
+        * @return {@link Epub#in}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private InputStream getFakeInput() throws IOException {
+               in.reset();
+               return in;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Fanfiction.java b/src/be/nikiroo/fanfix/supported/Fanfiction.java
new file mode 100644 (file)
index 0000000..cbbc085
--- /dev/null
@@ -0,0 +1,289 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://www.fanfiction.net/">Faniction.net</a>
+ * stories, a website dedicated to fanfictions of many, many different
+ * universes, from TV shows to novels to games.
+ * 
+ * @author niki
+ */
+class Fanfiction extends BasicSupport {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       public String getSourceName() {
+               return "Fanfiction.net";
+       }
+
+       @Override
+       protected String getSubject(URL source, InputStream in) {
+               String line = getLine(in, "id=pre_story_links", 0);
+               if (line != null) {
+                       int pos = line.lastIndexOf('"');
+                       if (pos >= 1) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('<');
+                               if (pos >= 0) {
+                                       return line.substring(0, pos);
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<String> getTags(URL source, InputStream in)
+                       throws IOException {
+               List<String> tags = super.getTags(source, in);
+
+               String key = "title=\"Send Private Message\"";
+               String line = getLine(in, key, 2);
+               if (line != null) {
+                       key = "Rated:";
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               key = "Chapters:";
+                               pos = line.indexOf(key);
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos);
+                                       line = StringUtils.unhtml(line).trim();
+                                       if (line.endsWith("-")) {
+                                               line = line.substring(0, line.length() - 1);
+                                       }
+
+                                       for (String tag : line.split("-")) {
+                                               tags.add(tag.trim());
+                                       }
+                               }
+                       }
+               }
+