+ * As long the cached resource is not too old, it will use it instead of
+ * retrieving the file again.
+ *
+ * @author niki
+ */
+public class DataLoader {
+ private Downloader downloader;
+ private Downloader downloaderNoCache;
+ private Cache cache;
+ private boolean offline;
+
+ /**
+ * Create a new {@link DataLoader} object.
+ *
+ * @param dir
+ * the directory to use as cache
+ * @param UA
+ * the User-Agent to use to download the resources
+ * @param hoursChanging
+ * the number of hours after which a cached file that is thought
+ * to change ~often is considered too old (or -1 for
+ * "never too old")
+ * @param hoursStable
+ * the number of hours after which a LARGE cached file that is
+ * thought to change rarely is considered too old (or -1 for
+ * "never too old")
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public DataLoader(File dir, String UA, int hoursChanging, int hoursStable)
+ throws IOException {
+ downloader = new Downloader(UA, new Cache(dir, hoursChanging,
+ hoursStable));
+ downloaderNoCache = new Downloader(UA);
+
+ cache = downloader.getCache();
+ }
+
+ /**
+ * Create a new {@link DataLoader} object without disk cache (will keep a
+ * memory cache for manual cache operations).
+ *
+ * @param UA
+ * the User-Agent to use to download the resources
+ */
+ public DataLoader(String UA) {
+ downloader = new Downloader(UA);
+ downloaderNoCache = downloader;
+ cache = new CacheMemory();
+ }
+
+ /**
+ * This {@link Downloader} is forbidden to try and connect to the network.
+ *
+ * If TRUE, it will only check the cache (even in no-cache mode!).
+ *
+ * Default is FALSE.
+ *
+ * @return TRUE if offline
+ */
+ public boolean isOffline() {
+ return offline;
+ }
+
+ /**
+ * This {@link Downloader} is forbidden to try and connect to the network.
+ *
+ * If TRUE, it will only check the cache (even in no-cache mode!).
+ *
+ * Default is FALSE.
+ *
+ * @param offline TRUE for offline, FALSE for online
+ */
+ public void setOffline(boolean offline) {
+ this.offline = offline;
+ downloader.setOffline(offline);
+ downloaderNoCache.setOffline(offline);
+
+ // If we don't, we cannot support no-cache using code in OFFLINE mode
+ if (offline) {
+ downloaderNoCache.setCache(cache);
+ } else {
+ downloaderNoCache.setCache(null);
+ }
+ }
+
+ /**
+ * The traces handler for this {@link Cache}.
+ *
+ * @param tracer
+ * the new traces handler
+ */
+ public void setTraceHandler(TraceHandler tracer) {
+ downloader.setTraceHandler(tracer);
+ downloaderNoCache.setTraceHandler(tracer);
+ cache.setTraceHandler(tracer);
+ if (downloader.getCache() != null) {
+ downloader.getCache().setTraceHandler(tracer);
+ }
+
+ }
+
+ /**
+ * Open a resource (will load it from the cache if possible, or save it into
+ * the cache after downloading if not).
+ *
+ * The cached resource will be assimilated to the given original {@link URL}
+ *
+ * @param url
+ * the resource to open
+ * @param support
+ * the support to use to download the resource (can be NULL)
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the opened resource, NOT NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public InputStream open(URL url, BasicSupport support, boolean stable)
+ throws IOException {
+ return open(url, url, support, stable, null, null, null);
+ }
+
+ /**
+ * Open a resource (will load it from the cache if possible, or save it into
+ * the cache after downloading if not).
+ *
+ * The cached resource will be assimilated to the given original {@link URL}
+ *
+ * @param url
+ * the resource to open
+ * @param originalUrl
+ * the original {@link URL} before any redirection occurs, which
+ * is also used for the cache ID if needed (so we can retrieve
+ * the content with this URL if needed)
+ * @param support
+ * the support to use to download the resource
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the opened resource, NOT NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public InputStream open(URL url, URL originalUrl, BasicSupport support,
+ boolean stable) throws IOException {
+ return open(url, originalUrl, support, stable, null, null, null);
+ }
+
+ /**
+ * Open a resource (will load it from the cache if possible, or save it into
+ * the cache after downloading if not).
+ *
+ * The cached resource will be assimilated to the given original {@link URL}
+ *
+ * @param url
+ * the resource to open
+ * @param originalUrl
+ * the original {@link URL} before any redirection occurs, which
+ * is also used for the cache ID if needed (so we can retrieve
+ * the content with this URL if needed)
+ * @param support
+ * the support to use to download the resource (can be NULL)
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ * @param postParams
+ * the POST parameters
+ * @param getParams
+ * the GET parameters (priority over POST)
+ * @param oauth
+ * OAuth authorization (aka, "bearer XXXXXXX")
+ *
+ * @return the opened resource, NOT NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public InputStream open(URL url, URL originalUrl, BasicSupport support,
+ boolean stable, Map postParams,
+ Map getParams, String oauth) throws IOException {
+
+ Map cookiesValues = null;
+ URL currentReferer = url;
+
+ if (support != null) {
+ cookiesValues = support.getCookies();
+ currentReferer = support.getCurrentReferer();
+ // priority: arguments
+ if (oauth == null) {
+ oauth = support.getOAuth();
+ }
+ }
+
+ return downloader.open(url, originalUrl, currentReferer, cookiesValues,
+ postParams, getParams, oauth, stable);
+ }
+
+ /**
+ * Open the given {@link URL} without using the cache, but still using and
+ * updating the cookies.
+ *
+ * @param url
+ * the {@link URL} to open
+ * @param support
+ * the {@link BasicSupport} used for the cookies
+ * @param postParams
+ * the POST parameters
+ * @param getParams
+ * the GET parameters (priority over POST)
+ * @param oauth
+ * OAuth authorization (aka, "bearer XXXXXXX")
+ *
+ * @return the {@link InputStream} of the opened page
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public InputStream openNoCache(URL url, BasicSupport support,
+ Map postParams, Map getParams,
+ String oauth) throws IOException {
+
+ Map cookiesValues = null;
+ URL currentReferer = url;
+ if (support != null) {
+ cookiesValues = support.getCookies();
+ currentReferer = support.getCurrentReferer();
+ // priority: arguments
+ if (oauth == null) {
+ oauth = support.getOAuth();
+ }
+ }
+
+ return downloaderNoCache.open(url, currentReferer, cookiesValues,
+ postParams, getParams, oauth);
+ }
+
+ /**
+ * Refresh the resource into cache if needed.
+ *
+ * @param url
+ * the resource to open
+ * @param support
+ * the support to use to download the resource (can be NULL)
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public void refresh(URL url, BasicSupport support, boolean stable)
+ throws IOException {
+ if (!check(url, stable)) {
+ open(url, url, support, stable, null, null, null).close();
+ }
+ }
+
+ /**
+ * Check the resource to see if it is in the cache.
+ *
+ * @param url
+ * the resource to check
+ * @param stable
+ * a stable file (that dones't change too often) -- parameter
+ * used to check if the file is too old to keep or not
+ *
+ * @return TRUE if it is
+ *
+ */
+ public boolean check(URL url, boolean stable) {
+ return downloader.getCache() != null
+ && downloader.getCache().check(url, false, stable);
+ }
+
+ /**
+ * Save the given resource as an image on disk using the default image
+ * format for content or cover -- will automatically add the extension, too.
+ *
+ * @param img
+ * the resource
+ * @param target
+ * the target file without extension
+ * @param cover
+ * use the cover image format instead of the content image format
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public void saveAsImage(Image img, File target, boolean cover)
+ throws IOException {
+ String format;
+ if (cover) {
+ format = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+ } else {
+ format = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_CONTENT)
+ .toLowerCase();
+ }
+ saveAsImage(img, new File(target.toString() + "." + format), format);
+ }
+
+ /**
+ * Save the given resource as an image on disk using the given image format
+ * for content, or with "png" format if it fails.
+ *
+ * @param img
+ * the resource
+ * @param target
+ * the target file
+ * @param format
+ * the file format ("png", "jpeg", "bmp"...)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public void saveAsImage(Image img, File target, String format)
+ throws IOException {
+ ImageUtils.getInstance().saveAsImage(img, target, format);
+ }
+
+ /**
+ * Manually add this item to the cache.
+ *
+ * @param in
+ * the input data
+ * @param uniqueID
+ * a unique ID for this resource
+ *
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public void addToCache(InputStream in, String uniqueID) throws IOException {
+ cache.save(in, uniqueID);
+ }
+
+ /**
+ * Return the {@link InputStream} corresponding to the given unique ID, or
+ * NULL if none found.
+ *
+ * @param uniqueID
+ * the unique ID
+ *
+ * @return the content or NULL
+ */
+ public InputStream getFromCache(String uniqueID) {
+ return cache.load(uniqueID, true, true);
+ }
+
+ /**
+ * Remove the given resource from the cache.
+ *
+ * @param uniqueID
+ * a unique ID used to locate the cached resource
+ *
+ * @return TRUE if it was removed
+ */
+ public boolean removeFromCache(String uniqueID) {
+ return cache.remove(uniqueID);
+ }
+
+ /**
+ * Clean the cache (delete the cached items).
+ *
+ * @param onlyOld
+ * only clean the files that are considered too old
+ *
+ * @return the number of cleaned items
+ */
+ public int cleanCache(boolean onlyOld) {
+ return cache.clean(onlyOld);
+ }
+}
diff --git a/src/be/nikiroo/fanfix/Instance.java b/src/be/nikiroo/fanfix/Instance.java
new file mode 100644
index 0000000..a2cb90a
--- /dev/null
+++ b/src/be/nikiroo/fanfix/Instance.java
@@ -0,0 +1,699 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.bundles.StringIdBundle;
+import be.nikiroo.fanfix.bundles.StringIdGuiBundle;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.bundles.UiConfigBundle;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.fanfix.library.WebLibrary;
+import be.nikiroo.utils.Cache;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Proxy;
+import be.nikiroo.utils.TempFiles;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.resources.Bundles;
+
+/**
+ * Global state for the program (services and singletons).
+ *
+ * @author niki
+ */
+public class Instance {
+ static private Instance instance;
+ static private Object instancelock = new Object();
+
+ private ConfigBundle config;
+ private UiConfigBundle uiconfig;
+ private StringIdBundle trans;
+ private DataLoader cache;
+ private StringIdGuiBundle transGui;
+ private BasicLibrary lib;
+ private File coverDir;
+ private File readerTmp;
+ private File remoteDir;
+ private String configDir;
+ private TraceHandler tracer;
+ private TempFiles tempFiles;
+
+ /**
+ * Initialise the instance -- if already initialised, nothing will happen.
+ *
+ * Before calling this method, you may call
+ * {@link Bundles#setDirectory(String)} if wanted.
+ *
+ * Note that this method will honour some environment variables, the 3 most
+ * important ones probably being:
+ *
+ *
DEBUG: will enable DEBUG output if set to 1 (or Y or TRUE or
+ * ON, case insensitive)
+ *
CONFIG_DIR: will use this directory as configuration
+ * directory (supports $HOME notation, defaults to $HOME/.fanfix
+ *
BOOKS_DIR: will use this directory as library directory
+ * (supports $HOME notation, defaults to $HOME/Books
+ *
+ */
+ static public void init() {
+ init(false);
+ }
+
+ /**
+ * Initialise the instance -- if already initialised, nothing will happen
+ * unless you pass TRUE to force.
+ *
+ * Before calling this method, you may call
+ * {@link Bundles#setDirectory(String)} if wanted.
+ *
+ * Note: forcing the initialisation can be dangerous, so make sure to only
+ * make it under controlled circumstances -- for instance, at the start of
+ * the program, you could call {@link Instance#init()}, change some settings
+ * because you want to force those settings (it will also forbid users to
+ * change them!) and then call {@link Instance#init(boolean)} with
+ * force set to TRUE.
+ *
+ * @param force
+ * force the initialisation even if already initialised
+ */
+ static public void init(boolean force) {
+ synchronized (instancelock) {
+ if (instance == null || force) {
+ instance = new Instance();
+ }
+ }
+
+ }
+
+ /**
+ * Force-initialise the {@link Instance} to a known value.
+ *
+ * Usually for DEBUG/Test purposes.
+ *
+ * @param instance
+ * the actual Instance to use
+ */
+ static public void init(Instance instance) {
+ Instance.instance = instance;
+ }
+
+ /**
+ * The (mostly unique) instance of this {@link Instance}.
+ *
+ * @return the (mostly unique) instance
+ */
+ public static Instance getInstance() {
+ return instance;
+ }
+
+ /**
+ * Actually initialise the instance.
+ *
+ * Before calling this method, you may call
+ * {@link Bundles#setDirectory(String)} if wanted.
+ */
+ protected Instance() {
+ // Before we can configure it:
+ Boolean debug = checkEnv("DEBUG");
+ boolean trace = debug != null && debug;
+ tracer = new TraceHandler(true, trace, trace);
+
+ // config dir:
+ configDir = getConfigDir();
+ if (!new File(configDir).exists()) {
+ new File(configDir).mkdirs();
+ }
+
+ // Most of the rest is dependent upon this:
+ createConfigs(configDir, false);
+
+ // Proxy support
+ Proxy.use(config.getString(Config.NETWORK_PROXY));
+
+ // update tracer:
+ if (debug == null) {
+ debug = config.getBoolean(Config.DEBUG_ERR, false);
+ trace = config.getBoolean(Config.DEBUG_TRACE, false);
+ }
+
+ tracer = new TraceHandler(true, debug, trace);
+
+ // default Library
+ remoteDir = new File(configDir, "remote");
+ lib = createDefaultLibrary(remoteDir);
+
+ // create cache and TMP
+ File tmp = getFile(Config.CACHE_DIR, configDir, "tmp");
+ Image.setTemporaryFilesRoot(new File(tmp.getParent(), "tmp.images"));
+
+ String ua = config.getString(Config.NETWORK_USER_AGENT, "");
+ try {
+ int hours = config.getInteger(Config.CACHE_MAX_TIME_CHANGING, 0);
+ int hoursLarge = config.getInteger(Config.CACHE_MAX_TIME_STABLE, 0);
+ cache = new DataLoader(tmp, ua, hours, hoursLarge);
+ } catch (IOException e) {
+ tracer.error(new IOException(
+ "Cannot create cache (will continue without cache)", e));
+ cache = new DataLoader(ua);
+ }
+
+ cache.setTraceHandler(tracer);
+
+ // readerTmp / coverDir
+ readerTmp = getFile(UiConfig.CACHE_DIR_LOCAL_READER, configDir,
+ "tmp-reader");
+ coverDir = getFile(Config.DEFAULT_COVERS_DIR, configDir, "covers");
+ coverDir.mkdirs();
+
+ try {
+ tempFiles = new TempFiles("fanfix");
+ } catch (IOException e) {
+ tracer.error(
+ new IOException("Cannot create temporary directory", e));
+ }
+ }
+
+ /**
+ * The traces handler for this {@link Cache}.
+ *
+ * It is never NULL.
+ *
+ * @return the traces handler (never NULL)
+ */
+ public TraceHandler getTraceHandler() {
+ return tracer;
+ }
+
+ /**
+ * The traces handler for this {@link Cache}.
+ *
+ * @param tracer
+ * the new traces handler or NULL
+ */
+ public void setTraceHandler(TraceHandler tracer) {
+ if (tracer == null) {
+ tracer = new TraceHandler(false, false, false);
+ }
+
+ this.tracer = tracer;
+ cache.setTraceHandler(tracer);
+ }
+
+ /**
+ * Get the (unique) configuration service for the program.
+ *
+ * @return the configuration service
+ */
+ public ConfigBundle getConfig() {
+ return config;
+ }
+
+ /**
+ * Get the (unique) UI configuration service for the program.
+ *
+ * @return the configuration service
+ */
+ public UiConfigBundle getUiConfig() {
+ return uiconfig;
+ }
+
+ /**
+ * Reset the configuration.
+ *
+ * @param resetTrans
+ * also reset the translation files
+ */
+ public void resetConfig(boolean resetTrans) {
+ String dir = Bundles.getDirectory();
+ Bundles.setDirectory(null);
+ try {
+ try {
+ ConfigBundle config = new ConfigBundle();
+ config.updateFile(configDir);
+ } catch (IOException e) {
+ tracer.error(e);
+ }
+ try {
+ UiConfigBundle uiconfig = new UiConfigBundle();
+ uiconfig.updateFile(configDir);
+ } catch (IOException e) {
+ tracer.error(e);
+ }
+
+ if (resetTrans) {
+ try {
+ StringIdBundle trans = new StringIdBundle(null);
+ trans.updateFile(configDir);
+ } catch (IOException e) {
+ tracer.error(e);
+ }
+ }
+ } finally {
+ Bundles.setDirectory(dir);
+ }
+ }
+
+ /**
+ * Get the (unique) {@link DataLoader} for the program.
+ *
+ * @return the {@link DataLoader}
+ */
+ public DataLoader getCache() {
+ return cache;
+ }
+
+ /**
+ * Get the (unique) {link StringIdBundle} for the program.
+ *
+ * This is used for the translations of the core parts of Fanfix.
+ *
+ * @return the {link StringIdBundle}
+ */
+ public StringIdBundle getTrans() {
+ return trans;
+ }
+
+ /**
+ * Get the (unique) {link StringIdGuiBundle} for the program.
+ *
+ * This is used for the translations of the GUI parts of Fanfix.
+ *
+ * @return the {link StringIdGuiBundle}
+ */
+ public StringIdGuiBundle getTransGui() {
+ return transGui;
+ }
+
+ /**
+ * Get the (unique) {@link BasicLibrary} for the program.
+ *
+ * @return the {@link BasicLibrary}
+ */
+ public BasicLibrary getLibrary() {
+ if (lib == null) {
+ throw new NullPointerException("We don't have a library to return");
+ }
+
+ return lib;
+ }
+
+ /**
+ * Change the default {@link BasicLibrary} for this program.
+ *
+ * Be careful.
+ *
+ * @param lib
+ * the new {@link BasicLibrary}
+ */
+ public void setLibrary(BasicLibrary lib) {
+ this.lib = lib;
+ }
+
+ /**
+ * Return the directory where to look for default cover pages.
+ *
+ * @return the default covers directory
+ */
+ public File getCoverDir() {
+ return coverDir;
+ }
+
+ /**
+ * Return the directory where to store temporary files for the local reader.
+ *
+ * @return the directory
+ */
+ public File getReaderDir() {
+ return readerTmp;
+ }
+
+ /**
+ * Return the directory where to store temporary files for the remote
+ * {@link LocalLibrary}.
+ *
+ * @param host
+ * the remote for this host
+ *
+ * @return the directory
+ */
+ public File getRemoteDir(String host) {
+ return getRemoteDir(remoteDir, host);
+ }
+
+ /**
+ * Return the directory where to store temporary files for the remote
+ * {@link LocalLibrary}.
+ *
+ * @param remoteDir
+ * the base remote directory
+ * @param host
+ * the remote for this host
+ *
+ * @return the directory
+ */
+ private File getRemoteDir(File remoteDir, String host) {
+ remoteDir.mkdirs();
+
+ if (host != null) {
+ return new File(remoteDir, host);
+ }
+
+ return remoteDir;
+ }
+
+ /**
+ * Check if we need to check that a new version of Fanfix is available.
+ *
+ * @return TRUE if we need to
+ */
+ public boolean isVersionCheckNeeded() {
+ try {
+ long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0)
+ * 24 * 60 * 60 * 1000;
+ if (wait >= 0) {
+ String lastUpString = IOUtils
+ .readSmallFile(new File(configDir, "LAST_UPDATE"));
+ long delay = new Date().getTime()
+ - Long.parseLong(lastUpString);
+ if (delay > wait) {
+ return true;
+ }
+ } else {
+ return false;
+ }
+ } catch (Exception e) {
+ // No file or bad file:
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Notify that we checked for a new version of Fanfix.
+ */
+ public void setVersionChecked() {
+ try {
+ IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE",
+ Long.toString(new Date().getTime()));
+ } catch (IOException e) {
+ tracer.error(e);
+ }
+ }
+
+ /**
+ * The facility to use temporary files in this program.
+ *
+ * MUST be closed at end of program.
+ *
+ * @return the facility
+ */
+ public TempFiles getTempFiles() {
+ return tempFiles;
+ }
+
+ /**
+ * The configuration directory (will check, in order of preference, the
+ * system properties, the environment and then defaults to
+ * {@link Instance#getHome()}/.fanfix).
+ *
+ * @return the config directory
+ */
+ private String getConfigDir() {
+ String configDir = System.getProperty("CONFIG_DIR");
+
+ if (configDir == null) {
+ configDir = System.getenv("CONFIG_DIR");
+ }
+
+ if (configDir == null) {
+ configDir = new File(getHome(), ".fanfix").getPath();
+ }
+
+ return configDir;
+ }
+
+ /**
+ * Create the config variables ({@link Instance#config},
+ * {@link Instance#uiconfig}, {@link Instance#trans} and
+ * {@link Instance#transGui}).
+ *
+ * @param configDir
+ * the directory where to find the configuration files
+ * @param refresh
+ * TRUE to reset the configuration files from the default
+ * included ones
+ */
+ private void createConfigs(String configDir, boolean refresh) {
+ if (!refresh) {
+ Bundles.setDirectory(configDir);
+ }
+
+ try {
+ config = new ConfigBundle();
+ config.updateFile(configDir);
+ } catch (IOException e) {
+ tracer.error(e);
+ }
+
+ try {
+ uiconfig = new UiConfigBundle();
+ uiconfig.updateFile(configDir);
+ } catch (IOException e) {
+ tracer.error(e);
+ }
+
+ // No updateFile for this one! (we do not want the user to have custom
+ // translations that won't accept updates from newer versions)
+ trans = new StringIdBundle(getLang());
+ transGui = new StringIdGuiBundle(getLang());
+
+ // Fix an old bug (we used to store custom translation files by
+ // default):
+ if (trans.getString(StringId.INPUT_DESC_CBZ) == null) {
+ trans.deleteFile(configDir);
+ }
+
+ Boolean noutf = checkEnv("NOUTF");
+ if (noutf != null && noutf) {
+ trans.setUnicode(false);
+ transGui.setUnicode(false);
+ }
+
+ Bundles.setDirectory(configDir);
+ }
+
+ /**
+ * Create the default library as specified by the config.
+ *
+ * @param remoteDir
+ * the base remote directory if needed
+ *
+ * @return the default {@link BasicLibrary}
+ */
+ private BasicLibrary createDefaultLibrary(File remoteDir) {
+ BasicLibrary lib = null;
+
+ boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED,
+ false);
+ if (useRemote) {
+ String host = null;
+ int port = -1;
+ try {
+ host = config.getString(Config.REMOTE_LIBRARY_HOST,
+ "fanfix://localhost");
+ port = config.getInteger(Config.REMOTE_LIBRARY_PORT, -1);
+ String key = config.getString(Config.REMOTE_LIBRARY_KEY);
+
+ if (!host.startsWith("http://") && !host.startsWith("https://")
+ && !host.startsWith("fanfix://")) {
+ host = "fanfix://" + host;
+ }
+
+ tracer.trace("Selecting remote library " + host + ":" + port);
+
+ if (host.startsWith("fanfix://")) {
+ lib = new RemoteLibrary(key, host, port);
+ } else {
+ lib = new WebLibrary(key, host, port);
+ }
+
+ lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib,
+ uiconfig);
+ } catch (Exception e) {
+ tracer.error(
+ new IOException("Cannot create remote library for: "
+ + host + ":" + port, e));
+ }
+ } else {
+ String libDir = System.getenv("BOOKS_DIR");
+ if (libDir == null || libDir.isEmpty()) {
+ libDir = getFile(Config.LIBRARY_DIR, configDir, "$HOME/Books")
+ .getPath();
+ }
+ try {
+ lib = new LocalLibrary(new File(libDir), config);
+ } catch (Exception e) {
+ tracer.error(new IOException(
+ "Cannot create library for directory: " + libDir, e));
+ }
+ }
+
+ return lib;
+ }
+
+ /**
+ * Return a path, but support the special $HOME variable.
+ *
+ * @param id
+ * the key for the path, which may contain "$HOME"
+ * @param configDir
+ * the directory to use as base if not absolute
+ * @param def
+ * the default value if none (will be configDir-rooted if needed)
+ * @return the path, with expanded "$HOME" if needed
+ */
+ protected File getFile(Config id, String configDir, String def) {
+ String path = config.getString(id, def);
+ return getFile(path, configDir);
+ }
+
+ /**
+ * Return a path, but support the special $HOME variable.
+ *
+ * @param id
+ * the key for the path, which may contain "$HOME"
+ * @param configDir
+ * the directory to use as base if not absolute
+ * @param def
+ * the default value if none (will be configDir-rooted if needed)
+ * @return the path, with expanded "$HOME" if needed
+ */
+ protected File getFile(UiConfig id, String configDir, String def) {
+ String path = uiconfig.getString(id, def);
+ return getFile(path, configDir);
+ }
+
+ /**
+ * Return a path, but support the special $HOME variable.
+ *
+ * @param path
+ * the path, which may contain "$HOME"
+ * @param configDir
+ * the directory to use as base if not absolute
+ * @return the path, with expanded "$HOME" if needed
+ */
+ protected File getFile(String path, String configDir) {
+ File file = null;
+ if (path != null && !path.isEmpty()) {
+ path = path.replace('/', File.separatorChar);
+ if (path.contains("$HOME")) {
+ path = path.replace("$HOME", getHome());
+ } else if (!path.startsWith("/")) {
+ path = new File(configDir, path).getPath();
+ }
+
+ file = new File(path);
+ }
+
+ return file;
+ }
+
+ /**
+ * Return the home directory from the environment (FANFIX_DIR) or the system
+ * properties.
+ *
+ * The environment variable is tested first. Then, the custom property
+ * "fanfix.home" is tried, followed by the usual "user.home" then
+ * "java.io.tmp" if nothing else is found.
+ *
+ * @return the home
+ */
+ protected String getHome() {
+ String home = System.getenv("FANFIX_DIR");
+ if (home != null && new File(home).isFile()) {
+ home = null;
+ }
+
+ if (home == null || home.trim().isEmpty()) {
+ home = System.getProperty("fanfix.home");
+ if (home != null && new File(home).isFile()) {
+ home = null;
+ }
+ }
+
+ if (home == null || home.trim().isEmpty()) {
+ home = System.getProperty("user.home");
+ if (!new File(home).isDirectory()) {
+ home = null;
+ }
+ }
+
+ if (home == null || home.trim().isEmpty()) {
+ home = System.getProperty("java.io.tmpdir");
+ if (!new File(home).isDirectory()) {
+ home = null;
+ }
+ }
+
+ if (home == null) {
+ home = "";
+ }
+
+ return home;
+ }
+
+ /**
+ * The language to use for the application (NULL = default system language).
+ *
+ * @return the language
+ */
+ protected String getLang() {
+ String lang = config.getString(Config.LANG);
+
+ if (lang == null || lang.isEmpty()) {
+ if (System.getenv("LANG") != null
+ && !System.getenv("LANG").isEmpty()) {
+ lang = System.getenv("LANG");
+ }
+ }
+
+ if (lang != null && lang.isEmpty()) {
+ lang = null;
+ }
+
+ return lang;
+ }
+
+ /**
+ * Check that the given environment variable is "enabled".
+ *
+ * @param key
+ * the variable to check
+ *
+ * @return TRUE if it is
+ */
+ protected Boolean checkEnv(String key) {
+ String value = System.getenv(key);
+ if (value != null) {
+ value = value.trim().toLowerCase();
+ if ("yes".equals(value) || "true".equals(value)
+ || "on".equals(value) || "1".equals(value)
+ || "y".equals(value)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return null;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/Main.java b/src/be/nikiroo/fanfix/Main.java
new file mode 100644
index 0000000..7be305a
--- /dev/null
+++ b/src/be/nikiroo/fanfix/Main.java
@@ -0,0 +1,1104 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.fanfix.library.RemoteLibraryServer;
+import be.nikiroo.fanfix.library.WebLibraryServer;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.reader.BasicReader;
+import be.nikiroo.fanfix.reader.CliReader;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.VersionCheck;
+
+/**
+ * Main program entry point.
+ *
+ * @author niki
+ */
+public class Main {
+ private enum MainAction {
+ IMPORT, EXPORT, CONVERT, READ, READ_URL, LIST, HELP, START, VERSION, SERVER, STOP_SERVER, REMOTE, SET_SOURCE, SET_TITLE, SET_AUTHOR, SEARCH, SEARCH_TAG
+ }
+
+ /**
+ * Main program entry point.
+ *
+ * Known environment variables:
+ *
+ *
NOUTF: if set to 1 or 'true', the program will prefer non-unicode
+ * {@link String}s when possible
+ *
CONFIG_DIR: a path where to look for the .properties files
+ * before taking the usual ones; they will also be saved/updated into this
+ * path when the program starts
+ *
DEBUG: if set to 1 or 'true', the program will override the DEBUG_ERR
+ * configuration value with 'true'
+ *
+ *
+ *
+ *
--import [URL]: import into library
+ *
--export [id] [output_type] [target]: export story to target
--read [id] ([chapter number]): read the given story from the library
+ *
+ *
--read-url [URL] ([chapter number]): convert on the fly and read the
+ * story, without saving it
+ *
--search: list the supported websites (where)
+ *
--search [where] [keywords] (page [page]) (item [item]): search on
+ * the supported website and display the given results page of stories it
+ * found, or the story details if asked
+ *
--search-tag [where]: list all the tags supported by this website
+ *
--search-tag [index 1]... (page [page]) (item [item]): search for the
+ * given stories or subtags, tag by tag, and display information about a
+ * specific page of results or about a specific item if requested
+ *
--list ([type]): list the stories present in the library
+ *
--set-source [id] [new source]: change the source of the given story
+ *
--set-title [id] [new title]: change the title of the given story
+ *
--set-author [id] [new author]: change the author of the given story
+ *
--version: get the version of the program
+ *
--server: start the server mode (see config file for parameters)
+ *
--stop-server: stop the running server on this port if any
+ *
--remote [key] [host] [port]: use the given remote library
+ *
+ *
+ * @param args
+ * see method description
+ */
+ public static void main(String[] args) {
+ new Main().start(args);
+ }
+
+ /**
+ * Start the default handling for the application.
+ *
+ * If specific actions were asked (with correct parameters), they will be
+ * forwarded to the different protected methods that you can override.
+ *
+ * You will probably want to override that one if you offer a user
+ * interface.
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected void start() throws IOException {
+ new CliReader().listBooks(null);
+ }
+
+ /**
+ * Will check if updates are available, synchronously.
+ *
+ * For this, it will simply forward the call to
+ * {@link Main#checkUpdates(String)} with a value of "nikiroo/fanfix".
+ *
+ * You may want to override it so you call the forward method with the right
+ * parameters (or also if you want it to be asynchronous).
+ *
+ * @return the newer version information or NULL if nothing new
+ */
+ protected VersionCheck checkUpdates() {
+ return checkUpdates("nikiroo/fanfix");
+ }
+
+ /**
+ * Will check if updates are available on a specific GitHub project.
+ *
+ * Will be called by {@link Main#checkUpdates()}, but if you override that
+ * one you mall call it with another project.
+ *
+ * @param githubProject
+ * the GitHub project, for instance "nikiroo/fanfix"
+ *
+ * @return the newer version information or NULL if nothing new
+ */
+ protected VersionCheck checkUpdates(String githubProject) {
+ try {
+ VersionCheck updates = VersionCheck.check(githubProject,
+ Instance.getInstance().getTrans().getLocale());
+ if (updates.isNewVersionAvailable()) {
+ notifyUpdates(updates);
+ return updates;
+ }
+ } catch (IOException e) {
+ // Maybe no internet. Do not report any update.
+ }
+
+ return null;
+ }
+
+ /**
+ * Notify the user about available updates.
+ *
+ * Will only be called when a version is available.
+ *
+ * Note that you can call {@link Instance#setVersionChecked()} on it if the
+ * user has read the information (by default, it is marked read only on
+ * certain other actions).
+ *
+ * @param updates
+ * the new version information
+ */
+ protected void notifyUpdates(VersionCheck updates) {
+ // Sent to syserr so not to cause problem if one tries to capture a
+ // story content in text mode
+ System.err.println(
+ "A new version of the program is available at https://github.com/nikiroo/fanfix/releases");
+ System.err.println("");
+ for (Version v : updates.getNewer()) {
+ System.err.println("\tVersion " + v);
+ System.err.println("\t-------------");
+ System.err.println("");
+ for (String it : updates.getChanges().get(v)) {
+ System.err.println("\t- " + it);
+ }
+ System.err.println("");
+ }
+ }
+
+ /**
+ * Import the given resource into the {@link LocalLibrary}.
+ *
+ * @param url
+ * the resource to import
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the exit return code (0 = success)
+ */
+ protected static int imprt(URL url, Progress pg) {
+ try {
+ MetaData meta = Instance.getInstance().getLibrary().imprt(url, pg);
+ System.out.println(meta.getLuid() + ": \"" + meta.getTitle() + "\" imported.");
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ return 1;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Export the {@link Story} from the {@link LocalLibrary} to the given
+ * target.
+ *
+ * @param luid
+ * the story LUID
+ * @param type
+ * the {@link OutputType} to use
+ * @param target
+ * the target
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the exit return code (0 = success)
+ */
+ protected static int export(String luid, OutputType type, String target,
+ Progress pg) {
+ try {
+ Instance.getInstance().getLibrary().export(luid, type, target, pg);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ return 4;
+ }
+
+ return 0;
+ }
+
+ /**
+ * List the stories of the given source from the {@link LocalLibrary}
+ * (unless NULL is passed, in which case all stories will be listed).
+ *
+ * @param source
+ * the source to list the known stories of, or NULL to list all
+ * stories
+ *
+ * @return the exit return code (0 = success)
+ */
+ protected int list(String source) {
+ try {
+ new CliReader().listBooks(source);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ return 66;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Start the current reader for this {@link Story}.
+ *
+ * @param story
+ * the story to read
+ * @param chap
+ * which {@link Chapter} to read (starting at 1), or NULL to get
+ * the {@link Story} description
+ *
+ * @return the exit return code (0 = success)
+ */
+ protected int read(Story story, Integer chap) {
+ if (story != null) {
+ try {
+ if (chap == null) {
+ new CliReader().listChapters(story);
+ } else {
+ new CliReader().printChapter(story, chap);
+ }
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException("Failed to read book", e));
+ return 2;
+ }
+ } else {
+ Instance.getInstance().getTraceHandler()
+ .error("Cannot find book: " + story);
+ return 2;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Convert the {@link Story} into another format.
+ *
+ * @param urlString
+ * the source {@link Story} to convert
+ * @param type
+ * the {@link OutputType} to convert to
+ * @param target
+ * the target file
+ * @param infoCover
+ * TRUE to also export the cover and info file, even if the given
+ * {@link OutputType} does not usually save them
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the exit return code (0 = success)
+ */
+ protected int convert(String urlString, OutputType type,
+ String target, boolean infoCover, Progress pg) {
+ int exitCode = 0;
+
+ Instance.getInstance().getTraceHandler().trace("Convert: " + urlString);
+ String sourceName = urlString;
+ try {
+ URL source = BasicReader.getUrl(urlString);
+ sourceName = source.toString();
+ if (sourceName.startsWith("file://")) {
+ sourceName = sourceName.substring("file://".length());
+ }
+
+ try {
+ BasicSupport support = BasicSupport.getSupport(source);
+
+ if (support != null) {
+ Instance.getInstance().getTraceHandler()
+ .trace("Support found: " + support.getClass());
+ Progress pgIn = new Progress();
+ Progress pgOut = new Progress();
+ if (pg != null) {
+ pg.setMax(2);
+ pg.addProgress(pgIn, 1);
+ pg.addProgress(pgOut, 1);
+ }
+
+ Story story = support.process(pgIn);
+ try {
+ target = new File(target).getAbsolutePath();
+ BasicOutput.getOutput(type, infoCover, infoCover)
+ .process(story, target, pgOut);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException(
+ trans(StringId.ERR_SAVING, target), e));
+ exitCode = 5;
+ }
+ } else {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException(
+ trans(StringId.ERR_NOT_SUPPORTED, source)));
+
+ exitCode = 4;
+ }
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(new IOException(
+ trans(StringId.ERR_LOADING, sourceName), e));
+ exitCode = 3;
+ }
+ } catch (MalformedURLException e) {
+ Instance.getInstance().getTraceHandler().error(new IOException(trans(StringId.ERR_BAD_URL, sourceName), e));
+ exitCode = 1;
+ }
+
+ return exitCode;
+ }
+
+ /**
+ * Display the correct syntax of the program to the user to stdout, or an
+ * error message if the syntax used was wrong on stderr.
+ *
+ * @param showHelp
+ * TRUE to show the syntax help, FALSE to show "syntax error"
+ */
+ protected void syntax(boolean showHelp) {
+ if (showHelp) {
+ StringBuilder builder = new StringBuilder();
+ for (SupportType type : SupportType.values()) {
+ builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+ type.getDesc()));
+ builder.append('\n');
+ }
+
+ String typesIn = builder.toString();
+ builder.setLength(0);
+
+ for (OutputType type : OutputType.values()) {
+ builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+ type.getDesc(true)));
+ builder.append('\n');
+ }
+
+ String typesOut = builder.toString();
+
+ System.out.println(trans(StringId.HELP_SYNTAX, typesIn, typesOut));
+ } else {
+ System.err.println(trans(StringId.ERR_SYNTAX));
+ }
+ }
+
+ /**
+ * Starts a search operation (i.e., list the available web sites we can
+ * search on).
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ protected void search() throws IOException {
+ new CliReader().listSearchables();
+ }
+
+ /**
+ * Search for books by keywords on the given supported web site.
+ *
+ * @param searchOn
+ * the web site to search on
+ * @param search
+ * the keyword to look for
+ * @param page
+ * the page of results to get, or 0 to inquire about the number
+ * of pages
+ * @param item
+ * the index of the book we are interested by, or 0 to query
+ * about how many books are in that page of results
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected void searchKeywords(SupportType searchOn, String search,
+ int page, Integer item) throws IOException {
+ new CliReader().searchBooksByKeyword(searchOn, search, page, item);
+ }
+
+ /**
+ * Search for books by tags on the given supported web site.
+ *
+ * @param searchOn
+ * the web site to search on
+ * @param page
+ * the page of results to get, or 0 to inquire about the number
+ * of pages
+ * @param item
+ * the index of the book we are interested by, or 0 to query
+ * about how many books are in that page of results
+ * @param tags
+ * the tags to look for
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected void searchTags(SupportType searchOn, Integer page, Integer item,
+ Integer[] tags) throws IOException {
+ new CliReader().searchBooksByTag(searchOn, page, item, tags);
+ }
+
+ /**
+ * Start a Fanfix server.
+ *
+ * @throws IOException
+ * in case of I/O errors
+ * @throws SSLException
+ * when the key was not accepted
+ */
+ private void startServer() throws IOException {
+ String mode = Instance.getInstance().getConfig()
+ .getString(Config.SERVER_MODE, "fanfix");
+ if (mode.equals("fanfix")) {
+ RemoteLibraryServer server = new RemoteLibraryServer();
+ server.setTraceHandler(Instance.getInstance().getTraceHandler());
+ server.run();
+ } else if (mode.equals("http")) {
+ WebLibraryServer server = new WebLibraryServer(false);
+ server.setTraceHandler(Instance.getInstance().getTraceHandler());
+ server.run();
+ } else if (mode.equals("https")) {
+ WebLibraryServer server = new WebLibraryServer(true);
+ server.setTraceHandler(Instance.getInstance().getTraceHandler());
+ server.run();
+ } else {
+ throw new IOException("Unknown server mode: " + mode);
+ }
+ }
+
+ /**
+ * Stop a running Fanfix server.
+ *
+ * @param key
+ * the key to contact the Fanfix server
+ * @param host
+ * the host on which it runs (NULL means localhost)
+ * @param port
+ * the port on which it runs
+ *
+ * @throws IOException
+ * in case of I/O errors
+ * @throws SSLException
+ * when the key was not accepted
+ */
+ private void stopServer(
+ String key, String host, Integer port)
+ throws IOException, SSLException {
+ new RemoteLibrary(key, host, port).exit();
+ }
+
+ /**
+ * We are done and ready to exit.
+ *
+ * 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(info = "%s = supported input, %s = supported output", description = "help message for the syntax")
+ HELP_SYNTAX, //
+ @Meta(description = "syntax error message")
+ ERR_SYNTAX, //
+ @Meta(info = "%s = support name, %s = support desc", description = "an input or output support type description")
+ ERR_SYNTAX_TYPE, //
+ @Meta(info = "%s = input string", description = "Error when retrieving data")
+ ERR_LOADING, //
+ @Meta(info = "%s = save target", description = "Error when saving to given target")
+ ERR_SAVING, //
+ @Meta(info = "%s = bad output format", description = "Error when unknown output format")
+ ERR_BAD_OUTPUT_TYPE, //
+ @Meta(info = "%s = input string", description = "Error when converting input to URL/File")
+ ERR_BAD_URL, //
+ @Meta(info = "%s = input url", description = "URL/File not supported")
+ ERR_NOT_SUPPORTED, //
+ @Meta(info = "%s = cover URL", description = "Failed to download cover : %s")
+ ERR_BS_NO_COVER, //
+ @Meta(def = "`", info = "single char", description = "Canonical OPEN SINGLE QUOTE char (for instance: â)")
+ OPEN_SINGLE_QUOTE, //
+ @Meta(def = "â", info = "single char", description = "Canonical CLOSE SINGLE QUOTE char (for instance: â)")
+ CLOSE_SINGLE_QUOTE, //
+ @Meta(def = "â", info = "single char", description = "Canonical OPEN DOUBLE QUOTE char (for instance: â)")
+ OPEN_DOUBLE_QUOTE, //
+ @Meta(def = "â", info = "single char", description = "Canonical CLOSE DOUBLE QUOTE char (for instance: â)")
+ CLOSE_DOUBLE_QUOTE, //
+ @Meta(def = "Description", description = "Name of the description fake chapter")
+ DESCRIPTION, //
+ @Meta(def = "Chapter %d: %s", info = "%d = number, %s = name", description = "Name of a chapter with a name")
+ CHAPTER_NAMED, //
+ @Meta(def = "Chapter %d", info = "%d = number, %s = name", description = "Name of a chapter without name")
+ CHAPTER_UNNAMED, //
+ @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
+ INPUT_DESC, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_EPUB, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_TEXT, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_INFO_TEXT, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_FANFICTION, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_FIMFICTION, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_MANGAFOX, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_E621, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_E_HENTAI, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_YIFFSTAR, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_CBZ, //
+ @Meta(description = "Description of this input type")
+ INPUT_DESC_HTML, //
+ @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
+ OUTPUT_DESC, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_EPUB, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_TEXT, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_INFO_TEXT, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_CBZ, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_HTML, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_LATEX, //
+ @Meta(description = "Description of this output type")
+ OUTPUT_DESC_SYSOUT, //
+ @Meta(group = true, info = "%s = type", description = "Default description when the type is not known by i18n")
+ OUTPUT_DESC_SHORT, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_EPUB, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_TEXT, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_INFO_TEXT, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_CBZ, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_LATEX, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_SYSOUT, //
+ @Meta(description = "Short description of this output type")
+ OUTPUT_DESC_SHORT_HTML, //
+ @Meta(info = "%s = the unknown 2-code language", description = "Error message for unknown 2-letter LaTeX language code")
+ LATEX_LANG_UNKNOWN, //
+ @Meta(def = "by", description = "'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection")
+ BY, //
+
+ ;
+
+ /**
+ * Write the header found in the configuration .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..b9a0d79
--- /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.TransBundle;
+
+/**
+ * This class manages the translation resources of the application (Core).
+ *
+ * @author niki
+ */
+public class StringIdBundle extends TransBundle {
+ /**
+ * Create a translation service for the given language (will fall back to
+ * the default one if not found).
+ *
+ * @param lang
+ * the language to use
+ */
+ public StringIdBundle(String lang) {
+ super(StringId.class, Target.resources_core, lang);
+ }
+
+ /**
+ * Update resource file.
+ *
+ * @param args
+ * not used
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static void main(String[] args) throws IOException {
+ String path = new File(".").getAbsolutePath()
+ + "/src/be/nikiroo/fanfix/bundles/";
+ new StringIdBundle(null).updateFile(path);
+ System.out.println("Path updated: " + path);
+ }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGui.java b/src/be/nikiroo/fanfix/bundles/StringIdGui.java
new file mode 100644
index 0000000..c109f42
--- /dev/null
+++ b/src/be/nikiroo/fanfix/bundles/StringIdGui.java
@@ -0,0 +1,199 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The {@link Enum} representing textual information to be translated to the
+ * user as a key.
+ *
+ * Note that each key that should be translated must be annotated with a
+ * {@link Meta} annotation.
+ *
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum StringIdGui {
+ /**
+ * A special key used for technical reasons only, without annotations so it
+ * is not visible in .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(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library", info = "%s = current Fanfix version")
+ // The titles/subtitles:
+ TITLE_LIBRARY, //
+ @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)", info = "%s = current Fanfix version, %s = library name")
+ TITLE_LIBRARY_WITH_NAME, //
+ @Meta(def = "Fanfix Configuration", format = Format.STRING, description = "the title of the configuration window of Fanfix, also the name of the menu button")
+ TITLE_CONFIG, //
+ @Meta(def = "This is where you configure the options of the program.", format = Format.STRING, description = "the subtitle of the configuration window of Fanfix")
+ SUBTITLE_CONFIG, //
+ @Meta(def = "UI Configuration", format = Format.STRING, description = "the title of the UI configuration window of Fanfix, also the name of the menu button")
+ TITLE_CONFIG_UI, //
+ @Meta(def = "This is where you configure the graphical appearence of the program.", format = Format.STRING, description = "the subtitle of the UI configuration window of Fanfix")
+ SUBTITLE_CONFIG_UI, //
+ @Meta(def = "Save", format = Format.STRING, description = "the title of the 'save to/export to' window of Fanfix")
+ TITLE_SAVE, //
+ @Meta(def = "Moving story", format = Format.STRING, description = "the title of the 'move to' window of Fanfix")
+ TITLE_MOVE_TO, //
+ @Meta(def = "Move to:", format = Format.STRING, description = "the subtitle of the 'move to' window of Fanfix")
+ SUBTITLE_MOVE_TO, //
+ @Meta(def = "Delete story", format = Format.STRING, description = "the title of the 'delete' window of Fanfix")
+ TITLE_DELETE, //
+ @Meta(def = "Delete %s: %s", format = Format.STRING, description = "the subtitle of the 'delete' window of Fanfix", info = "%s = LUID of the story, %s = title of the story")
+ SUBTITLE_DELETE, //
+ @Meta(def = "Library error", format = Format.STRING, description = "the title of the 'library error' dialogue")
+ TITLE_ERROR_LIBRARY, //
+ @Meta(def = "Importing from URL", format = Format.STRING, description = "the title of the 'import URL' dialogue")
+ TITLE_IMPORT_URL, //
+ @Meta(def = "URL of the story to import:", format = Format.STRING, description = "the subtitle of the 'import URL' dialogue")
+ SUBTITLE_IMPORT_URL, //
+ @Meta(def = "Error", format = Format.STRING, description = "the title of general error dialogues")
+ TITLE_ERROR, //
+ @Meta(def = "%s: %s", format = Format.STRING, description = "the title of a story for the properties dialogue, the viewers...", info = "%s = LUID of the story, %s = title of the story")
+ TITLE_STORY, //
+
+ //
+
+ @Meta(def = "A new version of the program is available at %s", format = Format.STRING, description = "HTML text used to notify of a new version", info = "%s = url link in HTML")
+ NEW_VERSION_AVAILABLE, //
+ @Meta(def = "Updates available", format = Format.STRING, description = "text used as title for the update dialogue")
+ NEW_VERSION_TITLE, //
+ @Meta(def = "Version %s", format = Format.STRING, description = "HTML text used to specify a newer version title and number, used for each version newer than the current one", info = "%s = the newer version number")
+ NEW_VERSION_VERSION, //
+ @Meta(def = "%s words", format = Format.STRING, description = "show the number of words of a book", info = "%s = the number")
+ BOOK_COUNT_WORDS, //
+ @Meta(def = "%s images", format = Format.STRING, description = "show the number of images of a book", info = "%s = the number")
+ BOOK_COUNT_IMAGES, //
+ @Meta(def = "%s stories", format = Format.STRING, description = "show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)", info = "%s = the number")
+ BOOK_COUNT_STORIES, //
+
+ // Menu (and popup) items:
+
+ @Meta(def = "File", format = Format.STRING, description = "the file menu")
+ MENU_FILE, //
+ @Meta(def = "Exit", format = Format.STRING, description = "the file/exit menu button")
+ MENU_FILE_EXIT, //
+ @Meta(def = "Import File...", format = Format.STRING, description = "the file/import_file menu button")
+ MENU_FILE_IMPORT_FILE, //
+ @Meta(def = "Import URL...", format = Format.STRING, description = "the file/import_url menu button")
+ MENU_FILE_IMPORT_URL, //
+ @Meta(def = "Save as...", format = Format.STRING, description = "the file/export menu button")
+ MENU_FILE_EXPORT, //
+ @Meta(def = "Move to", format = Format.STRING, description = "the file/move to menu button")
+ MENU_FILE_MOVE_TO, //
+ @Meta(def = "Set author", format = Format.STRING, description = "the file/set author menu button")
+ MENU_FILE_SET_AUTHOR, //
+ @Meta(def = "New source...", format = Format.STRING, description = "the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source")
+ MENU_FILE_MOVE_TO_NEW_TYPE, //
+ @Meta(def = "New author...", format = Format.STRING, description = "the file/move to/new author menu button, that will trigger a dialogue to create a new author")
+ MENU_FILE_MOVE_TO_NEW_AUTHOR, //
+ @Meta(def = "Rename...", format = Format.STRING, description = "the file/rename menu item, that will trigger a dialogue to ask for a new title for the story")
+ MENU_FILE_RENAME, //
+ @Meta(def = "Properties", format = Format.STRING, description = "the file/Properties menu item, that will trigger a dialogue to show the properties of the story")
+ MENU_FILE_PROPERTIES, //
+ @Meta(def = "Open", format = Format.STRING, description = "the file/open menu item, that will open the story or fake-story (an author or a source/type)")
+ MENU_FILE_OPEN, //
+ @Meta(def = "Edit", format = Format.STRING, description = "the edit menu")
+ MENU_EDIT, //
+ @Meta(def = "Prefetch to cache", format = Format.STRING, description = "the edit/send to cache menu button, to download the story into the cache if not already done")
+ MENU_EDIT_DOWNLOAD_TO_CACHE, //
+ @Meta(def = "Clear cache", format = Format.STRING, description = "the clear cache menu button, to clear the cache for a single book")
+ MENU_EDIT_CLEAR_CACHE, //
+ @Meta(def = "Redownload", format = Format.STRING, description = "the edit/redownload menu button, to download the latest version of the book")
+ MENU_EDIT_REDOWNLOAD, //
+ @Meta(def = "Delete", format = Format.STRING, description = "the edit/delete menu button")
+ MENU_EDIT_DELETE, //
+ @Meta(def = "Set as cover for source", format = Format.STRING, description = "the edit/Set as cover for source menu button")
+ MENU_EDIT_SET_COVER_FOR_SOURCE, //
+ @Meta(def = "Set as cover for author", format = Format.STRING, description = "the edit/Set as cover for author menu button")
+ MENU_EDIT_SET_COVER_FOR_AUTHOR, //
+ @Meta(def = "Search", format = Format.STRING, description = "the search menu to open the earch stories on one of the searchable websites")
+ MENU_SEARCH,
+ @Meta(def = "View", format = Format.STRING, description = "the view menu")
+ MENU_VIEW, //
+ @Meta(def = "Word count", format = Format.STRING, description = "the view/word_count menu button, to show the word/image/story count as secondary info")
+ MENU_VIEW_WCOUNT, //
+ @Meta(def = "Author", format = Format.STRING, description = "the view/author menu button, to show the author as secondary info")
+ MENU_VIEW_AUTHOR, //
+ @Meta(def = "Sources", format = Format.STRING, description = "the sources menu, to select the books from a specific source; also used as a title for the source books")
+ MENU_SOURCES, //
+ @Meta(def = "Authors", format = Format.STRING, description = "the authors menu, to select the books of a specific author; also used as a title for the author books")
+ MENU_AUTHORS, //
+ @Meta(def = "Options", format = Format.STRING, description = "the options menu, to configure Fanfix from the GUI")
+ MENU_OPTIONS, //
+ @Meta(def = "All", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, by group (one book = one group)")
+ MENU_XXX_ALL_GROUPED, //
+ @Meta(def = "Listing", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)")
+ MENU_XXX_ALL_LISTING, //
+ @Meta(def = "[unknown]", format = Format.STRING, description = "a special menu button to select the books without author")
+ MENU_AUTHORS_UNKNOWN, //
+
+ // Progress names
+ @Meta(def = "Reload books", format = Format.STRING, description = "progress bar caption for the 'reload books' step of all outOfUi operations")
+ PROGRESS_OUT_OF_UI_RELOAD_BOOKS, //
+ @Meta(def = "Change the source of the book to %s", format = Format.STRING, description = "progress bar caption for the 'change source' step of the ReDownload operation", info = "%s = new source name")
+ PROGRESS_CHANGE_SOURCE, //
+
+ // Error messages
+ @Meta(def = "An error occured when contacting the library", format = Format.STRING, description = "default description if the error is not known")
+ ERROR_LIB_STATUS, //
+ @Meta(def = "You are not allowed to access this library", format = Format.STRING, description = "library access not allowed")
+ ERROR_LIB_STATUS_UNAUTHORIZED, //
+ @Meta(def = "Library not valid", format = Format.STRING, description = "the library is invalid (not correctly set up)")
+ ERROR_LIB_STATUS_INVALID, //
+ @Meta(def = "Library currently unavailable", format = Format.STRING, description = "the library is out of commission")
+ ERROR_LIB_STATUS_UNAVAILABLE, //
+ @Meta(def = "Cannot open the selected book", format = Format.STRING, description = "cannot open the book, internal or external viewer")
+ ERROR_CANNOT_OPEN, //
+ @Meta(def = "URL not supported: %s", format = Format.STRING, description = "URL is not supported by Fanfix", info = "%s = URL")
+ ERROR_URL_NOT_SUPPORTED, //
+ @Meta(def = "Failed to import %s:\n%s", format = Format.STRING, description = "cannot import the URL", info = "%s = URL, %s = reasons")
+ ERROR_URL_IMPORT_FAILED,
+
+ // Others
+ @Meta(def = " Chapitre %d / %d", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters")
+ CHAPTER_HTML_UNNAMED, //
+ @Meta(def = " Chapitre %d / %d: %s", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters, %s = chapter name")
+ CHAPTER_HTML_NAMED, //
+ @Meta(def = "Image %d / %d", format = Format.STRING, description = "(NO html) the chapter progression value used on the viewers", info = "%d = current image number, %d = total images")
+ IMAGE_PROGRESSION, //
+
+ ;
+
+ /**
+ * Write the header found in the configuration .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/StringIdGuiBundle.java b/src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java
new file mode 100644
index 0000000..c036381
--- /dev/null
+++ b/src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the translation resources of the application (GUI).
+ *
+ * @author niki
+ */
+public class StringIdGuiBundle extends TransBundle {
+ /**
+ * Create a translation service for the given language (will fall back to
+ * the default one if not found).
+ *
+ * @param lang
+ * the language to use
+ */
+ public StringIdGuiBundle(String lang) {
+ super(StringIdGui.class, Target.resources_gui, lang);
+ }
+
+ /**
+ * Update resource file.
+ *
+ * @param args
+ * not used
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public static void main(String[] args) throws IOException {
+ String path = new File(".").getAbsolutePath()
+ + "/src/be/nikiroo/fanfix/bundles/";
+ new StringIdGuiBundle(null).updateFile(path);
+ System.out.println("Path updated: " + path);
+ }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Target.java b/src/be/nikiroo/fanfix/bundles/Target.java
new file mode 100644
index 0000000..64284c6
--- /dev/null
+++ b/src/be/nikiroo/fanfix/bundles/Target.java
@@ -0,0 +1,27 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * The type of configuration information the associated {@link Bundle} will
+ * convey.
+ *
+ * This can be NULL if we don't have a resume for this {@link Story}.
+ *
+ * @return the resume
+ */
+ public Chapter getResume() {
+ return resume;
+ }
+
+ /**
+ * The story resume (a.k.a. description).
+ *
+ * @param resume
+ * the resume to set
+ */
+ public void setResume(Chapter resume) {
+ this.resume = resume;
+ }
+
+ /**
+ * The cover image of the story if any (can be NULL).
+ *
+ * @return the cover
+ */
+ public Image getCover() {
+ return cover;
+ }
+
+ /**
+ * The cover image of the story if any (can be NULL).
+ *
+ * @param cover
+ * the cover to set
+ */
+ public void setCover(Image cover) {
+ this.cover = cover;
+ }
+
+ /**
+ * The subject of the story (or instance, if it is a fanfiction, what is the
+ * original work; if it is a technical text, what is the technical
+ * subject...).
+ *
+ * @return the subject
+ */
+ public String getSubject() {
+ return subject;
+ }
+
+ /**
+ * The subject of the story (for instance, if it is a fanfiction, what is
+ * the original work; if it is a technical text, what is the technical
+ * subject...).
+ *
+ * @param subject
+ * the subject to set
+ */
+ public void setSubject(String subject) {
+ this.subject = subject;
+ }
+
+ /**
+ * The source of this story (which online library it was downloaded from).
+ *
+ * @return the source
+ */
+ public String getSource() {
+ return source;
+ }
+
+ /**
+ * The source of this story (which online library it was downloaded from).
+ *
+ * @param source
+ * the source to set
+ */
+ public void setSource(String source) {
+ this.source = source;
+ }
+
+ /**
+ * The original URL from which this {@link Story} was imported.
+ *
+ * @return the url
+ */
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * The original URL from which this {@link Story} was imported.
+ *
+ * @param url
+ * the new url to set
+ */
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ /**
+ * A unique value representing the story (it is often a URL).
+ *
+ * @return the uuid
+ */
+ public String getUuid() {
+ return uuid;
+ }
+
+ /**
+ * A unique value representing the story (it is often a URL).
+ *
+ * @param uuid
+ * the uuid to set
+ */
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ /**
+ * A unique value representing the story in the local library.
+ *
+ * @return the luid
+ */
+ public String getLuid() {
+ return luid;
+ }
+
+ /**
+ * A unique value representing the story in the local library.
+ *
+ * @param luid
+ * the luid to set
+ */
+ public void setLuid(String luid) {
+ this.luid = luid;
+ }
+
+ /**
+ * The 2-letter code language of this story.
+ *
+ * @return the lang
+ */
+ public String getLang() {
+ return lang;
+ }
+
+ /**
+ * The 2-letter code language of this story.
+ *
+ * @param lang
+ * the lang to set
+ */
+ public void setLang(String lang) {
+ this.lang = lang;
+ }
+
+ /**
+ * The story publisher (other the same as the source).
+ *
+ * @return the publisher
+ */
+ public String getPublisher() {
+ return publisher;
+ }
+
+ /**
+ * The story publisher (other the same as the source).
+ *
+ * @param publisher
+ * the publisher to set
+ */
+ public void setPublisher(String publisher) {
+ this.publisher = publisher;
+ }
+
+ /**
+ * The output type this {@link Story} is in.
+ *
+ * @return the type the type
+ */
+ public String getType() {
+ return type;
+ }
+
+ /**
+ * The output type this {@link Story} is in.
+ *
+ * @param type
+ * the new type to set
+ */
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ /**
+ * Document catering mostly to image files.
+ *
+ * @return the imageDocument state
+ */
+ public boolean isImageDocument() {
+ return imageDocument;
+ }
+
+ /**
+ * Document catering mostly to image files.
+ *
+ * @param imageDocument
+ * the imageDocument state to set
+ */
+ public void setImageDocument(boolean imageDocument) {
+ this.imageDocument = imageDocument;
+ }
+
+ /**
+ * The number of words in the related {@link Story}.
+ *
+ * @return the number of words
+ */
+ public long getWords() {
+ return words;
+ }
+
+ /**
+ * The number of words in the related {@link Story}.
+ *
+ * @param words
+ * the number of words to set
+ */
+ public void setWords(long words) {
+ this.words = words;
+ }
+
+ /**
+ * The (Fanfix) {@link Story} creation date.
+ *
+ * @return the creationDate
+ */
+ public String getCreationDate() {
+ return creationDate;
+ }
+
+ /**
+ * The (Fanfix) {@link Story} creation date.
+ *
+ * @param creationDate
+ * the creationDate to set
+ */
+ public void setCreationDate(String creationDate) {
+ this.creationDate = creationDate;
+ }
+
+ /**
+ * The cover in this {@link MetaData} object is "fake", in the sens that it
+ * comes from the actual content images.
+ *
+ * @return TRUE for a fake cover
+ */
+ public boolean isFakeCover() {
+ return fakeCover;
+ }
+
+ /**
+ * The cover in this {@link MetaData} object is "fake", in the sens that it
+ * comes from the actual content images
+ *
+ * @param fakeCover
+ * TRUE for a fake cover
+ */
+ public void setFakeCover(boolean fakeCover) {
+ this.fakeCover = fakeCover;
+ }
+
+ @Override
+ public int compareTo(MetaData o) {
+ if (o == null) {
+ return 1;
+ }
+
+ String id = (getTitle() == null ? "" : getTitle())
+ + (getUuid() == null ? "" : getUuid())
+ + (getLuid() == null ? "" : getLuid());
+ String oId = (getTitle() == null ? "" : o.getTitle())
+ + (getUuid() == null ? "" : o.getUuid())
+ + (o.getLuid() == null ? "" : o.getLuid());
+
+ return id.compareToIgnoreCase(oId);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof MetaData)) {
+ return false;
+ }
+
+ return compareTo((MetaData) obj) == 0;
+ }
+
+ @Override
+ public int hashCode() {
+ String uuid = getUuid();
+ if (uuid == null) {
+ uuid = "" + title + author + source;
+ }
+
+ return uuid.hashCode();
+ }
+
+ @Override
+ public MetaData clone() {
+ MetaData meta = null;
+ try {
+ meta = (MetaData) super.clone();
+ } catch (CloneNotSupportedException e) {
+ // Did the clones rebel?
+ System.err.println(e);
+ }
+
+ if (tags != null) {
+ meta.tags = new ArrayList(tags);
+ }
+
+ if (resume != null) {
+ meta.resume = resume.clone();
+ }
+
+ return meta;
+ }
+
+ /**
+ * Display a DEBUG {@link String} representation of this object.
+ *
+ * This is not efficient, nor intended to be.
+ */
+ @Override
+ public String toString() {
+ String title = "";
+ if (getTitle() != null) {
+ title = getTitle();
+ }
+
+ StringBuilder tags = new StringBuilder();
+ if (getTags() != null) {
+ for (String tag : getTags()) {
+ if (tags.length() > 0) {
+ tags.append(", ");
+ }
+ tags.append(tag);
+ }
+ }
+
+ String resume = "";
+ if (getResume() != null) {
+ for (Paragraph para : getResume()) {
+ resume += "\n\t";
+ resume += para.toString().substring(0,
+ Math.min(para.toString().length(), 120));
+ }
+ resume += "\n";
+ }
+
+ String cover = "none";
+ if (getCover() != null) {
+ cover = StringUtils.formatNumber(getCover().getSize())
+ + "bytes";
+ }
+
+ return String.format(
+ "Meta %s:\n\tTitle: [%s]\n\tAuthor: [%s]\n\tDate: [%s]\n\tTags: [%s]"
+ + "\n\tResume: [%s]\n\tCover: [%s]", luid, title,
+ getAuthor(), getDate(), tags.toString(), resume, cover);
+ }
+}
diff --git a/src/be/nikiroo/fanfix/data/Paragraph.java b/src/be/nikiroo/fanfix/data/Paragraph.java
new file mode 100644
index 0000000..d5a0f1c
--- /dev/null
+++ b/src/be/nikiroo/fanfix/data/Paragraph.java
@@ -0,0 +1,182 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+
+import be.nikiroo.utils.Image;
+
+/**
+ * A paragraph in a chapter of the story.
+ *
+ * @author niki
+ */
+public class Paragraph implements Cloneable, Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * A paragraph type, that will dictate how the paragraph will be handled.
+ *
+ * @author niki
+ */
+ public enum ParagraphType {
+ /** Normal paragraph (text) */
+ NORMAL,
+ /** Blank line */
+ BLANK,
+ /** A Break paragraph, i.e.: HR (Horizontal Line) or '* * *' or whatever */
+ BREAK,
+ /** Quotation (dialogue) */
+ QUOTE,
+ /** An image (no text) */
+ IMAGE, ;
+
+ /**
+ * This paragraph type is of a text kind (quote or not).
+ *
+ * @param allowEmpty
+ * allow empty text as text, too (blanks, breaks...)
+ * @return TRUE if it is
+ */
+ public boolean isText(boolean allowEmpty) {
+ return (this == NORMAL || this == QUOTE)
+ || (allowEmpty && (this == BLANK || this == BREAK));
+ }
+ }
+
+ private ParagraphType type;
+ private String content;
+ private Image contentImage;
+ private long words;
+
+ /**
+ * Empty constructor, not to use.
+ */
+ @SuppressWarnings("unused")
+ private Paragraph() {
+ // for serialisation purposes
+ }
+
+ /**
+ * Create a new {@link Paragraph} with the given image.
+ *
+ * @param contentImage
+ * the image
+ */
+ public Paragraph(Image contentImage) {
+ this(ParagraphType.IMAGE, null, 1);
+ this.contentImage = contentImage;
+ }
+
+ /**
+ * Create a new {@link Paragraph} with the given values.
+ *
+ * @param type
+ * the {@link ParagraphType}
+ * @param content
+ * the content of this paragraph
+ * @param words
+ * the number of words (or images)
+ */
+ public Paragraph(ParagraphType type, String content, long words) {
+ this.type = type;
+ this.content = content;
+ this.words = words;
+ }
+
+ /**
+ * The {@link ParagraphType}.
+ *
+ * @return the type
+ */
+ public ParagraphType getType() {
+ return type;
+ }
+
+ /**
+ * The {@link ParagraphType}.
+ *
+ * @param type
+ * the type to set
+ */
+ public void setType(ParagraphType type) {
+ this.type = type;
+ }
+
+ /**
+ * The content of this {@link Paragraph} if it is not an image.
+ *
+ * @return the content
+ */
+ public String getContent() {
+ return content;
+ }
+
+ /**
+ * The content of this {@link Paragraph}.
+ *
+ * @param content
+ * the content to set
+ */
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ /**
+ * The content of this {@link Paragraph} if it is an image.
+ *
+ * @return the content
+ */
+ public Image getContentImage() {
+ return contentImage;
+ }
+
+ /**
+ * The content of this {@link Paragraph} if it is an image.
+ *
+ * @param contentImage
+ * the content
+ */
+ public void setContentImage(Image contentImage) {
+ this.contentImage = contentImage;
+ }
+
+ /**
+ * The number of words (or images) in this {@link Paragraph}.
+ *
+ * @return the number of words
+ */
+ public long getWords() {
+ return words;
+ }
+
+ /**
+ * The number of words (or images) in this {@link Paragraph}.
+ *
+ * @param words
+ * the number of words to set
+ */
+ public void setWords(long words) {
+ this.words = words;
+ }
+
+ /**
+ * Display a DEBUG {@link String} representation of this object.
+ */
+ @Override
+ public String toString() {
+ return String.format("%s: [%s]", "" + type, content == null ? "N/A"
+ : content);
+ }
+
+ @Override
+ public Paragraph clone() {
+ Paragraph para = null;
+ try {
+ para = (Paragraph) super.clone();
+ } catch (CloneNotSupportedException e) {
+ // Did the clones rebel?
+ System.err.println(e);
+ }
+
+ return para;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/data/Story.java b/src/be/nikiroo/fanfix/data/Story.java
new file mode 100644
index 0000000..fc3f909
--- /dev/null
+++ b/src/be/nikiroo/fanfix/data/Story.java
@@ -0,0 +1,101 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * The main data class, where the whole story resides.
+ *
+ * @author niki
+ */
+public class Story implements Iterable, Cloneable, Serializable {
+ private static final long serialVersionUID = 1L;
+
+ 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.
+ */
+ @Override
+ 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() {
+ if (getMeta() != null)
+ return "Story: [\n" + getMeta().toString() + "\n]";
+ return "Story: [ no metadata found ]";
+ }
+
+ @Override
+ public Story clone() {
+ Story story = null;
+ try {
+ story = (Story) super.clone();
+ } catch (CloneNotSupportedException e) {
+ // Did the clones rebel?
+ System.err.println(e);
+ }
+
+ if (meta != null) {
+ story.meta = meta.clone();
+ }
+
+ if (chapters != null) {
+ story.chapters = new ArrayList();
+ for (Chapter chap : chapters) {
+ story.chapters.add(chap.clone());
+ }
+ }
+
+ return story;
+ }
+}
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..57db36b
--- /dev/null
+++ b/src/be/nikiroo/fanfix/data/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * This package contains the data structure used by the program, without the
+ * logic behind them.
+ *
+ * All the classes inside are serializable.
+ *
+ * @author niki
+ */
+package be.nikiroo.fanfix.data;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/BasicLibrary.java b/src/be/nikiroo/fanfix/library/BasicLibrary.java
new file mode 100644
index 0000000..d435f8d
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/BasicLibrary.java
@@ -0,0 +1,904 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Manage a library of Stories: import, export, list, modify.
+ *
+ * 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}.
+ *
+ * Most of the {@link BasicLibrary} functions work on a partial (cover
+ * MAY not be included) {@link MetaData} object.
+ *
+ * @author niki
+ */
+abstract public class BasicLibrary {
+ /**
+ * A {@link BasicLibrary} status.
+ *
+ * @author niki
+ */
+ public enum Status {
+ /** The library is ready and r/w. */
+ READ_WRITE,
+ /** The library is ready, but read-only. */
+ READ_ONLY,
+ /** The library is invalid (not correctly set up). */
+ INVALID,
+ /** You are not allowed to access this library. */
+ UNAUTHORIZED,
+ /** The library is currently out of commission. */
+ UNAVAILABLE;
+
+ /**
+ * The library is available (you can query it).
+ *
+ * It does not specify if it is read-only or not.
+ *
+ * @return TRUE if it is
+ */
+ public boolean isReady() {
+ return (this == READ_WRITE || this == READ_ONLY);
+ }
+
+ /**
+ * This library can be modified (= you are allowed to modify it).
+ *
+ * @return TRUE if it is
+ */
+ public boolean isWritable() {
+ return (this == READ_WRITE);
+ }
+ }
+
+ /**
+ * Return a name for this library (the UI may display this).
+ *
+ * Must not be NULL.
+ *
+ * @return the name, or an empty {@link String} if none
+ */
+ public String getLibraryName() {
+ return "";
+ }
+
+ /**
+ * The library status.
+ *
+ * @return the current status
+ */
+ public Status getStatus() {
+ return Status.READ_WRITE;
+ }
+
+ /**
+ * Retrieve the main {@link File} corresponding to the given {@link Story},
+ * which can be passed to an external reader or instance.
+ *
+ * Do NOT alter this file.
+ *
+ * @param luid
+ * the Library UID of the story, can be NULL
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @return the corresponding {@link Story}
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public abstract File getFile(String luid, Progress pg) throws IOException;
+
+ /**
+ * Return the cover image associated to this story.
+ *
+ * @param luid
+ * the Library UID of the story
+ *
+ * @return the cover image
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public abstract Image getCover(String luid) throws IOException;
+
+ // TODO: ensure it is the main used interface
+ public MetaResultList getList(Progress pg) throws IOException {
+ return new MetaResultList(getMetas(pg));
+ }
+
+ // TODO: make something for (normal and custom) not-story covers
+
+ /**
+ * Return the cover image associated to this source.
+ *
+ * By default, return the custom cover if any, and if not, return the cover
+ * of the first story with this source.
+ *
+ * @param source
+ * the source
+ *
+ * @return the cover image or NULL
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Image getSourceCover(String source) throws IOException {
+ Image custom = getCustomSourceCover(source);
+ if (custom != null) {
+ return custom;
+ }
+
+ List metas = getList().filter(source, null, null);
+ if (metas.size() > 0) {
+ return getCover(metas.get(0).getLuid());
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the cover image associated to this author.
+ *
+ * By default, return the custom cover if any, and if not, return the cover
+ * of the first story with this author.
+ *
+ * @param author
+ * the author
+ *
+ * @return the cover image or NULL
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Image getAuthorCover(String author) throws IOException {
+ Image custom = getCustomAuthorCover(author);
+ if (custom != null) {
+ return custom;
+ }
+
+ List metas = getList().filter(null, author, null);
+ if (metas.size() > 0) {
+ return getCover(metas.get(0).getLuid());
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the custom cover image associated to this source.
+ *
+ * By default, return NULL.
+ *
+ * @param source
+ * the source to look for
+ *
+ * @return the custom cover or NULL if none
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ @SuppressWarnings("unused")
+ public Image getCustomSourceCover(String source) throws IOException {
+ return null;
+ }
+
+ /**
+ * Return the custom cover image associated to this author.
+ *
+ * By default, return NULL.
+ *
+ * @param author
+ * the author to look for
+ *
+ * @return the custom cover or NULL if none
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ @SuppressWarnings("unused")
+ public Image getCustomAuthorCover(String author) throws IOException {
+ return null;
+ }
+
+ /**
+ * Set the source cover to the given story cover.
+ *
+ * @param source
+ * the source to change
+ * @param luid
+ * the story LUID
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public abstract void setSourceCover(String source, String luid)
+ throws IOException;
+
+ /**
+ * Set the author cover to the given story cover.
+ *
+ * @param author
+ * the author to change
+ * @param luid
+ * the story LUID
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public abstract void setAuthorCover(String author, String luid)
+ throws IOException;
+
+ /**
+ * Return the list of stories (represented by their {@link MetaData}, which
+ * MAY not have the cover included).
+ *
+ * The returned list MUST be a copy, not the original one.
+ *
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @return the list (can be empty but not NULL)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ protected abstract List getMetas(Progress pg) throws IOException;
+
+ /**
+ * Invalidate the {@link Story} cache (when the content should be re-read
+ * because it was changed).
+ */
+ protected void invalidateInfo() {
+ invalidateInfo(null);
+ }
+
+ /**
+ * Invalidate the {@link Story} cache (when the content is removed).
+ *
+ * All the cache can be deleted if NULL is passed as meta.
+ *
+ * @param luid
+ * the LUID of the {@link Story} to clear from the cache, or NULL
+ * for all stories
+ */
+ protected abstract void invalidateInfo(String luid);
+
+ /**
+ * Invalidate the {@link Story} cache (when the content has changed, but we
+ * already have it) with the new given meta.
+ *
+ * @param meta
+ * the {@link Story} to clear from the cache
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ protected abstract void updateInfo(MetaData meta) throws IOException;
+
+ /**
+ * Return the next LUID that can be used.
+ *
+ * @return the next luid
+ */
+ protected abstract int getNextId();
+
+ /**
+ * Delete the target {@link Story}.
+ *
+ * @param luid
+ * the LUID of the {@link Story}
+ *
+ * @throws IOException
+ * in case of I/O error or if the {@link Story} wa not found
+ */
+ protected abstract void doDelete(String luid) throws IOException;
+
+ /**
+ * Actually save the story to the back-end.
+ *
+ * @param story
+ * the {@link Story} to save
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @return the saved {@link Story} (which may have changed, especially
+ * regarding the {@link MetaData})
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract Story doSave(Story story, Progress pg)
+ throws IOException;
+
+ /**
+ * Refresh the {@link BasicLibrary}, that is, make sure all metas are
+ * loaded.
+ *
+ * @param pg
+ * the optional progress reporter
+ */
+ public void refresh(Progress pg) {
+ try {
+ getMetas(pg);
+ } catch (IOException e) {
+ // We will let it fail later
+ }
+ }
+
+ /**
+ * Check if the {@link Story} denoted by this Library UID is present in the
+ * cache (if we have no cache, we default to true).
+ *
+ * @param luid
+ * the Library UID
+ *
+ * @return TRUE if it is
+ */
+ public boolean isCached(@SuppressWarnings("unused") String luid) {
+ // By default, everything is cached
+ return true;
+ }
+
+ /**
+ * Clear the {@link Story} from the cache, if needed.
+ *
+ * The next time we try to retrieve the {@link Story}, it may be required to
+ * cache it again.
+ *
+ * @param luid
+ * the story to clear
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ @SuppressWarnings("unused")
+ public void clearFromCache(String luid) throws IOException {
+ // By default, this is a noop.
+ }
+
+ /**
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getSources()} instead.
+ */
+ @Deprecated
+ public List getSources() throws IOException {
+ return getList().getSources();
+ }
+
+ /**
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getSourcesGrouped()} instead.
+ */
+ @Deprecated
+ public Map> getSourcesGrouped() throws IOException {
+ return getList().getSourcesGrouped();
+ }
+
+ /**
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getAuthors()} instead.
+ */
+ @Deprecated
+ public List getAuthors() throws IOException {
+ return getList().getAuthors();
+ }
+
+ /**
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getAuthorsGrouped()} instead.
+ */
+ public Map> getAuthorsGrouped() throws IOException {
+ return getList().getAuthorsGrouped();
+ }
+
+ /**
+ * List all the stories in the {@link BasicLibrary}.
+ *
+ * Cover images MAYBE not included.
+ *
+ * @return the stories
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public MetaResultList getList() throws IOException {
+ return getList(null);
+ }
+
+ /**
+ * Retrieve a {@link MetaData} corresponding to the given {@link Story},
+ * cover image MAY not be included.
+ *
+ * @param luid
+ * the Library UID of the story, can be NULL
+ *
+ * @return the corresponding {@link Story} or NULL if not found
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public MetaData getInfo(String luid) throws IOException {
+ if (luid != null) {
+ for (MetaData meta : getMetas(null)) {
+ if (luid.equals(meta.getLuid())) {
+ return meta;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Retrieve a specific {@link Story}.
+ *
+ * @param luid
+ * the Library UID of the story
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the corresponding {@link Story} or NULL if not found
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Story getStory(String luid, Progress pg) throws IOException {
+ Progress pgMetas = new Progress();
+ Progress pgStory = new Progress();
+ if (pg != null) {
+ pg.setMinMax(0, 100);
+ pg.addProgress(pgMetas, 10);
+ pg.addProgress(pgStory, 90);
+ }
+
+ MetaData meta = null;
+ for (MetaData oneMeta : getMetas(pgMetas)) {
+ if (oneMeta.getLuid().equals(luid)) {
+ meta = oneMeta;
+ break;
+ }
+ }
+
+ pgMetas.done();
+
+ Story story = getStory(luid, meta, pgStory);
+ pgStory.done();
+
+ return story;
+ }
+
+ /**
+ * Retrieve a specific {@link Story}.
+ *
+ * @param luid
+ * the LUID of the story
+ * @param meta
+ * the meta of the story
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the corresponding {@link Story} or NULL if not found
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+ throws IOException {
+
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Progress pgGet = new Progress();
+ Progress pgProcess = new Progress();
+
+ pg.setMinMax(0, 2);
+ pg.addProgress(pgGet, 1);
+ pg.addProgress(pgProcess, 1);
+
+ Story story = null;
+ File file = null;
+
+ if (luid != null && meta != null) {
+ file = getFile(luid, pgGet);
+ }
+
+ pgGet.done();
+ try {
+ if (file != null) {
+ SupportType type = SupportType.valueOfAllOkUC(meta.getType());
+ if (type == null) {
+ throw new IOException("Unknown type: " + meta.getType());
+ }
+
+ URL url = file.toURI().toURL();
+ story = BasicSupport.getSupport(type, url) //
+ .process(pgProcess);
+
+ // Because we do not want to clear the meta cache:
+ meta.setCover(story.getMeta().getCover());
+ meta.setResume(story.getMeta().getResume());
+ story.setMeta(meta);
+ }
+ } catch (IOException e) {
+ // We should not have not-supported files in the library
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException(String.format(
+ "Cannot load file of type '%s' from library: %s",
+ meta.getType(), file), e));
+ } finally {
+ pgProcess.done();
+ pg.done();
+ }
+
+ return story;
+ }
+
+ /**
+ * Import the {@link Story} at the given {@link URL} into the
+ * {@link BasicLibrary}.
+ *
+ * @param url
+ * the {@link URL} to import
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the imported Story {@link MetaData}
+ *
+ * @throws UnknownHostException
+ * if the host is not supported
+ * @throws IOException
+ * in case of I/O error
+ */
+ public MetaData imprt(URL url, Progress pg) throws IOException {
+ if (pg == null)
+ pg = new Progress();
+
+ pg.setMinMax(0, 1000);
+ Progress pgProcess = new Progress();
+ Progress pgSave = new Progress();
+ pg.addProgress(pgProcess, 800);
+ pg.addProgress(pgSave, 200);
+
+ BasicSupport support = BasicSupport.getSupport(url);
+ if (support == null) {
+ throw new UnknownHostException("" + url);
+ }
+
+ Story story = save(support.process(pgProcess), pgSave);
+ pg.done();
+
+ return story.getMeta();
+ }
+
+ /**
+ * Import the story from one library to another, and keep the same LUID.
+ *
+ * @param other
+ * the other library to import from
+ * @param luid
+ * the Library UID
+ * @param pg
+ * the optional progress reporter
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public void imprt(BasicLibrary other, String luid, Progress pg)
+ throws IOException {
+ Progress pgGetStory = new Progress();
+ Progress pgSave = new Progress();
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ pg.setMinMax(0, 2);
+ pg.addProgress(pgGetStory, 1);
+ pg.addProgress(pgSave, 1);
+
+ Story story = other.getStory(luid, pgGetStory);
+ if (story != null) {
+ story = this.save(story, luid, pgSave);
+ pg.done();
+ } else {
+ pg.done();
+ throw new IOException("Cannot find story in Library: " + luid);
+ }
+ }
+
+ /**
+ * Export the {@link Story} to the given target in the given format.
+ *
+ * @param luid
+ * the {@link Story} ID
+ * @param type
+ * the {@link OutputType} to transform it to
+ * @param target
+ * the target to save to
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the saved resource (the main saved {@link File})
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public File export(String luid, OutputType type, String target, Progress pg)
+ throws IOException {
+ Progress pgGetStory = new Progress();
+ Progress pgOut = new Progress();
+ if (pg != null) {
+ pg.setMax(2);
+ pg.addProgress(pgGetStory, 1);
+ pg.addProgress(pgOut, 1);
+ }
+
+ BasicOutput out = BasicOutput.getOutput(type, false, false);
+ if (out == null) {
+ throw new IOException("Output type not supported: " + type);
+ }
+
+ Story story = getStory(luid, pgGetStory);
+ if (story == null) {
+ throw new IOException("Cannot find story to export: " + luid);
+ }
+
+ return out.process(story, target, pgOut);
+ }
+
+ /**
+ * Save a {@link Story} to the {@link BasicLibrary}.
+ *
+ * @param story
+ * the {@link Story} to save
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the same {@link Story}, whose LUID may have changed
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public Story save(Story story, Progress pg) throws IOException {
+ return save(story, null, pg);
+ }
+
+ /**
+ * Save a {@link Story} to the {@link BasicLibrary} -- the LUID must
+ * be correct, or NULL to get the next free one.
+ *
+ * Will override any previous {@link Story} with the same LUID.
+ *
+ * @param story
+ * the {@link Story} to save
+ * @param luid
+ * the correct LUID or NULL to get the next free one
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the same {@link Story}, whose LUID may have changed
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public synchronized Story save(Story story, String luid, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Instance.getInstance().getTraceHandler().trace(
+ this.getClass().getSimpleName() + ": saving story " + luid);
+
+ // Do not change the original metadata, but change the original story
+ MetaData meta = story.getMeta().clone();
+ story.setMeta(meta);
+
+ pg.setName("Saving story");
+
+ if (luid == null || luid.isEmpty()) {
+ meta.setLuid(String.format("%03d", getNextId()));
+ } else {
+ meta.setLuid(luid);
+ }
+
+ if (luid != null && getInfo(luid) != null) {
+ delete(luid);
+ }
+
+ story = doSave(story, pg);
+
+ updateInfo(story.getMeta());
+
+ Instance.getInstance().getTraceHandler()
+ .trace(this.getClass().getSimpleName() + ": story saved ("
+ + luid + ")");
+
+ pg.setName(meta.getTitle());
+ pg.done();
+ return story;
+ }
+
+ /**
+ * Delete the given {@link Story} from this {@link BasicLibrary}.
+ *
+ * @param luid
+ * the LUID of the target {@link Story}
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public synchronized void delete(String luid) throws IOException {
+ Instance.getInstance().getTraceHandler().trace(
+ this.getClass().getSimpleName() + ": deleting story " + luid);
+
+ doDelete(luid);
+ invalidateInfo(luid);
+
+ Instance.getInstance().getTraceHandler()
+ .trace(this.getClass().getSimpleName() + ": story deleted ("
+ + luid + ")");
+ }
+
+ /**
+ * Change the type (source) of the given {@link Story}.
+ *
+ * @param luid
+ * the {@link Story} LUID
+ * @param newSource
+ * the new source
+ * @param pg
+ * the optional progress reporter
+ *
+ * @throws IOException
+ * in case of I/O error or if the {@link Story} was not found
+ */
+ public synchronized void changeSource(String luid, String newSource,
+ Progress pg) throws IOException {
+ MetaData meta = getInfo(luid);
+ if (meta == null) {
+ throw new IOException("Story not found: " + luid);
+ }
+
+ changeSTA(luid, newSource, meta.getTitle(), meta.getAuthor(), pg);
+ }
+
+ /**
+ * Change the title (name) of the given {@link Story}.
+ *
+ * @param luid
+ * the {@link Story} LUID
+ * @param newTitle
+ * the new title
+ * @param pg
+ * the optional progress reporter
+ *
+ * @throws IOException
+ * in case of I/O error or if the {@link Story} was not found
+ */
+ public synchronized void changeTitle(String luid, String newTitle,
+ Progress pg) throws IOException {
+ MetaData meta = getInfo(luid);
+ if (meta == null) {
+ throw new IOException("Story not found: " + luid);
+ }
+
+ changeSTA(luid, meta.getSource(), newTitle, meta.getAuthor(), pg);
+ }
+
+ /**
+ * Change the author of the given {@link Story}.
+ *
+ * @param luid
+ * the {@link Story} LUID
+ * @param newAuthor
+ * the new author
+ * @param pg
+ * the optional progress reporter
+ *
+ * @throws IOException
+ * in case of I/O error or if the {@link Story} was not found
+ */
+ public synchronized void changeAuthor(String luid, String newAuthor,
+ Progress pg) throws IOException {
+ MetaData meta = getInfo(luid);
+ if (meta == null) {
+ throw new IOException("Story not found: " + luid);
+ }
+
+ changeSTA(luid, meta.getSource(), meta.getTitle(), newAuthor, pg);
+ }
+
+ /**
+ * Change the Source, Title and Author of the {@link Story} in one single
+ * go.
+ *
+ * @param luid
+ * the {@link Story} LUID
+ * @param newSource
+ * the new source
+ * @param newTitle
+ * the new title
+ * @param newAuthor
+ * the new author
+ * @param pg
+ * the optional progress reporter
+ *
+ * @throws IOException
+ * in case of I/O error or if the {@link Story} was not found
+ */
+ protected synchronized void changeSTA(String luid, String newSource,
+ String newTitle, String newAuthor, Progress pg) throws IOException {
+ MetaData meta = getInfo(luid);
+ if (meta == null) {
+ throw new IOException("Story not found: " + luid);
+ }
+
+ meta.setSource(newSource);
+ meta.setTitle(newTitle);
+ meta.setAuthor(newAuthor);
+ saveMeta(meta, pg);
+ }
+
+ /**
+ * Save back the current state of the {@link MetaData} (LUID MUST NOT
+ * change) for this {@link Story}.
+ *
+ * By default, delete the old {@link Story} then recreate a new
+ * {@link Story}.
+ *
+ * Note that this behaviour can lead to data loss in case of problems!
+ *
+ * @param meta
+ * the new {@link MetaData} (LUID MUST NOT change)
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @throws IOException
+ * in case of I/O error or if the {@link Story} was not found
+ */
+ protected synchronized void saveMeta(MetaData meta, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Progress pgGet = new Progress();
+ Progress pgSet = new Progress();
+ pg.addProgress(pgGet, 50);
+ pg.addProgress(pgSet, 50);
+
+ Story story = getStory(meta.getLuid(), pgGet);
+ if (story == null) {
+ throw new IOException("Story not found: " + meta.getLuid());
+ }
+
+ // TODO: this is not safe!
+ delete(meta.getLuid());
+ story.setMeta(meta);
+ save(story, meta.getLuid(), pgSet);
+
+ pg.done();
+ }
+}
diff --git a/src/be/nikiroo/fanfix/library/CacheLibrary.java b/src/be/nikiroo/fanfix/library/CacheLibrary.java
new file mode 100644
index 0000000..a3c3b5e
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/CacheLibrary.java
@@ -0,0 +1,435 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeSet;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.bundles.UiConfigBundle;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * This library will cache another pre-existing {@link BasicLibrary}.
+ *
+ * @author niki
+ */
+public class CacheLibrary extends BasicLibrary {
+ private List metasReal;
+ private List metasMixed;
+ private Object metasLock = new Object();
+
+ private BasicLibrary lib;
+ private LocalLibrary cacheLib;
+
+ /**
+ * Create a cache library around the given one.
+ *
+ * It will return the same result, but those will be saved to disk at the
+ * same time to be fetched quicker the next time.
+ *
+ * @param cacheDir
+ * the cache directory where to save the files to disk
+ * @param lib
+ * the original library to wrap
+ * @param config
+ * the configuration used to know which kind of default
+ * {@link OutputType} to use for images and non-images stories
+ */
+ public CacheLibrary(File cacheDir, BasicLibrary lib,
+ UiConfigBundle config) {
+ this.cacheLib = new LocalLibrary(cacheDir, //
+ config.getString(UiConfig.GUI_NON_IMAGES_DOCUMENT_TYPE),
+ config.getString(UiConfig.GUI_IMAGES_DOCUMENT_TYPE), true);
+ this.lib = lib;
+ }
+
+ @Override
+ public String getLibraryName() {
+ return lib.getLibraryName();
+ }
+
+ @Override
+ public Status getStatus() {
+ return lib.getStatus();
+ }
+
+ @Override
+ protected List getMetas(Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ List copy;
+ synchronized (metasLock) {
+ // We make sure that cached metas have precedence
+ if (metasMixed == null) {
+ if (metasReal == null) {
+ metasReal = lib.getMetas(pg);
+ }
+
+ metasMixed = new ArrayList();
+ TreeSet cachedLuids = new TreeSet();
+ for (MetaData cachedMeta : cacheLib.getMetas(null)) {
+ metasMixed.add(cachedMeta);
+ cachedLuids.add(cachedMeta.getLuid());
+ }
+ for (MetaData realMeta : metasReal) {
+ if (!cachedLuids.contains(realMeta.getLuid())) {
+ metasMixed.add(realMeta);
+ }
+ }
+ }
+
+ copy = new ArrayList(metasMixed);
+ }
+
+ pg.done();
+ return copy;
+ }
+
+ @Override
+ public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Progress pgImport = new Progress();
+ Progress pgGet = new Progress();
+
+ pg.setMinMax(0, 4);
+ pg.addProgress(pgImport, 3);
+ pg.addProgress(pgGet, 1);
+
+ if (!isCached(luid)) {
+ try {
+ cacheLib.imprt(lib, luid, pgImport);
+ updateMetaCache(metasMixed, cacheLib.getInfo(luid));
+ pgImport.done();
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+
+ pgImport.done();
+ pgGet.done();
+ }
+
+ String type = cacheLib.getOutputType(meta.isImageDocument());
+ MetaData cachedMeta = meta.clone();
+ cachedMeta.setType(type);
+
+ return cacheLib.getStory(luid, cachedMeta, pg);
+ }
+
+ @Override
+ public synchronized File getFile(final String luid, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Progress pgGet = new Progress();
+ Progress pgRecall = new Progress();
+
+ pg.setMinMax(0, 5);
+ pg.addProgress(pgGet, 4);
+ pg.addProgress(pgRecall, 1);
+
+ if (!isCached(luid)) {
+ getStory(luid, pgGet);
+ pgGet.done();
+ }
+
+ File file = cacheLib.getFile(luid, pgRecall);
+ pgRecall.done();
+
+ pg.done();
+ return file;
+ }
+
+ @Override
+ public Image getCover(final String luid) throws IOException {
+ if (isCached(luid)) {
+ return cacheLib.getCover(luid);
+ }
+
+ // We could update the cache here, but it's not easy
+ return lib.getCover(luid);
+ }
+
+ @Override
+ public Image getSourceCover(String source) throws IOException {
+ Image custom = getCustomSourceCover(source);
+ if (custom != null) {
+ return custom;
+ }
+
+ Image cached = cacheLib.getSourceCover(source);
+ if (cached != null) {
+ return cached;
+ }
+
+ return lib.getSourceCover(source);
+ }
+
+ @Override
+ public Image getAuthorCover(String author) throws IOException {
+ Image custom = getCustomAuthorCover(author);
+ if (custom != null) {
+ return custom;
+ }
+
+ Image cached = cacheLib.getAuthorCover(author);
+ if (cached != null) {
+ return cached;
+ }
+
+ return lib.getAuthorCover(author);
+ }
+
+ @Override
+ public Image getCustomSourceCover(String source) throws IOException {
+ Image custom = cacheLib.getCustomSourceCover(source);
+ if (custom == null) {
+ custom = lib.getCustomSourceCover(source);
+ if (custom != null) {
+ cacheLib.setSourceCover(source, custom);
+ }
+ }
+
+ return custom;
+ }
+
+ @Override
+ public Image getCustomAuthorCover(String author) throws IOException {
+ Image custom = cacheLib.getCustomAuthorCover(author);
+ if (custom == null) {
+ custom = lib.getCustomAuthorCover(author);
+ if (custom != null) {
+ cacheLib.setAuthorCover(author, custom);
+ }
+ }
+
+ return custom;
+ }
+
+ @Override
+ public void setSourceCover(String source, String luid) throws IOException {
+ lib.setSourceCover(source, luid);
+ cacheLib.setSourceCover(source, getCover(luid));
+ }
+
+ @Override
+ public void setAuthorCover(String author, String luid) throws IOException {
+ lib.setAuthorCover(author, luid);
+ cacheLib.setAuthorCover(author, getCover(luid));
+ }
+
+ /**
+ * Invalidate the {@link Story} cache (when the content has changed, but we
+ * already have it) with the new given meta.
+ *
+ * Make sure to always use {@link MetaData} from the cached library in
+ * priority, here.
+ *
+ * @param meta
+ * the {@link Story} to clear from the cache
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ @Override
+ @Deprecated
+ protected void updateInfo(MetaData meta) throws IOException {
+ throw new IOException(
+ "This method is not supported in a CacheLibrary, please use updateMetaCache");
+ }
+
+ // relplace the meta in Metas by Meta, add it if needed
+ // return TRUE = added
+ private boolean updateMetaCache(List metas, MetaData meta) {
+ if (meta != null && metas != null) {
+ synchronized (metasLock) {
+ boolean changed = false;
+ for (int i = 0; i < metas.size(); i++) {
+ if (metas.get(i).getLuid().equals(meta.getLuid())) {
+ metas.set(i, meta);
+ changed = true;
+ }
+ }
+
+ if (!changed) {
+ metas.add(meta);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void invalidateInfo(String luid) {
+ if (luid == null) {
+ synchronized (metasLock) {
+ metasReal = null;
+ metasMixed = null;
+ }
+ } else {
+ invalidateInfo(metasReal, luid);
+ invalidateInfo(metasMixed, luid);
+ }
+
+ cacheLib.invalidateInfo(luid);
+ lib.invalidateInfo(luid);
+ }
+
+ // luid cannot be null
+ private void invalidateInfo(List metas, String luid) {
+ if (metas != null) {
+ synchronized (metasLock) {
+ for (int i = 0; i < metas.size(); i++) {
+ if (metas.get(i).getLuid().equals(luid)) {
+ metas.remove(i--);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public synchronized Story save(Story story, String luid, Progress pg)
+ throws IOException {
+ Progress pgLib = new Progress();
+ Progress pgCacheLib = new Progress();
+
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ pg.setMinMax(0, 2);
+ pg.addProgress(pgLib, 1);
+ pg.addProgress(pgCacheLib, 1);
+
+ story = lib.save(story, luid, pgLib);
+ updateMetaCache(metasReal, story.getMeta());
+
+ story = cacheLib.save(story, story.getMeta().getLuid(), pgCacheLib);
+ updateMetaCache(metasMixed, story.getMeta());
+
+ return story;
+ }
+
+ @Override
+ public synchronized void delete(String luid) throws IOException {
+ if (isCached(luid)) {
+ cacheLib.delete(luid);
+ }
+ lib.delete(luid);
+
+ invalidateInfo(luid);
+ }
+
+ @Override
+ protected synchronized void changeSTA(String luid, String newSource,
+ String newTitle, String newAuthor, Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Progress pgCache = new Progress();
+ Progress pgOrig = new Progress();
+ pg.setMinMax(0, 2);
+ pg.addProgress(pgCache, 1);
+ pg.addProgress(pgOrig, 1);
+
+ MetaData meta = getInfo(luid);
+ if (meta == null) {
+ throw new IOException("Story not found: " + luid);
+ }
+
+ if (isCached(luid)) {
+ cacheLib.changeSTA(luid, newSource, newTitle, newAuthor, pgCache);
+ }
+ pgCache.done();
+
+ lib.changeSTA(luid, newSource, newTitle, newAuthor, pgOrig);
+ pgOrig.done();
+
+ meta.setSource(newSource);
+ meta.setTitle(newTitle);
+ meta.setAuthor(newAuthor);
+ pg.done();
+
+ if (isCached(luid)) {
+ updateMetaCache(metasMixed, meta);
+ updateMetaCache(metasReal, lib.getInfo(luid));
+ } else {
+ updateMetaCache(metasReal, meta);
+ }
+ }
+
+ @Override
+ public boolean isCached(String luid) {
+ try {
+ return cacheLib.getInfo(luid) != null;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void clearFromCache(String luid) throws IOException {
+ if (isCached(luid)) {
+ cacheLib.delete(luid);
+ }
+ }
+
+ @Override
+ public MetaData imprt(URL url, Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Progress pgImprt = new Progress();
+ Progress pgCache = new Progress();
+ pg.setMinMax(0, 10);
+ pg.addProgress(pgImprt, 7);
+ pg.addProgress(pgCache, 3);
+
+ MetaData meta = lib.imprt(url, pgImprt);
+ updateMetaCache(metasReal, meta);
+ metasMixed = null;
+
+ clearFromCache(meta.getLuid());
+
+ pg.done();
+ return meta;
+ }
+
+ // All the following methods are only used by Save and Delete in
+ // BasicLibrary:
+
+ @Override
+ protected int getNextId() {
+ throw new java.lang.InternalError("Should not have been called");
+ }
+
+ @Override
+ protected void doDelete(String luid) throws IOException {
+ throw new java.lang.InternalError("Should not have been called");
+ }
+
+ @Override
+ protected Story doSave(Story story, Progress pg) throws IOException {
+ throw new java.lang.InternalError("Should not have been called");
+ }
+}
diff --git a/src/be/nikiroo/fanfix/library/LocalLibrary.java b/src/be/nikiroo/fanfix/library/LocalLibrary.java
new file mode 100644
index 0000000..6720972
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/LocalLibrary.java
@@ -0,0 +1,754 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.output.InfoCover;
+import be.nikiroo.fanfix.supported.InfoReader;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This {@link BasicLibrary} will store the stories locally on disk.
+ *
+ * @author niki
+ */
+public class LocalLibrary extends BasicLibrary {
+ private int lastId;
+ private Object lock = new Object();
+ private Map stories; // Files: [ infoFile, TargetFile ]
+ private Map sourceCovers;
+ private Map authorCovers;
+
+ private File baseDir;
+ private OutputType text;
+ private OutputType image;
+
+ /**
+ * Create a new {@link LocalLibrary} with the given back-end directory.
+ *
+ * @param baseDir
+ * the directory where to find the {@link Story} objects
+ * @param config
+ * the configuration used to know which kind of default
+ * {@link OutputType} to use for images and non-images stories
+ */
+ public LocalLibrary(File baseDir, ConfigBundle config) {
+ this(baseDir, //
+ config.getString(Config.FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE),
+ config.getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE),
+ false);
+ }
+
+ /**
+ * Create a new {@link LocalLibrary} with the given back-end directory.
+ *
+ * @param baseDir
+ * the directory where to find the {@link Story} objects
+ * @param text
+ * the {@link OutputType} to use for non-image documents
+ * @param image
+ * the {@link OutputType} to use for image documents
+ * @param defaultIsHtml
+ * if the given text or image is invalid, use HTML by default (if
+ * not, it will be INFO_TEXT/CBZ by default)
+ */
+ public LocalLibrary(File baseDir, String text, String image,
+ boolean defaultIsHtml) {
+ this(baseDir,
+ OutputType.valueOfAllOkUC(text,
+ defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
+ OutputType.valueOfAllOkUC(image,
+ defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
+ }
+
+ /**
+ * Create a new {@link LocalLibrary} with the given back-end directory.
+ *
+ * @param baseDir
+ * the directory where to find the {@link Story} objects
+ * @param text
+ * the {@link OutputType} to use for non-image documents
+ * @param image
+ * the {@link OutputType} to use for image documents
+ */
+ public LocalLibrary(File baseDir, OutputType text, OutputType image) {
+ this.baseDir = baseDir;
+ this.text = text;
+ this.image = image;
+
+ this.lastId = 0;
+ this.stories = null;
+ this.sourceCovers = null;
+
+ baseDir.mkdirs();
+ }
+
+ @Override
+ protected List getMetas(Progress pg) {
+ return new ArrayList(getStories(pg).keySet());
+ }
+
+ @Override
+ public File getFile(String luid, Progress pg) throws IOException {
+ Instance.getInstance().getTraceHandler().trace(
+ this.getClass().getSimpleName() + ": get file for " + luid);
+
+ File file = null;
+ String mess = "no file found for ";
+
+ MetaData meta = getInfo(luid);
+ if (meta != null) {
+ File[] files = getStories(pg).get(meta);
+ if (files != null) {
+ mess = "file retrieved for ";
+ file = files[1];
+ }
+ }
+
+ Instance.getInstance().getTraceHandler()
+ .trace(this.getClass().getSimpleName() + ": " + mess + luid
+ + " (" + meta.getTitle() + ")");
+
+ return file;
+ }
+
+ @Override
+ public Image getCover(String luid) throws IOException {
+ MetaData meta = getInfo(luid);
+ if (meta != null) {
+ if (meta.getCover() != null) {
+ return meta.getCover();
+ }
+
+ File[] files = getStories(null).get(meta);
+ if (files != null) {
+ File infoFile = files[0];
+
+ try {
+ meta = InfoReader.readMeta(infoFile, true);
+ return meta.getCover();
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void updateInfo(MetaData meta) {
+ invalidateInfo();
+ }
+
+ @Override
+ protected void invalidateInfo(String luid) {
+ synchronized (lock) {
+ stories = null;
+ sourceCovers = null;
+ }
+ }
+
+ @Override
+ protected int getNextId() {
+ getStories(null); // make sure lastId is set
+
+ synchronized (lock) {
+ return ++lastId;
+ }
+ }
+
+ @Override
+ protected void doDelete(String luid) throws IOException {
+ for (File file : getRelatedFiles(luid)) {
+ // TODO: throw an IOException if we cannot delete the files?
+ IOUtils.deltree(file);
+ file.getParentFile().delete();
+ }
+ }
+
+ @Override
+ protected Story doSave(Story story, Progress pg) throws IOException {
+ MetaData meta = story.getMeta();
+
+ File expectedTarget = getExpectedFile(meta);
+ expectedTarget.getParentFile().mkdirs();
+
+ BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
+ it.process(story, expectedTarget.getPath(), pg);
+
+ return story;
+ }
+
+ @Override
+ protected synchronized void saveMeta(MetaData meta, Progress pg)
+ throws IOException {
+ File newDir = getExpectedDir(meta.getSource());
+ if (!newDir.exists()) {
+ newDir.mkdirs();
+ }
+
+ List relatedFiles = getRelatedFiles(meta.getLuid());
+ for (File relatedFile : relatedFiles) {
+ // TODO: this is not safe at all.
+ // We should copy all the files THEN delete them
+ // Maybe also adding some rollback cleanup if possible
+ if (relatedFile.getName().endsWith(".info")) {
+ try {
+ String name = relatedFile.getName().replaceFirst("\\.info$",
+ "");
+ relatedFile.delete();
+ InfoCover.writeInfo(newDir, name, meta);
+ relatedFile.getParentFile().delete();
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ } else {
+ relatedFile.renameTo(new File(newDir, relatedFile.getName()));
+ relatedFile.getParentFile().delete();
+ }
+ }
+
+ updateInfo(meta);
+ }
+
+ @Override
+ public Image getCustomSourceCover(String source) {
+ synchronized (lock) {
+ if (sourceCovers == null) {
+ sourceCovers = new HashMap();
+ }
+ }
+
+ synchronized (lock) {
+ Image img = sourceCovers.get(source);
+ if (img != null) {
+ return img;
+ }
+ }
+
+ File coverDir = getExpectedDir(source);
+ if (coverDir.isDirectory()) {
+ File cover = new File(coverDir, ".cover.png");
+ if (cover.exists()) {
+ InputStream in;
+ try {
+ in = new FileInputStream(cover);
+ try {
+ synchronized (lock) {
+ sourceCovers.put(source, new Image(in));
+ }
+ } finally {
+ in.close();
+ }
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException(
+ "Cannot load the existing custom source cover: "
+ + cover,
+ e));
+ }
+ }
+ }
+
+ synchronized (lock) {
+ return sourceCovers.get(source);
+ }
+ }
+
+ @Override
+ public Image getCustomAuthorCover(String author) {
+ synchronized (lock) {
+ if (authorCovers == null) {
+ authorCovers = new HashMap();
+ }
+ }
+
+ synchronized (lock) {
+ Image img = authorCovers.get(author);
+ if (img != null) {
+ return img;
+ }
+ }
+
+ File cover = getAuthorCoverFile(author);
+ if (cover.exists()) {
+ InputStream in;
+ try {
+ in = new FileInputStream(cover);
+ try {
+ synchronized (lock) {
+ authorCovers.put(author, new Image(in));
+ }
+ } finally {
+ in.close();
+ }
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException(
+ "Cannot load the existing custom author cover: "
+ + cover,
+ e));
+ }
+ }
+
+ synchronized (lock) {
+ return authorCovers.get(author);
+ }
+ }
+
+ @Override
+ public void setSourceCover(String source, String luid) throws IOException {
+ setSourceCover(source, getCover(luid));
+ }
+
+ @Override
+ public void setAuthorCover(String author, String luid) throws IOException {
+ setAuthorCover(author, getCover(luid));
+ }
+
+ /**
+ * Set the source cover to the given story cover.
+ *
+ * @param source
+ * the source to change
+ * @param coverImage
+ * the cover image
+ */
+ void setSourceCover(String source, Image coverImage) {
+ File dir = getExpectedDir(source);
+ dir.mkdirs();
+ File cover = new File(dir, ".cover");
+ try {
+ Instance.getInstance().getCache().saveAsImage(coverImage, cover,
+ true);
+ synchronized (lock) {
+ if (sourceCovers != null) {
+ sourceCovers.put(source, coverImage);
+ }
+ }
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ }
+
+ /**
+ * Set the author cover to the given story cover.
+ *
+ * @param author
+ * the author to change
+ * @param coverImage
+ * the cover image
+ */
+ void setAuthorCover(String author, Image coverImage) {
+ File cover = getAuthorCoverFile(author);
+ cover.getParentFile().mkdirs();
+ try {
+ Instance.getInstance().getCache().saveAsImage(coverImage, cover,
+ true);
+ synchronized (lock) {
+ if (authorCovers != null) {
+ authorCovers.put(author, coverImage);
+ }
+ }
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ }
+
+ @Override
+ public void imprt(BasicLibrary other, String luid, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ // Check if we can simply copy the files instead of the whole process
+ if (other instanceof LocalLibrary) {
+ LocalLibrary otherLocalLibrary = (LocalLibrary) other;
+
+ MetaData meta = otherLocalLibrary.getInfo(luid);
+ String expectedType = ""
+ + (meta != null && meta.isImageDocument() ? image : text);
+ if (meta != null && meta.getType().equals(expectedType)) {
+ File from = otherLocalLibrary.getExpectedDir(meta.getSource());
+ File to = this.getExpectedDir(meta.getSource());
+ List relatedFiles = otherLocalLibrary
+ .getRelatedFiles(luid);
+ if (!relatedFiles.isEmpty()) {
+ pg.setMinMax(0, relatedFiles.size());
+ }
+
+ for (File relatedFile : relatedFiles) {
+ File target = new File(relatedFile.getAbsolutePath()
+ .replace(from.getAbsolutePath(),
+ to.getAbsolutePath()));
+ if (!relatedFile.equals(target)) {
+ target.getParentFile().mkdirs();
+ InputStream in = null;
+ try {
+ in = new FileInputStream(relatedFile);
+ IOUtils.write(in, target);
+ } catch (IOException e) {
+ if (in != null) {
+ try {
+ in.close();
+ } catch (Exception ee) {
+ }
+ }
+
+ pg.done();
+ throw e;
+ }
+ }
+
+ pg.add(1);
+ }
+
+ invalidateInfo();
+ pg.done();
+ return;
+ }
+ }
+
+ super.imprt(other, luid, pg);
+ }
+
+ /**
+ * Return the {@link OutputType} for this {@link Story}.
+ *
+ * @param meta
+ * the {@link Story} {@link MetaData}
+ *
+ * @return the type
+ */
+ private OutputType getOutputType(MetaData meta) {
+ if (meta != null && meta.isImageDocument()) {
+ return image;
+ }
+
+ return text;
+ }
+
+ /**
+ * Return the default {@link OutputType} for this kind of {@link Story}.
+ *
+ * @param imageDocument
+ * TRUE for images document, FALSE for text documents
+ *
+ * @return the type
+ */
+ public String getOutputType(boolean imageDocument) {
+ if (imageDocument) {
+ return image.toString();
+ }
+
+ return text.toString();
+ }
+
+ /**
+ * Get the target {@link File} related to the given .info
+ * {@link File} and {@link MetaData}.
+ *
+ * @param meta
+ * the meta
+ * @param infoFile
+ * the .info {@link File}
+ *
+ * @return the target {@link File}
+ */
+ private File getTargetFile(MetaData meta, File infoFile) {
+ // Replace .info with whatever is needed:
+ String path = infoFile.getPath();
+ path = path.substring(0, path.length() - ".info".length());
+ String newExt = getOutputType(meta).getDefaultExtension(true);
+
+ return new File(path + newExt);
+ }
+
+ /**
+ * The target (full path) where the {@link Story} related to this
+ * {@link MetaData} should be located on disk for a new {@link Story}.
+ *
+ * @param key
+ * the {@link Story} {@link MetaData}
+ *
+ * @return the target
+ */
+ private File getExpectedFile(MetaData key) {
+ String title = key.getTitle();
+ if (title == null) {
+ title = "";
+ }
+ title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
+ if (title.length() > 40) {
+ title = title.substring(0, 40);
+ }
+ return new File(getExpectedDir(key.getSource()),
+ key.getLuid() + "_" + title);
+ }
+
+ /**
+ * The directory (full path) where the new {@link Story} related to this
+ * {@link MetaData} should be located on disk.
+ *
+ * @param source
+ * the type (source)
+ *
+ * @return the target directory
+ */
+ private File getExpectedDir(String source) {
+ String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
+
+ while (sanitizedSource.startsWith("/")
+ || sanitizedSource.startsWith("_")) {
+ if (sanitizedSource.length() > 1) {
+ sanitizedSource = sanitizedSource.substring(1);
+ } else {
+ sanitizedSource = "";
+ }
+ }
+
+ sanitizedSource = sanitizedSource.replace("/", File.separator);
+
+ if (sanitizedSource.isEmpty()) {
+ sanitizedSource = "_EMPTY";
+ }
+
+ return new File(baseDir, sanitizedSource);
+ }
+
+ /**
+ * Return the full path to the file to use for the custom cover of this
+ * author.
+ *
+ * One or more of the parent directories MAY not exist.
+ *
+ * @param author
+ * the author
+ *
+ * @return the custom cover file
+ */
+ private File getAuthorCoverFile(String author) {
+ File aDir = new File(baseDir, "_AUTHORS");
+ String hash = StringUtils.getMd5Hash(author);
+ String ext = Instance.getInstance().getConfig()
+ .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
+ return new File(aDir, hash + "." + ext.toLowerCase());
+ }
+
+ /**
+ * Return the list of files/directories on disk for this {@link Story}.
+ *
+ * If the {@link Story} is not found, and empty list is returned.
+ *
+ * @param luid
+ * the {@link Story} LUID
+ *
+ * @return the list of {@link File}s
+ *
+ * @throws IOException
+ * if the {@link Story} was not found
+ */
+ private List getRelatedFiles(String luid) throws IOException {
+ List files = new ArrayList();
+
+ MetaData meta = getInfo(luid);
+ if (meta == null) {
+ throw new IOException("Story not found: " + luid);
+ }
+
+ File infoFile = getStories(null).get(meta)[0];
+ File targetFile = getStories(null).get(meta)[1];
+
+ files.add(infoFile);
+ files.add(targetFile);
+
+ String readerExt = getOutputType(meta).getDefaultExtension(true);
+ String fileExt = getOutputType(meta).getDefaultExtension(false);
+
+ String path = targetFile.getAbsolutePath();
+ if (readerExt != null && !readerExt.equals(fileExt)) {
+ path = path.substring(0, path.length() - readerExt.length())
+ + fileExt;
+ File relatedFile = new File(path);
+
+ if (relatedFile.exists()) {
+ files.add(relatedFile);
+ }
+ }
+
+ String coverExt = "." + Instance.getInstance().getConfig()
+ .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+ File coverFile = new File(path + coverExt);
+ if (!coverFile.exists()) {
+ coverFile = new File(
+ path.substring(0, path.length() - fileExt.length())
+ + coverExt);
+ }
+
+ if (coverFile.exists()) {
+ files.add(coverFile);
+ }
+
+ return files;
+ }
+
+ /**
+ * Fill the list of stories by reading the content of the local directory
+ * {@link LocalLibrary#baseDir}.
+ *
+ * Will use a cached list when possible (see
+ * {@link BasicLibrary#invalidateInfo()}).
+ *
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @return the list of stories (for each item, the first {@link File} is the
+ * info file, the second file is the target {@link File})
+ */
+ private Map getStories(Progress pg) {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
+
+ Map stories = this.stories;
+ if (stories == null) {
+ stories = getStoriesDo(pg);
+ synchronized (lock) {
+ if (this.stories == null)
+ this.stories = stories;
+ else
+ stories = this.stories;
+ }
+ }
+
+ pg.done();
+ return stories;
+
+ }
+
+ /**
+ * Actually do the work of {@link LocalLibrary#getStories(Progress)} (i.e.,
+ * do not retrieve the cache).
+ *
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @return the list of stories (for each item, the first {@link File} is the
+ * info file, the second file is the target {@link File})
+ */
+ private synchronized Map getStoriesDo(Progress pg) {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
+
+ Map stories = new HashMap();
+
+ File[] dirs = baseDir.listFiles(new FileFilter() {
+ @Override
+ public boolean accept(File file) {
+ return file != null && file.isDirectory();
+ }
+ });
+
+ if (dirs != null) {
+ Progress pgDirs = new Progress(0, 100 * dirs.length);
+ pg.addProgress(pgDirs, 100);
+
+ for (File dir : dirs) {
+ Progress pgFiles = new Progress();
+ pgDirs.addProgress(pgFiles, 100);
+ pgDirs.setName("Loading from: " + dir.getName());
+
+ addToStories(stories, pgFiles, dir);
+
+ pgFiles.setName(null);
+ }
+
+ pgDirs.setName("Loading directories");
+ }
+
+ pg.done();
+
+ return stories;
+ }
+
+ private void addToStories(Map stories, Progress pgFiles,
+ File dir) {
+ File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
+ @Override
+ public boolean accept(File file) {
+ boolean info = file != null && file.isFile()
+ && file.getPath().toLowerCase().endsWith(".info");
+ boolean dir = file != null && file.isDirectory();
+ boolean isExpandedHtml = new File(file, "index.html").isFile();
+ return info || (dir && !isExpandedHtml);
+ }
+ });
+
+ if (pgFiles != null) {
+ pgFiles.setMinMax(0, infoFilesAndSubdirs.length);
+ }
+
+ for (File infoFileOrSubdir : infoFilesAndSubdirs) {
+ if (infoFileOrSubdir.isDirectory()) {
+ addToStories(stories, null, infoFileOrSubdir);
+ } else {
+ try {
+ MetaData meta = InfoReader.readMeta(infoFileOrSubdir,
+ false);
+ try {
+ int id = Integer.parseInt(meta.getLuid());
+ if (id > lastId) {
+ lastId = id;
+ }
+
+ stories.put(meta, new File[] { infoFileOrSubdir,
+ getTargetFile(meta, infoFileOrSubdir) });
+ } catch (Exception e) {
+ // not normal!!
+ throw new IOException("Cannot understand the LUID of "
+ + infoFileOrSubdir + ": " + meta.getLuid(), e);
+ }
+ } catch (IOException e) {
+ // We should not have not-supported files in the
+ // library
+ Instance.getInstance().getTraceHandler().error(
+ new IOException("Cannot load file from library: "
+ + infoFileOrSubdir, e));
+ }
+ }
+
+ if (pgFiles != null) {
+ pgFiles.add(1);
+ }
+ }
+ }
+}
diff --git a/src/be/nikiroo/fanfix/library/MetaResultList.java b/src/be/nikiroo/fanfix/library/MetaResultList.java
new file mode 100644
index 0000000..8b8a167
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/MetaResultList.java
@@ -0,0 +1,419 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.StringUtils;
+
+public class MetaResultList {
+ /** Max number of items before splitting in [A-B] etc. for eligible items */
+ static private final int MAX = 20;
+
+ private List metas;
+
+ // Lazy lists:
+ // TODO: sync-protect them?
+ private List sources;
+ private List authors;
+ private List tags;
+
+ // can be null (will consider it empty)
+ public MetaResultList(List metas) {
+ if (metas == null) {
+ metas = new ArrayList();
+ }
+
+ Collections.sort(metas);
+ this.metas = metas;
+ }
+
+ // not NULL
+ // sorted
+ public List getMetas() {
+ return metas;
+ }
+
+ public List getSources() {
+ if (sources == null) {
+ sources = new ArrayList();
+ for (MetaData meta : metas) {
+ if (!sources.contains(meta.getSource()))
+ sources.add(meta.getSource());
+ }
+ sort(sources);
+ }
+
+ return sources;
+ }
+
+ // A -> (A), A/ -> (A, A/*) if we can find something for "*"
+ public List getSources(String source) {
+ List linked = new ArrayList();
+ if (source != null && !source.isEmpty()) {
+ if (!source.endsWith("/")) {
+ linked.add(source);
+ } else {
+ linked.add(source.substring(0, source.length() - 1));
+ for (String src : getSources()) {
+ if (src.startsWith(source)) {
+ linked.add(src);
+ }
+ }
+ }
+ }
+
+ sort(linked);
+ return linked;
+ }
+
+ /**
+ * List all the known types (sources) of stories, grouped by directory
+ * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
+ *
+ * Note that an empty item in the list means a non-grouped source (type) --
+ * e.g., you could have for Source_1:
+ *
+ *
: empty, so source is "Source_1"
+ *
a: empty, so source is "Source_1/a"
+ *
b: empty, so source is "Source_1/b"
+ *
+ *
+ * @return the grouped list
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Map> getSourcesGrouped() throws IOException {
+ Map> map = new TreeMap>();
+ for (String source : getSources()) {
+ String name;
+ String subname;
+
+ int pos = source.indexOf('/');
+ if (pos > 0 && pos < source.length() - 1) {
+ name = source.substring(0, pos);
+ subname = source.substring(pos + 1);
+
+ } else {
+ name = source;
+ subname = "";
+ }
+
+ List list = map.get(name);
+ if (list == null) {
+ list = new ArrayList();
+ map.put(name, list);
+ }
+ list.add(subname);
+ }
+
+ return map;
+ }
+
+ public List getAuthors() {
+ if (authors == null) {
+ authors = new ArrayList();
+ for (MetaData meta : metas) {
+ if (!authors.contains(meta.getAuthor()))
+ authors.add(meta.getAuthor());
+ }
+ sort(authors);
+ }
+
+ return authors;
+ }
+
+ /**
+ * Return the list of authors, grouped by starting letter(s) if needed.
+ *
+ * If the number of authors is not too high, only one group with an empty
+ * name and all the authors will be returned.
+ *
+ * If not, the authors will be separated into groups:
+ *
+ *
*: any author whose name doesn't contain letters nor numbers
+ *
+ *
0-9: any author whose name starts with a number
+ *
A-C (for instance): any author whose name starts with
+ * A, B or C
+ *
+ * Note that the letters used in the groups can vary (except * and
+ * 0-9, which may only be present or not).
+ *
+ * @return the authors' names, grouped by letter(s)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Map> getAuthorsGrouped() throws IOException {
+ return group(getAuthors());
+ }
+
+ public List getTags() {
+ if (tags == null) {
+ tags = new ArrayList();
+ for (MetaData meta : metas) {
+ for (String tag : meta.getTags()) {
+ if (!tags.contains(tag))
+ tags.add(tag);
+ }
+ }
+ sort(tags);
+ }
+
+ return tags;
+ }
+
+ /**
+ * Return the list of tags, grouped by starting letter(s) if needed.
+ *
+ * If the number of tags is not too high, only one group with an empty name
+ * and all the tags will be returned.
+ *
+ * If not, the tags will be separated into groups:
+ *
+ *
*: any tag which name doesn't contain letters nor numbers
+ *
+ *
0-9: any tag which name starts with a number
+ *
A-C (for instance): any tag which name starts with
+ * A, B or C
+ *
+ * Note that the letters used in the groups can vary (except * and
+ * 0-9, which may only be present or not).
+ *
+ * @return the tags' names, grouped by letter(s)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Map> getTagsGrouped() throws IOException {
+ return group(getTags());
+ }
+
+ // helper
+ public List filter(String source, String author, String tag) {
+ List sources = source == null ? null : Arrays.asList(source);
+ List authors = author == null ? null : Arrays.asList(author);
+ List tags = tag == null ? null : Arrays.asList(tag);
+
+ return filter(sources, authors, tags);
+ }
+
+ // null or empty -> no check, rest = must be included
+ // source: a source ending in "/" means "this or any source starting with
+ // this",
+ // i;e., to enable source hierarchy
+ // + sorted
+ public List filter(List sources, List authors,
+ List tags) {
+ if (sources != null && sources.isEmpty())
+ sources = null;
+ if (authors != null && authors.isEmpty())
+ authors = null;
+ if (tags != null && tags.isEmpty())
+ tags = null;
+
+ // Quick check
+ if (sources == null && authors == null && tags == null) {
+ return metas;
+ }
+
+ // allow "sources/" hierarchy
+ if (sources != null) {
+ List folders = new ArrayList();
+ List leaves = new ArrayList();
+ for (String source : sources) {
+ if (source.endsWith("/")) {
+ if (!folders.contains(source))
+ folders.add(source);
+ } else {
+ if (!leaves.contains(source))
+ leaves.add(source);
+ }
+ }
+
+ sources = leaves;
+ for (String folder : folders) {
+ for (String otherLeaf : getSources(folder)) {
+ if (!sources.contains(otherLeaf)) {
+ sources.add(otherLeaf);
+ }
+ }
+ }
+ }
+
+ List result = new ArrayList();
+ for (MetaData meta : metas) {
+ if (sources != null && !sources.contains(meta.getSource())) {
+ continue;
+ }
+ if (authors != null && !authors.contains(meta.getAuthor())) {
+ continue;
+ }
+
+ if (tags != null) {
+ boolean keep = false;
+ for (String thisTag : meta.getTags()) {
+ if (tags.contains(thisTag))
+ keep = true;
+ }
+
+ if (!keep)
+ continue;
+ }
+
+ result.add(meta);
+ }
+
+ Collections.sort(result);
+ return result;
+ }
+
+ /**
+ * Return the list of values, grouped by starting letter(s) if needed.
+ *
+ * If the number of values is not too high, only one group with an empty
+ * name and all the values will be returned (see
+ * {@link MetaResultList#MAX}).
+ *
+ * If not, the values will be separated into groups:
+ *
+ *
*: any value which name doesn't contain letters nor numbers
+ *
+ *
0-9: any value which name starts with a number
+ *
A-C (for instance): any value which name starts with
+ * A, B or C
+ *
+ * Note that the letters used in the groups can vary (except * and
+ * 0-9, which may only be present or not).
+ *
+ * @param values
+ * the values to group
+ *
+ * @return the values, grouped by letter(s)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ private Map> group(List values)
+ throws IOException {
+ Map> groups = new TreeMap>();
+
+ // If all authors fit the max, just report them as is
+ if (values.size() <= MAX) {
+ groups.put("", values);
+ return groups;
+ }
+
+ // Create groups A to Z, which can be empty here
+ for (char car = 'A'; car <= 'Z'; car++) {
+ groups.put(Character.toString(car), find(values, car));
+ }
+
+ // Collapse them
+ List keys = new ArrayList(groups.keySet());
+ for (int i = 0; i + 1 < keys.size(); i++) {
+ String keyNow = keys.get(i);
+ String keyNext = keys.get(i + 1);
+
+ List now = groups.get(keyNow);
+ List next = groups.get(keyNext);
+
+ int currentTotal = now.size() + next.size();
+ if (currentTotal <= MAX) {
+ String key = keyNow.charAt(0) + "-"
+ + keyNext.charAt(keyNext.length() - 1);
+
+ List all = new ArrayList();
+ all.addAll(now);
+ all.addAll(next);
+
+ groups.remove(keyNow);
+ groups.remove(keyNext);
+ groups.put(key, all);
+
+ keys.set(i, key); // set the new key instead of key(i)
+ keys.remove(i + 1); // remove the next, consumed key
+ i--; // restart at key(i)
+ }
+ }
+
+ // Add "special" groups
+ groups.put("*", find(values, '*'));
+ groups.put("0-9", find(values, '0'));
+
+ // Prune empty groups
+ keys = new ArrayList(groups.keySet());
+ for (String key : keys) {
+ if (groups.get(key).isEmpty()) {
+ groups.remove(key);
+ }
+ }
+
+ return groups;
+ }
+
+ /**
+ * Get all the authors that start with the given character:
+ *
+ *
*: any author whose name doesn't contain letters nor numbers
+ *
+ *
0: any authors whose name starts with a number
+ *
A (any capital latin letter): any author whose name starts
+ * with A
+ *
+ *
+ * @param values
+ * the full list of authors
+ * @param car
+ * the starting character, *, 0 or a capital
+ * letter
+ *
+ * @return the authors that fulfil the starting letter
+ */
+ private List find(List values, char car) {
+ List accepted = new ArrayList();
+ for (String value : values) {
+ char first = '*';
+ for (int i = 0; first == '*' && i < value.length(); i++) {
+ String san = StringUtils.sanitize(value, true, true);
+ char c = san.charAt(i);
+ if (c >= '0' && c <= '9') {
+ first = '0';
+ } else if (c >= 'a' && c <= 'z') {
+ first = (char) (c - 'a' + 'A');
+ } else if (c >= 'A' && c <= 'Z') {
+ first = c;
+ }
+ }
+
+ if (first == car) {
+ accepted.add(value);
+ }
+ }
+
+ return accepted;
+ }
+
+ /**
+ * Sort the given {@link String} values, ignoring case.
+ *
+ * @param values
+ * the values to sort
+ */
+ private void sort(List values) {
+ Collections.sort(values, new Comparator() {
+ @Override
+ public int compare(String o1, String o2) {
+ return ("" + o1).compareToIgnoreCase("" + o2);
+ }
+ });
+ }
+}
diff --git a/src/be/nikiroo/fanfix/library/RemoteLibrary.java b/src/be/nikiroo/fanfix/library/RemoteLibrary.java
new file mode 100644
index 0000000..9fa8c66
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/RemoteLibrary.java
@@ -0,0 +1,590 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ConnectActionClientObject;
+
+/**
+ * This {@link BasicLibrary} will access a remote server to list the available
+ * stories, and download the ones you try to load to the local directory
+ * specified in the configuration.
+ *
+ * This remote library uses a custom fanfix:// protocol.
+ *
+ * @author niki
+ */
+public class RemoteLibrary extends BasicLibrary {
+ interface RemoteAction {
+ public void action(ConnectActionClientObject action) throws Exception;
+ }
+
+ class RemoteConnectAction extends ConnectActionClientObject {
+ public RemoteConnectAction() throws IOException {
+ super(host, port, key);
+ }
+
+ @Override
+ public Object send(Object data)
+ throws IOException, NoSuchFieldException, NoSuchMethodException,
+ ClassNotFoundException {
+ Object rep = super.send(data);
+ if (rep instanceof RemoteLibraryException) {
+ RemoteLibraryException remoteEx = (RemoteLibraryException) rep;
+ throw remoteEx.unwrapException();
+ }
+
+ return rep;
+ }
+ }
+
+ private String host;
+ private int port;
+ private final String key;
+ private final String subkey;
+
+ // informative only (server will make the actual checks)
+ private boolean rw;
+
+ /**
+ * Create a {@link RemoteLibrary} linked to the given server.
+ *
+ * Note that the key is structured:
+ * xxx(|yyy|wl)(|rw)
+ *
+ * Note that anything before the first pipe (|) character is
+ * considered to be the encryption key, anything after that character is
+ * called the subkey (including the other pipe characters and flags!).
+ *
+ * This is important because the subkey (including the pipe characters and
+ * flags) must be present as-is in the server configuration file to be
+ * allowed.
+ *
+ *
xxx: the encryption key used to communicate with the
+ * server
+ *
yyy: the secondary key
+ *
rw: flag to allow read and write access if it is not the
+ * default on this server
+ *
wl: flag to allow access to all the stories (bypassing the
+ * whitelist if it exists)
+ *
+ *
+ * Some examples:
+ *
+ *
my_key: normal connection, will take the default server
+ * options
+ *
my_key|agzyzz|wl: will ask to bypass the white list (if it
+ * exists)
+ *
my_key|agzyzz|rw: will ask read-write access (if the default
+ * is read-only)
+ *
my_key|agzyzz|wl|rw: will ask both read-write access and white
+ * list bypass
+ *
+ *
+ * @param key
+ * the key that will allow us to exchange information with the
+ * server
+ * @param host
+ * the host to contact or NULL for localhost
+ * @param port
+ * the port to contact it on
+ */
+ public RemoteLibrary(String key, String host, int port) {
+ int index = -1;
+ if (key != null) {
+ index = key.indexOf('|');
+ }
+
+ if (index >= 0) {
+ this.key = key.substring(0, index);
+ this.subkey = key.substring(index + 1);
+ } else {
+ this.key = key;
+ this.subkey = "";
+ }
+
+ if (host.startsWith("fanfix://")) {
+ host = host.substring("fanfix://".length());
+ }
+
+ this.host = host;
+ this.port = port;
+ }
+
+ @Override
+ public String getLibraryName() {
+ return (rw ? "[READ-ONLY] " : "") + "fanfix://" + host + ":" + port;
+ }
+
+ @Override
+ public Status getStatus() {
+ Instance.getInstance().getTraceHandler()
+ .trace("Getting remote lib status...");
+ Status status = getStatusDo();
+ Instance.getInstance().getTraceHandler()
+ .trace("Remote lib status: " + status);
+ return status;
+ }
+
+ private Status getStatusDo() {
+ final Status[] result = new Status[1];
+
+ result[0] = Status.INVALID;
+
+ try {
+ new RemoteConnectAction() {
+ @Override
+ public void action(Version serverVersion) throws Exception {
+ Object rep = send(new Object[] { subkey, "PING" });
+
+ if ("r/w".equals(rep)) {
+ rw = true;
+ result[0] = Status.READ_WRITE;
+ } else if ("r/o".equals(rep)) {
+ rw = false;
+ result[0] = Status.READ_ONLY;
+ } else {
+ result[0] = Status.UNAUTHORIZED;
+ }
+ }
+
+ @Override
+ protected void onError(Exception e) {
+ if (e instanceof SSLException) {
+ result[0] = Status.UNAUTHORIZED;
+ } else {
+ result[0] = Status.UNAVAILABLE;
+ }
+ }
+ }.connect();
+ } catch (UnknownHostException e) {
+ result[0] = Status.INVALID;
+ } catch (IllegalArgumentException e) {
+ result[0] = Status.INVALID;
+ } catch (Exception e) {
+ result[0] = Status.UNAVAILABLE;
+ }
+
+ return result[0];
+ }
+
+ @Override
+ public Image getCover(final String luid) throws IOException {
+ final Image[] result = new Image[1];
+
+ connectRemoteAction(new RemoteAction() {
+ @Override
+ public void action(ConnectActionClientObject action)
+ throws Exception {
+ Object rep = action
+ .send(new Object[] { subkey, "GET_COVER", luid });
+ result[0] = (Image) rep;
+ }
+ });
+
+ return result[0];
+ }
+
+ @Override
+ public Image getCustomSourceCover(final String source) throws IOException {
+ return getCustomCover(source, "SOURCE");
+ }
+
+ @Override
+ public Image getCustomAuthorCover(final String author) throws IOException {
+ return getCustomCover(author, "AUTHOR");
+ }
+
+ // type: "SOURCE" or "AUTHOR"
+ private Image getCustomCover(final String source, final String type)
+ throws IOException {
+ final Image[] result = new Image[1];
+
+ connectRemoteAction(new RemoteAction() {
+ @Override
+ public void action(ConnectActionClientObject action)
+ throws Exception {
+ Object rep = action.send(new Object[] { subkey,
+ "GET_CUSTOM_COVER", type, source });
+ result[0] = (Image) rep;
+ }
+ });
+
+ return result[0];
+ }
+
+ @Override
+ public synchronized Story getStory(final String luid, Progress pg)
+ throws IOException {
+ final Progress pgF = pg;
+ final Story[] result = new Story[1];
+
+ connectRemoteAction(new RemoteAction() {
+ @Override
+ public void action(ConnectActionClientObject action)
+ throws Exception {
+ Progress pg = pgF;
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ Object rep = action
+ .send(new Object[] { subkey, "GET_STORY", luid });
+
+ MetaData meta = null;
+ if (rep instanceof MetaData) {
+ meta = (MetaData) rep;
+ if (meta.getWords() <= Integer.MAX_VALUE) {
+ pg.setMinMax(0, (int) meta.getWords());
+ }
+ }
+
+ List
+
diff --git a/src/be/nikiroo/fanfix/library/web/search-32x32.png b/src/be/nikiroo/fanfix/library/web/search-32x32.png
new file mode 100644
index 0000000..92b716d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/search-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/search-64x64.png b/src/be/nikiroo/fanfix/library/web/search-64x64.png
new file mode 100644
index 0000000..93dbf6d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/search-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/style.css b/src/be/nikiroo/fanfix/library/web/style.css
new file mode 100644
index 0000000..c520d78
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/web/style.css
@@ -0,0 +1,196 @@
+html, body, .main {
+ margin: 0;
+ padding: 0;
+ font-family : Verdana, "Bitstream Vera Sans", "DejaVu Sans", Tahoma, Geneva, Arial, Sans-serif;
+ font-size: 12px;
+ DISABLED_color: #635c4a;
+ height: 100%;
+}
+
+table {
+ width: 100%;
+}
+
+.banner {
+}
+
+.banner .ico {
+ display: block;
+ height: 50px;
+ float: left;
+ padding: 10px;
+}
+
+.banner h1, .banner h2 {
+}
+
+.main {
+ display: block;
+}
+
+.message {
+ background-color: #ddffdd;
+ border: 1px solid #88dd88;
+ clear: left;
+ border-radius: 5px;
+ padding: 5px;
+ margin: 10px;
+}
+
+.error {
+ background-color: #ffdddd;
+ border: 1px solid #dd8888;
+ clear: left;
+ border-radius: 5px;
+ padding: 5px;
+ margin: 10px;
+}
+
+/* all links and clickable should show a pointer cursor */
+[onclick], h2[onclick]:before, h3[onclick]:before {
+ cursor: pointer;
+}
+
+a:hover {
+ background-color: rgb(225, 225, 225);
+}
+
+h2 {
+ border-bottom: 1px solid #AAA391;
+}
+
+h3 {
+ border-bottom: 1px solid #AAA391;
+ margin-left: 20px;
+}
+
+.login {
+ width: 250px;
+ display: flex;
+ margin: auto;
+ margin-top: 200px;
+ flex-direction: column;
+ border: 1px solid gray;
+ padding: 20px;
+ border-radius: 10px;
+}
+
+.login input {
+ margin: 5px;
+ min-height: 22px;
+}
+
+.login input[type='submit'] {
+ margin-top: 15px;
+}
+
+.breadcrumbs {
+}
+
+.filter {
+ padding: 10px;
+}
+
+.books {
+}
+
+.book_line {
+ width: 100%;
+ display: flex;
+}
+
+.book_line .link, .book_line .title {
+ flex-grow: 100;
+ padding-right: 5px;
+ padding-left: 5px;
+}
+
+.book_line .link {
+ text-decoration: none;
+}
+
+.book_line .cache_icon {
+ color: green;
+}
+
+.book_line .luid {
+ color: gray;
+ padding-right: 10px;
+ padding-left: 10px;
+}
+
+.book_line .title {
+ color: initial;
+}
+
+.book_line .author {
+ float: right;
+ color: blue;
+}
+
+.bar {
+ height: 64px;
+ width: 100%;
+ display: block;
+ background: white;
+ position: fixed;
+}
+
+.viewer {
+ padding-top: 64px;
+ padding-bottom: 64px;
+}
+
+a.viewer.link:hover {
+ background-color: transparent;
+}
+
+.viewer.text {
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+.bar.navbar {
+ padding-left: calc(50% - (4 * 64px / 2));
+}
+
+.bar.optionbar {
+ bottom: 0;
+}
+
+.bar.optionbar.s1 {
+ padding-left: calc(50% - (1 * 64px / 2));
+}
+
+.bar.optionbar.s4 {
+ padding-left: calc(50% - (4 * 64px / 2));
+}
+
+.bar .button {
+ height: 54px;
+ width: 54px;
+ line-height: 64px;
+ display: inline-block;
+ text-align: center;
+ color: transparent;
+ text-decoration: none;
+ background-position: center;
+ background-repeat: no-repeat;
+ border-radius: 5px;
+ border: 1px solid #bac2e1;
+ margin: 5px;
+}
+
+.bar .button:hover {
+ background-color: bac2e1;
+}
+
+.bar .button.first { background-image: url('/arrow_double_left-32x32.png'); }
+.bar .button.previous { background-image: url('/arrow_left-32x32.png'); }
+.bar .button.next { background-image: url('/arrow_right-32x32.png'); }
+.bar .button.last { background-image: url('/arrow_double_right-32x32.png'); }
+
+.bar .button.back { background-image: url('/back-32x32.png'); }
+.bar .button.zoomreal { background-image: url('/actual_size-32x32.png'); }
+.bar .button.zoomwidth { background-image: url('/fit_to_width-32x32.png'); }
+.bar .button.zoomheight { background-image: url('/fit_to_height-32x32.png'); }
diff --git a/src/be/nikiroo/fanfix/library/web/unknown-32x32.png b/src/be/nikiroo/fanfix/library/web/unknown-32x32.png
new file mode 100644
index 0000000..d2315d1
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/unknown-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/unknown-64x64.png b/src/be/nikiroo/fanfix/library/web/unknown-64x64.png
new file mode 100644
index 0000000..261889d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/unknown-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/output/BasicOutput.java b/src/be/nikiroo/fanfix/output/BasicOutput.java
new file mode 100644
index 0000000..41634fa
--- /dev/null
+++ b/src/be/nikiroo/fanfix/output/BasicOutput.java
@@ -0,0 +1,553 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+
+/**
+ * This class is the base class used by the other output classes. It can be used
+ * outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ *
+ * @author niki
+ */
+public abstract class BasicOutput {
+ /**
+ * The supported output types for which we can get a {@link BasicOutput}
+ * object.
+ *
+ * @author niki
+ */
+ public enum OutputType {
+ /** EPUB files created with this program */
+ EPUB,
+ /** Pure text file with some rules */
+ TEXT,
+ /** TEXT but with associated .info file */
+ INFO_TEXT,
+ /** DEBUG output to console */
+ SYSOUT,
+ /** ZIP with (PNG) images */
+ CBZ,
+ /** LaTeX file with "book" template */
+ LATEX,
+ /** HTML files in a dedicated directory */
+ HTML,
+
+ ;
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase();
+ }
+
+ /**
+ * A description of this output type.
+ *
+ * @param longDesc
+ * TRUE for the long description, FALSE for the short one
+ *
+ * @return the description
+ */
+ public String getDesc(boolean longDesc) {
+ StringId id = longDesc ? StringId.OUTPUT_DESC
+ : StringId.OUTPUT_DESC_SHORT;
+
+ String desc = Instance.getInstance().getTrans().getStringX(id, this.name());
+
+ if (desc == null) {
+ desc = Instance.getInstance().getTrans().getString(id, this.toString());
+ }
+
+ if (desc == null || desc.isEmpty()) {
+ desc = this.toString();
+ }
+
+ return desc;
+ }
+
+ /**
+ * The default extension to add to the output files.
+ *
+ * @param readerTarget
+ * TRUE to point to the main {@link Story} entry point for a
+ * reader (for instance, the main entry point if this
+ * {@link Story} is in a directory bundle), FALSE to point to
+ * the main file even if it is a directory for instance
+ *
+ * @return the extension
+ */
+ public String getDefaultExtension(boolean readerTarget) {
+ BasicOutput output = BasicOutput.getOutput(this, false, false);
+ if (output != null) {
+ return output.getDefaultExtension(readerTarget);
+ }
+
+ return null;
+ }
+
+ /**
+ * Call {@link OutputType#valueOf(String)} after conversion to upper
+ * case.
+ *
+ * @param typeName
+ * the possible type name
+ *
+ * @return NULL or the type
+ */
+ public static OutputType valueOfUC(String typeName) {
+ return OutputType.valueOf(typeName == null ? null : typeName
+ .toUpperCase());
+ }
+
+ /**
+ * Call {@link OutputType#valueOf(String)} after conversion to upper
+ * case but return def for NULL and empty instead of raising an
+ * exception.
+ *
+ * @param typeName
+ * the possible type name
+ * @param def
+ * the default value
+ *
+ * @return NULL or the type
+ */
+ public static OutputType valueOfNullOkUC(String typeName, OutputType def) {
+ if (typeName == null || typeName.isEmpty()) {
+ return def;
+ }
+
+ return OutputType.valueOfUC(typeName);
+ }
+
+ /**
+ * Call {@link OutputType#valueOf(String)} after conversion to upper
+ * case but return def in case of error instead of raising an exception.
+ *
+ * @param typeName
+ * the possible type name
+ * @param def
+ * the default value
+ *
+ * @return NULL or the type
+ */
+ public static OutputType valueOfAllOkUC(String typeName, OutputType def) {
+ try {
+ return OutputType.valueOfUC(typeName);
+ } catch (Exception e) {
+ return def;
+ }
+ }
+ }
+
+ /** The creator name (this program, by me!) */
+ static protected final String EPUB_CREATOR = "Fanfix "
+ + Version.getCurrentVersion() + " (by Niki)";
+
+ /** The current best name for an image */
+ private String imageName;
+ private File targetDir;
+ private String targetName;
+ private OutputType type;
+ private boolean writeCover;
+ private boolean writeInfo;
+ private Progress storyPg;
+ private Progress chapPg;
+
+ /**
+ * Process the {@link Story} into the given target.
+ *
+ * @param story
+ * the {@link Story} to export
+ * @param target
+ * the target where to save to (will not necessary be taken as is
+ * by the processor, for instance an extension can be added)
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the actual main target saved, which can be slightly different
+ * that the input one
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public File process(Story story, String target, Progress pg)
+ throws IOException {
+ storyPg = pg;
+
+ File targetDir = null;
+ String targetName = null;
+ if (target != null) {
+ target = new File(target).getAbsolutePath();
+ targetDir = new File(target).getParentFile();
+ targetName = new File(target).getName();
+
+ String ext = getDefaultExtension(false);
+ if (ext != null && !ext.isEmpty()) {
+ if (targetName.toLowerCase().endsWith(ext)) {
+ targetName = targetName.substring(0, targetName.length()
+ - ext.length());
+ }
+ }
+ }
+
+ return process(story, targetDir, targetName);
+ }
+
+ /**
+ * Process the {@link Story} into the given target.
+ *
+ * This method is expected to be overridden in most cases.
+ *
+ * @param story
+ * the {@link Story} to export
+ * @param targetDir
+ * the target dir where to save to
+ * @param targetName
+ * the target filename (will not necessary be taken as is by the
+ * processor, for instance an extension can be added)
+ *
+ *
+ * @return the actual main target saved, which can be slightly different
+ * that the input one
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected File process(Story story, File targetDir, String targetName)
+ throws IOException {
+ this.targetDir = targetDir;
+ this.targetName = targetName;
+
+ writeStory(story);
+
+ return null;
+ }
+
+ /**
+ * The output type.
+ *
+ * @return the type
+ */
+ public OutputType getType() {
+ return type;
+ }
+
+ /**
+ * Enable the creation of a .info file next to the resulting processed file.
+ *
+ * @return TRUE to enable it
+ */
+ protected boolean isWriteInfo() {
+ return writeInfo;
+ }
+
+ /**
+ * Enable the creation of a cover file next to the resulting processed file
+ * if possible.
+ *
+ * @return TRUE to enable it
+ */
+ protected boolean isWriteCover() {
+ return writeCover;
+ }
+
+ /**
+ * The output type.
+ *
+ * @param type
+ * the new type
+ * @param writeCover
+ * TRUE to enable the creation of a cover if possible
+ * @param writeInfo
+ * TRUE to enable the creation of a .info file
+ *
+ * @return this
+ */
+ protected BasicOutput setType(OutputType type, boolean writeInfo,
+ boolean writeCover) {
+ this.type = type;
+ this.writeInfo = writeInfo;
+ this.writeCover = writeCover;
+
+ return this;
+ }
+
+ /**
+ * The default extension to add to the output files.
+ *
+ * @param readerTarget
+ * TRUE to point to the main {@link Story} entry point for a
+ * reader (for instance, the main entry point if this
+ * {@link Story} is in a directory bundle), FALSE to point to the
+ * main file even if it is a directory for instance
+ *
+ * @return the extension
+ */
+ public String getDefaultExtension(
+ @SuppressWarnings("unused") boolean readerTarget) {
+ return "";
+ }
+
+ @SuppressWarnings("unused")
+ protected void writeStoryHeader(Story story) throws IOException {
+ }
+
+ @SuppressWarnings("unused")
+ protected void writeChapterHeader(Chapter chap) throws IOException {
+ }
+
+ @SuppressWarnings("unused")
+ protected void writeParagraphHeader(Paragraph para) throws IOException {
+ }
+
+ @SuppressWarnings("unused")
+ protected void writeStoryFooter(Story story) throws IOException {
+ }
+
+ @SuppressWarnings("unused")
+ protected void writeChapterFooter(Chapter chap) throws IOException {
+ }
+
+ @SuppressWarnings("unused")
+ protected void writeParagraphFooter(Paragraph para) throws IOException {
+ }
+
+ protected void writeStory(Story story) throws IOException {
+ if (storyPg == null) {
+ storyPg = new Progress(0, story.getChapters().size() + 2);
+ } else {
+ storyPg.setMinMax(0, story.getChapters().size() + 2);
+ }
+
+ String chapterNameNum = String.format("%03d", 0);
+ String paragraphNumber = String.format("%04d", 0);
+ imageName = paragraphNumber + "_" + chapterNameNum;
+
+ if (story.getMeta() != null) {
+ story.getMeta().setType("" + getType());
+ }
+
+ if (isWriteCover()) {
+ InfoCover.writeCover(targetDir, targetName, story.getMeta());
+ }
+ if (isWriteInfo()) {
+ InfoCover.writeInfo(targetDir, targetName, story.getMeta());
+ }
+
+ storyPg.setProgress(1);
+
+ List
+ * Of those, only {@link be.nikiroo.fanfix.output.BasicOutput} is public,
+ * but it contains a method
+ * ({@link be.nikiroo.fanfix.output.BasicOutput#getOutput(be.nikiroo.fanfix.output.BasicOutput.OutputType, boolean,boolean)})
+ * to get all the other
+ * {@link be.nikiroo.fanfix.output.BasicOutput.OutputType}s.
+ *
+ * @author niki
+ */
+package be.nikiroo.fanfix.output;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/package-info.java b/src/be/nikiroo/fanfix/package-info.java
new file mode 100644
index 0000000..cfd9cbe
--- /dev/null
+++ b/src/be/nikiroo/fanfix/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * Fanfix 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.BasicLibrary} system,
+ * too, and can even offer its services over the network.
+ *
+ * @author niki
+ */
+package be.nikiroo.fanfix;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/reader/BasicReader.java b/src/be/nikiroo/fanfix/reader/BasicReader.java
new file mode 100644
index 0000000..9ec0879
--- /dev/null
+++ b/src/be/nikiroo/fanfix/reader/BasicReader.java
@@ -0,0 +1,231 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * The class that handles the different {@link Story} readers you can use.
+ *
+ * @author niki
+ */
+public abstract class BasicReader {
+ /**
+ * Return an {@link URL} from this {@link String}, be it a file path or an
+ * actual {@link URL}.
+ *
+ * @param sourceString
+ * the source
+ *
+ * @return the corresponding {@link URL}
+ *
+ * @throws MalformedURLException
+ * if this is neither a file nor a conventional {@link URL}
+ */
+ public static URL getUrl(String sourceString) throws MalformedURLException {
+ if (sourceString == null || sourceString.isEmpty()) {
+ throw new MalformedURLException("Empty url");
+ }
+
+ URL source = null;
+ try {
+ source = new URL(sourceString);
+ } catch (MalformedURLException e) {
+ File sourceFile = new File(sourceString);
+ source = sourceFile.toURI().toURL();
+ }
+
+ return source;
+ }
+
+ /**
+ * Describe a {@link Story} from its {@link MetaData} and return a list of
+ * title/value that represent this {@link Story}.
+ *
+ * @param meta
+ * the {@link MetaData} to represent
+ *
+ * @return the information
+ */
+ public static Map getMetaDesc(MetaData meta) {
+ Map metaDesc = new TreeMap();
+
+ // TODO: i18n
+
+ StringBuilder tags = new StringBuilder();
+ for (String tag : meta.getTags()) {
+ if (tags.length() > 0) {
+ tags.append(", ");
+ }
+ tags.append(tag);
+ }
+
+ // TODO: i18n
+ metaDesc.put("Author", meta.getAuthor());
+ metaDesc.put("Publication date", formatDate(meta.getDate()));
+ metaDesc.put("Published on", meta.getPublisher());
+ metaDesc.put("URL", meta.getUrl());
+ String count = "";
+ if (meta.getWords() > 0) {
+ count = StringUtils.formatNumber(meta.getWords());
+ }
+ if (meta.isImageDocument()) {
+ metaDesc.put("Number of images", count);
+ } else {
+ metaDesc.put("Number of words", count);
+ }
+ metaDesc.put("Source", meta.getSource());
+ metaDesc.put("Subject", meta.getSubject());
+ metaDesc.put("Language", meta.getLang());
+ metaDesc.put("Tags", tags.toString());
+
+ return metaDesc;
+ }
+
+ /**
+ * Open the {@link Story} with an external reader (the program will be
+ * passed the main file associated with this {@link Story}).
+ *
+ * @param lib
+ * the {@link BasicLibrary} to select the {@link Story} from
+ * @param luid
+ * the {@link Story} LUID
+ * @param sync
+ * execute the process synchronously (wait until it is terminated
+ * before returning)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public void openExternal(BasicLibrary lib, String luid, boolean sync)
+ throws IOException {
+ MetaData meta = lib.getInfo(luid);
+ File target = lib.getFile(luid, null);
+
+ openExternal(meta, target, sync);
+ }
+
+ /**
+ * Open the {@link Story} with an external reader (the program will be
+ * passed the given target file).
+ *
+ * @param meta
+ * the {@link Story} to load
+ * @param target
+ * the target {@link File}
+ * @param sync
+ * execute the process synchronously (wait until it is terminated
+ * before returning)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected void openExternal(MetaData meta, File target, boolean sync)
+ throws IOException {
+ String program = null;
+ if (meta.isImageDocument()) {
+ program = Instance.getInstance().getUiConfig().getString(UiConfig.IMAGES_DOCUMENT_READER);
+ } else {
+ program = Instance.getInstance().getUiConfig().getString(UiConfig.NON_IMAGES_DOCUMENT_READER);
+ }
+
+ if (program != null && program.trim().isEmpty()) {
+ program = null;
+ }
+
+ start(target, program, sync);
+ }
+
+ /**
+ * Start a file and open it with the given program if given or the first
+ * default system starter we can find.
+ *
+ * @param target
+ * the target to open
+ * @param program
+ * the program to use or NULL for the default system starter
+ * @param sync
+ * execute the process synchronously (wait until it is terminated
+ * before returning)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected void start(File target, String program, boolean sync)
+ throws IOException {
+
+ Process proc = null;
+ if (program == null) {
+ boolean ok = false;
+ for (String starter : new String[] { "xdg-open", "open", "see",
+ "start", "run" }) {
+ try {
+ Instance.getInstance().getTraceHandler().trace("starting external program");
+ proc = Runtime.getRuntime().exec(new String[] { starter, target.getAbsolutePath() });
+ ok = true;
+ break;
+ } catch (IOException e) {
+ }
+ }
+ if (!ok) {
+ throw new IOException("Cannot find a program to start the file");
+ }
+ } else {
+ Instance.getInstance().getTraceHandler().trace("starting external program");
+ proc = Runtime.getRuntime().exec(
+ new String[] { program, target.getAbsolutePath() });
+ }
+
+ if (proc != null && sync) {
+ try {
+ proc.waitFor();
+ } catch (InterruptedException e) {
+ }
+ }
+ }
+
+ static private String formatDate(String date) {
+ long ms = 0;
+
+ if (date != null && !date.isEmpty()) {
+ try {
+ ms = StringUtils.toTime(date);
+ } catch (ParseException e) {
+ }
+
+ if (ms <= 0) {
+ SimpleDateFormat sdf = new SimpleDateFormat(
+ "yyyy-MM-dd'T'HH:mm:ssSSS");
+ try {
+ ms = sdf.parse(date).getTime();
+ } catch (ParseException e) {
+ }
+ }
+
+ if (ms > 0) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ return sdf.format(new Date(ms));
+ }
+ }
+
+ if (date == null) {
+ date = "";
+ }
+
+ // :(
+ return date;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/reader/CliReader.java b/src/be/nikiroo/fanfix/reader/CliReader.java
new file mode 100644
index 0000000..96ca644
--- /dev/null
+++ b/src/be/nikiroo/fanfix/reader/CliReader.java
@@ -0,0 +1,256 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.IOException;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.searchable.SearchableTag;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Command line {@link Story} reader.
+ *
");
+
+ break;
+ case IMAGE:
+ }
+ }
+
+ @Override
+ protected String enbold(String word) {
+ // Used to be COLOR='#7777DD'
+ return "" + word + "";
+ }
+
+ @Override
+ protected String italize(String word) {
+ return "" + word + "";
+ }
+ };
+ }
+
+ /**
+ * Convert the chapter into HTML3 code.
+ *
+ * @param chap
+ * the {@link Chapter} to convert
+ * @param chapterName
+ * display the chapter name
+ *
+ * @return HTML3 code tested with Java Swing
+ */
+ public String convert(Chapter chap, boolean chapterName) {
+ this.chapterName = chapterName;
+ builder.setLength(0);
+ try {
+ fakeStory.setChapters(Arrays.asList(chap));
+ output.process(fakeStory, null, null);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ return builder.toString();
+ }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/BasicSearchable.java b/src/be/nikiroo/fanfix/searchable/BasicSearchable.java
new file mode 100644
index 0000000..cb0b171
--- /dev/null
+++ b/src/be/nikiroo/fanfix/searchable/BasicSearchable.java
@@ -0,0 +1,275 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+
+/**
+ * This class supports browsing through stories on the supported websites. It
+ * will fetch some {@link MetaData} that satisfy a search query or some tags if
+ * supported.
+ *
+ * @author niki
+ */
+public abstract class BasicSearchable {
+ private SupportType type;
+ private BasicSupport support;
+
+ /**
+ * Create a new {@link BasicSearchable} of the given type.
+ *
+ * @param type
+ * the type, must not be NULL
+ */
+ public BasicSearchable(SupportType type) {
+ setType(type);
+ support = BasicSupport.getSupport(getType(), null);
+ }
+
+ /**
+ * Find the given tag by its hierarchical IDs.
+ *
+ * I.E., it will take the tag A, subtag B, subsubtag C...
+ *
+ * @param ids
+ * the IDs to look for
+ *
+ * @return the appropriate tag fully filled, or NULL if not found
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public SearchableTag getTag(Integer... ids) throws IOException {
+ SearchableTag tag = null;
+ List tags = getTags();
+
+ for (Integer tagIndex : ids) {
+ // ! 1-based index !
+ if (tagIndex == null || tags == null || tagIndex <= 0
+ || tagIndex > tags.size()) {
+ return null;
+ }
+
+ tag = tags.get(tagIndex - 1);
+ fillTag(tag);
+ tags = tag.getChildren();
+ }
+
+ return tag;
+ }
+
+ /**
+ * The support type.
+ *
+ * @return the type
+ */
+ public SupportType getType() {
+ return type;
+ }
+
+ /**
+ * The support type.
+ *
+ * @param type
+ * the new type
+ */
+ protected void setType(SupportType type) {
+ this.type = type;
+ }
+
+ /**
+ * The associated {@link BasicSupport}.
+ *
+ * Mostly used to download content.
+ *
+ * @return the support
+ */
+ protected BasicSupport getSupport() {
+ return support;
+ }
+
+ /**
+ * Get a list of tags that can be browsed here.
+ *
+ * @return the list of tags
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ abstract public List getTags() throws IOException;
+
+ /**
+ * Fill the tag (set it 'complete') with more information from the support.
+ *
+ * @param tag
+ * the tag to fill
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ abstract public void fillTag(SearchableTag tag) throws IOException;
+
+ /**
+ * Search for the given term and return the number of pages of results of
+ * stories satisfying this search term.
+ *
+ * @param search
+ * the term to search for
+ *
+ * @return a number of pages
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ abstract public int searchPages(String search) throws IOException;
+
+ /**
+ * Search for the given tag and return the number of pages of results of
+ * stories satisfying this tag.
+ *
+ * @param tag
+ * the tag to search for
+ *
+ * @return a number of pages
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ abstract public int searchPages(SearchableTag tag) throws IOException;
+
+ /**
+ * Search for the given term and return a list of stories satisfying this
+ * search term.
+ *
+ * Not that the returned stories will NOT be complete, but will only
+ * contain enough information to present them to the user and retrieve them.
+ *
+ * URL is guaranteed to be usable, LUID will always be NULL.
+ *
+ * @param search
+ * the term to search for
+ * @param page
+ * the page to use for result pagination, index is 1-based
+ *
+ * @return a list of stories that satisfy that search term
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ abstract public List search(String search, int page)
+ throws IOException;
+
+ /**
+ * Search for the given tag and return a list of stories satisfying this
+ * tag.
+ *
+ * Not that the returned stories will NOT be complete, but will only
+ * contain enough information to present them to the user and retrieve them.
+ *
+ * URL is guaranteed to be usable, LUID will always be NULL.
+ *
+ * @param tag
+ * the tag to search for
+ * @param page
+ * the page to use for result pagination (see
+ * {@link SearchableTag#getPages()}, remember to check for -1),
+ * index is 1-based
+ *
+ * @return a list of stories that satisfy that search term
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ abstract public List search(SearchableTag tag, int page)
+ throws IOException;
+
+ /**
+ * Load a document from its url.
+ *
+ * @param url
+ * the URL to load
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the document
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Document load(String url, boolean stable) throws IOException {
+ return load(new URL(url), stable);
+ }
+
+ /**
+ * Load a document from its url.
+ *
+ * @param url
+ * the URL to load
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the document
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Document load(URL url, boolean stable) throws IOException {
+ return DataUtil.load(Instance.getInstance().getCache().open(url, support, stable), "UTF-8", url.toString());
+ }
+
+ /**
+ * Return a {@link BasicSearchable} implementation supporting the given
+ * type, or NULL if it does not exist.
+ *
+ * @param type
+ * the type, can be NULL (will just return NULL, since we do not
+ * support it)
+ *
+ * @return an implementation that supports it, or NULL
+ */
+ static public BasicSearchable getSearchable(SupportType type) {
+ BasicSearchable support = null;
+
+ if (type != null) {
+ switch (type) {
+ case FIMFICTION:
+ // TODO searchable for FIMFICTION
+ break;
+ case FANFICTION:
+ support = new Fanfiction(type);
+ break;
+ case MANGAHUB:
+ // TODO searchable for MANGAHUB
+ break;
+ case E621:
+ // TODO searchable for E621
+ break;
+ case YIFFSTAR:
+ // TODO searchable for YIFFSTAR
+ break;
+ case E_HENTAI:
+ // TODO searchable for E_HENTAI
+ break;
+ case MANGA_LEL:
+ support = new MangaLel();
+ break;
+ case CBZ:
+ case HTML:
+ case INFO_TEXT:
+ case TEXT:
+ case EPUB:
+ break;
+ }
+ }
+
+ return support;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/Fanfiction.java b/src/be/nikiroo/fanfix/searchable/Fanfiction.java
new file mode 100644
index 0000000..e2fba1f
--- /dev/null
+++ b/src/be/nikiroo/fanfix/searchable/Fanfiction.java
@@ -0,0 +1,411 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * A {@link BasicSearchable} for Fanfiction.NET.
+ *
+ * @author niki
+ */
+class Fanfiction extends BasicSearchable {
+ static private String BASE_URL = "http://fanfiction.net/";
+
+ /**
+ * Create a new {@link Fanfiction}.
+ *
+ * @param type
+ * {@link SupportType#FANFICTION}
+ */
+ public Fanfiction(SupportType type) {
+ super(type);
+ }
+
+ @Override
+ public List getTags() throws IOException {
+ String storiesName = null;
+ String crossoversName = null;
+ Map stories = new HashMap();
+ Map crossovers = new HashMap();
+
+ Document mainPage = load(BASE_URL, true);
+ Element menu = mainPage.getElementsByClass("dropdown").first();
+ if (menu != null) {
+ Element ul = menu.getElementsByClass("dropdown-menu").first();
+ if (ul != null) {
+ Map currentList = null;
+ for (Element li : ul.getElementsByTag("li")) {
+ if (li.hasClass("disabled")) {
+ if (storiesName == null) {
+ storiesName = li.text();
+ currentList = stories;
+ } else {
+ crossoversName = li.text();
+ currentList = crossovers;
+ }
+ } else if (currentList != null) {
+ Element a = li.getElementsByTag("a").first();
+ if (a != null) {
+ currentList.put(a.absUrl("href"), a.text());
+ }
+ }
+ }
+ }
+ }
+
+ List tags = new ArrayList();
+
+ if (storiesName != null) {
+ SearchableTag tag = new SearchableTag(null, storiesName, false);
+ for (String id : stories.keySet()) {
+ tag.add(new SearchableTag(id, stories.get(id), false, false));
+ }
+ tags.add(tag);
+ }
+
+ if (crossoversName != null) {
+ SearchableTag tag = new SearchableTag(null, crossoversName, false);
+ for (String id : crossovers.keySet()) {
+ tag.add(new SearchableTag(id, crossovers.get(id), false, false));
+ }
+ tags.add(tag);
+ }
+
+ return tags;
+ }
+
+ @Override
+ public void fillTag(SearchableTag tag) throws IOException {
+ if (tag.getId() == null || tag.isComplete()) {
+ return;
+ }
+
+ Document doc = load(tag.getId(), false);
+ Element list = doc.getElementById("list_output");
+ if (list != null) {
+ Element table = list.getElementsByTag("table").first();
+ if (table != null) {
+ for (Element div : table.getElementsByTag("div")) {
+ Element a = div.getElementsByTag("a").first();
+ Element span = div.getElementsByTag("span").first();
+
+ if (a != null) {
+ String subid = a.absUrl("href");
+ boolean crossoverSubtag = subid
+ .contains("/crossovers/");
+
+ SearchableTag subtag = new SearchableTag(subid,
+ a.text(), !crossoverSubtag, !crossoverSubtag);
+
+ tag.add(subtag);
+ if (span != null) {
+ String nr = span.text();
+ if (nr.startsWith("(")) {
+ nr = nr.substring(1);
+ }
+ if (nr.endsWith(")")) {
+ nr = nr.substring(0, nr.length() - 1);
+ }
+ nr = nr.trim();
+
+ // TODO: fix toNumber/fromNumber
+ nr = nr.replaceAll("\\.[0-9]*", "");
+
+ subtag.setCount(StringUtils.toNumber(nr));
+ }
+ }
+ }
+ }
+ }
+
+ tag.setComplete(true);
+ }
+
+ @Override
+ public List search(String search, int page) throws IOException {
+ String encoded = URLEncoder.encode(search.toLowerCase(), "utf-8");
+ String url = BASE_URL + "search/?ready=1&type=story&keywords="
+ + encoded + "&ppage=" + page;
+
+ return getStories(url, null, null);
+ }
+
+ @Override
+ public List search(SearchableTag tag, int page)
+ throws IOException {
+ List metas = new ArrayList();
+
+ String url = tag.getId();
+ if (url != null) {
+ if (page > 1) {
+ int pos = url.indexOf("&p=");
+ if (pos >= 0) {
+ url = url.replaceAll("(.*\\&p=)[0-9]*(.*)", "$1\\" + page
+ + "$2");
+ } else {
+ url += "&p=" + page;
+ }
+ }
+
+ Document doc = load(url, false);
+
+ // Update the pages number if needed
+ if (tag.getPages() < 0 && tag.isLeaf()) {
+ tag.setPages(getPages(doc));
+ }
+
+ // Find out the full subjects (including parents)
+ String subjects = "";
+ for (SearchableTag t = tag; t != null; t = t.getParent()) {
+ if (!subjects.isEmpty()) {
+ subjects += ", ";
+ }
+ subjects += t.getName();
+ }
+
+ metas = getStories(url, doc, subjects);
+ }
+
+ return metas;
+ }
+
+ @Override
+ public int searchPages(String search) throws IOException {
+ String encoded = URLEncoder.encode(search.toLowerCase(), "utf-8");
+ String url = BASE_URL + "search/?ready=1&type=story&keywords="
+ + encoded;
+
+ return getPages(load(url, false));
+ }
+
+ @Override
+ public int searchPages(SearchableTag tag) throws IOException {
+ if (tag.isLeaf()) {
+ String url = tag.getId();
+ return getPages(load(url, false));
+ }
+
+ return 0;
+ }
+
+ /**
+ * Return the number of pages in this stories result listing.
+ *
+ * @param doc
+ * the document
+ *
+ * @return the number of pages or -1 if unknown
+ */
+ private int getPages(Document doc) {
+ int pages = -1;
+
+ if (doc != null) {
+ Element center = doc.getElementsByTag("center").first();
+ if (center != null) {
+ for (Element a : center.getElementsByTag("a")) {
+ if (a.absUrl("href").contains("&p=")) {
+ int thisLinkPages = -1;
+ try {
+ String[] tab = a.absUrl("href").split("=");
+ tab = tab[tab.length - 1].split("&");
+ thisLinkPages = Integer
+ .parseInt(tab[tab.length - 1]);
+ } catch (Exception e) {
+ }
+
+ pages = Math.max(pages, thisLinkPages);
+ }
+ }
+ }
+ }
+
+ return pages;
+ }
+
+ /**
+ * Fetch the stories from the given page.
+ *
+ * @param sourceUrl
+ * the url of the document
+ * @param doc
+ * the document to use (if NULL, will be loaded from
+ * sourceUrl)
+ * @param mainSubject
+ * the main subject (the anime/book/movie item related to the
+ * stories, like "MLP" or "Doctor Who"), or NULL if none
+ *
+ * @return the stories found in it
+ *
+ * @throws IOException
+ * in case of I/O errors
+ */
+ private List getStories(String sourceUrl, Document doc,
+ String mainSubject) throws IOException {
+ List metas = new ArrayList();
+
+ if (doc == null) {
+ doc = load(sourceUrl, false);
+ }
+
+ for (Element story : doc.getElementsByClass("z-list")) {
+ MetaData meta = new MetaData();
+ meta.setImageDocument(false);
+ meta.setSource(getType().getSourceName());
+ meta.setPublisher(getType().getSourceName());
+ meta.setType(getType().toString());
+
+ // Title, URL, Cover
+ Element stitle = story.getElementsByClass("stitle").first();
+ if (stitle != null) {
+ meta.setTitle(stitle.text());
+ meta.setUrl(stitle.absUrl("href"));
+ meta.setUuid(meta.getUrl());
+ Element cover = stitle.getElementsByTag("img").first();
+ if (cover != null) {
+ // note: see data-original if needed?
+ String coverUrl = cover.absUrl("src");
+
+ try {
+ InputStream in = Instance.getInstance().getCache().open(new URL(coverUrl), getSupport(), true);
+ try {
+ meta.setCover(new Image(in));
+ } finally {
+ in.close();
+ }
+ } catch (Exception e) {
+ // Should not happen on Fanfiction.net
+ Instance.getInstance().getTraceHandler().error(new Exception(
+ "Cannot download cover for Fanfiction story in search mode: " + meta.getTitle(), e));
+ }
+ }
+ }
+
+ // Author
+ Elements as = story.getElementsByTag("a");
+ if (as.size() > 1) {
+ meta.setAuthor(as.get(1).text());
+ }
+
+ // Tags (concatenated text), published date, updated date, Resume
+ String tags = "";
+ List tagList = new ArrayList();
+ Elements divs = story.getElementsByTag("div");
+ if (divs.size() > 1 && divs.get(1).childNodeSize() > 0) {
+ String resume = divs.get(1).text();
+ if (divs.size() > 2) {
+ tags = divs.get(2).text();
+ resume = resume.substring(0,
+ resume.length() - tags.length()).trim();
+
+ for (Element d : divs.get(2).getElementsByAttribute(
+ "data-xutime")) {
+ String secs = d.attr("data-xutime");
+ try {
+ String date = new SimpleDateFormat("yyyy-MM-dd")
+ .format(new Date(
+ Long.parseLong(secs) * 1000));
+ // (updated, ) published
+ if (meta.getDate() != null) {
+ tagList.add("Updated: " + meta.getDate());
+ }
+ meta.setDate(date);
+ } catch (Exception e) {
+ }
+ }
+ }
+
+ meta.setResume(getSupport().makeChapter(new URL(sourceUrl), 0,
+ Instance.getInstance().getTrans().getString(StringId.DESCRIPTION), resume));
+ }
+
+ // How are the tags ordered?
+ // We have "Rated: xx", then the language, then all other tags
+ // If the subject(s) is/are present, they are before "Rated: xx"
+
+ // ////////////
+ // Examples: //
+ // ////////////
+
+ // Search (Luna) Tags: [Harry Potter, Rated: T, English, Chapters:
+ // 1, Words: 270, Reviews: 2, Published: 2/19/2013, Luna L.]
+
+ // Normal (MLP) Tags: [Rated: T, Spanish, Drama/Suspense, Chapters:
+ // 2, Words: 8,686, Reviews: 1, Favs: 1, Follows: 1, Updated: 4/7,
+ // Published: 4/2]
+
+ // Crossover (MLP/Who) Tags: [Rated: K+, English, Adventure/Romance,
+ // Chapters: 8, Words: 7,788, Reviews: 2, Favs: 2, Follows: 1,
+ // Published: 9/1/2016]
+
+ boolean rated = false;
+ boolean isLang = false;
+ String subject = mainSubject == null ? "" : mainSubject;
+ String[] tab = tags.split(" *- *");
+ for (int i = 0; i < tab.length; i++) {
+ String tag = tab[i];
+ if (tag.startsWith("Rated: ")) {
+ rated = true;
+ }
+
+ if (!rated) {
+ if (!subject.isEmpty()) {
+ subject += ", ";
+ }
+ subject += tag;
+ } else if (isLang) {
+ meta.setLang(tag);
+ isLang = false;
+ } else {
+ if (tag.contains(":")) {
+ // Handle special tags:
+ if (tag.startsWith("Words: ")) {
+ try {
+ meta.setWords(Long.parseLong(tag
+ .substring("Words: ".length())
+ .replace(",", "").trim()));
+ } catch (Exception e) {
+ }
+ } else if (tag.startsWith("Rated: ")) {
+ tagList.add(tag);
+ }
+ } else {
+ // Normal tags are "/"-separated
+ for (String t : tag.split("/")) {
+ tagList.add(t);
+ }
+ }
+
+ if (tag.startsWith("Rated: ")) {
+ isLang = true;
+ }
+ }
+ }
+
+ meta.setSubject(subject);
+ meta.setTags(tagList);
+
+ metas.add(meta);
+ }
+
+ return metas;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/MangaLel.java b/src/be/nikiroo/fanfix/searchable/MangaLel.java
new file mode 100644
index 0000000..5ba21a0
--- /dev/null
+++ b/src/be/nikiroo/fanfix/searchable/MangaLel.java
@@ -0,0 +1,184 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+class MangaLel extends BasicSearchable {
+ private String BASE_URL = "http://mangas-lecture-en-ligne.fr/index_lel.php";
+
+ public MangaLel() {
+ super(SupportType.MANGA_LEL);
+ }
+
+ @Override
+ public List getTags() throws IOException {
+ List tags = new ArrayList();
+
+ String url = BASE_URL + "?page=recherche";
+ Document doc = load(url, false);
+
+ Element genre = doc.getElementsByClass("genre").first();
+ if (genre != null) {
+ for (Element el : genre.getElementsByAttributeValueStarting("for",
+ "genre")) {
+ tags.add(new SearchableTag(el.attr("for"), el.text(), true));
+ }
+ }
+
+ return tags;
+ }
+
+ @Override
+ public void fillTag(SearchableTag tag) throws IOException {
+ // Tags are always complete
+ }
+
+ @Override
+ public List search(String search, int page) throws IOException {
+ String url = BASE_URL + "?nomProjet="
+ + URLEncoder.encode(search, "utf-8")
+ + "&nomAuteur=&nomTeam=&page=recherche&truc=truc";
+
+ // No pagination
+ return getResults(url);
+ }
+
+ @Override
+ public List search(SearchableTag tag, int page)
+ throws IOException {
+ String url = BASE_URL + "?nomProjet=&nomAuteur=&nomTeam=&"
+ + tag.getId() + "=on&page=recherche&truc=truc";
+
+ // No pagination
+ return getResults(url);
+ }
+
+ @Override
+ public int searchPages(String search) throws IOException {
+ // No pagination
+ return 1;
+ }
+
+ @Override
+ public int searchPages(SearchableTag tag) throws IOException {
+ if (tag.isLeaf()) {
+ // No pagination
+ return 1;
+ }
+
+ return 0;
+ }
+
+ private List getResults(String sourceUrl) throws IOException {
+ List metas = new ArrayList();
+
+ Document doc = DataUtil.load(Instance.getInstance().getCache().open(new URL(sourceUrl), getSupport(), false),
+ "UTF-8", sourceUrl);
+
+ for (Element result : doc.getElementsByClass("rechercheAffichage")) {
+ Element a = result.getElementsByTag("a").first();
+ if (a != null) {
+ int projectId = -1;
+
+ MetaData meta = new MetaData();
+
+ // Target:
+ // http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet=218
+
+ // a.absUrl("href"):
+ // http://mangas-lecture-en-ligne.fr/index_lel?onCommence=oui&idChapitre=2805
+
+ // ...but we need the PROJECT id, not the CHAPTER id -> use
+ //
+
+ Elements infos = result.getElementsByClass("texte");
+ if (infos != null) {
+ String[] tab = infos.outerHtml().split(" ");
+
+ meta.setLang("fr");
+ meta.setSource(getType().getSourceName());
+ meta.setPublisher(getType().getSourceName());
+ meta.setType(getType().toString());
+ meta.setSubject("manga");
+ meta.setImageDocument(true);
+ meta.setTitle(getVal(tab, 0));
+ meta.setAuthor(getVal(tab, 1));
+ meta.setTags(Arrays.asList(getVal(tab, 2).split(" ")));
+
+ meta.setResume(getSupport().makeChapter(new URL(sourceUrl), 0,
+ Instance.getInstance().getTrans().getString(StringId.DESCRIPTION), getVal(tab, 5)));
+ }
+
+ Element img = result.getElementsByTag("img").first();
+ if (img != null) {
+ try {
+ String[] tab = img.attr("src").split("/");
+ String str = tab[tab.length - 1];
+ tab = str.split("\\.");
+ str = tab[0];
+ projectId = Integer.parseInt(str);
+
+ String coverUrl = img.absUrl("src");
+ try {
+ InputStream in = Instance.getInstance().getCache().open(new URL(coverUrl), getSupport(),
+ true);
+ try {
+ meta.setCover(new Image(in));
+ } finally {
+ in.close();
+ }
+ } catch (Exception e) {
+ // Happen often on MangaLEL...
+ Instance.getInstance().getTraceHandler().trace(
+ "Cannot download cover for MangaLEL story in search mode: " + meta.getTitle());
+ }
+ } catch (Exception e) {
+ // no project id... cannot use the story :(
+ Instance.getInstance().getTraceHandler()
+ .error("Cannot find ProjectId for MangaLEL story in search mode: " + meta.getTitle());
+ }
+ }
+
+ if (projectId >= 0) {
+ meta.setUrl("http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet="
+ + projectId);
+ meta.setUuid(meta.getUrl());
+ metas.add(meta);
+ }
+ }
+ }
+
+ return metas;
+ }
+
+ private String getVal(String[] tab, int i) {
+ String val = "";
+
+ if (i < tab.length) {
+ val = StringUtils.unhtml(tab[i]);
+ int pos = val.indexOf(":");
+ if (pos >= 0) {
+ val = val.substring(pos + 1).trim();
+ }
+ }
+
+ return val;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/SearchableTag.java b/src/be/nikiroo/fanfix/searchable/SearchableTag.java
new file mode 100644
index 0000000..de86798
--- /dev/null
+++ b/src/be/nikiroo/fanfix/searchable/SearchableTag.java
@@ -0,0 +1,324 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class represents a tag that can be searched on a supported website.
+ *
+ * @author niki
+ */
+public class SearchableTag {
+ private String id;
+ private String name;
+ private boolean complete;
+ private long count;
+
+ private SearchableTag parent;
+ private List children;
+
+ /**
+ * The number of stories result pages this tag can get.
+ *
+ * We keep more information than what the getter/setter returns/accepts.
+ *
+ *
-2: this tag does not support stories results (not a leaf tag)
+ *
-1: the number is not yet known, but will be known after a
+ * {@link BasicSearchable#fillTag(SearchableTag)} operation
+ *
X: the number of pages
+ *
+ */
+ private int pages;
+
+ /**
+ * Create a new {@link SearchableTag}.
+ *
+ * Note that tags are complete by default.
+ *
+ * @param id
+ * the ID (usually a way to find the linked stories later on)
+ * @param name
+ * the tag name, which can be displayed to the user
+ * @param leaf
+ * the tag is a leaf tag, that is, it will not return subtags
+ * with {@link BasicSearchable#fillTag(SearchableTag)} but will
+ * return stories with
+ * {@link BasicSearchable#search(SearchableTag, int)}
+ */
+ public SearchableTag(String id, String name, boolean leaf) {
+ this(id, name, leaf, true);
+ }
+
+ /**
+ * Create a new {@link SearchableTag}.
+ *
+ * @param id
+ * the ID (usually a way to find the linked stories later on)
+ * @param name
+ * the tag name, which can be displayed to the user
+ * @param leaf
+ * the tag is a leaf tag, that is, it will not return subtags
+ * with {@link BasicSearchable#fillTag(SearchableTag)} but will
+ * return stories with
+ * {@link BasicSearchable#search(SearchableTag, int)}
+ * @param complete
+ * the tag {@link SearchableTag#isComplete()} or not
+ */
+ public SearchableTag(String id, String name, boolean leaf, boolean complete) {
+ this.id = id;
+ this.name = name;
+ this.complete = leaf || complete;
+
+ setLeaf(leaf);
+
+ children = new ArrayList();
+ }
+
+ /**
+ * The ID (usually a way to find the linked stories later on).
+ *
+ * @return the ID
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * The tag name, which can be displayed to the user.
+ *
+ * @return then name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * The fully qualified tag name, which can be displayed to the user.
+ *
+ * It will display all the tags that lead to this one as well as this one.
+ *
+ * @return the fully qualified name
+ */
+ public String getFqName() {
+ if (parent != null) {
+ return parent.getFqName() + " / " + name;
+ }
+
+ return "" + name;
+ }
+
+ /**
+ * Non-complete, non-leaf tags can still be completed via a
+ * {@link BasicSearchable#fillTag(SearchableTag)} operation from a
+ * {@link BasicSearchable}, in order to gain (more?) subtag children.
+ *
+ * Leaf tags are always considered complete.
+ *
+ * @return TRUE if it is complete
+ */
+ public boolean isComplete() {
+ return complete;
+ }
+
+ /**
+ * Non-complete, non-leaf tags can still be completed via a
+ * {@link BasicSearchable#fillTag(SearchableTag)} operation from a
+ * {@link BasicSearchable}, in order to gain (more?) subtag children.
+ *
+ * Leaf tags are always considered complete.
+ *
+ * @param complete
+ * TRUE if it is complete
+ */
+ public void setComplete(boolean complete) {
+ this.complete = isLeaf() || complete;
+ }
+
+ /**
+ * The number of items that can be found with this tag if it is searched.
+ *
+ * Will report the number of subtags by default.
+ *
+ * @return the number of items
+ */
+ public long getCount() {
+ long count = this.count;
+ if (count <= 0) {
+ count = children.size();
+ }
+
+ return count;
+ }
+
+ /**
+ * The number of items that can be found with this tag if it is searched.
+ *
+ * @param count
+ * the new count
+ */
+ public void setCount(long count) {
+ this.count = count;
+ }
+
+ /**
+ * The number of stories result pages this tag contains, only make sense if
+ * {@link SearchableTag#isLeaf()} returns TRUE.
+ *
+ * Will return -1 if the number is not yet known.
+ *
+ * @return the number of pages, or -1
+ */
+ public int getPages() {
+ return Math.max(-1, pages);
+ }
+
+ /**
+ * The number of stories result pages this tag contains, only make sense if
+ * {@link SearchableTag#isLeaf()} returns TRUE.
+ *
+ * @param pages
+ * the (positive or 0) number of pages
+ */
+ public void setPages(int pages) {
+ this.pages = Math.max(-1, pages);
+ }
+
+ /**
+ * This tag is a leaf tag, that is, it will not return other subtags with
+ * {@link BasicSearchable#fillTag(SearchableTag)} but will return stories
+ * with {@link BasicSearchable#search(SearchableTag, int)}.
+ *
+ * @return TRUE if it is
+ */
+ public boolean isLeaf() {
+ return pages > -2;
+ }
+
+ /**
+ * This tag is a leaf tag, that is, it will not return other subtags with
+ * {@link BasicSearchable#fillTag(SearchableTag)} but will return stories
+ * with {@link BasicSearchable#search(SearchableTag, int)}.
+ *
+ * Will reset the number of pages to -1.
+ *
+ * @param leaf
+ * TRUE if it is
+ */
+ public void setLeaf(boolean leaf) {
+ pages = leaf ? -1 : -2;
+ if (leaf) {
+ complete = true;
+ }
+ }
+
+ /**
+ * The subtag children of this {@link SearchableTag}.
+ *
+ * Never NULL.
+ *
+ * Note that if {@link SearchableTag#isComplete()} returns false, you can
+ * still fill (more?) subtag children with a {@link BasicSearchable}.
+ *
+ * @return the subtag children, never NULL
+ */
+ public List getChildren() {
+ return children;
+ }
+
+ /**
+ * Add the given {@link SearchableTag} as a subtag child.
+ *
+ * @param tag
+ * the tag to add
+ */
+ public void add(SearchableTag tag) {
+ if (tag == null) {
+ throw new NullPointerException("tag");
+ }
+
+ for (SearchableTag p = this; p != null; p = p.parent) {
+ if (p.equals(tag)) {
+ throw new IllegalArgumentException(
+ "Tags do not allow recursion");
+ }
+ }
+ for (SearchableTag p = tag; p != null; p = p.parent) {
+ if (p.equals(this)) {
+ throw new IllegalArgumentException(
+ "Tags do not allow recursion");
+ }
+ }
+
+ children.add(tag);
+ tag.parent = this;
+ }
+
+ /**
+ * This {@link SearchableTag} parent tag, or NULL if none.
+ *
+ * @return the parent or NULL
+ */
+ public SearchableTag getParent() {
+ return parent;
+ }
+
+ /**
+ * Display a DEBUG {@link String} representation of this object.
+ */
+ @Override
+ public String toString() {
+ String rep = name + " [" + id + "]";
+ if (!complete) {
+ rep += "*";
+ }
+
+ if (getCount() > 0) {
+ rep += " (" + getCount() + ")";
+ }
+
+ if (!children.isEmpty()) {
+ String tags = "";
+ int i = 1;
+ for (SearchableTag tag : children) {
+ if (!tags.isEmpty()) {
+ tags += ", ";
+ }
+
+ if (i > 10) {
+ tags += "...";
+ break;
+ }
+
+ tags += tag;
+ i++;
+ }
+
+ rep += ": " + tags;
+ }
+
+ return rep;
+ }
+
+ @Override
+ public int hashCode() {
+ return getFqName().hashCode();
+ }
+
+ @Override
+ public boolean equals(Object otherObj) {
+ if (otherObj instanceof SearchableTag) {
+ SearchableTag other = (SearchableTag) otherObj;
+ if ((id == null && other.id == null)
+ || (id != null && id.equals(other.id))) {
+ if (getFqName().equals(other.getFqName())) {
+ if ((parent == null && other.parent == null)
+ || (parent != null && parent.equals(other.parent))) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport.java b/src/be/nikiroo/fanfix/supported/BasicSupport.java
new file mode 100644
index 0000000..56a3bb8
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/BasicSupport.java
@@ -0,0 +1,609 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Map.Entry;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class is the base class used by the other support classes. It can be
+ * used outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ *
+ * It will be used with 'resources' (usually web pages or files).
+ *
+ * @author niki
+ */
+public abstract class BasicSupport {
+ private Document sourceNode;
+ private URL source;
+ private SupportType type;
+ private URL currentReferer; // with only one 'r', as in 'HTTP'...
+
+ static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
+ static protected BasicSupportImages bsImages = new BasicSupportImages();
+ static protected BasicSupportPara bsPara = new BasicSupportPara(new BasicSupportHelper(), new BasicSupportImages());
+
+ /**
+ * Check if the given resource is supported by this {@link BasicSupport}.
+ *
+ * @param url
+ * the resource to check for
+ *
+ * @return TRUE if it is
+ */
+ protected abstract boolean supports(URL url);
+
+ /**
+ * Return TRUE if the support will return HTML encoded content values for
+ * the chapters content.
+ *
+ * @return TRUE for HTML
+ */
+ protected abstract boolean isHtml();
+
+ /**
+ * Return the {@link MetaData} of this story.
+ *
+ * @return the associated {@link MetaData}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract MetaData getMeta() throws IOException;
+
+ /**
+ * Return the story description.
+ *
+ * @return the description
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract String getDesc() throws IOException;
+
+ /**
+ * Return the list of chapters (name and resource).
+ *
+ * Can be NULL if this {@link BasicSupport} do no use chapters.
+ *
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the chapters or NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract List> getChapters(Progress pg)
+ throws IOException;
+
+ /**
+ * Return the content of the chapter (possibly HTML encoded, if
+ * {@link BasicSupport#isHtml()} is TRUE).
+ *
+ * @param chapUrl
+ * the chapter {@link URL}
+ * @param number
+ * the chapter number
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the content
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract String getChapterContent(URL chapUrl, int number,
+ Progress pg) throws IOException;
+
+ /**
+ * Return the list of cookies (values included) that must be used to
+ * correctly fetch the resources.
+ *
+ * You are expected to call the super method implementation if you override
+ * it.
+ *
+ * @return the cookies
+ */
+ public Map getCookies() {
+ return new HashMap();
+ }
+
+ /**
+ * OAuth authorisation (aka, "bearer XXXXXXX").
+ *
+ * @return the OAuth string
+ */
+ public String getOAuth() {
+ return null;
+ }
+
+ /**
+ * Return the canonical form of the main {@link URL}.
+ *
+ * @param source
+ * the source {@link URL}, which can be NULL
+ *
+ * @return the canonical form of this {@link URL} or NULL if the source was
+ * NULL
+ */
+ protected URL getCanonicalUrl(URL source) {
+ return source;
+ }
+
+ /**
+ * The main {@link Node} for this {@link Story}.
+ *
+ * @return the node
+ */
+ protected Element getSourceNode() {
+ return sourceNode;
+ }
+
+ /**
+ * The main {@link URL} for this {@link Story}.
+ *
+ * @return the URL
+ */
+ protected URL getSource() {
+ return source;
+ }
+
+ /**
+ * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+ * the current {@link URL} we work on.
+ *
+ * @return the referer
+ */
+ public URL getCurrentReferer() {
+ return currentReferer;
+ }
+
+ /**
+ * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+ * the current {@link URL} we work on.
+ *
+ * @param currentReferer
+ * the new referer
+ */
+ protected void setCurrentReferer(URL currentReferer) {
+ this.currentReferer = currentReferer;
+ }
+
+ /**
+ * The support type.
+ *
+ * @return the type
+ */
+ public SupportType getType() {
+ return type;
+ }
+
+ /**
+ * The support type.
+ *
+ * @param type
+ * the new type
+ */
+ protected void setType(SupportType type) {
+ this.type = type;
+ }
+
+ /**
+ * Open an input link that will be used for the support.
+ *
+ * Can return NULL, in which case you are supposed to work without a source
+ * node.
+ *
+ * @param source
+ * the source {@link URL}
+ *
+ * @return the {@link InputStream}
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Document loadDocument(URL source) throws IOException {
+ String url = getCanonicalUrl(source).toString();
+ return DataUtil.load(Instance.getInstance().getCache().open(source, this, false), "UTF-8", url.toString());
+ }
+
+ /**
+ * Log into the support (can be a no-op depending upon the support).
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected void login() throws IOException {
+ }
+
+ /**
+ * Now that we have processed the {@link Story}, close the resources if any.
+ */
+ protected void close() {
+ setCurrentReferer(null);
+ }
+
+ /**
+ * Process the given story resource into a partially filled {@link Story}
+ * object containing the name and metadata.
+ *
+ * @param getDesc
+ * retrieve the description of the story, or not
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Story}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Story processMeta(boolean getDesc, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
+
+ pg.setProgress(30);
+
+ Story story = new Story();
+ MetaData meta = getMeta();
+ if (meta.getCreationDate() == null
+ || meta.getCreationDate().trim().isEmpty()) {
+ meta.setCreationDate(bsHelper
+ .formatDate(StringUtils.fromTime(new Date().getTime())));
+ }
+ story.setMeta(meta);
+ pg.put("meta", meta);
+
+ pg.setProgress(50);
+
+ if (meta.getCover() == null) {
+ meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
+ }
+
+ pg.setProgress(60);
+
+ if (getDesc) {
+ String descChapterName = Instance.getInstance().getTrans().getString(StringId.DESCRIPTION);
+ story.getMeta().setResume(bsPara.makeChapter(this, source, 0, descChapterName, //
+ getDesc(), isHtml(), null));
+ }
+
+ pg.done();
+ return story;
+ }
+
+ /**
+ * Utility method to convert the given URL into a JSON object.
+ *
+ * Note that this method expects small JSON files (everything is copied into
+ * memory at least twice).
+ *
+ * @param url
+ * the URL to parse
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the JSON object
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected JSONObject getJson(String url, boolean stable)
+ throws IOException {
+ try {
+ return getJson(new URL(url), stable);
+ } catch (MalformedURLException e) {
+ throw new IOException("Malformed URL: " + url, e);
+ }
+ }
+
+ /**
+ * Utility method to convert the given URL into a JSON object.
+ *
+ * Note that this method expects small JSON files (everything is copied into
+ * memory at least twice).
+ *
+ * @param url
+ * the URL to parse
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the JSON object
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected JSONObject getJson(URL url, boolean stable) throws IOException {
+ InputStream in = Instance.getInstance().getCache().open(url, null,
+ stable);
+ try {
+ Scanner scan = new Scanner(in);
+ scan.useDelimiter("\0");
+ try {
+ return new JSONObject(scan.next());
+ } catch (JSONException e) {
+ throw new IOException(e);
+ } finally {
+ scan.close();
+ }
+ } finally {
+ in.close();
+ }
+ }
+
+ /**
+ * Process the given story resource into a fully filled {@link Story}
+ * object.
+ *
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Story}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ // TODO: ADD final when BasicSupport_Deprecated is gone
+ public Story process(Progress pg) throws IOException {
+ setCurrentReferer(source);
+ login();
+ sourceNode = loadDocument(source);
+
+ try {
+ Story story = doProcess(pg);
+
+ // Check for "no chapters" stories
+ if (story.getChapters().isEmpty()
+ && story.getMeta().getResume() != null
+ && !story.getMeta().getResume().getParagraphs().isEmpty()) {
+ Chapter resume = story.getMeta().getResume();
+ resume.setName("");
+ resume.setNumber(1);
+ story.getChapters().add(resume);
+ story.getMeta().setWords(resume.getWords());
+
+ String descChapterName = Instance.getInstance().getTrans()
+ .getString(StringId.DESCRIPTION);
+ resume = new Chapter(0, descChapterName);
+ story.getMeta().setResume(resume);
+ }
+
+ return story;
+ } finally {
+ close();
+ }
+ }
+
+ /**
+ * Actual processing step, without the calls to other methods.
+ *
+ * If it fails to do so, it will return the date as-is.
+ *
+ * @param date
+ * the date to convert
+ *
+ * @return the converted date, or the date as-is
+ */
+ public String formatDate(String date) {
+ long ms = 0;
+
+ if (date != null && !date.isEmpty()) {
+ // Default Fanfix format:
+ try {
+ ms = StringUtils.toTime(date);
+ } catch (ParseException e) {
+ }
+
+ // Second chance:
+ if (ms <= 0) {
+ SimpleDateFormat sdf = new SimpleDateFormat(
+ "yyyy-MM-dd'T'HH:mm:ssSSS");
+ try {
+ ms = sdf.parse(date).getTime();
+ } catch (ParseException e) {
+ }
+ }
+
+ // Last chance:
+ if (ms <= 0 && date.length() >= 10) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ try {
+ ms = sdf.parse(date.substring(0, 10)).getTime();
+ } catch (ParseException e) {
+ }
+ }
+
+ // If we found something, use THIS format:
+ if (ms > 0) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ return sdf.format(new Date(ms));
+ }
+ }
+
+ if (date == null) {
+ date = "";
+ }
+
+ // :(
+ return date;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportImages.java b/src/be/nikiroo/fanfix/supported/BasicSupportImages.java
new file mode 100644
index 0000000..576cb17
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/BasicSupportImages.java
@@ -0,0 +1,185 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.utils.Image;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to images for
+ * the classes that implement {@link BasicSupport}.
+ *
+ * @author niki
+ */
+public class BasicSupportImages {
+ /**
+ * Check if the given resource can be a local image or a remote image, then
+ * refresh the cache with it if it is.
+ *
+ * @param support
+ * the support to use to download the resource (can be NULL)
+ * @param dir
+ * the local directory to search, if any
+ * @param line
+ * the resource to check
+ *
+ * @return the image if found, or NULL
+ */
+ public Image getImage(BasicSupport support, File dir, String line) {
+ URL url = getImageUrl(support, dir, line);
+ return getImage(support,url);
+ }
+
+ /**
+ * Check if the given resource can be a local image or a remote image, then
+ * refresh the cache with it if it is.
+ *
+ * @param support
+ * the support to use to download the resource (can be NULL)
+ * @param url
+ * the actual URL to check (file or remote, can be NULL)
+ *
+ * @return the image if found, or NULL
+ */
+ public Image getImage(BasicSupport support, URL url) {
+ if (url != null) {
+ if ("file".equals(url.getProtocol())) {
+ if (new File(url.getPath()).isDirectory()) {
+ return null;
+ }
+ }
+ InputStream in = null;
+ try {
+ in = Instance.getInstance().getCache().open(url, support, true);
+ return new Image(in);
+ } catch (IOException e) {
+ } finally {
+ if (in != null) {
+ try {
+ in.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if the given resource can be a local image or a remote image, then
+ * refresh the cache with it if it is.
+ *
+ * @param support
+ * the support to use to download the resource (can be NULL)
+ * @param dir
+ * the local directory to search, if any
+ * @param line
+ * the resource to check
+ *
+ * @return the image URL if found, or NULL
+ *
+ */
+ public URL getImageUrl(BasicSupport support, File dir, String line) {
+ URL url = null;
+
+ if (line != null) {
+ // try for files
+ if (dir != null && dir.exists() && !dir.isFile()) {
+ try {
+
+ String relPath = null;
+ String absPath = null;
+ try {
+ relPath = new File(dir, line.trim()).getAbsolutePath();
+ } catch (Exception e) {
+ // Cannot be converted to path (one possibility to take
+ // into account: absolute path on Windows)
+ }
+ try {
+ absPath = new File(line.trim()).getAbsolutePath();
+ } catch (Exception e) {
+ // Cannot be converted to path (at all)
+ }
+
+ for (String ext : getImageExt(true)) {
+ File absFile = new File(absPath + ext);
+ File relFile = new File(relPath + ext);
+ if (absPath != null && absFile.exists()
+ && absFile.isFile()) {
+ url = absFile.toURI().toURL();
+ } else if (relPath != null && relFile.exists()
+ && relFile.isFile()) {
+ url = relFile.toURI().toURL();
+ }
+ }
+ } catch (Exception e) {
+ // Should not happen since we control the correct arguments
+ }
+ }
+
+ if (url == null) {
+ // try for URLs
+ try {
+ for (String ext : getImageExt(true)) {
+ if (Instance.getInstance().getCache()
+ .check(new URL(line + ext), true)) {
+ url = new URL(line + ext);
+ break;
+ }
+ }
+
+ // try out of cache
+ if (url == null) {
+ for (String ext : getImageExt(true)) {
+ try {
+ url = new URL(line + ext);
+ Instance.getInstance().getCache().refresh(url, support, true);
+ break;
+ } catch (IOException e) {
+ // no image with this ext
+ url = null;
+ }
+ }
+ }
+ } catch (MalformedURLException e) {
+ // Not an url
+ }
+ }
+
+ // refresh the cached file
+ if (url != null) {
+ try {
+ Instance.getInstance().getCache().refresh(url, support, true);
+ } catch (IOException e) {
+ // woops, broken image
+ url = null;
+ }
+ }
+ }
+
+ return url;
+ }
+
+ /**
+ * Return the list of supported image extensions.
+ *
+ * @param emptyAllowed
+ * TRUE to allow an empty extension on first place, which can be
+ * used when you may already have an extension in your input but
+ * are not sure about it
+ *
+ * @return the extensions
+ */
+ public String[] getImageExt(boolean emptyAllowed) {
+ if (emptyAllowed) {
+ return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+ }
+
+ return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportPara.java b/src/be/nikiroo/fanfix/supported/BasicSupportPara.java
new file mode 100644
index 0000000..1dbedc9
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/BasicSupportPara.java
@@ -0,0 +1,579 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to {@link Paragraph}
+ * and text formating for the {@link BasicSupport} class.
+ *
+ * @author niki
+ */
+public class BasicSupportPara {
+ // quote chars
+ private static char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+ private static char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+ private static char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+ private static char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+ // used by this class:
+ BasicSupportHelper bsHelper;
+ BasicSupportImages bsImages;
+
+ public BasicSupportPara(BasicSupportHelper bsHelper, BasicSupportImages bsImages) {
+ this.bsHelper = bsHelper;
+ this.bsImages = bsImages;
+ }
+
+ /**
+ * Create a {@link Chapter} object from the given information, formatting
+ * the content as it should be.
+ *
+ * @param support
+ * the linked {@link BasicSupport}
+ * @param source
+ * the source of the story (for image lookup in the same path if
+ * the source is a file, can be NULL)
+ * @param number
+ * the chapter number
+ * @param name
+ * the chapter name
+ * @param content
+ * the chapter content
+ * @param pg
+ * the optional progress reporter
+ * @param html
+ * TRUE if the input content is in HTML mode
+ *
+ * @return the {@link Chapter}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public Chapter makeChapter(BasicSupport support, URL source,
+ int number, String name, String content, boolean html, Progress pg)
+ throws IOException {
+ // Chapter name: process it correctly, then remove the possible
+ // redundant "Chapter x: " in front of it, or "-" (as in
+ // "Chapter 5: - Fun!" after the ": " was automatically added)
+ String chapterName = processPara(name, false)
+ .getContent().trim();
+ for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
+ String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
+ if (chapterName.startsWith(chapterWord)) {
+ chapterName = chapterName.substring(chapterWord.length())
+ .trim();
+ break;
+ }
+ }
+
+ if (chapterName.startsWith(Integer.toString(number))) {
+ chapterName = chapterName.substring(
+ Integer.toString(number).length()).trim();
+ }
+
+ while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
+ chapterName = chapterName.substring(1).trim();
+ }
+ //
+
+ Chapter chap = new Chapter(number, chapterName);
+
+ if (content != null) {
+ List paras = makeParagraphs(support, source, content,
+ html, pg);
+ long words = 0;
+ for (Paragraph para : paras) {
+ words += para.getWords();
+ }
+ chap.setParagraphs(paras);
+ chap.setWords(words);
+ }
+
+ return chap;
+ }
+
+ /**
+ * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+ * and requotify them (i.e., separate them into QUOTE paragraphs and other
+ * paragraphs (quotes or not)).
+ *
+ * @param para
+ * the paragraph to requotify (not necessarily a quote)
+ * @param html
+ * TRUE if the input content is in HTML mode
+ *
+ * @return the correctly (or so we hope) quotified paragraphs
+ */
+ protected List requotify(Paragraph para, boolean html) {
+ List newParas = new ArrayList();
+
+ if (para.getType() == ParagraphType.QUOTE
+ && para.getContent().length() > 2) {
+ String line = para.getContent();
+ boolean singleQ = line.startsWith("" + openQuote);
+ boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+ // Do not try when more than one quote at a time
+ // (some stories are not easily readable if we do)
+ if (singleQ
+ && line.indexOf(closeQuote, 1) < line
+ .lastIndexOf(closeQuote)) {
+ newParas.add(para);
+ return newParas;
+ }
+ if (doubleQ
+ && line.indexOf(closeDoubleQuote, 1) < line
+ .lastIndexOf(closeDoubleQuote)) {
+ newParas.add(para);
+ return newParas;
+ }
+ //
+
+ if (!singleQ && !doubleQ) {
+ line = openDoubleQuote + line + closeDoubleQuote;
+ newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
+ .getWords()));
+ } else {
+ char open = singleQ ? openQuote : openDoubleQuote;
+ char close = singleQ ? closeQuote : closeDoubleQuote;
+
+ int posDot = -1;
+ boolean inQuote = false;
+ int i = 0;
+ for (char car : line.toCharArray()) {
+ if (car == open) {
+ inQuote = true;
+ } else if (car == close) {
+ inQuote = false;
+ } else if (car == '.' && !inQuote) {
+ posDot = i;
+ break;
+ }
+ i++;
+ }
+
+ if (posDot >= 0) {
+ String rest = line.substring(posDot + 1).trim();
+ line = line.substring(0, posDot + 1).trim();
+ long words = 1;
+ for (char car : line.toCharArray()) {
+ if (car == ' ') {
+ words++;
+ }
+ }
+ newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
+ if (!rest.isEmpty()) {
+ newParas.addAll(requotify(processPara(rest, html), html));
+ }
+ } else {
+ newParas.add(para);
+ }
+ }
+ } else {
+ newParas.add(para);
+ }
+
+ return newParas;
+ }
+
+ /**
+ * Process a {@link Paragraph} from a raw line of text.
+ *
+ * Will also fix quotes and HTML encoding if needed.
+ *
+ * @param line
+ * the raw line
+ * @param html
+ * TRUE if the input content is in HTML mode
+ *
+ * @return the processed {@link Paragraph}, never NULL
+ */
+ protected Paragraph processPara(String line, boolean html) {
+ if (html) {
+ line = StringUtils.unhtml(line).trim();
+ }
+ boolean space = true;
+ boolean brk = true;
+ boolean quote = false;
+ boolean tentativeCloseQuote = false;
+ char prev = '\0';
+ int dashCount = 0;
+ long words = 1;
+
+ StringBuilder builder = new StringBuilder();
+ for (char car : line.toCharArray()) {
+ if (car != '-') {
+ if (dashCount > 0) {
+ // dash, ndash and mdash: - â â
+ // currently: always use mdash
+ builder.append(dashCount == 1 ? '-' : 'â');
+ }
+ dashCount = 0;
+ }
+
+ if (tentativeCloseQuote) {
+ tentativeCloseQuote = false;
+ if (Character.isLetterOrDigit(car)) {
+ builder.append("'");
+ } else {
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.append(closeDoubleQuote);
+ continue;
+ }
+
+ builder.append(closeQuote);
+ }
+ }
+
+ switch (car) {
+ case 'Â ': // note: unbreakable space
+ case ' ':
+ case '\t':
+ case '\n': // just in case
+ case '\r': // just in case
+ if (builder.length() > 0
+ && builder.charAt(builder.length() - 1) != ' ') {
+ words++;
+ }
+ builder.append(' ');
+ break;
+
+ case '\'':
+ if (space || (brk && quote)) {
+ quote = true;
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openQuote);
+ }
+ } else if (prev == ' ' || prev == car) {
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openQuote);
+ }
+ } else {
+ // it is a quote ("I'm off") or a 'quote' ("This
+ // 'good' restaurant"...)
+ tentativeCloseQuote = true;
+ }
+ break;
+
+ case '"':
+ if (space || (brk && quote)) {
+ quote = true;
+ builder.append(openDoubleQuote);
+ } else if (prev == ' ') {
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(closeDoubleQuote);
+ }
+ break;
+
+ case '-':
+ if (space) {
+ quote = true;
+ } else {
+ dashCount++;
+ }
+ space = false;
+ break;
+
+ case '*':
+ case '~':
+ case '/':
+ case '\\':
+ case '<':
+ case '>':
+ case '=':
+ case '+':
+ case '_':
+ case 'â':
+ case 'â':
+ space = false;
+ builder.append(car);
+ break;
+
+ case 'â':
+ case '`':
+ case 'â¹':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ if (space || (brk && quote)) {
+ quote = true;
+ builder.append(openQuote);
+ } else {
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openQuote);
+ }
+ }
+ space = false;
+ brk = false;
+ break;
+
+ case 'â':
+ case 'âº':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ space = false;
+ brk = false;
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(closeDoubleQuote);
+ } else {
+ builder.append(closeQuote);
+ }
+ break;
+
+ case '«':
+ case 'â':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ if (space || (brk && quote)) {
+ quote = true;
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openDoubleQuote);
+ }
+ space = false;
+ brk = false;
+ break;
+
+ case '»':
+ case 'â':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ space = false;
+ brk = false;
+ builder.append(closeDoubleQuote);
+ break;
+
+ default:
+ space = false;
+ brk = false;
+ builder.append(car);
+ break;
+ }
+
+ prev = car;
+ }
+
+ if (tentativeCloseQuote) {
+ tentativeCloseQuote = false;
+ builder.append(closeQuote);
+ }
+
+ line = builder.toString().trim();
+
+ ParagraphType type = ParagraphType.NORMAL;
+ if (space) {
+ type = ParagraphType.BLANK;
+ } else if (brk) {
+ type = ParagraphType.BREAK;
+ } else if (quote) {
+ type = ParagraphType.QUOTE;
+ }
+
+ return new Paragraph(type, line, words);
+ }
+
+ /**
+ * Convert the given content into {@link Paragraph}s.
+ *
+ * @param support
+ * the linked {@link BasicSupport} (can be NULL), used to
+ * download optional image content in []
+ * @param source
+ * the source URL of the story (for image lookup in the same path
+ * if the source is a file, can be NULL)
+ * @param content
+ * the textual content
+ * @param html
+ * TRUE if the input content is in HTML mode
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Paragraph}s (can be empty but never NULL)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected List makeParagraphs(BasicSupport support,
+ URL source, String content, boolean html, Progress pg)
+ throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ if (html) {
+ // Special processing:
+ content = content.replaceAll("(]*>)|()|()",
+ " * * * ");
+ }
+
+ List paras = new ArrayList();
+
+ if (content != null && !content.trim().isEmpty()) {
+ if (html) {
+ String[] tab = content.split("(
|
| | )");
+ pg.setMinMax(0, tab.length);
+ int i = 1;
+ for (String line : tab) {
+ if (line.startsWith("[") && line.endsWith("]")) {
+ pg.setName("Extracting image " + i);
+ }
+ paras.add(makeParagraph(support, source, line.trim(), html));
+ pg.setProgress(i++);
+ }
+ } else {
+ List lines = new ArrayList();
+ BufferedReader buff = null;
+ try {
+ buff = new BufferedReader(
+ new InputStreamReader(new ByteArrayInputStream(
+ content.getBytes("UTF-8")), "UTF-8"));
+ for (String line = buff.readLine(); line != null; line = buff
+ .readLine()) {
+ lines.add(line.trim());
+ }
+ } finally {
+ if (buff != null) {
+ buff.close();
+ }
+ }
+
+ pg.setMinMax(0, lines.size());
+ int i = 0;
+ for (String line : lines) {
+ if (line.startsWith("[") && line.endsWith("]")) {
+ pg.setName("Extracting image " + i);
+ }
+ paras.add(makeParagraph(support, source, line, html));
+ pg.setProgress(i++);
+ }
+ }
+
+ pg.done();
+ pg.setName(null);
+
+ // Check quotes for "bad" format
+ List newParas = new ArrayList();
+ for (Paragraph para : paras) {
+ newParas.addAll(requotify(para, html));
+ }
+ paras = newParas;
+
+ // Remove double blanks/brks
+ fixBlanksBreaks(paras);
+ }
+
+ return paras;
+ }
+
+ /**
+ * Convert the given line into a single {@link Paragraph}.
+ *
+ * @param support
+ * the linked {@link BasicSupport} (can be NULL), used to
+ * download optional image content in []
+ * @param source
+ * the source URL of the story (for image lookup in the same path
+ * if the source is a file, can be NULL)
+ * @param line
+ * the textual content of the paragraph
+ * @param html
+ * TRUE if the input content is in HTML mode
+ *
+ * @return the {@link Paragraph}, never NULL
+ */
+ protected Paragraph makeParagraph(BasicSupport support, URL source,
+ String line, boolean html) {
+ Image image = null;
+ if (line.startsWith("[") && line.endsWith("]")) {
+ image = bsHelper.getImage(support, source, line
+ .substring(1, line.length() - 1).trim());
+ }
+
+ if (image != null) {
+ return new Paragraph(image);
+ }
+
+ return processPara(line, html);
+ }
+
+ /**
+ * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
+ * those {@link Paragraph}s.
+ *
+ * The resulting list will not contain a starting or trailing blank/break
+ * nor 2 blanks or breaks following each other.
+ *
+ * @param paras
+ * the list of {@link Paragraph}s to fix
+ */
+ protected void fixBlanksBreaks(List paras) {
+ boolean space = false;
+ boolean brk = true;
+ for (int i = 0; i < paras.size(); i++) {
+ Paragraph para = paras.get(i);
+ boolean thisSpace = para.getType() == ParagraphType.BLANK;
+ boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+ if (i > 0 && space && thisBrk) {
+ paras.remove(i - 1);
+ i--;
+ } else if ((space || brk) && (thisSpace || thisBrk)) {
+ paras.remove(i);
+ i--;
+ }
+
+ space = thisSpace;
+ brk = thisBrk;
+ }
+
+ // Remove blank/brk at start
+ if (paras.size() > 0
+ && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
+ 0).getType() == ParagraphType.BREAK)) {
+ paras.remove(0);
+ }
+
+ // Remove blank/brk at end
+ int last = paras.size() - 1;
+ if (paras.size() > 0
+ && (paras.get(last).getType() == ParagraphType.BLANK || paras
+ .get(last).getType() == ParagraphType.BREAK)) {
+ paras.remove(last);
+ }
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java
new file mode 100644
index 0000000..a50ee3c
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java
@@ -0,0 +1,1327 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * DEPRECATED: use the new Jsoup 'Node' system.
+ *
+ * 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
+ */
+@Deprecated
+public abstract class BasicSupport_Deprecated extends BasicSupport {
+ private InputStream in;
+
+ // quote chars
+ private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+ private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+ private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+ private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+ // New methods not used in Deprecated mode
+ @Override
+ protected String getDesc() throws IOException {
+ throw new RuntimeException("should not be used by legacy code");
+ }
+
+ @Override
+ protected MetaData getMeta() throws IOException {
+ throw new RuntimeException("should not be used by legacy code");
+ }
+
+ @Override
+ protected List> getChapters(Progress pg)
+ throws IOException {
+ throw new RuntimeException("should not be used by legacy code");
+ }
+
+ @Override
+ protected String getChapterContent(URL chapUrl, int number, Progress pg)
+ throws IOException {
+ throw new RuntimeException("should not be used by legacy code");
+ }
+
+ @Override
+ public Story process(Progress pg) throws IOException {
+ return process(getSource(), pg);
+ }
+
+ //
+
+ /**
+ * Return the {@link MetaData} of this story.
+ *
+ * @param source
+ * the source of the story
+ * @param in
+ * the input (the main resource)
+ *
+ * @return the associated {@link MetaData}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract MetaData getMeta(URL source, InputStream in)
+ throws IOException;
+
+ /**
+ * Return the story description.
+ *
+ * @param source
+ * the source of the story
+ * @param in
+ * the input (the main resource)
+ *
+ * @return the description
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract String getDesc(URL source, InputStream in)
+ throws IOException;
+
+ /**
+ * Return the list of chapters (name and resource).
+ *
+ * @param source
+ * the source of the story
+ * @param in
+ * the input (the main resource)
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the chapters
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract List> getChapters(URL source,
+ InputStream in, Progress pg) throws IOException;
+
+ /**
+ * Return the content of the chapter (possibly HTML encoded, if
+ * {@link BasicSupport_Deprecated#isHtml()} is TRUE).
+ *
+ * @param source
+ * the source of the story
+ * @param in
+ * the input (the main resource)
+ * @param number
+ * the chapter number
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the content
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected abstract String getChapterContent(URL source, InputStream in,
+ int number, Progress pg) throws IOException;
+
+ /**
+ * Process the given story resource into a partially filled {@link Story}
+ * object containing the name and metadata, except for the description.
+ *
+ * @param url
+ * the story resource
+ *
+ * @return the {@link Story}
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ public Story processMeta(URL url) throws IOException {
+ return processMeta(url, true, false, null);
+ }
+
+ /**
+ * Process the given story resource into a partially filled {@link Story}
+ * object containing the name and metadata.
+ *
+ * @param url
+ * the story resource
+ * @param close
+ * close "this" and "in" when done
+ * @param getDesc
+ * retrieve the description of the story, or not
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Story}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Story processMeta(URL url, boolean close, boolean getDesc,
+ Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
+
+ login();
+ pg.setProgress(10);
+
+ url = getCanonicalUrl(url);
+
+ setCurrentReferer(url);
+
+ in = openInput(url); // NULL allowed here
+ try {
+ preprocess(url, getInput());
+ pg.setProgress(30);
+
+ Story story = new Story();
+ MetaData meta = getMeta(url, getInput());
+ if (meta.getCreationDate() == null
+ || meta.getCreationDate().trim().isEmpty()) {
+ meta.setCreationDate(bsHelper.formatDate(
+ StringUtils.fromTime(new Date().getTime())));
+ }
+ story.setMeta(meta);
+ pg.put("meta", meta);
+
+ pg.setProgress(50);
+
+ if (meta.getCover() == null) {
+ meta.setCover(getDefaultCover(meta.getSubject()));
+ }
+
+ pg.setProgress(60);
+
+ if (getDesc) {
+ String descChapterName = Instance.getInstance().getTrans().getString(StringId.DESCRIPTION);
+ story.getMeta().setResume(makeChapter(url, 0, descChapterName, getDesc(url, getInput()), null));
+ }
+
+ pg.setProgress(100);
+ return story;
+ } finally {
+ if (close) {
+ close();
+
+ if (in != null) {
+ in.close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Process the given story resource into a fully filled {@link Story}
+ * object.
+ *
+ * @param url
+ * the story resource
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Story}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Story process(URL url, Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
+
+ url = getCanonicalUrl(url);
+ pg.setProgress(1);
+ try {
+ Progress pgMeta = new Progress();
+ pg.addProgress(pgMeta, 10);
+ Story story = processMeta(url, false, true, pgMeta);
+ pg.put("meta", story.getMeta());
+ if (!pgMeta.isDone()) {
+ pgMeta.setProgress(pgMeta.getMax()); // 10%
+ }
+
+ setCurrentReferer(url);
+
+ Progress pgGetChapters = new Progress();
+ pg.addProgress(pgGetChapters, 10);
+ story.setChapters(new ArrayList());
+ List> chapters = getChapters(url, getInput(),
+ pgGetChapters);
+ if (!pgGetChapters.isDone()) {
+ pgGetChapters.setProgress(pgGetChapters.getMax()); // 20%
+ }
+
+ if (chapters != null) {
+ Progress pgChaps = new Progress("Extracting chapters", 0,
+ chapters.size() * 300);
+ pg.addProgress(pgChaps, 80);
+
+ long words = 0;
+ int i = 1;
+ for (Entry chap : chapters) {
+ pgChaps.setName("Extracting chapter " + i);
+ InputStream chapIn = null;
+ if (chap.getValue() != null) {
+ setCurrentReferer(chap.getValue());
+ chapIn = Instance.getInstance().getCache().open(chap.getValue(), this, false);
+ }
+ pgChaps.setProgress(i * 100);
+ try {
+ Progress pgGetChapterContent = new Progress();
+ Progress pgMakeChapter = new Progress();
+ pgChaps.addProgress(pgGetChapterContent, 100);
+ pgChaps.addProgress(pgMakeChapter, 100);
+
+ String content = getChapterContent(url, chapIn, i,
+ pgGetChapterContent);
+ if (!pgGetChapterContent.isDone()) {
+ pgGetChapterContent.setProgress(pgGetChapterContent
+ .getMax());
+ }
+
+ Chapter cc = makeChapter(url, i, chap.getKey(),
+ content, pgMakeChapter);
+ if (!pgMakeChapter.isDone()) {
+ pgMakeChapter.setProgress(pgMakeChapter.getMax());
+ }
+
+ words += cc.getWords();
+ story.getChapters().add(cc);
+ } finally {
+ if (chapIn != null) {
+ chapIn.close();
+ }
+ }
+
+ i++;
+ }
+
+ story.getMeta().setWords(words);
+
+ pgChaps.setName("Extracting chapters");
+ } else {
+ pg.setProgress(80);
+ }
+
+ // Check for "no chapters" stories
+ if (story.getChapters().isEmpty()
+ && story.getMeta().getResume() != null
+ && !story.getMeta().getResume().getParagraphs().isEmpty()) {
+ Chapter resume = story.getMeta().getResume();
+ resume.setName("");
+ resume.setNumber(1);
+ story.getChapters().add(resume);
+ story.getMeta().setWords(resume.getWords());
+
+ String descChapterName = Instance.getInstance().getTrans()
+ .getString(StringId.DESCRIPTION);
+ resume = new Chapter(0, descChapterName);
+ story.getMeta().setResume(resume);
+ }
+
+ return story;
+ } finally {
+ close();
+
+ if (in != null) {
+ in.close();
+ }
+ }
+ }
+
+ /**
+ * Prepare the support if needed before processing.
+ *
+ * @param source
+ * the source of the story
+ * @param in
+ * the input (the main resource)
+ *
+ * @throws IOException
+ * on I/O error
+ */
+ @SuppressWarnings("unused")
+ protected void preprocess(URL source, InputStream in) throws IOException {
+ }
+
+ /**
+ * Create a {@link Chapter} object from the given information, formatting
+ * the content as it should be.
+ *
+ * @param source
+ * the source of the story
+ * @param number
+ * the chapter number
+ * @param name
+ * the chapter name
+ * @param content
+ * the chapter content
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Chapter}, never NULL
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected Chapter makeChapter(URL source, int number, String name,
+ String content, Progress pg) throws IOException {
+ // Chapter name: process it correctly, then remove the possible
+ // redundant "Chapter x: " in front of it, or "-" (as in
+ // "Chapter 5: - Fun!" after the ": " was automatically added)
+ String chapterName = processPara(name).getContent().trim();
+ for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
+ String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
+ if (chapterName.startsWith(chapterWord)) {
+ chapterName = chapterName.substring(chapterWord.length())
+ .trim();
+ break;
+ }
+ }
+
+ if (chapterName.startsWith(Integer.toString(number))) {
+ chapterName = chapterName.substring(
+ Integer.toString(number).length()).trim();
+ }
+
+ while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
+ chapterName = chapterName.substring(1).trim();
+ }
+ //
+
+ Chapter chap = new Chapter(number, chapterName);
+
+ if (content != null) {
+ List paras = makeParagraphs(source, content, pg);
+ long words = 0;
+ for (Paragraph para : paras) {
+ words += para.getWords();
+ }
+ chap.setParagraphs(paras);
+ chap.setWords(words);
+ }
+
+ return chap;
+
+ }
+
+ /**
+ * Convert the given content into {@link Paragraph}s.
+ *
+ * @param source
+ * the source URL of the story
+ * @param content
+ * the textual content
+ * @param pg
+ * the optional progress reporter
+ *
+ * @return the {@link Paragraph}s (can be empty, but never NULL)
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected List makeParagraphs(URL source, String content,
+ Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ }
+
+ if (isHtml()) {
+ // Special processing:
+ content = content.replaceAll("(]*>)|()|()",
+ " * * * ");
+ }
+
+ List paras = new ArrayList();
+ if (content != null && !content.trim().isEmpty()) {
+ if (isHtml()) {
+ String[] tab = content.split("(
|
| | )");
+ pg.setMinMax(0, tab.length);
+ int i = 1;
+ for (String line : tab) {
+ if (line.startsWith("[") && line.endsWith("]")) {
+ pg.setName("Extracting image " + i);
+ }
+ paras.add(makeParagraph(source, line.trim()));
+ pg.setProgress(i++);
+ }
+ pg.setName(null);
+ } else {
+ List lines = new ArrayList();
+ BufferedReader buff = null;
+ try {
+ buff = new BufferedReader(
+ new InputStreamReader(new ByteArrayInputStream(
+ content.getBytes("UTF-8")), "UTF-8"));
+ for (String line = buff.readLine(); line != null; line = buff
+ .readLine()) {
+ lines.add(line.trim());
+ }
+ } finally {
+ if (buff != null) {
+ buff.close();
+ }
+ }
+
+ pg.setMinMax(0, lines.size());
+ int i = 0;
+ for (String line : lines) {
+ if (line.startsWith("[") && line.endsWith("]")) {
+ pg.setName("Extracting image " + i);
+ }
+ paras.add(makeParagraph(source, line));
+ pg.setProgress(i++);
+ }
+ pg.setName(null);
+ }
+
+ // Check quotes for "bad" format
+ List newParas = new ArrayList();
+ for (Paragraph para : paras) {
+ newParas.addAll(requotify(para));
+ }
+ paras = newParas;
+
+ // Remove double blanks/brks
+ fixBlanksBreaks(paras);
+ }
+
+ return paras;
+ }
+
+ /**
+ * Convert the given line into a single {@link Paragraph}.
+ *
+ * @param source
+ * the source URL of the story
+ * @param line
+ * the textual content of the paragraph
+ *
+ * @return the {@link Paragraph}, never NULL
+ */
+ private Paragraph makeParagraph(URL source, String line) {
+ Image image = null;
+ if (line.startsWith("[") && line.endsWith("]")) {
+ image = getImage(this, source, line.substring(1, line.length() - 1)
+ .trim());
+ }
+
+ if (image != null) {
+ return new Paragraph(image);
+ }
+
+ return processPara(line);
+ }
+
+ /**
+ * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
+ * those {@link Paragraph}s.
+ *
+ * The resulting list will not contain a starting or trailing blank/break
+ * nor 2 blanks or breaks following each other.
+ *
+ * @param paras
+ * the list of {@link Paragraph}s to fix
+ */
+ protected void fixBlanksBreaks(List paras) {
+ boolean space = false;
+ boolean brk = true;
+ for (int i = 0; i < paras.size(); i++) {
+ Paragraph para = paras.get(i);
+ boolean thisSpace = para.getType() == ParagraphType.BLANK;
+ boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+ if (i > 0 && space && thisBrk) {
+ paras.remove(i - 1);
+ i--;
+ } else if ((space || brk) && (thisSpace || thisBrk)) {
+ paras.remove(i);
+ i--;
+ }
+
+ space = thisSpace;
+ brk = thisBrk;
+ }
+
+ // Remove blank/brk at start
+ if (paras.size() > 0
+ && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
+ 0).getType() == ParagraphType.BREAK)) {
+ paras.remove(0);
+ }
+
+ // Remove blank/brk at end
+ int last = paras.size() - 1;
+ if (paras.size() > 0
+ && (paras.get(last).getType() == ParagraphType.BLANK || paras
+ .get(last).getType() == ParagraphType.BREAK)) {
+ paras.remove(last);
+ }
+ }
+
+ /**
+ * Get the default cover related to this subject (see .info files).
+ *
+ * @param subject
+ * the subject
+ *
+ * @return the cover if any, or NULL
+ */
+ static Image getDefaultCover(String subject) {
+ if (subject != null && !subject.isEmpty() && Instance.getInstance().getCoverDir() != null) {
+ try {
+ File fileCover = new File(Instance.getInstance().getCoverDir(), subject);
+ return getImage(null, fileCover.toURI().toURL(), subject);
+ } catch (MalformedURLException e) {
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the list of supported image extensions.
+ *
+ * @param emptyAllowed
+ * TRUE to allow an empty extension on first place, which can be
+ * used when you may already have an extension in your input but
+ * are not sure about it
+ *
+ * @return the extensions
+ */
+ static String[] getImageExt(boolean emptyAllowed) {
+ if (emptyAllowed) {
+ return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+ }
+
+ return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+ }
+
+ /**
+ * Check if the given resource can be a local image or a remote image, then
+ * refresh the cache with it if it is.
+ *
+ * @param source
+ * the story source
+ * @param line
+ * the resource to check
+ *
+ * @return the image if found, or NULL
+ *
+ */
+ static Image getImage(BasicSupport_Deprecated support, URL source,
+ String line) {
+ URL url = getImageUrl(support, source, line);
+ if (url != null) {
+ if ("file".equals(url.getProtocol())) {
+ if (new File(url.getPath()).isDirectory()) {
+ return null;
+ }
+ }
+ InputStream in = null;
+ try {
+ in = Instance.getInstance().getCache().open(url, getSupport(url), true);
+ return new Image(in);
+ } catch (IOException e) {
+ } finally {
+ if (in != null) {
+ try {
+ in.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if the given resource can be a local image or a remote image, then
+ * refresh the cache with it if it is.
+ *
+ * @param source
+ * the story source
+ * @param line
+ * the resource to check
+ *
+ * @return the image URL if found, or NULL
+ *
+ */
+ static URL getImageUrl(BasicSupport_Deprecated support, URL source,
+ String line) {
+ URL url = null;
+
+ if (line != null) {
+ // try for files
+ if (source != null) {
+ try {
+ String relPath = null;
+ String absPath = null;
+ try {
+ String path = new File(source.getFile()).getParent();
+ relPath = new File(new File(path), line.trim())
+ .getAbsolutePath();
+ } catch (Exception e) {
+ // Cannot be converted to path (one possibility to take
+ // into account: absolute path on Windows)
+ }
+ try {
+ absPath = new File(line.trim()).getAbsolutePath();
+ } catch (Exception e) {
+ // Cannot be converted to path (at all)
+ }
+
+ for (String ext : getImageExt(true)) {
+ File absFile = new File(absPath + ext);
+ File relFile = new File(relPath + ext);
+ if (absPath != null && absFile.exists()
+ && absFile.isFile()) {
+ url = absFile.toURI().toURL();
+ } else if (relPath != null && relFile.exists()
+ && relFile.isFile()) {
+ url = relFile.toURI().toURL();
+ }
+ }
+ } catch (Exception e) {
+ // Should not happen since we control the correct arguments
+ }
+ }
+
+ if (url == null) {
+ // try for URLs
+ try {
+ for (String ext : getImageExt(true)) {
+ if (Instance.getInstance().getCache().check(new URL(line + ext), true)) {
+ url = new URL(line + ext);
+ break;
+ }
+ }
+
+ // try out of cache
+ if (url == null) {
+ for (String ext : getImageExt(true)) {
+ try {
+ url = new URL(line + ext);
+ Instance.getInstance().getCache().refresh(url, support, true);
+ break;
+ } catch (IOException e) {
+ // no image with this ext
+ url = null;
+ }
+ }
+ }
+ } catch (MalformedURLException e) {
+ // Not an url
+ }
+ }
+
+ // refresh the cached file
+ if (url != null) {
+ try {
+ Instance.getInstance().getCache().refresh(url, support, true);
+ } catch (IOException e) {
+ // woops, broken image
+ url = null;
+ }
+ }
+ }
+
+ return url;
+ }
+
+ /**
+ * Open the input file that will be used through the support.
+ *
+ * Can return NULL, in which case you are supposed to work without an
+ * {@link InputStream}.
+ *
+ * @param source
+ * the source {@link URL}
+ *
+ * @return the {@link InputStream}
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected InputStream openInput(URL source) throws IOException {
+ return Instance.getInstance().getCache().open(source, this, false);
+ }
+
+ /**
+ * Reset then return {@link BasicSupport_Deprecated#in}.
+ *
+ * @return {@link BasicSupport_Deprecated#in}
+ */
+ protected InputStream getInput() {
+ return reset(in);
+ }
+
+ /**
+ * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+ * and requotify them (i.e., separate them into QUOTE paragraphs and other
+ * paragraphs (quotes or not)).
+ *
+ * @param para
+ * the paragraph to requotify (not necessarily a quote)
+ *
+ * @return the correctly (or so we hope) quotified paragraphs
+ */
+ protected List requotify(Paragraph para) {
+ List newParas = new ArrayList();
+
+ if (para.getType() == ParagraphType.QUOTE
+ && para.getContent().length() > 2) {
+ String line = para.getContent();
+ boolean singleQ = line.startsWith("" + openQuote);
+ boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+ // Do not try when more than one quote at a time
+ // (some stories are not easily readable if we do)
+ if (singleQ
+ && line.indexOf(closeQuote, 1) < line
+ .lastIndexOf(closeQuote)) {
+ newParas.add(para);
+ return newParas;
+ }
+ if (doubleQ
+ && line.indexOf(closeDoubleQuote, 1) < line
+ .lastIndexOf(closeDoubleQuote)) {
+ newParas.add(para);
+ return newParas;
+ }
+ //
+
+ if (!singleQ && !doubleQ) {
+ line = openDoubleQuote + line + closeDoubleQuote;
+ newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
+ .getWords()));
+ } else {
+ char open = singleQ ? openQuote : openDoubleQuote;
+ char close = singleQ ? closeQuote : closeDoubleQuote;
+
+ int posDot = -1;
+ boolean inQuote = false;
+ int i = 0;
+ for (char car : line.toCharArray()) {
+ if (car == open) {
+ inQuote = true;
+ } else if (car == close) {
+ inQuote = false;
+ } else if (car == '.' && !inQuote) {
+ posDot = i;
+ break;
+ }
+ i++;
+ }
+
+ if (posDot >= 0) {
+ String rest = line.substring(posDot + 1).trim();
+ line = line.substring(0, posDot + 1).trim();
+ long words = 1;
+ for (char car : line.toCharArray()) {
+ if (car == ' ') {
+ words++;
+ }
+ }
+ newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
+ if (!rest.isEmpty()) {
+ newParas.addAll(requotify(processPara(rest)));
+ }
+ } else {
+ newParas.add(para);
+ }
+ }
+ } else {
+ newParas.add(para);
+ }
+
+ return newParas;
+ }
+
+ /**
+ * Process a {@link Paragraph} from a raw line of text.
+ *
+ * Will also fix quotes and HTML encoding if needed.
+ *
+ * @param line
+ * the raw line
+ *
+ * @return the processed {@link Paragraph}, never NULL
+ */
+ protected Paragraph processPara(String line) {
+ line = ifUnhtml(line).trim();
+
+ boolean space = true;
+ boolean brk = true;
+ boolean quote = false;
+ boolean tentativeCloseQuote = false;
+ char prev = '\0';
+ int dashCount = 0;
+ long words = 1;
+
+ StringBuilder builder = new StringBuilder();
+ for (char car : line.toCharArray()) {
+ if (car != '-') {
+ if (dashCount > 0) {
+ // dash, ndash and mdash: - â â
+ // currently: always use mdash
+ builder.append(dashCount == 1 ? '-' : 'â');
+ }
+ dashCount = 0;
+ }
+
+ if (tentativeCloseQuote) {
+ tentativeCloseQuote = false;
+ if (Character.isLetterOrDigit(car)) {
+ builder.append("'");
+ } else {
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.append(closeDoubleQuote);
+ continue;
+ }
+
+ builder.append(closeQuote);
+ }
+ }
+
+ switch (car) {
+ case 'Â ': // note: unbreakable space
+ case ' ':
+ case '\t':
+ case '\n': // just in case
+ case '\r': // just in case
+ if (builder.length() > 0
+ && builder.charAt(builder.length() - 1) != ' ') {
+ words++;
+ }
+ builder.append(' ');
+ break;
+
+ case '\'':
+ if (space || (brk && quote)) {
+ quote = true;
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openQuote);
+ }
+ } else if (prev == ' ' || prev == car) {
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openQuote);
+ }
+ } else {
+ // it is a quote ("I'm off") or a 'quote' ("This
+ // 'good' restaurant"...)
+ tentativeCloseQuote = true;
+ }
+ break;
+
+ case '"':
+ if (space || (brk && quote)) {
+ quote = true;
+ builder.append(openDoubleQuote);
+ } else if (prev == ' ') {
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(closeDoubleQuote);
+ }
+ break;
+
+ case '-':
+ if (space) {
+ quote = true;
+ } else {
+ dashCount++;
+ }
+ space = false;
+ break;
+
+ case '*':
+ case '~':
+ case '/':
+ case '\\':
+ case '<':
+ case '>':
+ case '=':
+ case '+':
+ case '_':
+ case 'â':
+ case 'â':
+ space = false;
+ builder.append(car);
+ break;
+
+ case 'â':
+ case '`':
+ case 'â¹':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ if (space || (brk && quote)) {
+ quote = true;
+ builder.append(openQuote);
+ } else {
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openQuote);
+ }
+ }
+ space = false;
+ brk = false;
+ break;
+
+ case 'â':
+ case 'âº':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ space = false;
+ brk = false;
+ // handle double-single quotes as double quotes
+ if (prev == car) {
+ builder.deleteCharAt(builder.length() - 1);
+ builder.append(closeDoubleQuote);
+ } else {
+ builder.append(closeQuote);
+ }
+ break;
+
+ case '«':
+ case 'â':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ if (space || (brk && quote)) {
+ quote = true;
+ builder.append(openDoubleQuote);
+ } else {
+ builder.append(openDoubleQuote);
+ }
+ space = false;
+ brk = false;
+ break;
+
+ case '»':
+ case 'â':
+ case 'ï¹':
+ case 'ã':
+ case 'ã':
+ space = false;
+ brk = false;
+ builder.append(closeDoubleQuote);
+ break;
+
+ default:
+ space = false;
+ brk = false;
+ builder.append(car);
+ break;
+ }
+
+ prev = car;
+ }
+
+ if (tentativeCloseQuote) {
+ tentativeCloseQuote = false;
+ builder.append(closeQuote);
+ }
+
+ line = builder.toString().trim();
+
+ ParagraphType type = ParagraphType.NORMAL;
+ if (space) {
+ type = ParagraphType.BLANK;
+ } else if (brk) {
+ type = ParagraphType.BREAK;
+ } else if (quote) {
+ type = ParagraphType.QUOTE;
+ }
+
+ return new Paragraph(type, line, words);
+ }
+
+ /**
+ * Remove the HTML from the input if
+ * {@link BasicSupport_Deprecated#isHtml()} is true.
+ *
+ * @param input
+ * the input
+ *
+ * @return the no html version if needed
+ */
+ private String ifUnhtml(String input) {
+ if (isHtml() && input != null) {
+ return StringUtils.unhtml(input);
+ }
+
+ return input;
+ }
+
+ /**
+ * Reset the given {@link InputStream} and return it.
+ *
+ * @param in
+ * the {@link InputStream} to reset
+ *
+ * @return the same {@link InputStream} after reset
+ */
+ static protected InputStream reset(InputStream in) {
+ try {
+ if (in != null) {
+ in.reset();
+ }
+ } catch (IOException e) {
+ }
+
+ return in;
+ }
+
+ /**
+ * Return the first line from the given input which correspond to the given
+ * selectors.
+ *
+ * @param in
+ * the input
+ * @param needle
+ * a string that must be found inside the target line (also
+ * supports "^" at start to say "only if it starts with" the
+ * needle)
+ * @param relativeLine
+ * the line to return based upon the target line position (-1 =
+ * the line before, 0 = the target line...)
+ *
+ * @return the line, or NULL if not found
+ */
+ static protected String getLine(InputStream in, String needle,
+ int relativeLine) {
+ return getLine(in, needle, relativeLine, true);
+ }
+
+ /**
+ * Return a line from the given input which correspond to the given
+ * selectors.
+ *
+ * @param in
+ * the input
+ * @param needle
+ * a string that must be found inside the target line (also
+ * supports "^" at start to say "only if it starts with" the
+ * needle)
+ * @param relativeLine
+ * the line to return based upon the target line position (-1 =
+ * the line before, 0 = the target line...)
+ * @param first
+ * takes the first result (as opposed to the last one, which will
+ * also always spend the input)
+ *
+ * @return the line, or NULL if not found
+ */
+ static protected String getLine(InputStream in, String needle,
+ int relativeLine, boolean first) {
+ String rep = null;
+
+ reset(in);
+
+ List 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) {
+ if (needle.startsWith("^")) {
+ if (lines.get(lines.size() - 1).startsWith(
+ needle.substring(1))) {
+ index = lines.size() - 1;
+ }
+
+ } else {
+ if (lines.get(lines.size() - 1).contains(needle)) {
+ index = lines.size() - 1;
+ }
+ }
+ }
+
+ if (index >= 0 && index + relativeLine < lines.size()) {
+ rep = lines.get(index + relativeLine);
+ if (first) {
+ break;
+ }
+ }
+ }
+
+ return rep;
+ }
+
+ /**
+ * Return the text between the key and the endKey (and optional subKey can
+ * be passed, in this case we will look for the key first, then take the
+ * text between the subKey and the endKey).
+ *
+ * Will only match the first line with the given key if more than one are
+ * possible. Which also means that if the subKey or endKey is not found on
+ * that line, NULL will be returned.
+ *
+ * @param in
+ * the input
+ * @param key
+ * the key to match (also supports "^" at start to say
+ * "only if it starts with" the key)
+ * @param subKey
+ * the sub key or NULL if none
+ * @param endKey
+ * the end key or NULL for "up to the end"
+ * @return the text or NULL if not found
+ */
+ static protected String getKeyLine(InputStream in, String key,
+ String subKey, String endKey) {
+ return getKeyText(getLine(in, key, 0), key, subKey, endKey);
+ }
+
+ /**
+ * Return the text between the key and the endKey (and optional subKey can
+ * be passed, in this case we will look for the key first, then take the
+ * text between the subKey and the endKey).
+ *
+ * @param in
+ * the input
+ * @param key
+ * the key to match (also supports "^" at start to say
+ * "only if it starts with" the key)
+ * @param subKey
+ * the sub key or NULL if none
+ * @param endKey
+ * the end key or NULL for "up to the end"
+ * @return the text or NULL if not found
+ */
+ static protected String getKeyText(String in, String key, String subKey,
+ String endKey) {
+ String result = null;
+
+ String line = in;
+ if (line != null && line.contains(key)) {
+ line = line.substring(line.indexOf(key) + key.length());
+ if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
+ if (subKey != null) {
+ line = line.substring(line.indexOf(subKey)
+ + subKey.length());
+ }
+ if (endKey == null || line.contains(endKey)) {
+ if (endKey != null) {
+ line = line.substring(0, line.indexOf(endKey));
+ result = line;
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Return the text between the key and the endKey (optional subKeys can be
+ * passed, in this case we will look for the subKeys first, then take the
+ * text between the key and the endKey).
+ *
+ * @param in
+ * the input
+ * @param key
+ * the key to match
+ * @param endKey
+ * the end key or NULL for "up to the end"
+ * @param afters
+ * the sub-keys to find before checking for key/endKey
+ *
+ * @return the text or NULL if not found
+ */
+ static protected String getKeyTextAfter(String in, String key,
+ String endKey, String... afters) {
+
+ if (in != null && !in.isEmpty()) {
+ int pos = indexOfAfter(in, 0, afters);
+ if (pos < 0) {
+ return null;
+ }
+
+ in = in.substring(pos);
+ }
+
+ return getKeyText(in, key, null, endKey);
+ }
+
+ /**
+ * Return the first index after all the given "afters" have been found in
+ * the {@link String}, or -1 if it was not possible.
+ *
+ * @param in
+ * the input
+ * @param startAt
+ * start at this position in the string
+ * @param afters
+ * the sub-keys to find before checking for key/endKey
+ *
+ * @return the text or NULL if not found
+ */
+ static protected int indexOfAfter(String in, int startAt, String... afters) {
+ int pos = -1;
+ if (in != null && !in.isEmpty()) {
+ pos = startAt;
+ if (afters != null) {
+ for (int i = 0; pos >= 0 && i < afters.length; i++) {
+ String subKey = afters[i];
+ if (!subKey.isEmpty()) {
+ pos = in.indexOf(subKey, pos);
+ if (pos >= 0) {
+ pos += subKey.length();
+ }
+ }
+ }
+ }
+ }
+
+ return pos;
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Cbz.java b/src/be/nikiroo/fanfix/supported/Cbz.java
new file mode 100644
index 0000000..7fe496d
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/Cbz.java
@@ -0,0 +1,222 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for CBZ files (works better with CBZ created with this program,
+ * as they have some metadata available).
+ *
+ * @author niki
+ */
+class Cbz extends Epub {
+ @Override
+ protected boolean supports(URL url) {
+ return url.toString().toLowerCase().endsWith(".cbz");
+ }
+
+ @Override
+ protected String getDataPrefix() {
+ return "";
+ }
+
+ @Override
+ protected boolean requireInfo() {
+ return false;
+ }
+
+ @Override
+ protected boolean isImagesDocumentByDefault() {
+ return true;
+ }
+
+ @Override
+ protected boolean getCover() {
+ return false;
+ }
+
+ @Override
+ public Story doProcess(Progress pg) throws IOException {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
+
+ pg.setName("Initialising");
+
+ Progress pgMeta = new Progress();
+ pg.addProgress(pgMeta, 10);
+ Story story = processMeta(true, pgMeta);
+ MetaData meta = story.getMeta();
+
+ pgMeta.done(); // 10%
+
+ File tmpDir = Instance.getInstance().getTempFiles().createTempDir("info-text");
+ String basename = null;
+
+ Map images = new HashMap();
+ InputStream cbzIn = null;
+ ZipInputStream zipIn = null;
+ try {
+ cbzIn = new MarkableFileInputStream(getSourceFileOriginal());
+ zipIn = new ZipInputStream(cbzIn);
+ for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
+ .getNextEntry()) {
+ if (!entry.isDirectory()
+ && entry.getName().startsWith(getDataPrefix())) {
+ String entryLName = entry.getName().toLowerCase();
+ boolean imageEntry = false;
+ for (String ext : bsImages.getImageExt(false)) {
+ if (entryLName.endsWith(ext)) {
+ imageEntry = true;
+ }
+ }
+
+ if (imageEntry) {
+ String uuid = meta.getUuid() + "_" + entry.getName();
+ try {
+ images.put(uuid, new Image(zipIn));
+ } catch (Exception e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+
+ if (pg.getProgress() < 85) {
+ pg.add(1);
+ }
+ } else if (entryLName.endsWith(".info")) {
+ basename = entryLName.substring(0, entryLName.length()
+ - ".info".length());
+ IOUtils.write(zipIn, new File(tmpDir, entryLName));
+ } else if (entryLName.endsWith(".txt")) {
+ IOUtils.write(zipIn, new File(tmpDir, entryLName));
+ }
+ }
+ }
+
+ String ext = "."
+ + Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+ String coverName = meta.getUuid() + "_" + basename + ext;
+ Image cover = images.get(coverName);
+ images.remove(coverName);
+
+ pg.setProgress(85);
+
+ // ZIP order is not correct for us
+ List imagesList = new ArrayList(images.keySet());
+ Collections.sort(imagesList);
+
+ pg.setProgress(90);
+
+ // only the description/cover is kept
+ Story origStory = getStoryFromTxt(tmpDir, basename);
+ if (origStory != null) {
+ if (origStory.getMeta().getCover() == null) {
+ origStory.getMeta().setCover(story.getMeta().getCover());
+ }
+ story.setMeta(origStory.getMeta());
+ }
+ if (story.getMeta().getCover() == null) {
+ story.getMeta().setCover(cover);
+ }
+ story.setChapters(new ArrayList());
+
+ // Check if we can find non-images chapters, for hybrid-cbz support
+ if (origStory != null) {
+ for (Chapter chap : origStory) {
+ Boolean isImages = null;
+ for (Paragraph para : chap) {
+ ParagraphType t = para.getType();
+ if (isImages == null && !t.isText(true)) {
+ isImages = true;
+ }
+ if (t.isText(false)) {
+ String line = para.getContent();
+ // Images are saved in text mode as "[image-link]"
+ if (!(line.startsWith("[") && line.endsWith("]"))) {
+ isImages = false;
+ }
+ }
+ }
+
+ if (isImages != null && !isImages) {
+ story.getChapters().add(chap);
+ chap.setNumber(story.getChapters().size());
+ }
+ }
+ }
+
+ if (!imagesList.isEmpty()) {
+ Chapter chap = new Chapter(story.getChapters().size() + 1, "");
+ story.getChapters().add(chap);
+
+ for (String uuid : imagesList) {
+ try {
+ chap.getParagraphs().add(
+ new Paragraph(images.get(uuid)));
+ } catch (Exception e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ }
+ }
+
+ if (meta.getCover() == null && !images.isEmpty()) {
+ meta.setCover(images.get(imagesList.get(0)));
+ meta.setFakeCover(true);
+ }
+ } finally {
+ IOUtils.deltree(tmpDir);
+ if (zipIn != null) {
+ zipIn.close();
+ }
+ if (cbzIn != null) {
+ cbzIn.close();
+ }
+ }
+
+ pg.done();
+ return story;
+ }
+
+ private Story getStoryFromTxt(File tmpDir, String basename) {
+ Story origStory = null;
+
+ File txt = new File(tmpDir, basename + ".txt");
+ if (!txt.exists()) {
+ basename = null;
+ }
+ if (basename != null) {
+ try {
+ BasicSupport support = BasicSupport.getSupport(txt.toURI()
+ .toURL());
+ origStory = support.process(null);
+ } catch (Exception e) {
+ basename = null;
+ }
+ }
+
+ return origStory;
+
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/E621.java b/src/be/nikiroo/fanfix/supported/E621.java
new file mode 100644
index 0000000..adf8d28
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/E621.java
@@ -0,0 +1,432 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
+
+/**
+ * Support class for 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
+ protected boolean supports(URL url) {
+ String host = url.getHost();
+ if (host.startsWith("www.")) {
+ host = host.substring("www.".length());
+ }
+
+ return ("e621.net".equals(host) || "e926.net".equals(host))
+ && (isPool(url) || isSearchOrSet(url));
+ }
+
+ @Override
+ protected boolean isHtml() {
+ return true;
+ }
+
+ @Override
+ protected MetaData getMeta() throws IOException {
+ MetaData meta = new MetaData();
+
+ meta.setTitle(getTitle());
+ meta.setAuthor(getAuthor());
+ meta.setDate(bsHelper.formatDate(getDate()));
+ meta.setTags(getTags());
+ meta.setSource(getType().getSourceName());
+ meta.setUrl(getSource().toString());
+ meta.setPublisher(getType().getSourceName());
+ meta.setUuid(getSource().toString());
+ meta.setLuid("");
+ meta.setLang("en");
+ meta.setSubject("Furry");
+ meta.setType(getType().toString());
+ meta.setImageDocument(true);
+ meta.setCover(getCover());
+ meta.setFakeCover(true);
+
+ return meta;
+ }
+
+ @Override
+ protected String getDesc() throws IOException {
+ if (isSearchOrSet(getSource())) {
+ StringBuilder builder = new StringBuilder();
+ builder.append("A collection of images from ")
+ .append(getSource().getHost()).append("\n") //
+ .append("\tTime of creation: "
+ + StringUtils.fromTime(new Date().getTime()))
+ .append("\n") //
+ .append("\tTags: ");//
+ for (String tag : getTags()) {
+ builder.append("\t\t").append(tag);
+ }
+
+ return builder.toString();
+ }
+
+ if (isPool(getSource())) {
+ Element el = getSourceNode().getElementById("description");
+ if (el != null) {
+ return el.text();
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ protected List> getChapters(Progress pg)
+ throws IOException {
+ int i = 1;
+ String jsonUrl = getJsonUrl();
+ if (jsonUrl != null) {
+ for (i = 1; true; i++) {
+ if (i > 1) {
+ try {
+ // The API does not accept more than 2 request per sec,
+ // and asks us to limit at one per sec when possible
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ }
+ }
+
+ try {
+ JSONObject json = getJson(jsonUrl + "&page=" + i, false);
+ if (!json.has("posts"))
+ break;
+ JSONArray posts = json.getJSONArray("posts");
+ if (posts.isEmpty())
+ break;
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ // The last page was empty:
+ i--;
+ }
+
+ // The pages and images are in reverse order on /posts/
+ List> chapters = new LinkedList>();
+ for (int page = i; page > 0; page--) {
+ chapters.add(new AbstractMap.SimpleEntry(
+ "Page " + Integer.toString(i - page + 1),
+ new URL(jsonUrl + "&page=" + page)));
+ }
+
+ return chapters;
+ }
+
+ @Override
+ protected String getChapterContent(URL chapUrl, int number, Progress pg)
+ throws IOException {
+ StringBuilder builder = new StringBuilder();
+
+ JSONObject json = getJson(chapUrl, false);
+ JSONArray postsArr = json.getJSONArray("posts");
+
+ // The pages and images are in reverse order on /posts/
+ List posts = new ArrayList(postsArr.length());
+ for (int i = postsArr.length() - 1; i >= 0; i--) {
+ Object o = postsArr.get(i);
+ if (o instanceof JSONObject)
+ posts.add((JSONObject) o);
+ }
+
+ for (JSONObject post : posts) {
+ if (!post.has("file"))
+ continue;
+ JSONObject file = post.getJSONObject("file");
+ if (!file.has("url"))
+ continue;
+
+ try {
+ String url = file.getString("url");
+ builder.append("[");
+ builder.append(url);
+ builder.append("] ");
+ } catch (JSONException e) {
+ // Can be NULL if filtered
+ // When the value is NULL, we get an exception
+ // but the "has" method still returns true
+ Instance.getInstance().getTraceHandler()
+ .error("Cannot get image for chapter " + number + " of "
+ + getSource());
+ }
+ }
+
+ return builder.toString();
+ }
+
+ @Override
+ protected URL getCanonicalUrl(URL source) {
+ // Convert search-pools into proper pools
+ if (source.getPath().equals("/posts") && source.getQuery() != null
+ && source.getQuery().startsWith("tags=pool%3A")) {
+ String poolNumber = source.getQuery()
+ .substring("tags=pool%3A".length());
+ try {
+ Integer.parseInt(poolNumber);
+ String base = source.getProtocol() + "://" + source.getHost();
+ if (source.getPort() != -1) {
+ base = base + ":" + source.getPort();
+ }
+ source = new URL(base + "/pools/" + poolNumber);
+ } catch (NumberFormatException e) {
+ // Not a simple pool, skip
+ } catch (MalformedURLException e) {
+ // Cannot happen
+ }
+ }
+
+ if (isSetOriginalUrl(source)) {
+ try {
+ Document doc = DataUtil.load(Instance.getInstance().getCache()
+ .open(source, this, false), "UTF-8", source.toString());
+ for (Element shortname : doc
+ .getElementsByClass("set-shortname")) {
+ for (Element el : shortname.getElementsByTag("a")) {
+ if (!el.attr("href").isEmpty())
+ return new URL(el.absUrl("href"));
+ }
+ }
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ }
+
+ if (isPool(source)) {
+ try {
+ return new URL(
+ source.toString().replace("/pool/show/", "/pools/"));
+ } catch (MalformedURLException e) {
+ }
+ }
+
+ return super.getCanonicalUrl(source);
+ }
+
+ private String getTitle() {
+ String title = "";
+
+ Element el = getSourceNode().getElementsByTag("title").first();
+ if (el != null) {
+ title = el.text().trim();
+ }
+
+ for (String s : new String[] { "e621", "-", "e621", "Pool", "-" }) {
+ if (title.startsWith(s)) {
+ title = title.substring(s.length()).trim();
+ }
+ if (title.endsWith(s)) {
+ title = title.substring(0, title.length() - s.length()).trim();
+ }
+ }
+
+ if (isSearchOrSet(getSource())) {
+ title = title.isEmpty() ? "e621" : "[e621] " + title;
+ }
+
+ return title;
+ }
+
+ private String getAuthor() {
+ List list = new ArrayList();
+ String jsonUrl = getJsonUrl();
+ if (jsonUrl != null) {
+ try {
+ JSONObject json = getJson(jsonUrl, false);
+ JSONArray posts = json.getJSONArray("posts");
+ for (Object obj : posts) {
+ if (!(obj instanceof JSONObject))
+ continue;
+
+ JSONObject post = (JSONObject) obj;
+ if (!post.has("tags"))
+ continue;
+
+ JSONObject tags = post.getJSONObject("tags");
+ if (!tags.has("artist"))
+ continue;
+
+ JSONArray artists = tags.getJSONArray("artist");
+ for (Object artist : artists) {
+ if (list.contains(artist.toString()))
+ continue;
+
+ list.add(artist.toString());
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ StringBuilder builder = new StringBuilder();
+ for (String artist : list) {
+ if (builder.length() > 0) {
+ builder.append(", ");
+ }
+ builder.append(artist);
+ }
+
+ return builder.toString();
+ }
+
+ private String getDate() {
+ String jsonUrl = getJsonUrl();
+ if (jsonUrl != null) {
+ try {
+ JSONObject json = getJson(jsonUrl, false);
+ JSONArray posts = json.getJSONArray("posts");
+ for (Object obj : posts) {
+ if (!(obj instanceof JSONObject))
+ continue;
+
+ JSONObject post = (JSONObject) obj;
+ if (!post.has("created_at"))
+ continue;
+
+ return post.getString("created_at");
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ return "";
+ }
+
+ // no tags for pools
+ private List getTags() {
+ List tags = new ArrayList();
+ if (isSearchOrSet(getSource())) {
+ String str = getTagsFromUrl(getSource());
+ for (String tag : str.split("\\+")) {
+ try {
+ tags.add(URLDecoder.decode(tag.trim(), "UTF-8").trim());
+ } catch (UnsupportedEncodingException e) {
+ }
+ }
+ }
+
+ return tags;
+ }
+
+ // returns "xxx+ddd+ggg" if "tags=xxx+ddd+ggg" was present in the query
+ private String getTagsFromUrl(URL url) {
+ String tags = url == null ? "" : url.getQuery();
+ int pos = tags.indexOf("tags=");
+
+ if (pos >= 0) {
+ tags = tags.substring(pos).substring("tags=".length());
+ } else {
+ return "";
+ }
+
+ pos = tags.indexOf('&');
+ if (pos > 0) {
+ tags = tags.substring(0, pos);
+ }
+ pos = tags.indexOf('/');
+ if (pos > 0) {
+ tags = tags.substring(0, pos);
+ }
+
+ return tags;
+ }
+
+ private Image getCover() throws IOException {
+ Image image = null;
+ List> chapters = getChapters(null);
+ if (!chapters.isEmpty()) {
+ URL chap1Url = chapters.get(0).getValue();
+ String imgsChap1 = getChapterContent(chap1Url, 1, null);
+ if (!imgsChap1.isEmpty()) {
+ imgsChap1 = imgsChap1.split("]")[0].substring(1).trim();
+ image = bsImages.getImage(this, new URL(imgsChap1));
+ }
+ }
+
+ return image;
+ }
+
+ // always /posts.json/ url
+ private String getJsonUrl() {
+ String url = null;
+ if (isSearchOrSet(getSource())) {
+ url = getSource().toString().replace("/posts", "/posts.json");
+ }
+
+ if (isPool(getSource())) {
+ String poolNumber = getSource().getPath()
+ .substring("/pools/".length());
+ url = "https://e621.net/posts.json" + "?tags=pool%3A" + poolNumber;
+ }
+
+ if (url != null) {
+ // Note: one way to override the blacklist
+ String login = Instance.getInstance().getConfig()
+ .getString(Config.LOGIN_E621_LOGIN);
+ String apk = Instance.getInstance().getConfig()
+ .getString(Config.LOGIN_E621_APIKEY);
+
+ if (login != null && !login.isEmpty() && apk != null
+ && !apk.isEmpty()) {
+ url = String.format("%s&login=%s&api_key=%s&_client=%s", url,
+ login, apk, "fanfix-" + Version.getCurrentVersion());
+ }
+ }
+
+ return url;
+ }
+
+ // note: will be removed at getCanonicalUrl()
+ private boolean isSetOriginalUrl(URL originalUrl) {
+ return originalUrl.getPath().startsWith("/post_sets/");
+ }
+
+ private boolean isPool(URL url) {
+ return url.getPath().startsWith("/pools/")
+ || url.getPath().startsWith("/pool/show/");
+ }
+
+ // set will be renamed into search by canonical url
+ private boolean isSearchOrSet(URL url) {
+ return
+ // search:
+ (url.getPath().equals("/posts") && url.getQuery().contains("tags="))
+ // or set:
+ || isSetOriginalUrl(url);
+ }
+}
diff --git a/src/be/nikiroo/fanfix/supported/EHentai.java b/src/be/nikiroo/fanfix/supported/EHentai.java
new file mode 100644
index 0000000..3c73432
--- /dev/null
+++ b/src/be/nikiroo/fanfix/supported/EHentai.java
@@ -0,0 +1,292 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for e-hentai.org, a website
+ * supporting mostly but not always NSFW comics, including some of MLP.
+ *
+ * @author niki
+ */
+class EHentai extends BasicSupport_Deprecated {
+ @Override
+ protected MetaData getMeta(URL source, InputStream in) throws IOException {
+ MetaData meta = new MetaData();
+
+ meta.setTitle(getTitle(reset(in)));
+ meta.setAuthor(getAuthor(reset(in)));
+ meta.setDate(getDate(reset(in)));
+ meta.setTags(getTags(reset(in)));
+ meta.setSource(getType().getSourceName());
+ meta.setUrl(source.toString());
+ meta.setPublisher(getType().getSourceName());
+ meta.setUuid(source.toString());
+ meta.setLuid("");
+ meta.setLang(getLang(reset(in)));
+ meta.setSubject("Hentai");
+ meta.setType(getType().toString());
+ meta.setImageDocument(true);
+ meta.setCover(getCover(source, reset(in)));
+ meta.setFakeCover(true);
+
+ return meta;
+ }
+
+ @Override
+ public Story process(URL url, Progress pg) throws IOException {
+ // There is no chapters on e621, just pagination...
+ Story story = super.process(url, pg);
+
+ Chapter only = new Chapter(1, "");
+ for (Chapter chap : story) {
+ only.getParagraphs().addAll(chap.getParagraphs());
+ }
+
+ story.getChapters().clear();
+ story.getChapters().add(only);
+
+ return story;
+ }
+
+ @Override
+ protected boolean supports(URL url) {
+ return "e-hentai.org".equals(url.getHost());
+ }
+
+ @Override
+ protected boolean isHtml() {
+ return true;
+ }
+
+ @Override
+ public Map getCookies() {
+ Map cookies = super.getCookies();
+ cookies.put("nw", "1");
+ return cookies;
+ }
+
+ private Image getCover(URL source, InputStream in) {
+ Image author = null;
+ String coverLine = getKeyLine(in, "
tagsAuthor = getTagsAuthor(in);
+ if (!tagsAuthor.isEmpty()) {
+ author = tagsAuthor.get(0);
+ }
+
+ return author;
+ }
+
+ private String getLang(InputStream in) {
+ String lang = null;
+
+ String langLine = getKeyLine(in, "class=\"gdt1\">Language",
+ "class=\"gdt2\"", "");
+ if (langLine != null) {
+ langLine = StringUtils.unhtml(langLine).trim();
+ if (langLine.equalsIgnoreCase("English")) {
+ lang = "en";
+ } else if (langLine.equalsIgnoreCase("Japanese")) {
+ lang = "jp";
+ } else if (langLine.equalsIgnoreCase("French")) {
+ lang = "fr";
+ } else {
+ // TODO find the code for other languages?
+ lang = langLine;
+ }
+ }
+
+ return lang;
+ }
+
+ private String getDate(InputStream in) {
+ String date = null;
+
+ String dateLine = getKeyLine(in, "class=\"gdt1\">Posted",
+ "class=\"gdt2\"", "");
+ if (dateLine != null) {
+ dateLine = StringUtils.unhtml(dateLine).trim();
+ if (dateLine.length() > 10) {
+ dateLine = dateLine.substring(0, 10).trim();
+ }
+
+ date = dateLine;
+ }
+
+ return date;
+ }
+
+ private List getTags(InputStream in) {
+ List tags = new ArrayList();
+ List tagsAuthor = getTagsAuthor(in);
+
+ for (int i = 1; i < tagsAuthor.size(); i++) {
+ tags.add(tagsAuthor.get(i));
+ }
+
+ return tags;
+ }
+
+ private List getTagsAuthor(InputStream in) {
+ List tags = new ArrayList();
+ String tagLine = getKeyLine(in, "", 0);
+ if (title != null) {
+ title = StringUtils.unhtml(title).trim();
+ if (title.endsWith(siteName)) {
+ title = title.substring(0, title.length() - siteName.length())
+ .trim();
+ }
+ }
+
+ return title;
+ }
+
+ @Override
+ protected String getDesc(URL source, InputStream in) throws IOException {
+ String desc = null;
+
+ String descLine = getKeyLine(in, "Uploader Comment", null,
+ "
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/web/index.pre.html b/src/be/nikiroo/fanfix/library/web/index.pre.html
new file mode 100644
index 0000000..18c1508
--- /dev/null
+++ b/src/be/nikiroo/fanfix/library/web/index.pre.html
@@ -0,0 +1,48 @@
+
+
+