From: Niki Roo Date: Sat, 11 Feb 2017 21:24:09 +0000 (+0100) Subject: Initial commit (working) X-Git-Tag: fanfix-0.9.1~11 X-Git-Url: https://git.nikiroo.be/?a=commitdiff_plain;h=08fe2e33007063e30fe22dc1d290f8afaa18eb1d;p=fanfix.git Initial commit (working) 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. --- diff --git a/Makefile.base b/Makefile.base new file mode 100644 index 0000000..300db50 --- /dev/null +++ b/Makefile.base @@ -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)" + diff --git a/README.md b/README.md index 83ca081..39f7fb4 100644 --- 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 index 0000000..2eab91b --- /dev/null +++ b/configure.sh @@ -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 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 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 index 0000000..9cec6ec --- /dev/null +++ b/libs/unbescape-1.1.4_ChangeLog.txt @@ -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 index 0000000..d645695 --- /dev/null +++ b/libs/unbescape-1.1.4_LICENSE.txt @@ -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 index 0000000..409851f --- /dev/null +++ b/src/.gitattributes @@ -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 index 0000000..5c79834 --- /dev/null +++ b/src/.gitignore @@ -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 index 0000000..75a0f5d --- /dev/null +++ b/src/be/nikiroo/fanfix/Cache.java @@ -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. + *

+ * 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). + *

+ * 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. + *

+ * 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 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 index 0000000..5c198ee --- /dev/null +++ b/src/be/nikiroo/fanfix/Instance.java @@ -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 index 0000000..9864ad7 --- /dev/null +++ b/src/be/nikiroo/fanfix/Library.java @@ -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. + *

