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;
}
/**
- * Initialise the instance -- if already initialised, nothing will happen unless
- * you pass TRUE to <tt>force</tt>.
+ * Initialise the instance -- if already initialised, nothing will happen
+ * unless you pass TRUE to <tt>force</tt>.
* <p>
- * Before calling this method, you may call {@link Bundles#setDirectory(String)}
- * if wanted.
+ * Before calling this method, you may call
+ * {@link Bundles#setDirectory(String)} if wanted.
* <p>
- * Note: forcing the initialisation can be dangerous, so make sure to only make
- * it under controlled circumstances -- for instance, at the start of the
- * program, you could call {@link Instance#init()}, change some settings because
- * you want to force those settings (it will also forbid users to change them!)
- * and then call {@link Instance#init(boolean)} with <tt>force</tt> set to TRUE.
- *
- * @param force force the initialisation even if already initialised
+ * Note: forcing the initialisation can be dangerous, so make sure to only
+ * make it under controlled circumstances -- for instance, at the start of
+ * the program, you could call {@link Instance#init()}, change some settings
+ * because you want to force those settings (it will also forbid users to
+ * change them!) and then call {@link Instance#init(boolean)} with
+ * <tt>force</tt> set to TRUE.
+ *
+ * @param force
+ * force the initialisation even if already initialised
*/
static public void init(boolean force) {
synchronized (instancelock) {
* <p>
* Usually for DEBUG/Test purposes.
*
- * @param instance the actual Instance to use
+ * @param instance
+ * the actual Instance to use
*/
static public void init(Instance instance) {
Instance.instance = instance;
/**
* Actually initialise the instance.
* <p>
- * Before calling this method, you may call {@link Bundles#setDirectory(String)}
- * if wanted.
+ * Before calling this method, you may call
+ * {@link Bundles#setDirectory(String)} if wanted.
*/
protected Instance() {
// Before we can configure it:
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));
+ 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");
+ 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));
+ tracer.error(
+ new IOException("Cannot create temporary directory", e));
}
}
/**
* The traces handler for this {@link Cache}.
*
- * @param tracer the new traces handler or NULL
+ * @param tracer
+ * the new traces handler or NULL
*/
public void setTraceHandler(TraceHandler tracer) {
if (tracer == null) {
/**
* Reset the configuration.
*
- * @param resetTrans also reset the translation files
+ * @param resetTrans
+ * also reset the translation files
*/
public void resetConfig(boolean resetTrans) {
String dir = Bundles.getDirectory();
* Return the directory where to store temporary files for the remote
* {@link LocalLibrary}.
*
- * @param host the remote for this host
+ * @param host
+ * the remote for this host
*
* @return the directory
*/
* 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
+ * @param remoteDir
+ * the base remote directory
+ * @param host
+ * the remote for this host
*
* @return the directory
*/
*/
public boolean isVersionCheckNeeded() {
try {
- long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0) * 24 * 60 * 60 * 1000;
+ 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);
+ String lastUpString = IOUtils
+ .readSmallFile(new File(configDir, "LAST_UPDATE"));
+ long delay = new Date().getTime()
+ - Long.parseLong(lastUpString);
if (delay > wait) {
return true;
}
*/
public void setVersionChecked() {
try {
- IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE", Long.toString(new Date().getTime()));
+ IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE",
+ Long.toString(new Date().getTime()));
} catch (IOException e) {
tracer.error(e);
}
}
/**
- * The configuration directory (will check, in order of preference, the system
- * properties, the environment and then defaults to
+ * 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
* {@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
+ * @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) {
/**
* Create the default library as specified by the config.
*
- * @param remoteDir the base remote directory if needed
+ * @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);
+ 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);
+ 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);
- lib = new RemoteLibrary(key, host, port);
- lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib, uiconfig);
+
+ 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));
+ 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();
+ 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));
+ tracer.error(new IOException(
+ "Cannot create library for directory: " + libDir, e));
}
}
/**
* 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)
+ * @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) {
/**
* 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)
+ * @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) {
/**
* 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
+ * @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) {
* properties.
* <p>
* The environment variable is tested first. Then, the custom property
- * "fanfix.home" is tried, followed by the usual "user.home" then "java.io.tmp"
- * if nothing else is found.
+ * "fanfix.home" is tried, followed by the usual "user.home" then
+ * "java.io.tmp" if nothing else is found.
*
* @return the home
*/
String lang = config.getString(Config.LANG);
if (lang == null || lang.isEmpty()) {
- if (System.getenv("LANG") != null && !System.getenv("LANG").isEmpty()) {
+ if (System.getenv("LANG") != null
+ && !System.getenv("LANG").isEmpty()) {
lang = System.getenv("LANG");
}
}
/**
* Check that the given environment variable is "enabled".
*
- * @param key the variable to check
+ * @param key
+ * the variable to check
*
* @return TRUE if it is
*/
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)
+ if ("yes".equals(value) || "true".equals(value)
+ || "on".equals(value) || "1".equals(value)
|| "y".equals(value)) {
return true;
}
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.utils.Progress;
import be.nikiroo.utils.Version;
import be.nikiroo.utils.VersionCheck;
- import be.nikiroo.utils.serial.server.ServerObject;
/**
* Main program entry point.
* <li>--version: get the version of the program</li>
* <li>--server: start the server mode (see config file for parameters)</li>
* <li>--stop-server: stop the running server on this port if any</li>
- * <li>--remote [key] [host] [port]: use a the given remote library</li>
+ * <li>--remote [key] [host] [port]: use the given remote library</li>
* </ul>
*
* @param args
}
break;
case SERVER:
- key = Instance.getInstance().getConfig().getString(Config.SERVER_KEY);
- port = Instance.getInstance().getConfig().getInteger(Config.SERVER_PORT);
- if (port == null) {
- System.err.println("No port configured in the config file");
- exitCode = 15;
- break;
- }
try {
- startServer(key, port);
+ startServer();
} catch (IOException e) {
Instance.getInstance().getTraceHandler().error(e);
}
/**
* Start a Fanfix server.
*
- * @param key
- * the key taht will be needed to contact the Fanfix server
- * @param port
- * the port on which to run
- *
* @throws IOException
* in case of I/O errors
* @throws SSLException
* when the key was not accepted
*/
- private void startServer(String key, int port) throws IOException {
- ServerObject server = new RemoteLibraryServer(key, port);
- server.setTraceHandler(Instance.getInstance().getTraceHandler());
- server.run();
+ 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);
+ }
}
/**
@Meta(description = "Use the remote Fanfix server configured here instead of the local library (if FALSE, the local library will be used instead)",//
format = Format.BOOLEAN, def = "false")
REMOTE_LIBRARY_ENABLED, //
- @Meta(description = "The remote Fanfix server to connect to",//
+ @Meta(description = "The remote Fanfix server to connect to (fanfix://, http://, https:// -- if not specified, fanfix:// is assumed)",//
format = Format.STRING)
REMOTE_LIBRARY_HOST, //
@Meta(description = "The port to use for the remote Fanfix server",//
@Meta(description = "Remote Server configuration\nNote that the key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
group = true)
SERVER, //
+ @Meta(description = "Remote Server mode: you can use the fanfix protocol (which is encrypted), http (which is not) or https (which requires a keystore.jks file)",//
+ format = Format.FIXED_LIST, list = { "fanfix", "http", "https" }, def = "fanfix")
+ SERVER_MODE,
@Meta(description = "The port on which we can start the server (must be a valid port, from 1 to 65535)", //
format = Format.INT, def = "58365")
SERVER_PORT, //
- @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty (it is *still* encrypted, but with an empty, easy to guess key)",//
+ @Meta(description = "A keystore.jks file, required to use HTTPS (the server will refuse to start in HTTPS mode without this file)", //
+ format = Format.STRING, def = "")
+ SERVER_SSL_KEYSTORE,
+ @Meta(description = "The pass phrase required to open the keystore.jks file (required for HTTPS mode)", //
+ format = Format.PASSWORD, def = "")
+ SERVER_SSL_KEYSTORE_PASS,
+ @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty -- is used to encrypt the traffic in fanfix mode (even if empty, traffic will be encrypted in fanfix mode), and used as a password for HTTP (clear text protocol) and HTTPS modes",//
format = Format.PASSWORD, def = "")
SERVER_KEY, //
@Meta(description = "Allow write access to the clients (download story, move story...) without RW subkeys", //
@Meta(description = "If not empty, only the EXACT listed sources will be available for clients without BL subkeys",//
array = true, format = Format.STRING, def = "")
SERVER_WHITELIST, //
- @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey ", //
+ @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey is used as a login for HTTP (clear text protocol) and HTTPS modes", //
array = true, format = Format.STRING, def = "")
SERVER_ALLOWED_SUBKEYS, //
+ @Meta(description = "The maximum size of the cache, in MegaBytes, for HTTP and HTTPS servers", //
+ format = Format.INT, def = "100")
+ SERVER_MAX_CACHE_MB,
@Meta(description = "DEBUG options",//
group = true)
--- /dev/null
+ package be.nikiroo.fanfix.data;
+
+ import java.util.ArrayList;
+ import java.util.List;
+
+ import org.json.JSONArray;
+ import org.json.JSONException;
+ import org.json.JSONObject;
+
+ public class JsonIO {
+ static public JSONObject toJson(MetaData meta) {
+ if (meta == null) {
+ return null;
+ }
+
+ JSONObject json = new JSONObject();
+ put(json, "", MetaData.class.getName());
+ put(json, "luid", meta.getLuid());
+ put(json, "title", meta.getTitle());
+ put(json, "author", meta.getAuthor());
+ put(json, "source", meta.getSource());
+ put(json, "url", meta.getUrl());
+ put(json, "words", meta.getWords());
+ put(json, "creation_date", meta.getCreationDate());
+ put(json, "date", meta.getDate());
+ put(json, "lang", meta.getLang());
+ put(json, "publisher", meta.getPublisher());
+ put(json, "subject", meta.getSubject());
+ put(json, "type", meta.getType());
+ put(json, "uuid", meta.getUuid());
+ put(json, "resume", toJson(meta.getResume()));
+ put(json, "tags", new JSONArray(meta.getTags()));
+
+ return json;
+ }
+
+ /**
+ *
+ * @param json
+ *
+ * @return
+ *
+ * @throws JSONException
+ * when it cannot be converted
+ */
+ static public MetaData toMetaData(JSONObject json) {
+ if (json == null) {
+ return null;
+ }
+
+ MetaData meta = new MetaData();
+ meta.setLuid(getString(json, "luid"));
+ meta.setTitle(getString(json, "title"));
+ meta.setAuthor(getString(json, "author"));
+ meta.setSource(getString(json, "source"));
+ meta.setUrl(getString(json, "url"));
+ meta.setWords(getLong(json, "words", 0));
+ meta.setCreationDate(getString(json, "creation_date"));
+ meta.setDate(getString(json, "date"));
+ meta.setLang(getString(json, "lang"));
+ meta.setPublisher(getString(json, "publisher"));
+ meta.setSubject(getString(json, "subject"));
+ meta.setType(getString(json, "type"));
+ meta.setUuid(getString(json, "uuid"));
+
+ meta.setResume(toChapter(getJson(json, "resume")));
+ meta.setTags(toListString(getJsonArr(json, "tags")));
+
+ return meta;
+ }
+
+ static public JSONObject toJson(Chapter chap) {
+ if (chap == null) {
+ return null;
+ }
+
+ JSONObject json = new JSONObject();
+
+ // TODO
+
+ return json;
+ }
+
+ /**
+ *
+ * @param json
+ *
+ * @return
+ *
+ * @throws JSONException
+ * when it cannot be converted
+ */
+ static public Chapter toChapter(JSONObject json) {
+ if (json == null) {
+ return null;
+ }
+
+ Chapter chap = new Chapter(0, "");
+
+ // TODO
+
+ return chap;
+ }
+
+ static public List<String> toListString(JSONArray array) {
+ if (array != null) {
+ List<String> values = new ArrayList<String>();
+ for (Object value : array.toList()) {
+ values.add(value == null ? null : value.toString());
+ }
+ return values;
+ }
+
+ return null;
+ }
+
+ static private void put(JSONObject json, String key, Object o) {
+ json.put(key, o == null ? JSONObject.NULL : o);
+ }
+
+ static String getString(JSONObject json, String key) {
+ if (json.has(key)) {
+ Object o = json.get(key);
+ if (o instanceof String) {
+ return (String) o;
+ }
+ }
+
+ return null;
+ }
+
+ static long getLong(JSONObject json, String key, long def) {
+ if (json.has(key)) {
+ Object o = json.get(key);
+ if (o instanceof Long) {
+ return (Long) o;
+ }
+ }
+
+ return def;
+ }
+
+ static JSONObject getJson(JSONObject json, String key) {
+ if (json.has(key)) {
+ Object o = json.get(key);
+ if (o instanceof JSONObject) {
+ return (JSONObject) o;
+ }
+ }
+
+ return null;
+ }
+
+ static JSONArray getJsonArr(JSONObject json, String key) {
+ if (json.has(key)) {
+ Object o = json.get(key);
+ if (o instanceof JSONArray) {
+ return (JSONArray) o;
+ }
+ }
+
+ return null;
+ }
+ }
if (sources == null && authors == null && tags == null) {
return metas;
}
-
+
// allow "sources/" hierarchy
if (sources != null) {
List<String> folders = new ArrayList<String>();
* This {@link BasicLibrary} will access a remote server to list the available
* stories, and download the ones you try to load to the local directory
* specified in the configuration.
+ * <p>
+ * This remote library uses a custom fanfix:// protocol.
*
* @author niki
*/
}
@Override
- public Object send(Object data) throws IOException,
- NoSuchFieldException, NoSuchMethodException,
+ public Object send(Object data)
+ throws IOException, NoSuchFieldException, NoSuchMethodException,
ClassNotFoundException {
Object rep = super.send(data);
if (rep instanceof RemoteLibraryException) {
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] " : "") + host + ":" + port;
+ return (rw ? "[READ-ONLY] " : "") + "fanfix://" + host + ":" + port;
}
@Override
public Status getStatus() {
- Instance.getInstance().getTraceHandler().trace("Getting remote lib status...");
+ Instance.getInstance().getTraceHandler()
+ .trace("Getting remote lib status...");
Status status = getStatusDo();
- Instance.getInstance().getTraceHandler().trace("Remote lib status: " + status);
+ Instance.getInstance().getTraceHandler()
+ .trace("Remote lib status: " + status);
return status;
}
@Override
public void action(ConnectActionClientObject action)
throws Exception {
- Object rep = action.send(new Object[] { subkey, "GET_COVER",
- luid });
+ Object rep = action
+ .send(new Object[] { subkey, "GET_COVER", luid });
result[0] = (Image) rep;
}
});
pg = new Progress();
}
- Object rep = action.send(new Object[] { subkey, "GET_STORY",
- luid });
+ Object rep = action
+ .send(new Object[] { subkey, "GET_STORY", luid });
MetaData meta = null;
if (rep instanceof MetaData) {
// Could work (more slowly) without it
public MetaData imprt(final URL url, Progress pg) throws IOException {
// Import the file locally if it is actually a file
-
+
if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
return super.imprt(url, pg);
}
throws Exception {
Progress pg = pgF;
- Object rep = action.send(new Object[] { subkey, "IMPORT",
- url.toString() });
+ Object rep = action.send(
+ new Object[] { subkey, "IMPORT", url.toString() });
while (true) {
if (!RemoteLibraryServer.updateProgress(pg, rep)) {
pg = new Progress();
}
- Object rep = action.send(new Object[] { subkey, "GET_METADATA",
- luid });
+ Object rep = action
+ .send(new Object[] { subkey, "GET_METADATA", luid });
while (true) {
if (!RemoteLibraryServer.updateProgress(pg, rep)) {
* Note: the key we use here is the encryption key (it must not contain a
* subkey).
*
- * @param key
- * the key that will restrict access to this server
- * @param port
- * the port to listen on
- *
* @throws IOException
* in case of I/O error
*/
- public RemoteLibraryServer(String key, int port) throws IOException {
- super("Fanfix remote library", port, key);
+ public RemoteLibraryServer() throws IOException {
+ super("Fanfix remote library",
+ Instance.getInstance().getConfig()
+ .getInteger(Config.SERVER_PORT),
+ Instance.getInstance().getConfig()
+ .getString(Config.SERVER_KEY));
+
setTraceHandler(Instance.getInstance().getTraceHandler());
}
--- /dev/null
+ package be.nikiroo.fanfix.library;
+
+ import java.io.File;
+ import java.io.IOException;
+ import java.io.InputStream;
+ import java.net.URL;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ import java.util.List;
+ import java.util.Map;
+
+ import org.json.JSONArray;
+ import org.json.JSONObject;
+
+ import be.nikiroo.fanfix.Instance;
+ import be.nikiroo.fanfix.data.JsonIO;
+ import be.nikiroo.fanfix.data.MetaData;
+ import be.nikiroo.fanfix.data.Story;
+ import be.nikiroo.utils.IOUtils;
+ import be.nikiroo.utils.Image;
+ import be.nikiroo.utils.Progress;
+
+ /**
+ * This {@link BasicLibrary} will access a remote server to list the available
+ * stories, and download the ones you try to load to the local directory
+ * specified in the configuration.
+ * <p>
+ * This remote library uses http:// or https://.
+ *
+ * @author niki
+ */
+ public class WebLibrary extends BasicLibrary {
+ private String host;
+ private int port;
+ private final String key;
+ private final String subkey;
+
+ // informative only (server will make the actual checks)
+ private boolean rw;
+
+ /**
+ * Create a {@link RemoteLibrary} linked to the given server.
+ * <p>
+ * Note that the key is structured:
+ * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>|<b>wl</b>)(|<b>rw</b>)</tt>
+ * <p>
+ * Note that anything before the first pipe (<tt>|</tt>) character is
+ * considered to be the encryption key, anything after that character is
+ * called the subkey (including the other pipe characters and flags!).
+ * <p>
+ * This is important because the subkey (including the pipe characters and
+ * flags) must be present as-is in the server configuration file to be
+ * allowed.
+ * <ul>
+ * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
+ * server</li>
+ * <li><b><i>yyy</i></b>: the secondary key</li>
+ * <li><b>rw</b>: flag to allow read and write access if it is not the
+ * default on this server</li>
+ * <li><b>wl</b>: flag to allow access to all the stories (bypassing the
+ * whitelist if it exists)</li>
+ * </ul>
+ * <p>
+ * Some examples:
+ * <ul>
+ * <li><b>my_key</b>: normal connection, will take the default server
+ * options</li>
+ * <li><b>my_key|agzyzz|wl</b>: will ask to bypass the white list (if it
+ * exists)</li>
+ * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
+ * is read-only)</li>
+ * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
+ * list bypass</li>
+ * </ul>
+ *
+ * @param key
+ * the key that will allow us to exchange information with the
+ * server
+ * @param host
+ * the host to contact or NULL for localhost
+ * @param port
+ * the port to contact it on
+ */
+ public WebLibrary(String key, String host, int port) {
+ int index = -1;
+ if (key != null) {
+ index = key.indexOf('|');
+ }
+
+ if (index >= 0) {
+ this.key = key.substring(0, index);
+ this.subkey = key.substring(index + 1);
+ } else {
+ this.key = key;
+ this.subkey = "";
+ }
+
+ this.rw = subkey.contains("|rw");
+
+ this.host = host;
+ this.port = port;
+
+ // TODO: not supported yet
+ this.rw = false;
+ }
+
+ @Override
+ public Status getStatus() {
+ try {
+ download("/");
+ } catch (IOException e) {
+ try {
+ download("/style.css");
+ return Status.UNAUTHORIZED;
+ } catch (IOException ioe) {
+ return Status.INVALID;
+ }
+ }
+
+ return rw ? Status.READ_WRITE : Status.READ_ONLY;
+ }
+
+ @Override
+ public String getLibraryName() {
+ return (rw ? "[READ-ONLY] " : "") + host + ":" + port;
+ }
+
+ @Override
+ public Image getCover(String luid) throws IOException {
+ InputStream in = download("/story/" + luid + "/cover");
+ if (in != null) {
+ return new Image(in);
+ }
+
+ return null;
+ }
+
+ @Override
+ public void setSourceCover(String source, String luid) throws IOException {
+ // TODO Auto-generated method stub
+ throw new IOException("Not implemented yet");
+ }
+
+ @Override
+ public void setAuthorCover(String author, String luid) throws IOException {
+ // TODO Auto-generated method stub
+ throw new IOException("Not implemented yet");
+ }
+
+ @Override
+ protected List<MetaData> getMetas(Progress pg) throws IOException {
+ List<MetaData> metas = new ArrayList<MetaData>();
+ InputStream in = download("/list/luids");
+ JSONArray jsonArr = new JSONArray(IOUtils.readSmallStream(in));
+ for (int i = 0; i < jsonArr.length(); i++) {
+ JSONObject json = jsonArr.getJSONObject(i);
+ metas.add(JsonIO.toMetaData(json));
+ }
+
+ return metas;
+ }
+
+ @Override
+ // Could work (more slowly) without it
+ public MetaData imprt(final URL url, Progress pg) throws IOException {
+ if (true)
+ throw new IOException("Not implemented yet");
+
+ // Import the file locally if it is actually a file
+
+ if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
+ return super.imprt(url, pg);
+ }
+
+ // Import it remotely if it is an URL
+
+ // TODO
+ return super.imprt(url, pg);
+ }
+
+ @Override
+ // Could work (more slowly) without it
+ protected synchronized void changeSTA(final String luid,
+ final String newSource, final String newTitle,
+ final String newAuthor, Progress pg) throws IOException {
+ // TODO
+ super.changeSTA(luid, newSource, newTitle, newAuthor, pg);
+ }
+
+ @Override
+ protected void updateInfo(MetaData meta) {
+ // Will be taken care of directly server side
+ }
+
+ @Override
+ protected void invalidateInfo(String luid) {
+ // Will be taken care of directly server side
+ }
+
+ // The following methods are only used by Save and Delete in BasicLibrary:
+
+ @Override
+ protected 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");
+ }
+
+ //
+
+ @Override
+ public File getFile(final String luid, Progress pg) {
+ throw new java.lang.InternalError(
+ "Operation not supportorted on remote Libraries");
+ }
+
+ // starts with "/"
+ private InputStream download(String path) throws IOException {
+ URL url = new URL(host + ":" + port + path);
+
+ Map<String, String> post = new HashMap<String, String>();
+ post.put("login", subkey);
+ post.put("password", key);
+
+ return Instance.getInstance().getCache().openNoCache(url, null, post,
+ null, null);
+ }
+ }
--- /dev/null
+ package be.nikiroo.fanfix.library;
+
+ import java.io.ByteArrayInputStream;
+ import java.io.File;
+ import java.io.FileInputStream;
+ import java.io.IOException;
+ import java.io.InputStream;
+ import java.security.KeyStore;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ import java.util.LinkedList;
+ import java.util.List;
+ import java.util.Map;
+
+ import javax.net.ssl.KeyManagerFactory;
+ import javax.net.ssl.SSLServerSocketFactory;
+
+ import org.json.JSONArray;
+ import org.json.JSONObject;
+
+ import be.nikiroo.fanfix.Instance;
+ import be.nikiroo.fanfix.bundles.Config;
+ import be.nikiroo.fanfix.bundles.UiConfig;
+ import be.nikiroo.fanfix.data.Chapter;
+ import be.nikiroo.fanfix.data.JsonIO;
+ import be.nikiroo.fanfix.data.MetaData;
+ import be.nikiroo.fanfix.data.Paragraph;
+ import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+ import be.nikiroo.fanfix.data.Story;
+ import be.nikiroo.fanfix.library.web.WebLibraryServerIndex;
+ import be.nikiroo.fanfix.reader.TextOutput;
+ import be.nikiroo.utils.CookieUtils;
+ import be.nikiroo.utils.IOUtils;
+ import be.nikiroo.utils.Image;
+ import be.nikiroo.utils.NanoHTTPD;
+ import be.nikiroo.utils.NanoHTTPD.IHTTPSession;
+ import be.nikiroo.utils.NanoHTTPD.Response;
+ import be.nikiroo.utils.NanoHTTPD.Response.Status;
+ import be.nikiroo.utils.TraceHandler;
+ import be.nikiroo.utils.Version;
+
+ public class WebLibraryServer implements Runnable {
+ static private String VIEWER_URL_BASE = "/view/story/";
+ static private String VIEWER_URL = VIEWER_URL_BASE + "{luid}/{chap}/{para}";
+ static private String STORY_URL_BASE = "/story/";
+ static private String STORY_URL = STORY_URL_BASE + "{luid}/{chap}/{para}";
+ static private String STORY_URL_COVER = STORY_URL_BASE + "{luid}/cover";
+ static private String LIST_URL = "/list/";
+
+ private class LoginResult {
+ private boolean success;
+ private boolean rw;
+ private boolean wl;
+ private String wookie;
+ private String token;
+ private boolean badLogin;
+ private boolean badToken;
+
+ public LoginResult(String who, String key, String subkey,
+ boolean success, boolean rw, boolean wl) {
+ this.success = success;
+ this.rw = rw;
+ this.wl = wl;
+ this.wookie = CookieUtils.generateCookie(who + key, 0);
+
+ String opts = "";
+ if (rw)
+ opts += "|rw";
+ if (!wl)
+ opts += "|wl";
+
+ this.token = wookie + "~"
+ + CookieUtils.generateCookie(wookie + subkey + opts, 0)
+ + "~" + opts;
+ this.badLogin = !success;
+ }
+
+ public LoginResult(String token, String who, String key,
+ List<String> subkeys) {
+
+ if (token != null) {
+ String hashes[] = token.split("~");
+ if (hashes.length >= 2) {
+ String wookie = hashes[0];
+ String rehashed = hashes[1];
+ String opts = hashes.length > 2 ? hashes[2] : "";
+
+ if (CookieUtils.validateCookie(who + key, wookie)) {
+ if (subkeys == null) {
+ subkeys = new ArrayList<String>();
+ }
+ subkeys = new ArrayList<String>(subkeys);
+ subkeys.add("");
+
+ for (String subkey : subkeys) {
+ if (CookieUtils.validateCookie(
+ wookie + subkey + opts, rehashed)) {
+ this.wookie = wookie;
+ this.token = token;
+ this.success = true;
+
+ this.rw = opts.contains("|rw");
+ this.wl = !opts.contains("|wl");
+ }
+ }
+ }
+ }
+
+ this.badToken = !success;
+ }
+
+ // No token -> no bad token
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public boolean isRw() {
+ return rw;
+ }
+
+ public boolean isWl() {
+ return wl;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public boolean isBadLogin() {
+ return badLogin;
+ }
+
+ public boolean isBadToken() {
+ return badToken;
+ }
+ }
+
+ private NanoHTTPD server;
+ private Map<String, Story> storyCache = new HashMap<String, Story>();
+ private LinkedList<String> storyCacheOrder = new LinkedList<String>();
+ private long storyCacheSize = 0;
+ private long maxStoryCacheSize;
+ private TraceHandler tracer = new TraceHandler();
+
+ public WebLibraryServer(boolean secure) throws IOException {
+ Integer port = Instance.getInstance().getConfig()
+ .getInteger(Config.SERVER_PORT);
+ if (port == null) {
+ throw new IOException(
+ "Cannot start web server: port not specified");
+ }
+
+ int cacheMb = Instance.getInstance().getConfig()
+ .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
+ maxStoryCacheSize = cacheMb * 1024 * 1024;
+
+ setTraceHandler(Instance.getInstance().getTraceHandler());
+
+ SSLServerSocketFactory ssf = null;
+ if (secure) {
+ String keystorePath = Instance.getInstance().getConfig()
+ .getString(Config.SERVER_SSL_KEYSTORE, "");
+ String keystorePass = Instance.getInstance().getConfig()
+ .getString(Config.SERVER_SSL_KEYSTORE_PASS);
+
+ if (secure && keystorePath.isEmpty()) {
+ throw new IOException(
+ "Cannot start a secure web server: no keystore.jks file povided");
+ }
+
+ if (!keystorePath.isEmpty()) {
+ File keystoreFile = new File(keystorePath);
+ try {
+ KeyStore keystore = KeyStore
+ .getInstance(KeyStore.getDefaultType());
+ InputStream keystoreStream = new FileInputStream(
+ keystoreFile);
+ try {
+ keystore.load(keystoreStream,
+ keystorePass.toCharArray());
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory
+ .getInstance(KeyManagerFactory
+ .getDefaultAlgorithm());
+ keyManagerFactory.init(keystore,
+ keystorePass.toCharArray());
+ ssf = NanoHTTPD.makeSSLSocketFactory(keystore,
+ keyManagerFactory);
+ } finally {
+ keystoreStream.close();
+ }
+ } catch (Exception e) {
+ throw new IOException(e.getMessage());
+ }
+ }
+ }
+
+ server = new NanoHTTPD(port) {
+ @Override
+ public Response serve(final IHTTPSession session) {
+ super.serve(session);
+
+ String query = session.getQueryParameterString(); // a=a%20b&dd=2
+ Method method = session.getMethod(); // GET, POST..
+ String uri = session.getUri(); // /home.html
+
+ // need them in real time (not just those sent by the UA)
+ Map<String, String> cookies = new HashMap<String, String>();
+ for (String cookie : session.getCookies()) {
+ cookies.put(cookie, session.getCookies().read(cookie));
+ }
+
+ List<String> whitelist = Instance.getInstance().getConfig()
+ .getList(Config.SERVER_WHITELIST);
+ if (whitelist == null) {
+ whitelist = new ArrayList<String>();
+ }
+
+ LoginResult login = null;
+ Map<String, String> params = session.getParms();
+ String who = session.getRemoteHostName()
+ + session.getRemoteIpAddress();
+ if (params.get("login") != null) {
+ login = login(who, params.get("password"),
+ params.get("login"), whitelist);
+ } else {
+ String token = cookies.get("token");
+ login = login(who, token, Instance.getInstance().getConfig()
+ .getList(Config.SERVER_ALLOWED_SUBKEYS));
+ }
+
+ if (login.isSuccess()) {
+ // refresh token
+ session.getCookies().set(new Cookie("token",
+ login.getToken(), "30; path=/"));
+
+ // set options
+ String optionName = params.get("optionName");
+ if (optionName != null && !optionName.isEmpty()) {
+ String optionValue = params.get("optionValue");
+ if (optionValue == null || optionValue.isEmpty()) {
+ session.getCookies().delete(optionName);
+ cookies.remove(optionName);
+ } else {
+ session.getCookies().set(new Cookie(optionName,
+ optionValue, "; path=/"));
+ cookies.put(optionName, optionValue);
+ }
+ }
+ }
+
+ Response rep = null;
+ if (!login.isSuccess() && (uri.equals("/") //
+ || uri.startsWith(STORY_URL_BASE) //
+ || uri.startsWith(VIEWER_URL_BASE) //
+ || uri.startsWith(LIST_URL))) {
+ rep = loginPage(login, uri);
+ }
+
+ if (rep == null) {
+ try {
+ if (uri.equals("/")) {
+ rep = root(session, cookies, whitelist);
+ } else if (uri.startsWith(LIST_URL)) {
+ rep = getList(uri, whitelist);
+ } else if (uri.startsWith(STORY_URL_BASE)) {
+ rep = getStoryPart(uri, whitelist);
+ } else if (uri.startsWith(VIEWER_URL_BASE)) {
+ rep = getViewer(cookies, uri, whitelist);
+ } else if (uri.equals("/logout")) {
+ session.getCookies().delete("token");
+ cookies.remove("token");
+ rep = loginPage(login, uri);
+ } else {
+ if (uri.startsWith("/"))
+ uri = uri.substring(1);
+ InputStream in = IOUtils.openResource(
+ WebLibraryServerIndex.class, uri);
+ if (in != null) {
+ String mimeType = MIME_PLAINTEXT;
+ if (uri.endsWith(".css")) {
+ mimeType = "text/css";
+ } else if (uri.endsWith(".html")) {
+ mimeType = "text/html";
+ } else if (uri.endsWith(".js")) {
+ mimeType = "text/javascript";
+ }
+ rep = newChunkedResponse(Status.OK, mimeType,
+ in);
+ } else {
+ getTraceHandler().trace("404: " + uri);
+ }
+ }
+
+ if (rep == null) {
+ rep = newFixedLengthResponse(Status.NOT_FOUND,
+ NanoHTTPD.MIME_PLAINTEXT, "Not Found");
+ }
+ } catch (Exception e) {
+ Instance.getInstance().getTraceHandler().error(
+ new IOException("Cannot process web request",
+ e));
+ rep = newFixedLengthResponse(Status.INTERNAL_ERROR,
+ NanoHTTPD.MIME_PLAINTEXT, "An error occured");
+ }
+ }
+
+ return rep;
+
+ // Get status: for story, use "luid" + active map of current
+ // luids
+ // map must use a addRef/removeRef and delete at 0
+
+ // http://localhost:2000/?token=ok
+
+ //
+ // MetaData meta = new MetaData();
+ // meta.setTitle("Title");
+ // meta.setLuid("000");
+ //
+ // JSONObject json = new JSONObject();
+ // json.put("", MetaData.class.getName());
+ // json.put("title", meta.getTitle());
+ // json.put("luid", meta.getLuid());
+ //
+ // return newFixedLengthResponse(json.toString());
+ }
+ };
+
+ if (ssf != null) {
+ getTraceHandler().trace("Install SSL on the web server...");
+ server.makeSecure(ssf, null);
+ getTraceHandler().trace("Done.");
+ }
+ }
+
+ @Override
+ public void run() {
+ try {
+ server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
+ } catch (IOException e) {
+ tracer.error(new IOException("Cannot start the web server", e));
+ }
+ }
+
+ /**
+ * Start the server (listen on the network for new connections).
+ * <p>
+ * Can only be called once.
+ * <p>
+ * This call is asynchronous, and will just start a new {@link Thread} on
+ * itself (see {@link WebLibraryServer#run()}).
+ */
+ public void start() {
+ new Thread(this).start();
+ }
+
+ /**
+ * The traces handler for this {@link WebLibraryServer}.
+ *
+ * @return the traces handler
+ */
+ public TraceHandler getTraceHandler() {
+ return tracer;
+ }
+
+ /**
+ * The traces handler for this {@link WebLibraryServer}.
+ *
+ * @param tracer
+ * the new traces handler
+ */
+ public void setTraceHandler(TraceHandler tracer) {
+ if (tracer == null) {
+ tracer = new TraceHandler(false, false, false);
+ }
+
+ this.tracer = tracer;
+ }
+
+ private LoginResult login(String who, String token, List<String> subkeys) {
+ String realKey = Instance.getInstance().getConfig()
+ .getString(Config.SERVER_KEY);
+ realKey = realKey == null ? "" : realKey;
+ return new LoginResult(token, who, realKey, subkeys);
+ }
+
+ // allow rw/wl
+ private LoginResult login(String who, String key, String subkey,
+ List<String> whitelist) {
+ String realKey = Instance.getInstance().getConfig()
+ .getString(Config.SERVER_KEY);
+
+ // I don't like NULLs...
+ realKey = realKey == null ? "" : realKey;
+ key = key == null ? "" : key;
+ subkey = subkey == null ? "" : subkey;
+
+ if (!realKey.equals(key)) {
+ return new LoginResult(null, null, null, false, false, false);
+ }
+
+ // defaults are positive (as previous versions without the feature)
+ boolean rw = true;
+ boolean wl = true;
+
+ if (whitelist.isEmpty()) {
+ wl = false;
+ }
+
+ rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
+ rw);
+ if (!subkey.isEmpty()) {
+ List<String> allowed = Instance.getInstance().getConfig()
+ .getList(Config.SERVER_ALLOWED_SUBKEYS);
+ if (allowed != null && allowed.contains(subkey)) {
+ if ((subkey + "|").contains("|rw|")) {
+ rw = true;
+ }
+ if ((subkey + "|").contains("|wl|")) {
+ wl = false; // |wl| = bypass whitelist
+ }
+ } else {
+ return new LoginResult(null, null, null, false, false, false);
+ }
+ }
+
+ return new LoginResult(who, key, subkey, true, rw, wl);
+ }
+
+ private Response loginPage(LoginResult login, String uri) {
+ StringBuilder builder = new StringBuilder();
+
+ appendPreHtml(builder, true);
+
+ if (login.isBadLogin()) {
+ builder.append("<div class='error'>Bad login or password</div>");
+ } else if (login.isBadToken()) {
+ builder.append("<div class='error'>Your session timed out</div>");
+ }
+
+ if (uri.equals("/logout")) {
+ uri = "/";
+ }
+
+ builder.append(
+ "<form method='POST' action='" + uri + "' class='login'>\n");
+ builder.append(
+ "<p>You must be logged into the system to see the stories.</p>");
+ builder.append("\t<input type='text' name='login' />\n");
+ builder.append("\t<input type='password' name='password' />\n");
+ builder.append("\t<input type='submit' value='Login' />\n");
+ builder.append("</form>\n");
+
+ appendPostHtml(builder);
+
+ return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
+ NanoHTTPD.MIME_HTML, builder.toString());
+ }
+
+ protected Response getList(String uri, List<String> whitelist)
+ throws IOException {
+ if (uri.equals("/list/luids")) {
+ BasicLibrary lib = Instance.getInstance().getLibrary();
+ List<MetaData> metas = lib.getList().filter(whitelist, null, null);
+ List<JSONObject> jsons = new ArrayList<JSONObject>();
+ for (MetaData meta : metas) {
+ jsons.add(JsonIO.toJson(meta));
+ }
+
+ return newInputStreamResponse("application/json",
+ new ByteArrayInputStream(
+ new JSONArray(jsons).toString().getBytes()));
+ }
+
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, null);
+ }
+
+ private Response root(IHTTPSession session, Map<String, String> cookies,
+ List<String> whitelist) throws IOException {
+ BasicLibrary lib = Instance.getInstance().getLibrary();
+ MetaResultList result = lib.getList();
+ result = new MetaResultList(result.filter(whitelist, null, null));
+ StringBuilder builder = new StringBuilder();
+
+ appendPreHtml(builder, true);
+
+ String filter = cookies.get("filter");
+ if (filter == null) {
+ filter = "";
+ }
+
+ Map<String, String> params = session.getParms();
+ String browser = params.get("browser") == null ? ""
+ : params.get("browser");
+ String browser2 = params.get("browser2") == null ? ""
+ : params.get("browser2");
+ String browser3 = params.get("browser3") == null ? ""
+ : params.get("browser3");
+
+ String filterSource = null;
+ String filterAuthor = null;
+ String filterTag = null;
+
+ // TODO: javascript in realtime, using visible=false + hide [submit]
+
+ builder.append("<form class='browser'>\n");
+ builder.append("<div class='breadcrumbs'>\n");
+
+ builder.append("\t<select name='browser'>");
+ appendOption(builder, 2, "", "", browser);
+ appendOption(builder, 2, "Sources", "sources", browser);
+ appendOption(builder, 2, "Authors", "authors", browser);
+ appendOption(builder, 2, "Tags", "tags", browser);
+ builder.append("\t</select>\n");
+
+ if (!browser.isEmpty()) {
+ builder.append("\t<select name='browser2'>");
+ if (browser.equals("sources")) {
+ filterSource = browser2.isEmpty() ? filterSource : browser2;
+ // TODO: if 1 group -> no group
+ appendOption(builder, 2, "", "", browser2);
+ Map<String, List<String>> sources = result.getSourcesGrouped();
+ for (String source : sources.keySet()) {
+ appendOption(builder, 2, source, source, browser2);
+ }
+ } else if (browser.equals("authors")) {
+ filterAuthor = browser2.isEmpty() ? filterAuthor : browser2;
+ // TODO: if 1 group -> no group
+ appendOption(builder, 2, "", "", browser2);
+ Map<String, List<String>> authors = result.getAuthorsGrouped();
+ for (String author : authors.keySet()) {
+ appendOption(builder, 2, author, author, browser2);
+ }
+ } else if (browser.equals("tags")) {
+ filterTag = browser2.isEmpty() ? filterTag : browser2;
+ appendOption(builder, 2, "", "", browser2);
+ for (String tag : result.getTags()) {
+ appendOption(builder, 2, tag, tag, browser2);
+ }
+ }
+ builder.append("\t</select>\n");
+ }
+
+ if (!browser2.isEmpty()) {
+ if (browser.equals("sources")) {
+ filterSource = browser3.isEmpty() ? filterSource : browser3;
+ Map<String, List<String>> sourcesGrouped = result
+ .getSourcesGrouped();
+ List<String> sources = sourcesGrouped.get(browser2);
+ if (sources != null && !sources.isEmpty()) {
+ // TODO: single empty value
+ builder.append("\t<select name='browser3'>");
+ appendOption(builder, 2, "", "", browser3);
+ for (String source : sources) {
+ appendOption(builder, 2, source, source, browser3);
+ }
+ builder.append("\t</select>\n");
+ }
+ } else if (browser.equals("authors")) {
+ filterAuthor = browser3.isEmpty() ? filterAuthor : browser3;
+ Map<String, List<String>> authorsGrouped = result
+ .getAuthorsGrouped();
+ List<String> authors = authorsGrouped.get(browser2);
+ if (authors != null && !authors.isEmpty()) {
+ // TODO: single empty value
+ builder.append("\t<select name='browser3'>");
+ appendOption(builder, 2, "", "", browser3);
+ for (String author : authors) {
+ appendOption(builder, 2, author, author, browser3);
+ }
+ builder.append("\t</select>\n");
+ }
+ }
+ }
+
+ builder.append("\t<input type='submit' value='Select'/>\n");
+ builder.append("</div>\n");
+
+ // TODO: javascript in realtime, using visible=false + hide [submit]
+ builder.append("<div class='filter'>\n");
+ builder.append("\tFilter: \n");
+ builder.append(
+ "\t<input name='optionName' type='hidden' value='filter' />\n");
+ builder.append("\t<input name='optionValue' type='text' value='"
+ + filter + "' place-holder='...' />\n");
+ builder.append(
+ "\t<input name='submit' type='submit' value='Filter' />\n");
+ builder.append("</div>\n");
+ builder.append("</form>\n");
+
+ builder.append("\t<div class='books'>");
+ for (MetaData meta : result.getMetas()) {
+ if (!filter.isEmpty() && !meta.getTitle().toLowerCase()
+ .contains(filter.toLowerCase())) {
+ continue;
+ }
+
+ // TODO Sub sources
+ if (filterSource != null
+ && !filterSource.equals(meta.getSource())) {
+ continue;
+ }
+
+ // TODO: sub authors
+ if (filterAuthor != null
+ && !filterAuthor.equals(meta.getAuthor())) {
+ continue;
+ }
+
+ if (filterTag != null && !meta.getTags().contains(filterTag)) {
+ continue;
+ }
+
+ builder.append("<div class='book_line'>");
+ builder.append("<a href='");
+ builder.append(getViewUrl(meta.getLuid(), 0, null));
+ builder.append("'");
+ builder.append(" class='link'>");
+
+ if (lib.isCached(meta.getLuid())) {
+ // â—‰ = ◉
+ builder.append(
+ "<span class='cache_icon cached'>◉</span>");
+ } else {
+ // â—‹ = ○
+ builder.append(
+ "<span class='cache_icon uncached'>○</span>");
+ }
+ builder.append("<span class='luid'>");
+ builder.append(meta.getLuid());
+ builder.append("</span>");
+ builder.append("<span class='title'>");
+ builder.append(meta.getTitle());
+ builder.append("</span>");
+ builder.append("<span class='author'>");
+ if (meta.getAuthor() != null && !meta.getAuthor().isEmpty()) {
+ builder.append("(").append(meta.getAuthor()).append(")");
+ }
+ builder.append("</span>");
+ builder.append("</a></div>\n");
+ }
+ builder.append("</div>");
+
+ appendPostHtml(builder);
+ return NanoHTTPD.newFixedLengthResponse(builder.toString());
+ }
+
+ // /story/luid/chapter/para <-- text/image
+ // /story/luid/cover <-- image
+ // /story/luid/metadata <-- json
+ private Response getStoryPart(String uri, List<String> whitelist) {
+ String[] cover = uri.split("/");
+ int off = 2;
+
+ if (cover.length < off + 2) {
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, null);
+ }
+
+ String luid = cover[off + 0];
+ String chapterStr = cover[off + 1];
+ String imageStr = cover.length < off + 3 ? null : cover[off + 2];
+
+ // 1-based (0 = desc)
+ int chapter = 0;
+ if (chapterStr != null && !"cover".equals(chapterStr)
+ && !"metadata".equals(chapterStr)) {
+ try {
+ chapter = Integer.parseInt(chapterStr);
+ if (chapter < 0) {
+ throw new NumberFormatException();
+ }
+ } catch (NumberFormatException e) {
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
+ }
+ }
+
+ // 1-based
+ int paragraph = 1;
+ if (imageStr != null) {
+ try {
+ paragraph = Integer.parseInt(imageStr);
+ if (paragraph < 0) {
+ throw new NumberFormatException();
+ }
+ } catch (NumberFormatException e) {
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
+ }
+ }
+
+ String mimeType = NanoHTTPD.MIME_PLAINTEXT;
+ InputStream in = null;
+ try {
+ if ("cover".equals(chapterStr)) {
+ Image img = getCover(luid, whitelist);
+ if (img != null) {
+ in = img.newInputStream();
+ }
+ } else if ("metadata".equals(chapterStr)) {
+ MetaData meta = meta(luid, whitelist);
+ JSONObject json = JsonIO.toJson(meta);
+ mimeType = "application/json";
+ in = new ByteArrayInputStream(json.toString().getBytes());
+ } else {
+ Story story = story(luid, whitelist);
+ if (story != null) {
+ if (chapter == 0) {
+ StringBuilder builder = new StringBuilder();
+ for (Paragraph p : story.getMeta().getResume()) {
+ if (builder.length() == 0) {
+ builder.append("\n");
+ }
+ builder.append(p.getContent());
+ }
+
+ in = new ByteArrayInputStream(
+ builder.toString().getBytes("utf-8"));
+ } else {
+ Paragraph para = story.getChapters().get(chapter - 1)
+ .getParagraphs().get(paragraph - 1);
+ Image img = para.getContentImage();
+ if (para.getType() == ParagraphType.IMAGE) {
+ // TODO: get correct image type
+ mimeType = "image/png";
+ in = img.newInputStream();
+ } else {
+ in = new ByteArrayInputStream(
+ para.getContent().getBytes("utf-8"));
+ }
+ }
+ }
+ }
+ } catch (IndexOutOfBoundsException e) {
+ return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+ NanoHTTPD.MIME_PLAINTEXT,
+ "Chapter or paragraph does not exist");
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException("Cannot get image: " + uri, e));
+ return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
+ NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
+ }
+
+ return newInputStreamResponse(mimeType, in);
+ }
+
+ private Response getViewer(Map<String, String> cookies, String uri,
+ List<String> whitelist) {
+ String[] cover = uri.split("/");
+ int off = 2;
+
+ if (cover.length < off + 2) {
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, null);
+ }
+
+ String type = cover[off + 0];
+ String luid = cover[off + 1];
+ String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
+ String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
+
+ // 1-based (0 = desc)
+ int chapter = -1;
+ if (chapterStr != null) {
+ try {
+ chapter = Integer.parseInt(chapterStr);
+ if (chapter < 0) {
+ throw new NumberFormatException();
+ }
+ } catch (NumberFormatException e) {
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
+ }
+ }
+
+ // 1-based
+ int paragraph = 0;
+ if (paragraphStr != null) {
+ try {
+ paragraph = Integer.parseInt(paragraphStr);
+ if (paragraph <= 0) {
+ throw new NumberFormatException();
+ }
+ } catch (NumberFormatException e) {
+ return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
+ }
+ }
+
+ try {
+ Story story = story(luid, whitelist);
+ if (story == null) {
+ return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+ NanoHTTPD.MIME_PLAINTEXT, "Story not found");
+ }
+
+ StringBuilder builder = new StringBuilder();
+ appendPreHtml(builder, false);
+
+ if (chapter < 0) {
+ builder.append(story);
+ } else {
+ if (chapter == 0) {
+ // TODO: description
+ chapter = 1;
+ }
+
+ Chapter chap = null;
+ try {
+ chap = story.getChapters().get(chapter - 1);
+ } catch (IndexOutOfBoundsException e) {
+ return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+ NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
+ }
+
+ if (story.getMeta().isImageDocument() && paragraph <= 0) {
+ paragraph = 1;
+ }
+
+ String first, previous, next, last;
+ String content;
+
+ if (paragraph <= 0) {
+ first = getViewUrl(luid, 1, null);
+ previous = getViewUrl(luid, (Math.max(chapter - 1, 1)),
+ null);
+ next = getViewUrl(luid,
+ (Math.min(chapter + 1, story.getChapters().size())),
+ null);
+ last = getViewUrl(luid, story.getChapters().size(), null);
+
+ content = "<div class='viewer text'>\n"
+ + new TextOutput(false).convert(chap, true)
+ + "</div>\n";
+ } else {
+ first = getViewUrl(luid, chapter, 1);
+ previous = getViewUrl(luid, chapter,
+ (Math.max(paragraph - 1, 1)));
+ next = getViewUrl(luid, chapter, (Math.min(paragraph + 1,
+ chap.getParagraphs().size())));
+ last = getViewUrl(luid, chapter,
+ chap.getParagraphs().size());
+
+ Paragraph para = null;
+ try {
+ para = chap.getParagraphs().get(paragraph - 1);
+ } catch (IndexOutOfBoundsException e) {
+ return NanoHTTPD.newFixedLengthResponse(
+ Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+ "Paragraph not found");
+ }
+
+ if (para.getType() == ParagraphType.IMAGE) {
+ String zoomStyle = "max-width: 100%;";
+ String zoomOption = cookies.get("zoom");
+ if (zoomOption != null && !zoomOption.isEmpty()) {
+ if (zoomOption.equals("real")) {
+ zoomStyle = "";
+ } else if (zoomOption.equals("width")) {
+ zoomStyle = "max-width: 100%;";
+ } else if (zoomOption.equals("height")) {
+ // see height of navbar + optionbar
+ zoomStyle = "max-height: calc(100% - 128px);";
+ }
+ }
+ content = String.format("" //
+ + "<a class='viewer link' href='%s'>" //
+ + "<img class='viewer img' style='%s' src='%s'/>"
+ + "</a>", //
+ next, //
+ zoomStyle, //
+ getStoryUrl(luid, chapter, paragraph));
+ } else {
+ content = para.getContent();
+ }
+
+ }
+
+ builder.append(String.format("" //
+ + "<div class='bar navbar'>\n" //
+ + "\t<a class='button first' href='%s'><<</a>\n"//
+ + "\t<a class='button previous' href='%s'><</a>\n"//
+ + "\t<a class='button next' href='%s'>></a>\n"//
+ + "\t<a class='button last' href='%s'>>></a>\n"//
+ + "</div>\n" //
+ + "%s", //
+ first, //
+ previous, //
+ next, //
+ last, //
+ content //
+ ));
+
+ builder.append("<div class='bar optionbar ");
+ if (paragraph > 0) {
+ builder.append("s4");
+ } else {
+ builder.append("s1");
+ }
+ builder.append("'>\n");
+ builder.append(
+ " <a class='button back' href='/'>BACK</a>\n");
+
+ if (paragraph > 0) {
+ builder.append(String.format("" //
+ + "\t<a class='button zoomreal' href='%s'>REAL</a>\n"//
+ + "\t<a class='button zoomwidth' href='%s'>WIDTH</a>\n"//
+ + "\t<a class='button zoomheight' href='%s'>HEIGHT</a>\n"//
+ + "</div>\n", //
+ uri + "?optionName=zoom&optionValue=real", //
+ uri + "?optionName=zoom&optionValue=width", //
+ uri + "?optionName=zoom&optionValue=height" //
+ ));
+ }
+ }
+
+ appendPostHtml(builder);
+ return NanoHTTPD.newFixedLengthResponse(Status.OK,
+ NanoHTTPD.MIME_HTML, builder.toString());
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler()
+ .error(new IOException("Cannot get image: " + uri, e));
+ return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
+ NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
+ }
+ }
+
+ private Response newInputStreamResponse(String mimeType, InputStream in) {
+ if (in == null) {
+ return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
+ null);
+ }
+ return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
+ }
+
+ private String getContentOf(String file) {
+ InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
+ file);
+ if (in != null) {
+ try {
+ return IOUtils.readSmallStream(in);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(
+ new IOException("Cannot get file: index.pre.html", e));
+ }
+ }
+
+ return "";
+ }
+
+ private String getViewUrl(String luid, int chap, Integer para) {
+ return VIEWER_URL //
+ .replace("{luid}", luid) //
+ .replace("{chap}", Integer.toString(chap)) //
+ .replace("/{para}",
+ para == null ? "" : "/" + Integer.toString(para));
+ }
+
+ private String getStoryUrl(String luid, int chap, Integer para) {
+ return STORY_URL //
+ .replace("{luid}", luid) //
+ .replace("{chap}", Integer.toString(chap)) //
+ .replace("{para}", para == null ? "" : Integer.toString(para));
+ }
+
+ private String getStoryUrlCover(String luid) {
+ return STORY_URL_COVER //
+ .replace("{luid}", luid);
+ }
+
+ private MetaData meta(String luid, List<String> whitelist)
+ throws IOException {
+ BasicLibrary lib = Instance.getInstance().getLibrary();
+ MetaData meta = lib.getInfo(luid);
+ if (!whitelist.isEmpty() && !whitelist.contains(meta.getSource())) {
+ return null;
+ }
+
+ return meta;
+ }
+
+ private Image getCover(String luid, List<String> whitelist)
+ throws IOException {
+ MetaData meta = meta(luid, whitelist);
+ if (meta != null) {
+ return meta.getCover();
+ }
+
+ return null;
+ }
+
+ // NULL if not whitelist OK or if not found
+ private Story story(String luid, List<String> whitelist)
+ throws IOException {
+ synchronized (storyCache) {
+ if (storyCache.containsKey(luid)) {
+ Story story = storyCache.get(luid);
+ if (!whitelist.isEmpty()
+ && !whitelist.contains(story.getMeta().getSource())) {
+ return null;
+ }
+
+ return story;
+ }
+ }
+
+ Story story = null;
+ MetaData meta = meta(luid, whitelist);
+ if (meta != null) {
+ BasicLibrary lib = Instance.getInstance().getLibrary();
+ story = lib.getStory(luid, null);
+ long size = sizeOf(story);
+
+ synchronized (storyCache) {
+ // Could have been added by another request
+ if (!storyCache.containsKey(luid)) {
+ while (!storyCacheOrder.isEmpty()
+ && storyCacheSize + size > maxStoryCacheSize) {
+ String oldestLuid = storyCacheOrder.removeFirst();
+ Story oldestStory = storyCache.remove(oldestLuid);
+ maxStoryCacheSize -= sizeOf(oldestStory);
+ }
+
+ storyCacheOrder.add(luid);
+ storyCache.put(luid, story);
+ }
+ }
+ }
+
+ return story;
+ }
+
+ private long sizeOf(Story story) {
+ long size = 0;
+ for (Chapter chap : story) {
+ for (Paragraph para : chap) {
+ if (para.getType() == ParagraphType.IMAGE) {
+ size += para.getContentImage().getSize();
+ } else {
+ size += para.getContent().length();
+ }
+ }
+ }
+
+ return size;
+ }
+
+ private void appendPreHtml(StringBuilder builder, boolean banner) {
+ String favicon = "favicon.ico";
+ String icon = Instance.getInstance().getUiConfig()
+ .getString(UiConfig.PROGRAM_ICON);
+ if (icon != null) {
+ favicon = "icon_" + icon.replace("-", "_") + ".png";
+ }
+
+ builder.append(
+ getContentOf("index.pre.html").replace("favicon.ico", favicon));
+
+ if (banner) {
+ builder.append("<div class='banner'>\n");
+ builder.append("\t<img class='ico' src='") //
+ .append(favicon) //
+ .append("'/>\n");
+ builder.append("\t<h1>Fanfix</h1>\n");
+ builder.append("\t<h2>") //
+ .append(Version.getCurrentVersion()) //
+ .append("</h2>\n");
+ builder.append("</div>\n");
+ }
+ }
+
+ private void appendPostHtml(StringBuilder builder) {
+ builder.append(getContentOf("index.post.html"));
+ }
+
+ private void appendOption(StringBuilder builder, int depth, String name,
+ String value, String selected) {
+ for (int i = 0; i < depth; i++) {
+ builder.append("\t");
+ }
+ builder.append("<option value='").append(value).append("'");
+ if (value.equals(selected)) {
+ builder.append(" selected='selected'");
+ }
+ builder.append(">").append(name).append("</option>\n");
+ }
+ }
--- /dev/null
+ package be.nikiroo.fanfix.library.web;
+
+ public class WebLibraryServerIndex {
+ }
--- /dev/null
+ </div>
+ </body>
--- /dev/null
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <!--
+ Copyright 2020 David ROULET
+
+ This file is part of fanfix.
+
+ fanfix is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ fanfix is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with fanfix. If not, see <https://www.gnu.org/licenses/>.
+ ___________________________________________________________________________
+
+ This website was coded by:
+ A kangaroo.
+ _ _
+ (\\( \
+ `.\-.)
+ _...._ _,-" `-.
+ \ ," `-._.- -.,-" . \
+ \`. ," `.
+ \ `-...__ / . .: y
+ `._ ``-...__ / ,"```-._/
+ `-._ ```-" | /_ //
+ `.._ _ ; <_ \ //
+ ``-.___ `. `-._ \ \ //
+ `- < `. (\ _/)/ `.\/ //
+ \ \ ` ^^^^^^^^^
+ ___________________________________________________________________________
+
+ -->
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Fanfix</title>
+ <link rel="stylesheet" type="text/css" href="/style.css" />
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+ </head>
+ <body>
+ <div class='main'>
--- /dev/null
+ 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'); }
--- /dev/null
+ package be.nikiroo.fanfix.reader;
+
+ import java.io.IOException;
+ import java.util.Arrays;
+
+ import be.nikiroo.fanfix.Instance;
+ import be.nikiroo.fanfix.data.Chapter;
+ import be.nikiroo.fanfix.data.Paragraph;
+ import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+ import be.nikiroo.fanfix.data.Story;
+ import be.nikiroo.fanfix.output.BasicOutput;
+
+ /**
+ * This class can export a chapter into HTML3 code ready for Java Swing support.
+ *
+ * @author niki
+ */
+ public class TextOutput {
+ private StringBuilder builder;
+ private BasicOutput output;
+ private Story fakeStory;
+ private boolean chapterName;
+
+ /**
+ * Create a new {@link TextOutput} that will convert a {@link Chapter} into
+ * HTML3 suited for Java Swing.
+ *
+ * @param standalone
+ * TRUE if you want a standalone document (with an <HTML> tag)
+ */
+ public TextOutput(final boolean standalone) {
+ builder = new StringBuilder();
+ fakeStory = new Story();
+
+ output = new BasicOutput() {
+ private boolean paraInQuote;
+
+ @Override
+ protected void writeChapterHeader(Chapter chap) throws IOException {
+ if (standalone) {
+ builder.append("<HTML style='line-height: 5px;'>");
+ }
+
+ if (chapterName) {
+ builder.append("<H1>");
+ builder.append("Chapter ");
+ builder.append(chap.getNumber());
+ if (chap.getName() != null
+ && !chap.getName().trim().isEmpty()) {
+ builder.append(": ");
+ builder.append(chap.getName());
+ }
+ builder.append("</H1>");
+ }
+
+ builder.append("<DIV align='justify'>");
+ }
+
+ @Override
+ protected void writeChapterFooter(Chapter chap) throws IOException {
+ if (paraInQuote) {
+ builder.append("</DIV>");
+ }
+ paraInQuote = false;
+
+ builder.append("</DIV>");
+
+ if (standalone) {
+ builder.append("</HTML>");
+ }
+ }
+
+ @Override
+ protected void writeParagraph(Paragraph para) throws IOException {
+ if ((para.getType() == ParagraphType.QUOTE) == !paraInQuote) {
+ paraInQuote = !paraInQuote;
+ if (paraInQuote) {
+ builder.append("<BR>");
+ builder.append("<DIV>");
+ } else {
+ builder.append("</DIV>");
+ builder.append("<BR>");
+ }
+ }
+
+ switch (para.getType()) {
+ case NORMAL:
+ builder.append(" ");
+ builder.append(decorateText(para.getContent()));
+ builder.append("<BR>");
+ break;
+ case BLANK:
+ builder.append("<FONT SIZE='1'><BR></FONT>");
+ break;
+ case BREAK:
+ // Used to be 7777DD
+ builder.append("<P COLOR='#AAAAAA' ALIGN='CENTER'>");
+ builder.append("<FONT SIZE='5'>* * *</FONT>");
+ builder.append("</P>");
+ builder.append("<BR>");
+ break;
+ case QUOTE:
+ builder.append("<DIV>");
+ builder.append(" ");
+ builder.append("— ");
+ builder.append(decorateText(para.getContent()));
+ builder.append("</DIV>");
+
+ break;
+ case IMAGE:
+ }
+ }
+
+ @Override
+ protected String enbold(String word) {
+ // Used to be COLOR='#7777DD'
+ return "<B>" + word + "</B>";
+ }
+
+ @Override
+ protected String italize(String word) {
+ return "<I COLOR='GRAY'>" + word + "</I>";
+ }
+ };
+ }
+
+ /**
+ * Convert the chapter into HTML3 code.
+ *
+ * @param chap
+ * the {@link Chapter} to convert
+ * @param chapterName
+ * display the chapter name
+ *
+ * @return HTML3 code tested with Java Swing
+ */
+ public String convert(Chapter chap, boolean chapterName) {
+ this.chapterName = chapterName;
+ builder.setLength(0);
+ try {
+ fakeStory.setChapters(Arrays.asList(chap));
+ output.process(fakeStory, null, null);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+ return builder.toString();
+ }
+ }