+ * 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 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(); + 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 getList(SupportType type) { + String typeString = type == null ? null : type.getSourceName(); + + List list = new ArrayList(); + for (Entry 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 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 must 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 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 index 0000000..45b87c4 --- /dev/null +++ b/src/be/nikiroo/fanfix/Main.java @@ -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. + *

+ * Known environment variables: + *

+ * + * @param args + *
    + *
  1. --import [URL]: import into library
  2. --export [id] + * [output_type] [target]: export story to target
  3. + * --convert [URL] [output_type] [target]: convert URL into + * target
  4. --read [id]: read the given story from the + * library
  5. --read-url [URL]: convert on the fly and read + * the story, without saving it
  6. --list: list the stories + * present in the library
  7. + *
+ */ + 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} or + * 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 index 0000000..969eb27 --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/Config.java @@ -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 index 0000000..4f2303e --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/ConfigBundle.java @@ -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 { + 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 index 0000000..39cb0b2 --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/StringId.java @@ -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 must 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 .properties files. + *

+ * Use it when you need NO translation. + */ + NULL, // + /** + * A special key used for technical reasons only, without annotations so it + * is not visible in .properties files. + *

+ * 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 .properties 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 index 0000000..3456b67 --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/StringIdBundle.java @@ -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 { + /** + * 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 index 0000000..212f8a7 --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/Target.java @@ -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 + * .properties 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 index 0000000..8a1d6c1 --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/config.properties @@ -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 index 0000000..50db011 --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/package-info.java @@ -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 index 0000000..13f6c2f --- /dev/null +++ b/src/be/nikiroo/fanfix/bundles/resources.properties @@ -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 index 0000000..5516063 --- /dev/null +++ b/src/be/nikiroo/fanfix/data/Chapter.java @@ -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 { + private String name; + private int number; + private List paragraphs = new ArrayList(); + private List empty = new ArrayList(); + + /** + * 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 getParagraphs() { + return paragraphs; + } + + /** + * The included paragraphs. + * + * @param paragraphes + * the paragraphs to set + */ + public void setParagraphs(List paragraphs) { + this.paragraphs = paragraphs; + } + + /** + * Get an iterator on the {@link Paragraph}s. + */ + public Iterator 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 index 0000000..3980e96 --- /dev/null +++ b/src/be/nikiroo/fanfix/data/MetaData.java @@ -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 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 getTags() { + return tags; + } + + /** + * The tags associated with this story. + * + * @param tags + * the tags to set + */ + public void setTags(List 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 index 0000000..feb949c --- /dev/null +++ b/src/be/nikiroo/fanfix/data/Paragraph.java @@ -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 index 0000000..cb65119 --- /dev/null +++ b/src/be/nikiroo/fanfix/data/Story.java @@ -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 { + private MetaData meta; + private List chapters = new ArrayList(); + private List empty = new ArrayList(); + + /** + * 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 getChapters() { + return chapters; + } + + /** + * The chapters of the story. + * + * @param chapters + * the chapters to set + */ + public void setChapters(List chapters) { + this.chapters = chapters; + } + + /** + * Get an iterator on the {@link Chapter}s. + */ + public Iterator iterator() { + return chapters == null ? empty.iterator() : chapters.iterator(); + } + + /** + * Display a DEBUG {@link String} representation of this object. + *

+ * 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 index 0000000..aaa02c3 --- /dev/null +++ b/src/be/nikiroo/fanfix/data/package-info.java @@ -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 index 0000000..2e77eae --- /dev/null +++ b/src/be/nikiroo/fanfix/output/BasicOutput.java @@ -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. + *

+ * 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. + *

+ * 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 bold. + * + * @param word + * the input + * + * @return the bold output + */ + protected String enbold(String word) { + return word; + } + + /** + * Return the given word or sentence as italic. + * + * @param word + * the input + * + * @return the italic output + */ + protected String italize(String word) { + return word; + } + + /** + * Decorate the given text with bold and italic 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 .info 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 index 0000000..51cf732 --- /dev/null +++ b/src/be/nikiroo/fanfix/output/Cbz.java @@ -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 index 0000000..1706d8b --- /dev/null +++ b/src/be/nikiroo/fanfix/output/Epub.java @@ -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 = "\n" + + "\n" + + "\t\n" + + "\t\t\n" + + "\t\n" + "\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(""); + writer.write("\n"); + writer.write("\n"); + writer.write("\n"); + writer.write("\n " + StringUtils.xmlEscape(title) + + ""); + writer.write("\n "); + writer.write("\n"); + writer.write("\n"); + writer.write("\n

"); + writer.write("\n Chapter " + + chap.getNumber() + ": "); + writer.write("\n " + + StringUtils.xmlEscape(nameOrNum) + ""); + writer.write("\n

"); + writer.write("\n "); + writer.write("\n
\n"); + } catch (Exception e) { + writer.close(); + throw new IOException(e); + } + } + + @Override + protected void writeChapterFooter(Chapter chap) throws IOException { + try { + if (inDialogue) { + writer.write("
\n"); + inDialogue = false; + } + if (inNormal) { + writer.write(" \n"); + inNormal = false; + } + writer.write(" \n\n\n"); + } finally { + writer.close(); + writer = null; + } + } + + @Override + protected void writeParagraphHeader(Paragraph para) throws IOException { + if (para.getType() == ParagraphType.QUOTE && !inDialogue) { + writer.write("
\n"); + inDialogue = true; + } else if (para.getType() != ParagraphType.QUOTE && inDialogue) { + writer.write("
\n"); + inDialogue = false; + } + + if (para.getType() == ParagraphType.NORMAL && !inNormal) { + writer.write("
\n"); + inNormal = true; + } else if (para.getType() != ParagraphType.NORMAL && inNormal) { + writer.write("
\n"); + inNormal = false; + } + + switch (para.getType()) { + case BLANK: + writer.write("
"); + break; + case BREAK: + writer.write("
"); + break; + case NORMAL: + writer.write(" "); + break; + case QUOTE: + writer.write("
— "); + break; + case IMAGE: + File file = new File(images, getCurrentImageBestName(false)); + Instance.getCache().saveAsImage(new URL(para.getContent()), file); + writer.write(" "); + break; + } + } + + @Override + protected void writeParagraphFooter(Paragraph para) throws IOException { + switch (para.getType()) { + case NORMAL: + writer.write("\n"); + break; + case QUOTE: + writer.write("
\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 "" + word + ""; + } + + @Override + protected String italize(String word) { + return "" + word + ""; + } + + 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(""); + builder.append("\n"); + builder.append("\n"); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n " + StringUtils.xmlEscape(title) + ""); + builder.append("\n "); + builder.append("\n "); + + builder.append("\n " + StringUtils.xmlEscape(author) + ""); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n Title Page"); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + + 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 "); + builder.append("\n\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 "); + builder.append("\n "); + builder.append("\n " + name + ""); + builder.append("\n "); + builder.append("\n "); + builder.append("\n \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(""); + builder.append("\n"); + builder.append("\n "); + builder.append("\n " + StringUtils.xmlEscape(title) + + ""); + builder.append("\n " + + StringUtils.xmlEscape(author) + ""); + builder.append("\n " + + StringUtils.xmlEscape(date) + ""); + builder.append("\n " + + StringUtils.xmlEscape(publisher) + ""); + builder.append("\n "); + builder.append("\n " + StringUtils.xmlEscape(subject) + + ""); + builder.append("\n " + StringUtils.xmlEscape(source) + + ""); + builder.append("\n Not for commercial use."); + builder.append("\n " + + StringUtils.xmlEscape(uuid) + ""); + builder.append("\n " + StringUtils.xmlEscape(lang) + + ""); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + for (int i = 0; i <= story.getChapters().size(); i++) { + String name = String.format("%s%03d", "chapter-", i); + builder.append("\n "); + } + + builder.append("\n "); + builder.append("\n "); + + builder.append("\n "); + + if (story.getMeta() != null && story.getMeta().getCover() != null) { + String format = Instance.getConfig() + .getString(Config.IMAGE_FORMAT_COVER).toLowerCase(); + builder.append("\n "); + } + + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + builder.append("\n "); + for (int i = 0; i <= story.getChapters().size(); i++) { + String name = String.format("%s%03d", "chapter-", i); + builder.append("\n "); + } + builder.append("\n "); + builder.append("\n\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(""); + builder.append("\n"); + builder.append("\n"); + builder.append("\n"); + builder.append("\n " + StringUtils.xmlEscape(title) + ""); + builder.append("\n "); + builder.append("\n"); + builder.append("\n"); + builder.append("\n
"); + builder.append("\n

" + StringUtils.xmlEscape(title) + "

"); + builder.append("\n
" + + StringUtils.xmlEscape(tags) + "
"); + builder.append("\n
"); + builder.append("\n "); + builder.append("\n
"); + builder.append("\n
" + + StringUtils.xmlEscape(author) + "
"); + builder.append("\n
"); + builder.append("\n"); + builder.append("\n\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 index 0000000..2280e2d --- /dev/null +++ b/src/be/nikiroo/fanfix/output/InfoCover.java @@ -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 index 0000000..6937685 --- /dev/null +++ b/src/be/nikiroo/fanfix/output/InfoText.java @@ -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 index 0000000..a8d6d37 --- /dev/null +++ b/src/be/nikiroo/fanfix/output/LaTeX.java @@ -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 index 0000000..f6cd789 --- /dev/null +++ b/src/be/nikiroo/fanfix/output/Sysout.java @@ -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 index 0000000..22056ed --- /dev/null +++ b/src/be/nikiroo/fanfix/output/Text.java @@ -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 index 0000000..3999b9c --- /dev/null +++ b/src/be/nikiroo/fanfix/output/epub.style.css @@ -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 index 0000000..6b7e490 --- /dev/null +++ b/src/be/nikiroo/fanfix/output/package-info.java @@ -0,0 +1,12 @@ +/** + * This package contains all the output processors. + *

+ * 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 index 0000000..e104339 --- /dev/null +++ b/src/be/nikiroo/fanfix/package-info.java @@ -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 epub (or other) + * files that you can read anywhere. + *

+ * 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 index 0000000..52a5ea4 --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/CliReader.java @@ -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. + *

+ * 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 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 index 0000000..74f1115 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/BasicSupport.java @@ -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. + *

+ * 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. + *

+ * 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> 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. + *

+ * You are expected to call the super method implementation if you override + * it. + * + * @return the cookies + */ + public Map getCookies() { + return new HashMap(); + } + + /** + * 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()); + + 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> chapters = getChapters(url, getInput()); + int i = 1; + if (chapters != null) { + for (Entry 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). + *

+ * 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. + *

+ * 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. + *

+ * 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 getTags(URL source, InputStream in) + throws IOException { + return new ArrayList(); + } + + /** + * Return the first line from the given input which correspond to the given + * selectors. + *

+ * 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. + *

+ * 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 lines = new ArrayList(); + @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


processing: + content = content.replaceAll("(
]*>)|(
)|(
)", + "\n* * *\n"); + } + + InputStream in = new ByteArrayInputStream( + content.getBytes(StandardCharsets.UTF_8)); + try { + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("(\\n|

)"); // \n for test,

for html + + List paras = new ArrayList(); + 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 newParas = new ArrayList(); + 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 requotify(Paragraph para) { + List newParas = new ArrayList(); + + 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. + *

+ * 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 if {@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 index 0000000..012c047 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/Cbz.java @@ -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 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 index 0000000..2455c87 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/E621.java @@ -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 e621.net and e926.net, a Furry website supporting comics, + * including some of MLP. + *

+ * e926.net 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("= 0) { + author = author.substring(pos); + pos = author.indexOf(""); + 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, "", 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 index 0000000..31bf725 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/Epub.java @@ -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 index 0000000..cbbc085 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/Fanfiction.java @@ -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()); + } + } + } + } + + return tags; + } + + @Override + protected String getTitle(URL source, InputStream in) { + int i = 0; + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + while (scan.hasNext()) { + String line = scan.next(); + if (line.contains("xcontrast_txt")) { + if ((++i) == 2) { + line = StringUtils.unhtml(line).trim(); + if (line.startsWith("Follow/Fav")) { + line = line.substring("Follow/Fav".length()).trim(); + } + + return line; + } + } + } + + return null; + } + + @Override + protected String getAuthor(URL source, InputStream in) { + int i = 0; + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + while (scan.hasNext()) { + String line = scan.next(); + if (line.contains("xcontrast_txt")) { + if ((++i) == 3) { + return StringUtils.unhtml(line).trim(); + } + } + } + + return null; + } + + @Override + protected String getDate(URL source, InputStream in) { + String key = "Published: <span data-xutime='"; + String line = getLine(in, key, 0); + if (line != null) { + int pos = line.indexOf(key); + if (pos >= 0) { + line = line.substring(pos + key.length()); + pos = line.indexOf('\''); + if (pos >= 0) { + line = line.substring(0, pos).trim(); + try { + SimpleDateFormat sdf = new SimpleDateFormat( + "YYYY-MM-dd"); + return sdf + .format(new Date(1000 * Long.parseLong(line))); + } catch (NumberFormatException e) { + Instance.syserr(new IOException( + "Cannot convert publication date: " + line, e)); + } + } + } + } + + return null; + } + + @Override + protected String getDesc(URL source, InputStream in) { + return getLine(in, "title=\"Send Private Message\"", 1); + } + + @Override + protected URL getCover(URL url, InputStream in) { + String key = "class='cimage"; + String line = getLine(in, key, 0); + if (line != null) { + int pos = line.indexOf(key); + if (pos >= 0) { + line = line.substring(pos + key.length()); + key = "src='"; + pos = line.indexOf(key); + if (pos >= 0) { + line = line.substring(pos + key.length()); + pos = line.indexOf('\''); + if (pos >= 0) { + line = line.substring(0, pos); + if (line.startsWith("//")) { + line = url.getProtocol() + "://" + + line.substring(2); + } else if (line.startsWith("//")) { + line = url.getProtocol() + "://" + url.getHost() + + "/" + line.substring(1); + } else { + line = url.getProtocol() + "://" + url.getHost() + + "/" + url.getPath() + "/" + line; + } + + try { + return new URL(line); + } catch (MalformedURLException e) { + Instance.syserr(e); + } + } + } + } + } + + return null; + } + + @Override + protected List<Entry<String, URL>> getChapters(URL source, InputStream in) { + List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>(); + + String base = source.toString(); + int pos = base.lastIndexOf('/'); + String suffix = base.substring(pos); // including '/' at start + base = base.substring(0, pos); + if (base.endsWith("/1")) { + base = base.substring(0, base.length() - 1); // including '/' at end + } + + String line = getLine(in, "id=chap_select", 0); + String key = "<option value="; + int i = 1; + for (pos = line.indexOf(key); pos >= 0; pos = line.indexOf(key, pos), i++) { + pos = line.indexOf('>', pos); + if (pos >= 0) { + int endOfName = line.indexOf('<', pos); + if (endOfName >= 0) { + String name = line.substring(pos + 1, endOfName); + String chapNum = i + "."; + if (name.startsWith(chapNum)) { + name = name.substring(chapNum.length(), name.length()); + } + + try { + final String chapName = name.trim(); + final URL chapURL = new URL(base + i + suffix); + urls.add(new Entry<String, URL>() { + public URL setValue(URL value) { + return null; + } + + public URL getValue() { + return chapURL; + } + + public String getKey() { + return chapName; + } + }); + } catch (MalformedURLException e) { + Instance.syserr(new IOException("Cannot parse chapter " + + i + " url: " + (base + i + suffix), e)); + } + } + } + } + + return urls; + } + + @Override + protected String getChapterContent(URL source, InputStream in, int number) { + StringBuilder builder = new StringBuilder(); + String startAt = "class='storytext "; + String endAt1 = "function review_init"; + String endAt2 = "id=chap_select"; + boolean ok = false; + + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + while (scan.hasNext()) { + String line = scan.next(); + if (!ok && line.contains(startAt)) { + ok = true; + } else if (ok && (line.contains(endAt1) || line.contains(endAt2))) { + ok = false; + break; + } + + if (ok) { + // First line may contain the title and chap name again + if (builder.length() == 0) { + int pos = line.indexOf("<hr"); + if (pos >= 0) { + line = line.substring(pos); + } + } + + builder.append(line); + } + } + + return builder.toString(); + } + + @Override + protected boolean supports(URL url) { + return "fanfiction.net".equals(url.getHost()) + || "www.fanfiction.net".equals(url.getHost()); + } +} diff --git a/src/be/nikiroo/fanfix/supported/Fimfiction.java b/src/be/nikiroo/fanfix/supported/Fimfiction.java new file mode 100644 index 0000000..61f61d2 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/Fimfiction.java @@ -0,0 +1,236 @@ +package be.nikiroo.fanfix.supported; + +import java.io.InputStream; +import java.net.MalformedURLException; +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 java.util.Scanner; + +import be.nikiroo.fanfix.Instance; + +/** + * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a> + * stories, a website dedicated to My Little Pony. + * + * @author niki + */ +class Fimfiction extends BasicSupport { + @Override + protected boolean isHtml() { + return true; + } + + @Override + public String getSourceName() { + return "FimFiction.net"; + } + + @Override + protected String getSubject(URL source, InputStream in) { + return "MLP"; + } + + @Override + public Map<String, String> getCookies() { + Map<String, String> cookies = new HashMap<String, String>(); + cookies.put("view_mature", "true"); + return cookies; + } + + @Override + protected List<String> getTags(URL source, InputStream in) { + List<String> tags = new ArrayList<String>(); + tags.add("MLP"); + + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + while (scan.hasNext()) { + String line = scan.next(); + if (line.contains("story_category") && !line.contains("title=")) { + int pos = line.indexOf('>'); + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('<'); + if (pos >= 0) { + line = line.substring(0, pos); + } + } + + line = line.trim(); + if (!tags.contains(line)) { + tags.add(line); + } + } + } + + return tags; + } + + @Override + protected String getTitle(URL source, InputStream in) { + String line = getLine(in, " property=\"og:title\"", 0); + if (line != null) { + int pos = -1; + for (int i = 0; i < 3; i++) { + pos = line.indexOf('"', pos + 1); + } + + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('"'); + if (pos >= 0) { + return line.substring(0, pos); + } + } + } + + return null; + } + + @Override + protected String getAuthor(URL source, InputStream in) { + String line = getLine(in, " href=\"/user/", 0); + if (line != null) { + int pos = line.indexOf('"'); + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('"'); + if (pos >= 0) { + line = line.substring(0, pos); + pos = line.lastIndexOf('/'); + if (pos >= 0) { + line = line.substring(pos + 1); + return line.replace('+', ' '); + } + } + } + } + + return null; + } + + @Override + protected String getDate(URL source, InputStream in) { + String line = getLine(in, "<span class=\"date\">", 0); + if (line != null) { + int pos = -1; + for (int i = 0; i < 3; i++) { + pos = line.indexOf('>', pos + 1); + } + + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('<'); + if (pos >= 0) { + return line.substring(0, pos).trim(); + } + } + } + + return null; + } + + @Override + protected String getDesc(URL source, InputStream in) { + // the og: meta version is the SHORT resume, this is the LONG resume + return getLine(in, "class=\"more_button hidden\"", -1); + } + + @Override + protected URL getCover(URL url, InputStream in) { + // Note: the 'og:image' is the SMALL cover, not the full version + String cover = getLine(in, "<div class=\"story_image\">", 1); + if (cover != null) { + int pos = cover.indexOf('"'); + if (pos >= 0) { + cover = cover.substring(pos + 1); + pos = cover.indexOf('"'); + if (pos >= 0) { + cover = cover.substring(0, pos); + } + } + } + + if (cover != null) { + try { + return new URL(cover); + } catch (MalformedURLException e) { + Instance.syserr(e); + } + } + + return null; + } + + @Override + protected List<Entry<String, URL>> getChapters(URL source, InputStream in) { + List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>(); + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + while (scan.hasNext()) { + String line = scan.next(); + if (line.contains("class=\"chapter_link\"") + || line.contains("class='chapter_link'")) { + // Chapter name + String name = line; + int pos = name.indexOf('>'); + if (pos >= 0) { + name = name.substring(pos + 1); + pos = name.indexOf('<'); + if (pos >= 0) { + name = name.substring(0, pos); + } + } + // Chapter content + pos = line.indexOf('/'); + if (pos >= 0) { + line = line.substring(pos); // we take the /, not +1 + pos = line.indexOf('"'); + if (pos >= 0) { + line = line.substring(0, pos); + } + } + + try { + final String key = name; + final URL value = new URL("http://www.fimfiction.net" + + line); + urls.add(new Entry<String, URL>() { + public URL setValue(URL value) { + return null; + } + + public String getKey() { + return key; + } + + public URL getValue() { + return value; + } + }); + } catch (MalformedURLException e) { + Instance.syserr(e); + } + } + } + + return urls; + } + + @Override + protected String getChapterContent(URL source, InputStream in, int number) { + return getLine(in, "<div id=\"chapter_container\">", 1); + } + + @Override + protected boolean supports(URL url) { + return "fimfiction.net".equals(url.getHost()) + || "www.fimfiction.net".equals(url.getHost()); + } +} diff --git a/src/be/nikiroo/fanfix/supported/InfoText.java b/src/be/nikiroo/fanfix/supported/InfoText.java new file mode 100644 index 0000000..a627714 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/InfoText.java @@ -0,0 +1,248 @@ +package be.nikiroo.fanfix.supported; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.List; + +import be.nikiroo.fanfix.Instance; + +/** + * Support class for <tt>.info</tt> text files ({@link Text} files with a + * <tt>.info</tt> metadata file next to them). + * <p> + * The <tt>.info</tt> file is supposed to be written by this program, or + * compatible. + * + * @author niki + */ +class InfoText extends Text { + @Override + public String getSourceName() { + return "info-text"; + } + + @Override + protected String getTitle(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "TITLE"); + if (tag != null) { + return tag; + } + + return super.getTitle(source, in); + } + + @Override + protected String getAuthor(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "AUTHOR"); + if (tag != null) { + return tag; + } + + return super.getAuthor(source, in); + } + + @Override + protected String getDate(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "DATE"); + if (tag != null) { + return tag; + } + + return super.getDate(source, in); + } + + @Override + protected String getSubject(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "SUBJECT"); + if (tag != null) { + return tag; + } + + return super.getSubject(source, in); + } + + @Override + protected String getLang(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "LANG"); + if (tag != null) { + return tag; + } + + return super.getLang(source, in); + } + + @Override + protected String getPublisher(URL source, InputStream in) + throws IOException { + String tag = getInfoTag(source, "PUBLISHER"); + if (tag != null) { + return tag; + } + + return super.getPublisher(source, in); + } + + @Override + protected String getUuid(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "UUID"); + if (tag != null) { + return tag; + } + + return super.getUuid(source, in); + } + + @Override + protected String getLuid(URL source, InputStream in) throws IOException { + String tag = getInfoTag(source, "LUID"); + if (tag != null) { + return tag; + } + + return super.getLuid(source, in); + } + + @Override + protected List<String> getTags(URL source, InputStream in) + throws IOException { + List<String> tags = super.getTags(source, in); + + String tt = getInfoTag(source, "TAGS"); + if (tt != null) { + for (String tag : tt.split(",")) { + tags.add(tag.trim()); + } + } + + return tags; + } + + @Override + public boolean isImageDocument(URL source, InputStream in) + throws IOException { + String tag = getInfoTag(source, "IMAGES_DOCUMENT"); + if (tag != null) { + return tag.trim().toLowerCase().equals("true"); + } + + return super.isImageDocument(source, in); + } + + @Override + protected URL getCover(URL source, InputStream in) { + File file; + try { + file = new File(source.toURI()); + file = new File(file.getPath() + ".info"); + } catch (URISyntaxException e) { + Instance.syserr(e); + file = null; + } + + String path = null; + if (file != null && file.exists()) { + try { + InputStream infoIn = new FileInputStream(file); + try { + String key = "COVER="; + String tt = getLine(infoIn, key, 0); + if (tt != null && !tt.isEmpty()) { + tt = tt.substring(key.length()).trim(); + if (tt.startsWith("'") && tt.endsWith("'")) { + tt = tt.substring(1, tt.length() - 1).trim(); + } + + URL cover = getImage(source, tt); + if (cover != null) { + path = cover.getFile(); + } + } + } finally { + infoIn.close(); + } + } catch (MalformedURLException e) { + Instance.syserr(e); + } catch (IOException e) { + Instance.syserr(e); + } + } + + if (path != null) { + try { + return new File(path).toURI().toURL(); + } catch (MalformedURLException e) { + Instance.syserr(e); + } + } + + return null; + } + + @Override + protected boolean supports(URL url) { + if ("file".equals(url.getProtocol())) { + File file; + try { + file = new File(url.toURI()); + file = new File(file.getPath() + ".info"); + } catch (URISyntaxException e) { + Instance.syserr(e); + file = null; + } + + return file != null && file.exists(); + } + + return false; + } + + /** + * Return the value of the given tag in the <tt>.info</tt> file if present. + * + * @param source + * the source story {@link URL} + * @param key + * the tag key + * + * @return the value or NULL + * + * @throws IOException + * in case of I/O error + */ + private String getInfoTag(URL source, String key) throws IOException { + key += "="; + + File file; + try { + file = new File(source.toURI()); + file = new File(file.getPath() + ".info"); + } catch (URISyntaxException e) { + throw new IOException(e); + } + + if (file.exists()) { + InputStream infoIn = new FileInputStream(file); + try { + String value = getLine(infoIn, key, 0); + if (value != null && !value.isEmpty()) { + value = value.trim().substring(key.length()).trim(); + if (value.startsWith("'") && value.endsWith("'") + || value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1).trim(); + } + + return value; + } + } finally { + infoIn.close(); + } + } + + return null; + } +} diff --git a/src/be/nikiroo/fanfix/supported/MangaFox.java b/src/be/nikiroo/fanfix/supported/MangaFox.java new file mode 100644 index 0000000..fb72bf5 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/MangaFox.java @@ -0,0 +1,409 @@ +package be.nikiroo.fanfix.supported; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map.Entry; +import java.util.Scanner; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.utils.StringUtils; + +class MangaFox extends BasicSupport { + @Override + protected boolean isHtml() { + return true; + } + + @Override + public String getSourceName() { + return "MangaFox.met"; + } + + @Override + protected String getSubject(URL source, InputStream in) { + return "manga"; + } + + @Override + public boolean isImageDocument(URL source, InputStream in) + throws IOException { + return true; + } + + @Override + protected List<String> getTags(URL source, InputStream in) { + List<String> tags = new ArrayList<String>(); + + String line = getLine(in, "/genres/", 0); + if (line != null) { + line = StringUtils.unhtml(line); + String[] tab = line.split(","); + if (tab != null) { + for (String tag : tab) { + tags.add(tag.trim()); + } + } + } + + return tags; + } + + @Override + protected String getTitle(URL source, InputStream in) { + String line = getLine(in, " property=\"og:title\"", 0); + if (line != null) { + int pos = -1; + for (int i = 0; i < 3; i++) { + pos = line.indexOf('"', pos + 1); + } + + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('"'); + if (pos >= 0) { + return line.substring(0, pos); + } + } + } + + return null; + } + + @Override + protected String getAuthor(URL source, InputStream in) { + List<String> authors = new ArrayList<String>(); + + String line = getLine(in, "/author/", 0, false); + if (line != null) { + for (String ln : StringUtils.unhtml(line).split(",")) { + if (ln != null && !ln.trim().isEmpty() + && !authors.contains(ln.trim())) { + authors.add(ln.trim()); + } + } + } + + try { + in.reset(); + } catch (IOException e) { + Instance.syserr(e); + } + + line = getLine(in, "/artist/", 0, false); + if (line != null) { + for (String ln : StringUtils.unhtml(line).split(",")) { + if (ln != null && !ln.trim().isEmpty() + && !authors.contains(ln.trim())) { + authors.add(ln.trim()); + } + } + } + + if (authors.isEmpty()) { + return null; + } else { + StringBuilder builder = new StringBuilder(); + for (String author : authors) { + if (builder.length() > 0) { + builder.append(", "); + } + + builder.append(author); + } + + return builder.toString(); + } + } + + @Override + protected String getDate(URL source, InputStream in) { + String line = getLine(in, "/released/", 0); + if (line != null) { + line = StringUtils.unhtml(line); + return line.trim(); + } + + return null; + } + + @Override + protected String getDesc(URL source, InputStream in) { + String line = getLine(in, " property=\"og:description\"", 0); + if (line != null) { + int pos = -1; + for (int i = 0; i < 3; i++) { + pos = line.indexOf('"', pos + 1); + } + + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('"'); + if (pos >= 0) { + return line.substring(0, pos); + } + } + } + + return null; + } + + @Override + protected URL getCover(URL url, InputStream in) { + String line = getLine(in, " property=\"og:image\"", 0); + String cover = null; + if (line != null) { + int pos = -1; + for (int i = 0; i < 3; i++) { + pos = line.indexOf('"', pos + 1); + } + + if (pos >= 0) { + line = line.substring(pos + 1); + pos = line.indexOf('"'); + if (pos >= 0) { + cover = line.substring(0, pos); + } + } + } + + if (cover != null) { + try { + return new URL(cover); + } catch (MalformedURLException e) { + Instance.syserr(e); + } + } + + return null; + } + + @Override + protected List<Entry<String, URL>> getChapters(URL source, InputStream in) { + List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>(); + + String volumeAt = "<h3 class=\"volume\">"; + String linkAt = "href=\"http://mangafox.me/"; + String endAt = "<script type=\"text/javascript\">"; + + boolean started = false; + + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + while (scan.hasNext()) { + String line = scan.next(); + + if (started && line.contains(endAt)) { + break; + } else if (!started && line.contains(volumeAt)) { + started = true; + } + + if (started && line.contains(linkAt)) { + // Chapter content url + String url = null; + int pos = line.indexOf("href=\""); + if (pos >= 0) { + line = line.substring(pos + "href=\"".length()); + pos = line.indexOf('\"'); + if (pos >= 0) { + url = line.substring(0, pos); + } + } + + // Chapter name + String name = null; + if (scan.hasNext()) { + name = StringUtils.unhtml(scan.next()).trim(); + // Remove the "new" tag if present + if (name.endsWith("new")) { + name = name.substring(0, name.length() - 3).trim(); + } + } + + // to help with the retry and the originalUrl + refresh(url); + + try { + final String key = name; + final URL value = new URL(url); + urls.add(new Entry<String, URL>() { + public URL setValue(URL value) { + return null; + } + + public String getKey() { + return key; + } + + public URL getValue() { + return value; + } + }); + } catch (MalformedURLException e) { + Instance.syserr(e); + } + } + } + + // the chapters are in reversed order + Collections.reverse(urls); + + return urls; + } + + @Override + protected String getChapterContent(URL source, InputStream in, int number) { + StringBuilder builder = new StringBuilder(); + String base = getCurrentReferer().toString(); + int pos = base.lastIndexOf('/'); + base = base.substring(0, pos + 1); // including the '/' at the end + + boolean close = false; + while (in != null) { + String linkNextLine = getLine(in, "return enlarge()", 0); + try { + in.reset(); + } catch (IOException e) { + Instance.syserr(e); + } + + String linkImageLine = getLine(in, "return enlarge()", 1); + String linkNext = null; + String linkImage = null; + pos = linkNextLine.indexOf("href=\""); + if (pos >= 0) { + linkNextLine = linkNextLine.substring(pos + "href=\"".length()); + pos = linkNextLine.indexOf('\"'); + if (pos >= 0) { + linkNext = linkNextLine.substring(0, pos); + } + } + pos = linkImageLine.indexOf("src=\""); + if (pos >= 0) { + linkImageLine = linkImageLine + .substring(pos + "src=\"".length()); + pos = linkImageLine.indexOf('\"'); + if (pos >= 0) { + linkImage = linkImageLine.substring(0, pos); + } + } + + if (linkImage != null) { + builder.append("["); + // to help with the retry and the originalUrl, part 1 + builder.append(withoutQuery(linkImage)); + builder.append("]\n"); + } + + // to help with the retry and the originalUrl, part 2 + refresh(linkImage); + + if (close) { + try { + in.close(); + } catch (IOException e) { + Instance.syserr(e); + } + } + + in = null; + if (linkNext != null && !"javascript:void(0);".equals(linkNext)) { + URL url; + try { + url = new URL(base + linkNext); + in = openEx(base + linkNext); + setCurrentReferer(url); + } catch (IOException e) { + Instance.syserr(new IOException( + "Cannot get the next manga page which is: " + + linkNext, e)); + } + } + + close = true; + } + + setCurrentReferer(source); + return builder.toString(); + } + + @Override + protected boolean supports(URL url) { + return "mangafox.me".equals(url.getHost()) + || "www.mangafox.me".equals(url.getHost()); + } + + /** + * Refresh the {@link URL} by calling {@link MangaFox#openEx(String)}. + * + * @param url + * the URL to refresh + * + * @return TRUE if it was refreshed + */ + private boolean refresh(String url) { + try { + openEx(url).close(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Open the URL through the cache, but: retry a second time after 100ms if + * it fails, remove the query part of the {@link URL} before saving it to + * the cache (so it can be recalled later). + * + * @param url + * the {@link URL} + * + * @return the resource + * + * @throws IOException + * in case of I/O error + */ + private InputStream openEx(String url) throws IOException { + try { + return Instance.getCache().open(new URL(url), this, true, + withoutQuery(url)); + } catch (Exception e) { + // second chance + try { + Thread.sleep(100); + } catch (InterruptedException ee) { + } + + return Instance.getCache().open(new URL(url), this, true, + withoutQuery(url)); + } + } + + /** + * Return the same input {@link URL} but without the query part. + * + * @param url + * the inpiut {@link URL} as a {@link String} + * + * @return the input {@link URL} without query + */ + private URL withoutQuery(String url) { + URL o = null; + try { + // Remove the query from o (originalUrl), so it can be cached + // correctly + o = new URL(url); + o = new URL(o.getProtocol() + "://" + o.getHost() + o.getPath()); + + return o; + } catch (MalformedURLException e) { + return null; + } + } +} diff --git a/src/be/nikiroo/fanfix/supported/Text.java b/src/be/nikiroo/fanfix/supported/Text.java new file mode 100644 index 0000000..f1ee71c --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/Text.java @@ -0,0 +1,295 @@ +package be.nikiroo.fanfix.supported; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map.Entry; +import java.util.Scanner; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.bundles.Config; + +/** + * Support class for local stories encoded in textual format, with a few rules: + * <ul> + * <li>The title must be on the first line</li> + * <li>The author (preceded by nothing, "by " or "©") must be on the second + * line, possibly with the publication date in parenthesis (i.e., " + * <tt>By Unknown (3rd October 1998)</tt>")</li> + * <li>Chapters must be declared with "<tt>Chapter x</tt>" or " + * <tt>Chapter x: NAME OF THE CHAPTER</tt>", where "<tt>x</tt>" is the chapter + * number</li> + * <li>A description of the story must be given as chapter number 0</li> + * <li>A cover may be present, with the same filename but a PNG, JPEG or JPG + * extension</li< + * </ul> + * + * @author niki + */ +class Text extends BasicSupport { + @Override + protected boolean isHtml() { + return false; + } + + @Override + public String getSourceName() { + return "text"; + } + + @Override + protected String getPublisher(URL source, InputStream in) + throws IOException { + return ""; + } + + @Override + protected String getSubject(URL source, InputStream in) throws IOException { + try { + File file = new File(source.toURI()); + return file.getParentFile().getName(); + } catch (URISyntaxException e) { + throw new IOException("Cannot parse the URL to File: " + + source.toString(), e); + } + + } + + @Override + protected String getLang(URL source, InputStream in) throws IOException { + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + scan.next(); // Title + scan.next(); // Author (Date) + String chapter0 = scan.next(); // empty or Chapter 0 + while (chapter0.isEmpty()) { + chapter0 = scan.next(); + } + + String lang = detectChapter(chapter0); + if (lang == null) { + lang = super.getLang(source, in); + } else { + lang = lang.toUpperCase(); + } + + return lang; + } + + @Override + protected String getTitle(URL source, InputStream in) throws IOException { + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + return scan.next(); + } + + @Override + protected String getAuthor(URL source, InputStream in) throws IOException { + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + scan.next(); + String authorDate = scan.next(); + + String author = authorDate; + int pos = authorDate.indexOf('('); + if (pos >= 0) { + author = authorDate.substring(0, pos); + } + + return author; + } + + @Override + protected String getDate(URL source, InputStream in) throws IOException { + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + scan.next(); + String authorDate = scan.next(); + + String date = ""; + int pos = authorDate.indexOf('('); + if (pos >= 0) { + date = authorDate.substring(pos + 1).trim(); + pos = date.lastIndexOf(')'); + if (pos >= 0) { + date = date.substring(0, pos).trim(); + } + } + + return date; + } + + @Override + protected String getDesc(URL source, InputStream in) { + return getChapterContent(source, in, 0); + } + + @Override + protected URL getCover(URL source, InputStream in) { + String path; + try { + path = new File(source.toURI()).getPath(); + } catch (URISyntaxException e) { + Instance.syserr(e); + path = null; + } + + for (String ext : new String[] { ".txt", ".text", ".story" }) { + if (path.endsWith(ext)) { + path = path.substring(0, path.length() - ext.length()); + } + } + + return getImage(source, path); + } + + @Override + protected List<Entry<String, URL>> getChapters(URL source, InputStream in) { + List<Entry<String, URL>> chaps = new ArrayList<Entry<String, URL>>(); + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + boolean descSkipped = false; + boolean prevLineEmpty = false; + while (scan.hasNext()) { + String line = scan.next(); + if (prevLineEmpty && detectChapter(line) != null) { + if (descSkipped) { + String chapName = Integer.toString(chaps.size()); + int pos = line.indexOf(':'); + if (pos >= 0 && pos + 1 < line.length()) { + chapName = line.substring(pos + 1).trim(); + } + final URL value = source; + final String key = chapName; + chaps.add(new Entry<String, URL>() { + public URL setValue(URL value) { + return null; + } + + public URL getValue() { + return value; + } + + public String getKey() { + return key; + } + }); + } else { + descSkipped = true; + } + } + + prevLineEmpty = line.trim().isEmpty(); + } + + return chaps; + } + + @Override + protected String getChapterContent(URL source, InputStream in, int number) { + StringBuilder builder = new StringBuilder(); + @SuppressWarnings("resource") + Scanner scan = new Scanner(in, "UTF-8"); + scan.useDelimiter("\\n"); + boolean inChap = false; + boolean prevLineEmpty = false; + while (scan.hasNext()) { + String line = scan.next(); + if (prevLineEmpty) { + if (detectChapter(line, number) != null) { + inChap = true; + } else if (inChap) { + if (prevLineEmpty && detectChapter(line) != null) { + break; + } + + builder.append(line); + builder.append("\n"); + } + } + + prevLineEmpty = line.trim().isEmpty(); + } + + return builder.toString(); + } + + @Override + protected boolean supports(URL url) { + if ("file".equals(url.getProtocol())) { + File file; + try { + file = new File(url.toURI()); + file = new File(file.getPath() + ".info"); + } catch (URISyntaxException e) { + Instance.syserr(e); + file = null; + } + + return file == null || !file.exists(); + } + + return false; + } + + /** + * Check if the given line looks like a starting chapter in a supported + * language, and return the language if it does (or NULL if not). + * + * @param line + * the line to check + * + * @return the language or NULL + */ + private String detectChapter(String line) { + return detectChapter(line, null); + } + + /** + * Check if the given line looks like the given starting chapter in a + * supported language, and return the language if it does (or NULL if not). + * + * @param line + * the line to check + * + * @return the language or NULL + */ + private String detectChapter(String line, Integer number) { + line = line.toUpperCase(); + for (String lang : Instance.getConfig().getString(Config.CHAPTER) + .split(",")) { + String chapter = Instance.getConfig().getStringX(Config.CHAPTER, + lang); + if (chapter != null && !chapter.isEmpty()) { + chapter = chapter.toUpperCase() + " "; + if (line.startsWith(chapter)) { + if (number != null) { + // We want "[CHAPTER] [number]: [name]", with ": [name]" + // optional + String test = line.substring(chapter.length()).trim(); + if (test.startsWith(Integer.toString(number))) { + test = test.substring( + Integer.toString(number).length()).trim(); + if (test.isEmpty() || test.startsWith(":")) { + return lang; + } + } + } else { + return lang; + } + } + } + } + + return null; + } +} diff --git a/src/be/nikiroo/fanfix/supported/package-info.java b/src/be/nikiroo/fanfix/supported/package-info.java new file mode 100644 index 0000000..1762e32 --- /dev/null +++ b/src/be/nikiroo/fanfix/supported/package-info.java @@ -0,0 +1,11 @@ +/** + * This package contains different implementation of + * {@link be.nikiroo.fanfix.supported.BasicSupport} to cater to different + * sources. + * <p> + * You are expected to use the static methods from + * {@link be.nikiroo.fanfix.supported.BasicSupport} to get those you need. + * + * @author niki + */ +package be.nikiroo.fanfix.supported; \ No newline at end of file