From: Niki Roo Date: Sat, 18 May 2019 16:20:34 +0000 (+0200) Subject: merge android branch (specs) X-Git-Url: http://git.nikiroo.be/?p=fanfix.git;a=commitdiff_plain;h=1feb447473d8176c3a218cc209c681fb41d468d1;hp=9978a56a375a8f6f456f0bfc7d1124d9281eea49 merge android branch (specs) --- diff --git a/Makefile.base b/Makefile.base index 586c38e..0d365b8 100644 --- a/Makefile.base +++ b/Makefile.base @@ -6,6 +6,9 @@ # - 1.2.0: add 'apk' # - 1.2.1: improve 'apk' and add 'android' # - 1.3.0: add 'man' for man(ual) pages +# - 1.4.0: remove android stuff (not working anyway) +# - 1.5.0: include sources and readme/changelog in jar +# - 1.5.1: include binaries from libs/bin/ into the jar # Required parameters (the commented out ones are supposed to be per project): @@ -18,9 +21,6 @@ #SJAR_FLAGS += a list of things to pack, each usually prefixed with "-C src/", # for *-sources.jar files #TEST_PARAMS = any parameter to pass to the test runnable when "test-run" -#ID_FOR_ANDROID = id of activity to launch for Android -#RM_FOR_ANDROID = packages (if it ends with /) or classes to ignore for APK -# generation JAVAC = javac JAVAC_FLAGS += -encoding UTF-8 -d ./bin/ -cp ./src/ @@ -48,12 +48,10 @@ help: @echo " make run-test : to run the test program from the binaries" @echo " make jrun : to run the program from the jar file" @echo " make install : to install the application into $$PREFIX" - @echo " make android : to prepare the sources in android/ for Studio" - @echo " make apk : to compile the APK file" @echo " make ifman : to make the manual pages (if pandoc is found)" @echo " make man : to make the manual pages (requires pandoc)" -.PHONY: all clean mrproper mrpropre build run jrun jar sjar resources test-resources install libs love apk android ifman man +.PHONY: all clean mrproper mrpropre build run jrun jar sjar resources test-resources install libs ifman man love bin: @mkdir -p bin @@ -83,8 +81,6 @@ test: test-resources clean: rm -rf bin/ - rm -rf android/.gradle android/build android/app/build android/app/build.gradle - [ ! -L android/app/src/main/java ] || rm -rf android/app/src @echo Removing sources taken from libs... @for lib in libs/*-sources.jar libs/*-sources.patch.jar; do \ if [ "$$lib" != 'libs/*-sources.jar' -a "$$lib" != 'libs/*-sources.patch.jar' ]; then \ @@ -107,8 +103,6 @@ mrpropre: clean rm -f $(NAME)-debug.apk [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`.jar" [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`-sources.jar" - [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`.apk" - [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`-debug.apk" love: @echo " ...not war." @@ -145,19 +139,29 @@ libs: bin @[ ! -d libs ] || touch bin/libs $(NAME)-sources.jar: libs + @ls *.md >/dev/null || cp VERSION README.md @echo Making sources JAR file... @echo > bin/manifest - @[ "$(SJAR_FLAGS)" = "" ] || echo Creating $(NAME)-sources.jar... - @[ "$(SJAR_FLAGS)" = "" ] || $(JAR) cfm $(NAME)-sources.jar bin/manifest $(SJAR_FLAGS) - @[ "$(SJAR_FLAGS)" = "" ] || [ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`-sources.jar"... - @[ "$(SJAR_FLAGS)" = "" ] || [ ! -e VERSION ] || cp $(NAME)-sources.jar "$(NAME)-`cat VERSION`-sources.jar" + @[ "$(SJAR_FLAGS)" != "" ] || echo No sources JAR file defined, skipping + @[ "$(SJAR_FLAGS)" = "" ] || echo Creating $(NAME)-sources.jar... + @[ "$(SJAR_FLAGS)" = "" ] || $(JAR) cfm $(NAME)-sources.jar bin/manifest -C ./ *.md $(SJAR_FLAGS) + @[ "$(SJAR_FLAGS)" = "" ] || [ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`-sources.jar"... + @[ "$(SJAR_FLAGS)" = "" ] || [ ! -e VERSION ] || cp $(NAME)-sources.jar "$(NAME)-`cat VERSION`-sources.jar" $(NAME).jar: resources @[ -e bin/$(MAIN).class ] || echo You need to build the sources @[ -e bin/$(MAIN).class ] + @ls *.md >/dev/null || cp VERSION README.md + @echo "Copying documentation into bin/..." + @cp -r *.md bin/ || cp VERSION bin/no-documentation.md + @[ ! -d libs/bin/ ] || echo "Copying additional binaries from libs/bin/ into bin/..." + @[ ! -d libs/bin/ ] || cp -r libs/bin/* bin/ + @echo "Copying sources into bin/..." + @cp -r src/* bin/ + @echo "Making jar..." @echo "Main-Class: `echo "$(MAIN)" | sed 's:/:.:g'`" > bin/manifest @echo >> bin/manifest - $(JAR) cfm $(NAME).jar bin/manifest $(JAR_FLAGS) + $(JAR) cfm $(NAME).jar bin/manifest -C ./ *.md $(JAR_FLAGS) @[ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`.jar"... @[ ! -e VERSION ] || cp $(NAME).jar "$(NAME)-`cat VERSION`.jar" @@ -192,58 +196,6 @@ install: cp -r man/ "$(PREFIX)"/share/; \ fi -android: android/app/src - -android/app/src: - @[ -d android ] || echo No android/ directory found - @[ -d android ] - @[ -e android/local.properties ] || echo 'You need to create android/local.properties and add "sdk.dir=PATH_TO_SDK"' - @[ -e android/local.properties ] - @mkdir -p android/app/src/main - @echo Linking sources... - @( \ - cd android/app/src/main; \ - ln -s ../../../../src/AndroidManifest.xml .; \ - ln -s ../../../../res .; \ - ln -s ../../../../src ./java; \ - ) - @echo Fixing configuration... - @( \ - cd android/app/src/main/java; \ - excl="\\n";\ - if [ "${RM_FOR_ANDROID}" != "" ]; then \ - echo Ignoring uneeded sources...; \ - for file in ${RM_FOR_ANDROID}; do \ - excl="$${excl}exclude '**/$${file}'\\n";\ - done; \ - fi; \ - cd ../../../ ; \ - cat build.gradle.base \ - | sed 's:\(applicationId "\)":\1${ID_FOR_ANDROID}":' \ - | sed "s:\s*exclude '':$$excl:g" \ - > build.gradle; \ - ) - -apk: libs ${NAME}.apk - @echo Building APK files... - -${NAME}.apk: ${NAME}-debug.apk - -${NAME}-debug.apk: android - @echo Starting gradlew assemble... - @( \ - cd android/; \ - bash gradlew assemble && ( \ - cd ..; \ - cp android/app/build/outputs/apk/release/app-release-unsigned.apk ${NAME}.apk; \ - cp android/app/build/outputs/apk/debug/app-debug.apk ${NAME}-debug.apk; \ - [ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`.apk"...; \ - [ ! -e VERSION ] || cp $(NAME).apk "$(NAME)-`cat VERSION`.apk"; \ - [ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`-debug.apk"...; \ - [ ! -e VERSION ] || cp $(NAME).apk "$(NAME)-`cat VERSION`-debug.apk"; \ - ); \ - ) - ifman: @if pandoc -v >/dev/null 2>&1; then \ make man; \ diff --git a/README-fr.md b/README-fr.md index cb76040..5a0d7f8 100644 --- a/README-fr.md +++ b/README-fr.md @@ -10,6 +10,10 @@ Fanfix est un petit programme Java qui peut télécharger des histoires sur inte - ```fanfix``` --convert [*URL*] [*output_type*] [*target*] (+info) - ```fanfix``` --read [*id*] ([*chapter number*]) - ```fanfix``` --read-url [*URL*] ([*chapter number*]) +- ```fanfix``` --search +- ```fanfix``` --search [*where*] [*keywords*] (page [*page*]) (item [*item*]) +- ```fanfix``` --search-tag +- ```fanfix``` --search-tag [*index 1*]... (page [*page*]) (item [*item*]) - ```fanfix``` --list - ```fanfix``` --set-reader [*GUI* | *TUI* | *CLI*] - ```fanfix``` --server [*key*] [*port*] @@ -46,7 +50,7 @@ Pour le moment, les sites suivants sont supportés : - https://e621.net/ : un site Furry proposant des comics, y compris de MLP - https://sofurry.com/ : même chose, mais orienté sur les histoires plutôt que les images - https://e-hentai.org/ : support ajouté sur demande : n'hésitez pas à demander un site ! -- https://www.manga-lel.com/ : un site proposant beaucoup de mangas, en français +- http://mangas-lecture-en-ligne.fr/ : un site proposant beaucoup de mangas, en français ### Types de fichiers supportés @@ -85,6 +89,10 @@ Les arguments suivants sont aussi supportés : - ```--convert [URL] [output_type] [target] (+info)```: convertir l'histoire vers le fichier donné, et forcer l'ajout d'un fichier .info si +info est utilisé - ```--read [id] ([chapter number])```: afficher l'histoire "id" - ```--read-url [URL] ([chapter number])```: convertir l'histoire et la lire à la volée, sans la sauver +- ```--search```: liste les sites supportés (```where```) +- ```--search [where] [keywords] (page [page]) (item [item])```: lance une recherche et affiche les résultats de la page ```page``` (page 1 par défaut), et de l'item ```item``` spécifique si demandé +- ```--tag [where]```: liste tous les tags supportés par ce site web +- ```--tag [index 1]... (page [page]) (item [item])```: affine la recherche, tag par tag, et affiche si besoin les sous-tags, les histoires ou les infos précises de l'histoire demandée - ```--list```: lister les histoires presentes dans la librairie et leurs IDs - ```--set-reader [reader type]```: changer le type de lecteur pour la commande en cours sur CLI, TUI ou GUI - ```--server [key] [port]```: démarrer un serveur d'histoires sur ce port diff --git a/README.md b/README.md index 22e0b60..7e7c3b9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Fanfix is a small Java program that can download stories from some supported web - ```fanfix``` --convert [*URL*] [*output_type*] [*target*] (+info) - ```fanfix``` --read [*id*] ([*chapter number*]) - ```fanfix``` --read-url [*URL*] ([*chapter number*]) +- ```fanfix``` --search +- ```fanfix``` --search [*where*] [*keywords*] (page [*page*]) (item [*item*]) +- ```fanfix``` --search-tag +- ```fanfix``` --search-tag [*index 1*]... (page [*page*]) (item [*item*]) - ```fanfix``` --list - ```fanfix``` --set-reader [*GUI* | *TUI* | *CLI*] - ```fanfix``` --server [*key*] [*port*] @@ -46,7 +50,7 @@ Currently, the following websites are supported: - https://e621.net/: a Furry website supporting comics, including MLP - https://sofurry.com/: same thing, but story-oriented - https://e-hentai.org/: done upon request (so, feel free to ask for more websites!) -- https://www.manga-lel.com/: a website offering a lot of mangas (in French) +- http://mangas-lecture-en-ligne.fr/: a website offering a lot of mangas (in French) ### Support file types @@ -85,6 +89,10 @@ The following arguments are also allowed: - ```--convert [URL] [output_type] [target] (+info)```: convert the story at URL into target, and force-add the .info and cover if +info is passed - ```--read [id] ([chapter number])```: read the given story denoted by ID from the library - ```--read-url [URL] ([chapter number])```: convert on the fly and read the story at URL, 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 +- ```--tag [where]```: list all the tags supported by this website +- ```--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```: list the stories present in the library and their associated IDs - ```--set-reader [reader type]```: set the reader type to CLI, TUI or GUI for this command - ```--server [key] [port]```: start a story server on this port diff --git a/TODO.md b/TODO.md index 5b3110b..f9f7692 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,7 @@ My current planning for Fanfix (but not everything appears on this list): - [x] [e-Hentai](https://e-hentai.org/) requested - [x] Find some FR comics/manga websites - [ ] Find more FR thingies +- [ ] Support videos (anime)? - [x] A GUI library - [x] Make one - [x] Make it run when no args passed @@ -60,6 +61,7 @@ My current planning for Fanfix (but not everything appears on this list): - [ ] Sort stories by Source/Author - [ ] Fix UI - [ ] support progress events + - [x] give up and ask a friend... - [ ] Translations - [x] i18n system in place - [x] Make use of it in text diff --git a/android/app/build.gradle.base b/android/app/build.gradle.base deleted file mode 100644 index 4dac2b3..0000000 --- a/android/app/build.gradle.base +++ /dev/null @@ -1,37 +0,0 @@ -apply plugin: 'com.android.application' - -android { - compileSdkVersion 26 - defaultConfig { - applicationId "" - minSdkVersion 14 - targetSdkVersion 14 - versionCode 1 - versionName "1.0" - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - sourceSets { - main { - java { - exclude '' - } - } - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'com.android.support.constraint:constraint-layout:1.0.2' - implementation 'com.android.support:support-v4:26.1.0' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'com.android.support.test:runner:1.0.1' - androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' -} - - diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index f1b4245..0000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index e6b32bc..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - -buildscript { - - repositories { - google() - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.0.1' - - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index aac7c9b..0000000 --- a/android/gradle.properties +++ /dev/null @@ -1,17 +0,0 @@ -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. - -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html - -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m - -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 13372ae..0000000 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 6040c18..0000000 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Mon Dec 04 00:21:46 CET 2017 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip diff --git a/android/gradlew b/android/gradlew deleted file mode 100755 index 9d82f78..0000000 --- a/android/gradlew +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bash - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn ( ) { - echo "$*" -} - -die ( ) { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; -esac - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" - -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat deleted file mode 100644 index aec9973..0000000 --- a/android/gradlew.bat +++ /dev/null @@ -1,90 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index e7b4def..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app' diff --git a/changelog-fr.md b/changelog-fr.md index efe992f..8980fad 100644 --- a/changelog-fr.md +++ b/changelog-fr.md @@ -1,5 +1,18 @@ # Fanfix +# Version WIP + +- new: recherche d'histoires (Fanfiction.net) +- new: support d'un proxy +- fix: support des CBZ contenant du texte +- fix: correction de DEBUG=0 +- gui: correction pour le focus +- MangaLEL: site web changé +- search: supporte MangaLEL +- remote: changement du chiffrement because Google +- remote: incompatible avec 2.x +- remote: perfs et utilisation de la mémoire améliorées + # Version 2.0.2 - i18n: changer la langue dans les options fonctionne aussi quand $LANG existe @@ -10,7 +23,7 @@ # Version 2.0.1 -- lib: un changement de titre/source/author n'était pas toujours visible en runtime +- core: un changement de titre/source/author n'était pas toujours visible en runtime - gui: ne recharger les histoires que quand nécessaire # Version 2.0.0 diff --git a/changelog.md b/changelog.md index 6a72565..a82e8b3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,25 @@ # Fanfix +# Version WIP + +- new: now android-compatible (see [companion project](https://gitlab.com/Rayman22/fanfix-android)) +- new: story search (not all sources yet) +- new: proxy support +- fix: support hybrid CBZ (with text) +- fix: fix DEBUG=0 +- gui: focus fix +- gui: bg colour fix +- gui: fix keyboard navigation support (up and down) +- MangaLEL: website has changed +- search: Fanfiction.net support +- search: MangaLEL support +- FimFictionAPI: fix NPE +- remote: encryption mode changed because Google +- remote: not compatible with 2.x +- remote: now use password from config file +- remote: worse perfs but much better memory usage +- remote: log now includes the time of events + # Version 2.0.2 - i18n: setting the language in the option panel now works even with $LANG set @@ -10,7 +30,7 @@ # Version 2.0.1 -- lib: a change of title/source/author was not always visible at runtime +- core: a change of title/source/author was not always visible at runtime - gui: only reload the stoies when needed # Version 2.0.0 diff --git a/configure.sh b/configure.sh index 2d3e2fe..1e12397 100755 --- a/configure.sh +++ b/configure.sh @@ -79,10 +79,8 @@ echo "TEST = be/nikiroo/fanfix/test/Test" >> Makefile echo "TEST_PARAMS = $cols $ok $ko" >> Makefile echo "NAME = fanfix" >> Makefile echo "PREFIX = $PREFIX" >> Makefile -echo "JAR_FLAGS += -C bin/ org $JCLI $JTUI $JGUI -C bin/ be -C bin/ VERSION" >> Makefile -echo "RM_FOR_ANDROID = jexer be/nikiroo/utils/ui/* be/nikiroo/fanfix/reader/tui/* be/nikiroo/fanfix/reader/ui/*" >> Makefile -echo "ID_FOR_ANDROID = be.nikiroo.fanfix.reader.android" >> Makefile -#echo "SJAR_FLAGS += -C src/ org -C src/ jexer -C src/ be -C ./ LICENSE -C ./ README.md -C ./ VERSION" >> Makefile +echo "JAR_FLAGS += -C bin/ org $JCLI $JTUI $JGUI -C bin/ be -C ./ LICENSE -C ./ VERSION -C libs/ licenses" >> Makefile +#echo "SJAR_FLAGS += -C src/ org -C src/ jexer -C src/ be -C ./ LICENSE -C ./ VERSION -C libs/ licenses" >> Makefile cat Makefile.base >> Makefile diff --git a/fanfix.sysv b/fanfix.sysv index 3d3b23b..5ab6912 100755 --- a/fanfix.sysv +++ b/fanfix.sysv @@ -12,10 +12,7 @@ ENABLED=true USER=fanfix - JAR=/path/to/fanfix.jar -PINCODE="my password" -PORT=12000 FPID=/tmp/fanfix.pid OUT=/var/log/fanfix @@ -39,7 +36,7 @@ start) else [ -e "$OUT" ] && mv "$OUT" "$OUT".previous [ -e "$ERR" ] && mv "$ERR" "$ERR".previous - sudo -u "$USER" -- java -jar "$JAR" --server "$PINCODE" "$PORT" > "$OUT" 2> "$ERR" & + sudo -u "$USER" -- java -jar "$JAR" --server > "$OUT" 2> "$ERR" & echo $! > "$FPID" fi @@ -48,7 +45,7 @@ start) ;; stop) if sh "$0" status --quiet; then - sudo -u "$USER" -- java -jar "$JAR" --stop-server "$PINCODE" "$PORT" + sudo -u "$USER" -- java -jar "$JAR" --stop-server fi i=1 diff --git a/libs/jexer-0.0.4_LICENSE.txt b/libs/licenses/jexer-0.0.4_LICENSE.txt similarity index 100% rename from libs/jexer-0.0.4_LICENSE.txt rename to libs/licenses/jexer-0.0.4_LICENSE.txt diff --git a/libs/unbescape-1.1.4_LICENSE.txt b/libs/licenses/unbescape-1.1.4_LICENSE.txt similarity index 100% rename from libs/unbescape-1.1.4_LICENSE.txt rename to libs/licenses/unbescape-1.1.4_LICENSE.txt diff --git a/libs/nikiroo-utils-4.5.2-sources.jar b/libs/nikiroo-utils-4.5.2-sources.jar deleted file mode 100644 index 3a652cf..0000000 Binary files a/libs/nikiroo-utils-4.5.2-sources.jar and /dev/null differ diff --git a/libs/nikiroo-utils-4.7.2-dev-sources.jar b/libs/nikiroo-utils-4.7.2-dev-sources.jar new file mode 100644 index 0000000..74f4387 Binary files /dev/null and b/libs/nikiroo-utils-4.7.2-dev-sources.jar differ diff --git a/src/AndroidManifest.xml b/src/AndroidManifest.xml deleted file mode 100644 index 7e919ba..0000000 --- a/src/AndroidManifest.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/be/nikiroo/fanfix/DataLoader.java b/src/be/nikiroo/fanfix/DataLoader.java index e2af070..abc9585 100644 --- a/src/be/nikiroo/fanfix/DataLoader.java +++ b/src/be/nikiroo/fanfix/DataLoader.java @@ -11,7 +11,6 @@ import be.nikiroo.fanfix.supported.BasicSupport; import be.nikiroo.utils.Cache; import be.nikiroo.utils.CacheMemory; import be.nikiroo.utils.Downloader; -import be.nikiroo.utils.IOUtils; import be.nikiroo.utils.Image; import be.nikiroo.utils.ImageUtils; import be.nikiroo.utils.TraceHandler; @@ -27,7 +26,7 @@ import be.nikiroo.utils.TraceHandler; */ public class DataLoader { private Downloader downloader; - private Cache downloadCache; + private Downloader downloaderNoCache; private Cache cache; /** @@ -51,9 +50,11 @@ public class DataLoader { */ public DataLoader(File dir, String UA, int hoursChanging, int hoursStable) throws IOException { - downloader = new Downloader(UA); - downloadCache = new Cache(dir, hoursChanging, hoursStable); - cache = downloadCache; + downloader = new Downloader(UA, new Cache(dir, hoursChanging, + hoursStable)); + downloaderNoCache = new Downloader(UA); + + cache = downloader.getCache(); } /** @@ -65,7 +66,7 @@ public class DataLoader { */ public DataLoader(String UA) { downloader = new Downloader(UA); - downloadCache = null; + downloaderNoCache = downloader; cache = new CacheMemory(); } @@ -77,9 +78,10 @@ public class DataLoader { */ public void setTraceHandler(TraceHandler tracer) { downloader.setTraceHandler(tracer); + downloaderNoCache.setTraceHandler(tracer); cache.setTraceHandler(tracer); - if (downloadCache != null) { - downloadCache.setTraceHandler(tracer); + if (downloader.getCache() != null) { + downloader.getCache().setTraceHandler(tracer); } } @@ -87,6 +89,8 @@ public class DataLoader { /** * 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 @@ -102,8 +106,7 @@ public class DataLoader { */ public InputStream open(URL url, BasicSupport support, boolean stable) throws IOException { - // MUST NOT return null - return open(url, support, stable, url); + return open(url, url, support, stable, null, null, null); } /** @@ -114,72 +117,71 @@ public class DataLoader { * * @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 - * @param originalUrl - * the original {@link URL} used to locate the cached resource * * @return the opened resource, NOT NULL * * @throws IOException * in case of I/O error */ - public InputStream open(URL url, BasicSupport support, boolean stable, - URL originalUrl) throws IOException { - // MUST NOT return null - try { - InputStream in = null; - - if (downloadCache != null) { - in = downloadCache.load(originalUrl, false, stable); - Instance.getTraceHandler().trace( - "Cache " + (in != null ? "hit" : "miss") + ": " + url); - } - - if (in == null) { - try { - in = openNoCache(url, support, null, null, null); - if (downloadCache != null) { - downloadCache.save(in, originalUrl); - // ..But we want a resetable stream - in.close(); - in = downloadCache.load(originalUrl, true, stable); - } else { - InputStream resetIn = IOUtils.forceResetableStream(in); - if (resetIn != in) { - in.close(); - in = resetIn; - } - } - } catch (IOException e) { - throw new IOException("Cannot save the url: " - + (url == null ? "null" : url.toString()), e); - } - } - - return in; - } catch (IOException e) { - throw new IOException("Cannot open the url: " - + (url == null ? "null" : url.toString()), e); - } + public InputStream open(URL url, URL originalUrl, BasicSupport support, + boolean stable) throws IOException { + return open(url, originalUrl, support, stable, null, null, null); } /** - * Open the given {@link URL} without using the cache, but still update the - * cookies. + * 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 {@link URL} to open + * 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 + * @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 + * @return the opened resource, NOT NULL * * @throws IOException * in case of I/O error */ - public InputStream openNoCache(URL url) throws IOException { - return downloader.open(url); + 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); } /** @@ -217,8 +219,8 @@ public class DataLoader { } } - return downloader.open(url, currentReferer, cookiesValues, postParams, - getParams, oauth); + return downloaderNoCache.open(url, currentReferer, cookiesValues, + postParams, getParams, oauth); } /** @@ -236,8 +238,8 @@ public class DataLoader { */ public void refresh(URL url, BasicSupport support, boolean stable) throws IOException { - if (downloadCache != null && !downloadCache.check(url, false, stable)) { - open(url, support, stable).close(); + if (!check(url, stable)) { + open(url, url, support, stable, null, null, null).close(); } } @@ -254,7 +256,8 @@ public class DataLoader { * */ public boolean check(URL url, boolean stable) { - return downloadCache != null && downloadCache.check(url, false, stable); + return downloader.getCache() != null + && downloader.getCache().check(url, false, stable); } /** diff --git a/src/be/nikiroo/fanfix/Instance.java b/src/be/nikiroo/fanfix/Instance.java index cacbbfe..96d5d6f 100644 --- a/src/be/nikiroo/fanfix/Instance.java +++ b/src/be/nikiroo/fanfix/Instance.java @@ -17,6 +17,8 @@ import be.nikiroo.fanfix.library.LocalLibrary; import be.nikiroo.fanfix.library.RemoteLibrary; 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; @@ -40,9 +42,46 @@ public class Instance { private static TraceHandler tracer; private static TempFiles tempFiles; - static { + private static boolean init; + + /** + * Initialise the instance -- if already initialised, nothing will happen. + *

+ * Before calling this method, you may call + * {@link Bundles#setDirectory(String)} if wanted. + */ + 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) { + if (init && !force) { + return; + } + + init = true; + // Before we can configure it: - tracer = new TraceHandler(true, checkEnv("DEBUG"), checkEnv("DEBUG")); + Boolean debug = checkEnv("DEBUG"); + boolean trace = debug != null && debug; + tracer = new TraceHandler(true, trace, trace); // config dir: configDir = getConfigDir(); @@ -53,15 +92,13 @@ public class Instance { // Most of the rest is dependent upon this: createConfigs(configDir, false); + // Proxy support + Proxy.use(Instance.getConfig().getString(Config.USE_PROXY)); + // update tracer: - boolean debug = Instance.getConfig() - .getBoolean(Config.DEBUG_ERR, false); - boolean trace = Instance.getConfig().getBoolean(Config.DEBUG_TRACE, - false); - - if (checkEnv("DEBUG")) { - debug = true; - trace = true; + if (debug == null) { + debug = Instance.getConfig().getBoolean(Config.DEBUG_ERR, false); + trace = Instance.getConfig().getBoolean(Config.DEBUG_TRACE, false); } tracer = new TraceHandler(true, debug, trace); @@ -70,7 +107,8 @@ public class Instance { remoteDir = new File(configDir, "remote"); lib = createDefaultLibrary(remoteDir); - // create cache + // create cache and TMP + Image.setTemporaryFilesRoot(new File(configDir, "tmp.images")); File tmp = getFile(Config.CACHE_DIR); if (tmp == null) { // Could have used: System.getProperty("java.io.tmpdir") @@ -404,7 +442,8 @@ public class Instance { trans.deleteFile(configDir); } - if (checkEnv("NOUTF")) { + Boolean noutf = checkEnv("NOUTF"); + if (noutf != null && noutf) { trans.setUnicode(false); transGui.setUnicode(false); } @@ -437,6 +476,7 @@ public class Instance { + getFile(libDir), e)); } } else { + Exception ex = null; int pos = remoteLib.lastIndexOf(":"); if (pos >= 0) { String port = remoteLib.substring(pos + 1).trim(); @@ -455,13 +495,14 @@ public class Instance { lib); } catch (Exception e) { + ex = e; } } } if (lib == null) { tracer.error(new IOException( - "Cannot create remote library for: " + remoteLib)); + "Cannot create remote library for: " + remoteLib, ex)); } } @@ -545,7 +586,7 @@ public class Instance { private static String getLang() { String lang = config.getString(Config.LANG); - if (lang == null | lang.isEmpty()) { + if (lang == null || lang.isEmpty()) { if (System.getenv("LANG") != null && !System.getenv("LANG").isEmpty()) { lang = System.getenv("LANG"); @@ -567,7 +608,7 @@ public class Instance { * * @return TRUE if it is */ - private static boolean checkEnv(String key) { + private static Boolean checkEnv(String key) { String value = System.getenv(key); if (value != null) { value = value.trim().toLowerCase(); @@ -576,8 +617,10 @@ public class Instance { || "y".equals(value)) { return true; } + + return false; } - return false; + return null; } } diff --git a/src/be/nikiroo/fanfix/Main.java b/src/be/nikiroo/fanfix/Main.java index 953bc45..b363361 100644 --- a/src/be/nikiroo/fanfix/Main.java +++ b/src/be/nikiroo/fanfix/Main.java @@ -4,11 +4,14 @@ 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; @@ -20,6 +23,7 @@ import be.nikiroo.fanfix.output.BasicOutput.OutputType; import be.nikiroo.fanfix.reader.BasicReader; import be.nikiroo.fanfix.reader.Reader; import be.nikiroo.fanfix.reader.Reader.ReaderType; +import be.nikiroo.fanfix.searchable.BasicSearchable; import be.nikiroo.fanfix.supported.BasicSupport; import be.nikiroo.fanfix.supported.SupportType; import be.nikiroo.utils.Progress; @@ -33,7 +37,7 @@ import be.nikiroo.utils.serial.server.ServerObject; */ public class Main { private enum MainAction { - IMPORT, EXPORT, CONVERT, READ, READ_URL, LIST, HELP, SET_READER, START, VERSION, SERVER, STOP_SERVER, REMOTE, SET_SOURCE, SET_TITLE, SET_AUTHOR + IMPORT, EXPORT, CONVERT, READ, READ_URL, LIST, HELP, SET_READER, START, VERSION, SERVER, STOP_SERVER, REMOTE, SET_SOURCE, SET_TITLE, SET_AUTHOR, SEARCH, SEARCH_TAG } /** @@ -59,6 +63,14 @@ public class Main { * *

  • --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
  • @@ -66,9 +78,8 @@ public class Main { *
  • --set-reader [reader type]: set the reader type to CLI, TUI or LOCAL * for this command
  • *
  • --version: get the version of the program
  • - *
  • --server [key] [port]: start a server on this port
  • - *
  • --stop-server [key] [port]: stop the running server on this port if - * any
  • + *
  • --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 a the given remote library
  • * * @@ -76,6 +87,9 @@ public class Main { * see method description */ public static void main(String[] args) { + // Only one line, but very important: + Instance.init(); + String urlString = null; String luid = null; String sourceString = null; @@ -88,6 +102,11 @@ public class Main { Boolean plusInfo = null; String host = null; Integer port = null; + SupportType searchOn = null; + String search = null; + List tags = new ArrayList(); + Integer page = null; + Integer item = null; boolean noMoreActions = false; @@ -200,6 +219,103 @@ public class Main { exitCode = 255; } break; + case SEARCH: + if (searchOn == null) { + searchOn = SupportType.valueOfAllOkUC(args[i]); + + if (searchOn == null) { + Instance.getTraceHandler().error( + "Website not known: <" + args[i] + ">"); + exitCode = 41; + break; + } + + if (BasicSearchable.getSearchable(searchOn) == null) { + Instance.getTraceHandler().error( + "Website not supported: " + searchOn); + exitCode = 42; + break; + } + } else if (search == null) { + search = args[i]; + } else if (page != null && page == -1) { + try { + page = Integer.parseInt(args[i]); + } catch (Exception e) { + page = -2; + } + } else if (item != null && item == -1) { + try { + item = Integer.parseInt(args[i]); + } catch (Exception e) { + item = -2; + } + } else if (page == null || item == null) { + if (page == null && "page".equals(args[i])) { + page = -1; + } else if (item == null && "item".equals(args[i])) { + item = -1; + } else { + exitCode = 255; + } + } else { + exitCode = 255; + } + break; + case SEARCH_TAG: + if (searchOn == null) { + searchOn = SupportType.valueOfAllOkUC(args[i]); + + if (searchOn == null) { + Instance.getTraceHandler().error( + "Website not known: <" + args[i] + ">"); + exitCode = 255; + } + + if (BasicSearchable.getSearchable(searchOn) == null) { + Instance.getTraceHandler().error( + "Website not supported: " + searchOn); + exitCode = 255; + } + } else if (page == null && item == null) { + if ("page".equals(args[i])) { + page = -1; + } else if ("item".equals(args[i])) { + item = -1; + } else { + try { + int index = Integer.parseInt(args[i]); + tags.add(index); + } catch (NumberFormatException e) { + Instance.getTraceHandler().error( + "Invalid tag index: " + args[i]); + exitCode = 255; + } + } + } else if (page != null && page == -1) { + try { + page = Integer.parseInt(args[i]); + } catch (Exception e) { + page = -2; + } + } else if (item != null && item == -1) { + try { + item = Integer.parseInt(args[i]); + } catch (Exception e) { + item = -2; + } + } else if (page == null || item == null) { + if (page == null && "page".equals(args[i])) { + page = -1; + } else if (item == null && "item".equals(args[i])) { + item = -1; + } else { + exitCode = 255; + } + } else { + exitCode = 255; + } + break; case HELP: exitCode = 255; break; @@ -214,14 +330,10 @@ public class Main { exitCode = 255; // no arguments for this option break; case SERVER: + exitCode = 255; // no arguments for this option + break; case STOP_SERVER: - if (key == null) { - key = args[i]; - } else if (port == null) { - port = Integer.parseInt(args[i]); - } else { - exitCode = 255; - } + exitCode = 255; // no arguments for this option break; case REMOTE: if (key == null) { @@ -282,14 +394,14 @@ public class Main { System.err.println("\tVersion " + v); System.err.println("\t-------------"); System.err.println(""); - for (String item : updates.getChanges().get(v)) { - System.err.println("\t- " + item); + for (String it : updates.getChanges().get(v)) { + System.err.println("\t- " + it); } System.err.println(""); } } - if (exitCode != 255) { + if (exitCode == 0) { switch (action) { case IMPORT: exitCode = imprt(urlString, pg); @@ -357,6 +469,81 @@ public class Main { break; } exitCode = read(urlString, chapString, false); + break; + case SEARCH: + page = page == null ? 1 : page; + if (page < 0) { + Instance.getTraceHandler().error("Incorrect page number"); + exitCode = 255; + break; + } + + item = item == null ? 0 : item; + if (item < 0) { + Instance.getTraceHandler().error("Incorrect item number"); + exitCode = 255; + break; + } + + if (BasicReader.getReader() == null) { + Instance.getTraceHandler() + .error(new Exception( + "No reader type has been configured")); + exitCode = 10; + break; + } + + try { + if (searchOn == null) { + BasicReader.getReader().search(true); + } else if (search != null) { + + BasicReader.getReader().search(searchOn, search, page, + item, true); + } else { + exitCode = 255; + } + } catch (IOException e1) { + Instance.getTraceHandler().error(e1); + exitCode = 20; + } + + break; + case SEARCH_TAG: + if (searchOn == null) { + exitCode = 255; + break; + } + + page = page == null ? 1 : page; + if (page < 0) { + Instance.getTraceHandler().error("Incorrect page number"); + exitCode = 255; + break; + } + + item = item == null ? 0 : item; + if (item < 0) { + Instance.getTraceHandler().error("Incorrect item number"); + exitCode = 255; + break; + } + + if (BasicReader.getReader() == null) { + Instance.getTraceHandler() + .error(new Exception( + "No reader type has been configured")); + exitCode = 10; + break; + } + + try { + BasicReader.getReader().searchTag(searchOn, page, item, + true, tags.toArray(new Integer[] {})); + } catch (IOException e1) { + Instance.getTraceHandler().error(e1); + } + break; case HELP: syntax(true); @@ -381,11 +568,19 @@ public class Main { exitCode = 10; break; } - BasicReader.getReader().browse(null); + try { + BasicReader.getReader().browse(null); + } catch (IOException e) { + Instance.getTraceHandler().error(e); + exitCode = 66; + } break; case SERVER: + key = Instance.getConfig().getString(Config.SERVER_KEY); + port = Instance.getConfig().getInteger(Config.SERVER_PORT); if (port == null) { - exitCode = 255; + System.err.println("No port configured in the config file"); + exitCode = 15; break; } try { @@ -397,12 +592,24 @@ public class Main { } return; case STOP_SERVER: + key = Instance.getConfig().getString(Config.SERVER_KEY); + port = Instance.getConfig().getInteger(Config.SERVER_PORT); if (port == null) { - exitCode = 255; + System.err.println("No port configured in the config file"); + exitCode = 15; break; } + try { + new RemoteLibrary(key, host, port).exit(); + } catch (SSLException e) { + Instance.getTraceHandler().error( + "Bad access key for remote library"); + exitCode = 43; + } catch (IOException e) { + Instance.getTraceHandler().error(e); + exitCode = 44; + } - new RemoteLibrary(key, host, port).exit(); break; case REMOTE: exitCode = 255; // should not be reachable (REMOTE -> START) @@ -494,18 +701,14 @@ public class Main { * @return the exit return code (0 = success) */ private static int list(String source) { - List stories; - stories = BasicReader.getReader().getLibrary().getListBySource(source); - - for (MetaData story : stories) { - String author = ""; - if (story.getAuthor() != null && !story.getAuthor().isEmpty()) { - author = " (" + story.getAuthor() + ")"; - } - - System.out.println(story.getLuid() + ": " + story.getTitle() - + author); + BasicReader.setDefaultReaderType(ReaderType.CLI); + try { + BasicReader.getReader().browse(source); + } catch (IOException e) { + Instance.getTraceHandler().error(e); + return 66; } + return 0; } diff --git a/src/be/nikiroo/fanfix/VersionCheck.java b/src/be/nikiroo/fanfix/VersionCheck.java index 76758af..2c9a032 100644 --- a/src/be/nikiroo/fanfix/VersionCheck.java +++ b/src/be/nikiroo/fanfix/VersionCheck.java @@ -120,7 +120,8 @@ public class VersionCheck { InputStream in = null; for (String url : new String[] { urlFrBE, urlFr, urlDefault }) { try { - in = Instance.getCache().openNoCache(new URL(url)); + in = Instance.getCache() + .open(new URL(url), null, false); break; } catch (IOException e) { } diff --git a/src/be/nikiroo/fanfix/bundles/Config.java b/src/be/nikiroo/fanfix/bundles/Config.java index ecbfa3d..35f63d6 100644 --- a/src/be/nikiroo/fanfix/bundles/Config.java +++ b/src/be/nikiroo/fanfix/bundles/Config.java @@ -32,6 +32,16 @@ public enum Config { DEFAULT_COVERS_DIR, // @Meta(description = "string", info = "The default library to use (KEY:SERVER:PORT), or empty for the local library") DEFAULT_LIBRARY, // + @Meta(def = "58365", description = "The port on which we can start the server", format = Format.INT, info = "A valid port") + SERVER_PORT, // + @Meta(def = "", description = "The encryption key for the server (NOT including a subkey)", format = Format.PASSWORD, info = "cannot contain the pipe character (|)") + SERVER_KEY, // + @Meta(def = "TRUE", description = "Allow write access to the clients by default (download story, move story...)", format = Format.BOOLEAN) + SERVER_RW, // + @Meta(def = "", description = "If not empty, only the EXACT listed sources will be available for clients", info = "list is comma-separated (,) and values are surrounded by double quotes (\"); any double quote in the value must be backslash-escaped (with \\\")") + SERVER_WHITELIST, // + @Meta(def = "", description = "The subkeys that the server will allow, including the modes", info = "list is comma-separated (,) and values are surrounded by double quotes (\"); any double quote in the value must be backslash-escaped (with \\\")") + SERVER_ALLOWED_SUBKEYS, // @Meta(def = "$HOME/Books", description = "absolute path, $HOME variable supported, / is always accepted as dir separator", format = Format.DIRECTORY, info = "The directory where to store the library") LIBRARY_DIR, // @Meta(def = "false", description = "boolean", format = Format.BOOLEAN, info = "Show debug information on errors") @@ -64,7 +74,8 @@ public enum Config { LOGIN_YIFFSTAR_PASS, // @Meta(description = "If the last update check was done at least that many days, check for updates at startup (-1 for 'no checks' -- default is 1 day)", format = Format.INT) UPDATE_INTERVAL, // - @Meta(description = "An API key required to create a token from FimFiction", format = Format.STRING) + @Meta(def = "", description = "", info = "Format is ((user(:pass)@)proxy:port), with ':' being system proxy and an empty String being no proxy") + USE_PROXY, @Meta(description = "An API key required to create a token from FimFiction", format = Format.STRING) LOGIN_FIMFICTION_APIKEY_CLIENT_ID, // @Meta(description = "An API key required to create a token from FimFiction", format = Format.PASSWORD) LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET, // diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGui.java b/src/be/nikiroo/fanfix/bundles/StringIdGui.java index 8d3022e..6bb774c 100644 --- a/src/be/nikiroo/fanfix/bundles/StringIdGui.java +++ b/src/be/nikiroo/fanfix/bundles/StringIdGui.java @@ -119,6 +119,8 @@ public enum StringIdGui { 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") @@ -148,7 +150,7 @@ public enum StringIdGui { @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_UNAUTORIZED, // + 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") diff --git a/src/be/nikiroo/fanfix/bundles/config.properties b/src/be/nikiroo/fanfix/bundles/config.properties index 8f22f9f..63b7642 100644 --- a/src/be/nikiroo/fanfix/bundles/config.properties +++ b/src/be/nikiroo/fanfix/bundles/config.properties @@ -33,6 +33,21 @@ USER_AGENT = Mozilla/5.0 (X11; Linux x86_64; rv:44.0) Gecko/20100101 Firefox/44. DEFAULT_COVERS_DIR = $HOME/.fanfix/covers/ # string (FORMAT: STRING) The default library to use (KEY:SERVER:PORT), or empty for the local library DEFAULT_LIBRARY = +# The port on which we can start the server +# (FORMAT: INT) A valid port +SERVER_PORT = 58365 +# The encryption key for the server (NOT including a subkey) +# (FORMAT: PASSWORD) cannot contain the pipe character (|) +SERVER_KEY = +# Allow write access to the clients by default (download story, move story...) +# (FORMAT: BOOLEAN) +SERVER_RW = +# If not empty, only the EXACT listed sources will be available for clients +# (FORMAT: STRING) list is comma-separated (,) and values are surrounded by double quotes ("); any double quote in the value must be backslash-escaped (with \") +SERVER_WHITELIST = +# The subkeys that the server will allow, including the modes +# (FORMAT: STRING) list is comma-separated (,) and values are surrounded by double quotes ("); any double quote in the value must be backslash-escaped (with \") +SERVER_ALLOWED_SUBKEYS = # absolute path, $HOME variable supported, / is always accepted as dir separator # (FORMAT: DIRECTORY) The directory where to store the library LIBRARY_DIR = $HOME/Books @@ -77,6 +92,8 @@ LOGIN_YIFFSTAR_PASS = # If the last update check was done at least that many days, check for updates at startup (-1 for 'no checks' -- default is 1 day) # (FORMAT: INT) UPDATE_INTERVAL = +# (FORMAT: STRING) Format is ((user(:pass)@)proxy:port), with ':' being system proxy and an empty String being no proxy +USE_PROXY = # An API key required to create a token from FimFiction # (FORMAT: STRING) LOGIN_FIMFICTION_APIKEY_CLIENT_ID = diff --git a/src/be/nikiroo/fanfix/bundles/resources_core.properties b/src/be/nikiroo/fanfix/bundles/resources_core.properties index 62fd158..1ebc3d5 100644 --- a/src/be/nikiroo/fanfix/bundles/resources_core.properties +++ b/src/be/nikiroo/fanfix/bundles/resources_core.properties @@ -16,14 +16,26 @@ HELP_SYNTAX = Valid options:\n\ \t--read [id] ([chapter number]): read the given story from the library\n\ \t--read-url [URL] ([chapter number]): convert on the fly and read the \n\ \t\tstory, without saving it\n\ +\t--search WEBSITE [free text] ([page] ([item])): search for the given terms, show the\n\ +\t\tgiven page (page 0 means "how many page do we have", starts at page 1)\n\ +\t--search-tag WEBSITE ([tag 1] [tag2...] ([page] ([item]))): list the known tags or \n\ +\t\tsearch the stories for the given tag(s), show the given page of results\n\ +\t--search: list the supported websites (where)\n\ +\t--search [where] [keywords] (page [page]) (item [item]): search on the supported \n\ +\t\twebsite and display the given results page of stories it found, or the story \n\ +\t\tdetails if asked\n\ +\t--search-tag [where]: list all the tags supported by this website\n\ +\t--search-tag [index 1]... (page [page]) (item [item]): search for the given stories or \n\ +\t\tsubtags, tag by tag, and display information about a specific page of results or \n\ +\t\tabout a specific item if requested\n\ \t--list ([type]) : list the stories present in the library\n\ \t--set-source [id] [new source]: change the source of the given story\n\ \t--set-title [id] [new title]: change the title of the given story\n\ \t--set-author [id] [new author]: change the author of the given story\n\ \t--set-reader [reader type]: set the reader type to CLI, TUI or GUI for \n\ \t\tthis command\n\ -\t--server [key] [port]: start a remote server on this port\n\ -\t--stop-server [key] [port]: stop the remote server running on this port\n\ +\t--server: start the server mode (see config file for parameters)\n\ +\t--stop-server: stop the remote server running on this port\n\ \t\tif any (key must be set to the same value)\n\ \t--remote [key] [host] [port]: select this remote server to get \n\ \t\t(or update or...) the stories from (key must be set to the \n\ diff --git a/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties index 092bd33..e64651b 100644 --- a/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties +++ b/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties @@ -15,13 +15,21 @@ HELP_SYNTAX = Options reconnues :\n\ \t--convert [URL] [output_type] [target] (+info): convertir l'histoire vers le fichier donné, et forcer l'ajout d'un fichier .info si +info est utilisé\n\ \t--read [id] ([chapter number]): afficher l'histoire "id"\n\ \t--read-url [URL] ([chapter number]): convertir l'histoire et la lire à la volée, sans la sauver\n\ +\t--search: liste les sites supportés (where)\n\ +\t--search [where] [keywords] (page [page]) (item [item]): lance une recherche et \n\ +\t\taffiche les résultats de la page page (page 1 par défaut), et de l'item item \n\ +\t\tspécifique si demandé\n\ +\t--search-tag [where]: liste tous les tags supportés par ce site web\n\ +\t--search-tag [index 1]... (page [page]) (item [item]): affine la recherche, tag par tag,\n\ +\t\tet affiche si besoin les sous-tags, les histoires ou les infos précises de \n\ +\t\tl'histoire demandée\n\ \t--list ([type]): lister les histoires presentes dans la librairie et leurs IDs\n\ \t--set-source [id] [nouvelle source]: change la source de l'histoire\n\ \t--set-title [id] [nouveau titre]: change le titre de l'histoire\n\ \t--set-author [id] [nouvel auteur]: change l'auteur de l'histoire\n\ \t--set-reader [reader type]: changer le type de lecteur pour la commande en cours sur CLI, TUI ou GUI\n\ -\t--server [key] [port]: démarrer un serveur d'histoires sur ce port\n\ -\t--stop-server [key] [port]: arrêter le serveur distant sur ce port (key doit avoir la même valeur) \n\ +\t--server: démarre le mode serveur (les paramètres sont dans le fichier de config)\n\ +\t--stop-server: arrêter le serveur distant sur ce port (key doit avoir la même valeur) \n\ \t--remote [key] [host] [port]: contacter ce server au lieu de la librairie habituelle (key doit avoir la même valeur)\n\ \t--help: afficher la liste des options disponibles\n\ \t--version: retourne la version du programme\n\ diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui.properties b/src/be/nikiroo/fanfix/bundles/resources_gui.properties index 5e49ceb..de44c18 100644 --- a/src/be/nikiroo/fanfix/bundles/resources_gui.properties +++ b/src/be/nikiroo/fanfix/bundles/resources_gui.properties @@ -125,6 +125,9 @@ MENU_EDIT_SET_COVER_FOR_SOURCE = Set as cover for source # the edit/Set as cover for author menu button # (FORMAT: STRING) MENU_EDIT_SET_COVER_FOR_AUTHOR = Set as cover for author +# the search menu to open the earch stories on one of the searchable websites +# (FORMAT: STRING) +MENU_SEARCH = Search # the view menu (FORMAT: STRING) MENU_VIEW = View # the view/word_count menu button, to show the word/image/story count as secondary info @@ -162,7 +165,7 @@ PROGRESS_CHANGE_SOURCE = Change the source of the book to %s ERROR_LIB_STATUS = An error occured when contacting the library # library access not allowed # (FORMAT: STRING) -ERROR_LIB_STATUS_UNAUTORIZED = You are not allowed to access this library +ERROR_LIB_STATUS_UNAUTHORIZED = You are not allowed to access this library # the library is invalid (not correctly set up) # (FORMAT: STRING) ERROR_LIB_STATUS_INVALID = Library not valid diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties index 6e14c98..2b6d192 100644 --- a/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties +++ b/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties @@ -125,6 +125,9 @@ MENU_EDIT_SET_COVER_FOR_SOURCE = Utiliser comme cover pour la source # the edit/Set as cover for author menu button # (FORMAT: STRING) MENU_EDIT_SET_COVER_FOR_AUTHOR = Utiliser comme cover pour l'auteur +# the search menu to open the earch stories on one of the searchable websites +# (FORMAT: STRING) +MENU_SEARCH = Recherche # the view menu (FORMAT: STRING) MENU_VIEW = Affichage # the view/word_count menu button, to show the word/image/story count as secondary info @@ -162,7 +165,7 @@ PROGRESS_CHANGE_SOURCE = Change la source du livre en %s ERROR_LIB_STATUS = Une erreur est survenue en contactant la librairie # library access not allowed # (FORMAT: STRING) -ERROR_LIB_STATUS_UNAUTORIZED = Vous n'étes pas autorisé à accéder à cette librairie +ERROR_LIB_STATUS_UNAUTHORIZED = Vous n'étes pas autorisé à accéder à cette librairie # the library is invalid (not correctly set up) # (FORMAT: STRING) ERROR_LIB_STATUS_INVALID = Librairie invalide diff --git a/src/be/nikiroo/fanfix/data/Chapter.java b/src/be/nikiroo/fanfix/data/Chapter.java index 873dcb8..d490058 100644 --- a/src/be/nikiroo/fanfix/data/Chapter.java +++ b/src/be/nikiroo/fanfix/data/Chapter.java @@ -1,5 +1,6 @@ package be.nikiroo.fanfix.data; +import java.io.Serializable; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -9,7 +10,9 @@ import java.util.List; * * @author niki */ -public class Chapter implements Iterable, Cloneable { +public class Chapter implements Iterable, Cloneable, Serializable { + private static final long serialVersionUID = 1L; + private String name; private int number; private List paragraphs = new ArrayList(); diff --git a/src/be/nikiroo/fanfix/data/MetaData.java b/src/be/nikiroo/fanfix/data/MetaData.java index cbaf84e..1781d86 100644 --- a/src/be/nikiroo/fanfix/data/MetaData.java +++ b/src/be/nikiroo/fanfix/data/MetaData.java @@ -1,16 +1,20 @@ package be.nikiroo.fanfix.data; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; import be.nikiroo.utils.Image; +import be.nikiroo.utils.StringUtils; /** * The meta data associated to a {@link Story} object. * * @author niki */ -public class MetaData implements Cloneable, Comparable { +public class MetaData implements Cloneable, Comparable, Serializable { + private static final long serialVersionUID = 1L; + private String title; private String author; private String date; @@ -466,19 +470,8 @@ public class MetaData implements Cloneable, Comparable { String cover = "none"; if (getCover() != null) { - cover = " bytes"; - - int size = getCover().getData().length; - if (size > 1000) { - size /= 1000; - cover = " kb"; - if (size > 1000) { - size /= 1000; - cover = " mb"; - } - } - - cover = size + cover; + cover = StringUtils.formatNumber(getCover().getData().length) + + "bytes"; } return String.format( diff --git a/src/be/nikiroo/fanfix/data/Paragraph.java b/src/be/nikiroo/fanfix/data/Paragraph.java index 0ed61fb..9adc51c 100644 --- a/src/be/nikiroo/fanfix/data/Paragraph.java +++ b/src/be/nikiroo/fanfix/data/Paragraph.java @@ -1,5 +1,7 @@ package be.nikiroo.fanfix.data; +import java.io.Serializable; + import be.nikiroo.utils.Image; /** @@ -7,7 +9,9 @@ import be.nikiroo.utils.Image; * * @author niki */ -public class Paragraph implements Cloneable { +public class Paragraph implements Cloneable, Serializable { + private static final long serialVersionUID = 1L; + /** * A paragraph type, that will dictate how the paragraph will be handled. * diff --git a/src/be/nikiroo/fanfix/data/Story.java b/src/be/nikiroo/fanfix/data/Story.java index 0e0279f..fc3f909 100644 --- a/src/be/nikiroo/fanfix/data/Story.java +++ b/src/be/nikiroo/fanfix/data/Story.java @@ -1,5 +1,6 @@ package be.nikiroo.fanfix.data; +import java.io.Serializable; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -9,7 +10,9 @@ import java.util.List; * * @author niki */ -public class Story implements Iterable, Cloneable { +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(); diff --git a/src/be/nikiroo/fanfix/data/package-info.java b/src/be/nikiroo/fanfix/data/package-info.java index aaa02c3..57db36b 100644 --- a/src/be/nikiroo/fanfix/data/package-info.java +++ b/src/be/nikiroo/fanfix/data/package-info.java @@ -1,6 +1,8 @@ /** * This package contains the data structure used by the program, without the * logic behind them. + *

    + * All the classes inside are serializable. * * @author niki */ diff --git a/src/be/nikiroo/fanfix/library/BasicLibrary.java b/src/be/nikiroo/fanfix/library/BasicLibrary.java index 7f7a09d..380c5c9 100644 --- a/src/be/nikiroo/fanfix/library/BasicLibrary.java +++ b/src/be/nikiroo/fanfix/library/BasicLibrary.java @@ -39,14 +39,36 @@ abstract public class BasicLibrary { * @author niki */ public enum Status { - /** The library is ready. */ - READY, + /** 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. */ - UNAUTORIZED, + UNAUTHORIZED, /** The library is currently out of commission. */ - UNAVAILABLE, + 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); + } } /** @@ -66,7 +88,7 @@ abstract public class BasicLibrary { * @return the current status */ public Status getStatus() { - return Status.READY; + return Status.READ_WRITE; } /** @@ -81,8 +103,11 @@ abstract public class BasicLibrary { * the optional {@link Progress} * * @return the corresponding {@link Story} + * + * @throws IOException + * in case of IOException */ - public abstract File getFile(String luid, Progress pg); + public abstract File getFile(String luid, Progress pg) throws IOException; /** * Return the cover image associated to this story. @@ -91,8 +116,11 @@ abstract public class BasicLibrary { * the Library UID of the story * * @return the cover image + * + * @throws IOException + * in case of IOException */ - public abstract Image getCover(String luid); + public abstract Image getCover(String luid) throws IOException; /** * Return the cover image associated to this source. @@ -104,8 +132,11 @@ abstract public class BasicLibrary { * the source * * @return the cover image or NULL + * + * @throws IOException + * in case of IOException */ - public Image getSourceCover(String source) { + public Image getSourceCover(String source) throws IOException { Image custom = getCustomSourceCover(source); if (custom != null) { return custom; @@ -129,8 +160,11 @@ abstract public class BasicLibrary { * the author * * @return the cover image or NULL + * + * @throws IOException + * in case of IOException */ - public Image getAuthorCover(String author) { + public Image getAuthorCover(String author) throws IOException { Image custom = getCustomAuthorCover(author); if (custom != null) { return custom; @@ -153,8 +187,12 @@ abstract public class BasicLibrary { * the source to look for * * @return the custom cover or NULL if none + * + * @throws IOException + * in case of IOException */ - public Image getCustomSourceCover(@SuppressWarnings("unused") String source) { + @SuppressWarnings("unused") + public Image getCustomSourceCover(String source) throws IOException { return null; } @@ -167,8 +205,12 @@ abstract public class BasicLibrary { * the author to look for * * @return the custom cover or NULL if none + * + * @throws IOException + * in case of IOException */ - public Image getCustomAuthorCover(@SuppressWarnings("unused") String author) { + @SuppressWarnings("unused") + public Image getCustomAuthorCover(String author) throws IOException { return null; } @@ -179,8 +221,12 @@ abstract public class BasicLibrary { * the source to change * @param luid * the story LUID + * + * @throws IOException + * in case of IOException */ - public abstract void setSourceCover(String source, String luid); + public abstract void setSourceCover(String source, String luid) + throws IOException; /** * Set the author cover to the given story cover. @@ -189,8 +235,12 @@ abstract public class BasicLibrary { * the author to change * @param luid * the story LUID + * + * @throws IOException + * in case of IOException */ - public abstract void setAuthorCover(String author, String luid); + public abstract void setAuthorCover(String author, String luid) + throws IOException; /** * Return the list of stories (represented by their {@link MetaData}, which @@ -200,8 +250,11 @@ abstract public class BasicLibrary { * 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); + protected abstract List getMetas(Progress pg) throws IOException; /** * Invalidate the {@link Story} cache (when the content should be re-read @@ -228,8 +281,11 @@ abstract public class BasicLibrary { * * @param meta * the {@link Story} to clear from the cache + * + * @throws IOException + * in case of IOException */ - protected abstract void updateInfo(MetaData meta); + protected abstract void updateInfo(MetaData meta) throws IOException; /** * Return the next LUID that can be used. @@ -274,15 +330,22 @@ abstract public class BasicLibrary { * the optional progress reporter */ public void refresh(Progress pg) { - getMetas(pg); + try { + getMetas(pg); + } catch (IOException e) { + // We will let it fail later + } } /** * List all the known types (sources) of stories. * * @return the sources + * + * @throws IOException + * in case of IOException */ - public synchronized List getSources() { + public synchronized List getSources() throws IOException { List list = new ArrayList(); for (MetaData meta : getMetas(null)) { String storySource = meta.getSource(); @@ -308,8 +371,12 @@ abstract public class BasicLibrary { * * * @return the grouped list + * + * @throws IOException + * in case of IOException */ - public synchronized Map> getSourcesGrouped() { + public synchronized Map> getSourcesGrouped() + throws IOException { Map> map = new TreeMap>(); for (String source : getSources()) { String name; @@ -340,8 +407,11 @@ abstract public class BasicLibrary { * List all the known authors of stories. * * @return the authors + * + * @throws IOException + * in case of IOException */ - public synchronized List getAuthors() { + public synchronized List getAuthors() throws IOException { List list = new ArrayList(); for (MetaData meta : getMetas(null)) { String storyAuthor = meta.getAuthor(); @@ -372,8 +442,11 @@ abstract public class BasicLibrary { * 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() { + public Map> getAuthorsGrouped() throws IOException { int MAX = 20; Map> groups = new TreeMap>(); @@ -448,7 +521,8 @@ abstract public class BasicLibrary { * @param car * the starting character, *, 0 or a capital * letter - * @return the authors that fulfill the starting letter + * + * @return the authors that fulfil the starting letter */ private List getAuthorsGroup(List authors, char car) { List accepted = new ArrayList(); @@ -480,8 +554,11 @@ abstract public class BasicLibrary { * Cover images MAYBE not included. * * @return the stories + * + * @throws IOException + * in case of IOException */ - public synchronized List getList() { + public synchronized List getList() throws IOException { return getMetas(null); } @@ -495,8 +572,12 @@ abstract public class BasicLibrary { * the type of story to retrieve, or NULL for all * * @return the stories + * + * @throws IOException + * in case of IOException */ - public synchronized List getListBySource(String type) { + public synchronized List getListBySource(String type) + throws IOException { List list = new ArrayList(); for (MetaData meta : getMetas(null)) { String storyType = meta.getSource(); @@ -519,8 +600,12 @@ abstract public class BasicLibrary { * the author of the stories to retrieve, or NULL for all * * @return the stories + * + * @throws IOException + * in case of IOException */ - public synchronized List getListByAuthor(String author) { + public synchronized List getListByAuthor(String author) + throws IOException { List list = new ArrayList(); for (MetaData meta : getMetas(null)) { String storyAuthor = meta.getAuthor(); @@ -541,8 +626,11 @@ abstract public class BasicLibrary { * the Library UID of the story * * @return the corresponding {@link Story} + * + * @throws IOException + * in case of IOException */ - public synchronized MetaData getInfo(String luid) { + public synchronized MetaData getInfo(String luid) throws IOException { if (luid != null) { for (MetaData meta : getMetas(null)) { if (luid.equals(meta.getLuid())) { @@ -563,8 +651,12 @@ abstract public class BasicLibrary { * 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, Progress pg) { + public synchronized Story getStory(String luid, Progress pg) + throws IOException { Progress pgMetas = new Progress(); Progress pgStory = new Progress(); if (pg != null) { @@ -598,9 +690,13 @@ abstract public class BasicLibrary { * 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, - @SuppressWarnings("javadoc") MetaData meta, Progress pg) { + @SuppressWarnings("javadoc") MetaData meta, Progress pg) + throws IOException { if (pg == null) { pg = new Progress(); diff --git a/src/be/nikiroo/fanfix/library/CacheLibrary.java b/src/be/nikiroo/fanfix/library/CacheLibrary.java index 8f6e9c2..019acd2 100644 --- a/src/be/nikiroo/fanfix/library/CacheLibrary.java +++ b/src/be/nikiroo/fanfix/library/CacheLibrary.java @@ -52,7 +52,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - protected List getMetas(Progress pg) { + protected List getMetas(Progress pg) throws IOException { if (pg == null) { pg = new Progress(); } @@ -66,7 +66,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - public synchronized MetaData getInfo(String luid) { + public synchronized MetaData getInfo(String luid) throws IOException { MetaData info = cacheLib.getInfo(luid); if (info == null) { info = lib.getInfo(luid); @@ -76,7 +76,8 @@ public class CacheLibrary extends BasicLibrary { } @Override - public synchronized Story getStory(String luid, MetaData meta, Progress pg) { + public synchronized Story getStory(String luid, MetaData meta, Progress pg) + throws IOException { if (pg == null) { pg = new Progress(); } @@ -109,7 +110,8 @@ public class CacheLibrary extends BasicLibrary { } @Override - public synchronized File getFile(final String luid, Progress pg) { + public synchronized File getFile(final String luid, Progress pg) + throws IOException { if (pg == null) { pg = new Progress(); } @@ -134,7 +136,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - public Image getCover(final String luid) { + public Image getCover(final String luid) throws IOException { if (isCached(luid)) { return cacheLib.getCover(luid); } @@ -144,7 +146,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - public Image getSourceCover(String source) { + public Image getSourceCover(String source) throws IOException { Image custom = getCustomSourceCover(source); if (custom != null) { return custom; @@ -159,7 +161,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - public Image getAuthorCover(String author) { + public Image getAuthorCover(String author) throws IOException { Image custom = getCustomAuthorCover(author); if (custom != null) { return custom; @@ -174,7 +176,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - public Image getCustomSourceCover(String source) { + public Image getCustomSourceCover(String source) throws IOException { Image custom = cacheLib.getCustomSourceCover(source); if (custom == null) { custom = lib.getCustomSourceCover(source); @@ -187,7 +189,7 @@ public class CacheLibrary extends BasicLibrary { } @Override - public Image getCustomAuthorCover(String author) { + public Image getCustomAuthorCover(String author) throws IOException { Image custom = cacheLib.getCustomAuthorCover(author); if (custom == null) { custom = lib.getCustomAuthorCover(author); @@ -200,19 +202,19 @@ public class CacheLibrary extends BasicLibrary { } @Override - public void setSourceCover(String source, String luid) { + 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) { + public void setAuthorCover(String author, String luid) throws IOException { lib.setAuthorCover(author, luid); cacheLib.setAuthorCover(author, getCover(luid)); } @Override - protected void updateInfo(MetaData meta) { + protected void updateInfo(MetaData meta) throws IOException { if (meta != null && metas != null) { for (int i = 0; i < metas.size(); i++) { if (metas.get(i).getLuid().equals(meta.getLuid())) { @@ -317,7 +319,11 @@ public class CacheLibrary extends BasicLibrary { * @return TRUE if it is */ public boolean isCached(String luid) { - return cacheLib.getInfo(luid) != null; + try { + return cacheLib.getInfo(luid) != null; + } catch (IOException e) { + return false; + } } /** diff --git a/src/be/nikiroo/fanfix/library/LocalLibrary.java b/src/be/nikiroo/fanfix/library/LocalLibrary.java index 59310fd..3b0a848 100644 --- a/src/be/nikiroo/fanfix/library/LocalLibrary.java +++ b/src/be/nikiroo/fanfix/library/LocalLibrary.java @@ -100,7 +100,7 @@ public class LocalLibrary extends BasicLibrary { } @Override - public File getFile(String luid, Progress pg) { + public File getFile(String luid, Progress pg) throws IOException { Instance.getTraceHandler().trace( this.getClass().getSimpleName() + ": get file for " + luid); @@ -122,7 +122,7 @@ public class LocalLibrary extends BasicLibrary { } @Override - public Image getCover(String luid) { + public Image getCover(String luid) throws IOException { MetaData meta = getInfo(luid); if (meta != null) { if (meta.getCover() != null) { @@ -288,12 +288,12 @@ public class LocalLibrary extends BasicLibrary { } @Override - public void setSourceCover(String source, String luid) { + public void setSourceCover(String source, String luid) throws IOException { setSourceCover(source, getCover(luid)); } @Override - public void setAuthorCover(String author, String luid) { + public void setAuthorCover(String author, String luid) throws IOException { setAuthorCover(author, getCover(luid)); } diff --git a/src/be/nikiroo/fanfix/library/RemoteLibrary.java b/src/be/nikiroo/fanfix/library/RemoteLibrary.java index 8442aed..a6c6854 100644 --- a/src/be/nikiroo/fanfix/library/RemoteLibrary.java +++ b/src/be/nikiroo/fanfix/library/RemoteLibrary.java @@ -7,12 +7,13 @@ 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.StringUtils; import be.nikiroo.utils.Version; import be.nikiroo.utils.serial.server.ConnectActionClientObject; @@ -24,12 +25,71 @@ import be.nikiroo.utils.serial.server.ConnectActionClientObject; * @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 md5; + 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 @@ -40,41 +100,67 @@ public class RemoteLibrary extends BasicLibrary { * the port to contact it on */ public RemoteLibrary(String key, String host, int port) { - this.md5 = StringUtils.getMd5Hash(key); + 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.host = host; this.port = port; } @Override public String getLibraryName() { - return host + ":" + port; + return (rw ? "[READ-ONLY] " : "") + host + ":" + port; } @Override public Status getStatus() { + Instance.getTraceHandler().trace("Getting remote lib status..."); + Status status = getStatusDo(); + Instance.getTraceHandler().trace("Remote lib status: " + status); + return status; + } + + private Status getStatusDo() { final Status[] result = new Status[1]; result[0] = Status.INVALID; - ConnectActionClientObject action = null; try { - action = new ConnectActionClientObject(host, port, true) { + new RemoteConnectAction() { @Override public void action(Version serverVersion) throws Exception { - Object rep = send(new Object[] { md5, "PING" }); - if ("PONG".equals(rep)) { - result[0] = Status.READY; + 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.UNAUTORIZED; + result[0] = Status.UNAUTHORIZED; } } @Override protected void onError(Exception e) { - result[0] = Status.UNAVAILABLE; + if (e instanceof SSLException) { + result[0] = Status.UNAUTHORIZED; + } else { + result[0] = Status.UNAVAILABLE; + } } - }; - + }.connect(); } catch (UnknownHostException e) { result[0] = Status.INVALID; } catch (IllegalArgumentException e) { @@ -83,118 +169,91 @@ public class RemoteLibrary extends BasicLibrary { result[0] = Status.UNAVAILABLE; } - if (action != null) { - try { - action.connect(); - } catch (Exception e) { - result[0] = Status.UNAVAILABLE; - } - } - return result[0]; } @Override - public Image getCover(final String luid) { + public Image getCover(final String luid) throws IOException { final Image[] result = new Image[1]; - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - Object rep = send(new Object[] { md5, "GET_COVER", luid }); - result[0] = (Image) rep; - } - - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); - } - }.connect(); - } catch (Exception e) { - Instance.getTraceHandler().error(e); - } + 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) { + public Image getCustomSourceCover(final String source) throws IOException { return getCustomCover(source, "SOURCE"); } @Override - public Image getCustomAuthorCover(final String author) { + 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) { + private Image getCustomCover(final String source, final String type) + throws IOException { final Image[] result = new Image[1]; - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - Object rep = send(new Object[] { md5, "GET_CUSTOM_COVER", - type, source }); - result[0] = (Image) rep; - } - - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); - } - }.connect(); - } catch (Exception e) { - Instance.getTraceHandler().error(e); - } + 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) { + public synchronized Story getStory(final String luid, Progress pg) + throws IOException { final Progress pgF = pg; final Story[] result = new Story[1]; - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - Progress pg = pgF; - if (pg == null) { - pg = new Progress(); - } - - Object rep = send(new Object[] { md5, "GET_STORY", luid }); + connectRemoteAction(new RemoteAction() { + @Override + public void action(ConnectActionClientObject action) + throws Exception { + Progress pg = pgF; + if (pg == null) { + pg = new Progress(); + } - MetaData meta = null; - if (rep instanceof MetaData) { - meta = (MetaData) rep; - if (meta.getWords() <= Integer.MAX_VALUE) { - pg.setMinMax(0, (int) meta.getWords()); - } - } + Object rep = action.send(new Object[] { subkey, "GET_STORY", + luid }); - List list = new ArrayList(); - for (Object obj = send(null); obj != null; obj = send(null)) { - list.add(obj); - pg.add(1); + MetaData meta = null; + if (rep instanceof MetaData) { + meta = (MetaData) rep; + if (meta.getWords() <= Integer.MAX_VALUE) { + pg.setMinMax(0, (int) meta.getWords()); } - - result[0] = RemoteLibraryServer.rebuildStory(list); - pg.done(); } - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); + List list = new ArrayList(); + for (Object obj = action.send(null); obj != null; obj = action + .send(null)) { + list.add(obj); + pg.add(1); } - }.connect(); - } catch (Exception e) { - Instance.getTraceHandler().error(e); - } + + result[0] = RemoteLibraryServer.rebuildStory(list); + pg.done(); + } + }); return result[0]; } @@ -202,6 +261,7 @@ public class RemoteLibrary extends BasicLibrary { @Override public synchronized Story save(final Story story, final String luid, Progress pg) throws IOException { + final String[] luidSaved = new String[1]; Progress pgSave = new Progress(); Progress pgRefresh = new Progress(); @@ -215,32 +275,28 @@ public class RemoteLibrary extends BasicLibrary { final Progress pgF = pgSave; - new ConnectActionClientObject(host, port, true) { + connectRemoteAction(new RemoteAction() { @Override - public void action(Version serverVersion) throws Exception { + public void action(ConnectActionClientObject action) + throws Exception { Progress pg = pgF; if (story.getMeta().getWords() <= Integer.MAX_VALUE) { pg.setMinMax(0, (int) story.getMeta().getWords()); } - send(new Object[] { md5, "SAVE_STORY", luid }); + action.send(new Object[] { subkey, "SAVE_STORY", luid }); List list = RemoteLibraryServer.breakStory(story); for (Object obj : list) { - send(obj); + action.send(obj); pg.add(1); } - luidSaved[0] = (String) send(null); + luidSaved[0] = (String) action.send(null); pg.done(); } - - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); - } - }.connect(); + }); // because the meta changed: MetaData meta = getInfo(luidSaved[0]); @@ -260,47 +316,38 @@ public class RemoteLibrary extends BasicLibrary { @Override public synchronized void delete(final String luid) throws IOException { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - send(new Object[] { md5, "DELETE_STORY", luid }); - } - + connectRemoteAction(new RemoteAction() { @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); + public void action(ConnectActionClientObject action) + throws Exception { + action.send(new Object[] { subkey, "DELETE_STORY", luid }); } - }.connect(); + }); } @Override - public void setSourceCover(final String source, final String luid) { + public void setSourceCover(final String source, final String luid) + throws IOException { setCover(source, luid, "SOURCE"); } @Override - public void setAuthorCover(final String author, final String luid) { + public void setAuthorCover(final String author, final String luid) + throws IOException { setCover(author, luid, "AUTHOR"); } // type = "SOURCE" | "AUTHOR" private void setCover(final String value, final String luid, - final String type) { - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - send(new Object[] { md5, "SET_COVER", type, value, luid }); - } - - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); - } - }.connect(); - } catch (IOException e) { - Instance.getTraceHandler().error(e); - } + final String type) throws IOException { + connectRemoteAction(new RemoteAction() { + @Override + public void action(ConnectActionClientObject action) + throws Exception { + action.send(new Object[] { subkey, "SET_COVER", type, value, + luid }); + } + }); } @Override @@ -326,35 +373,27 @@ public class RemoteLibrary extends BasicLibrary { final Progress pgF = pgImprt; final String[] luid = new String[1]; - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - Progress pg = pgF; - - Object rep = send(new Object[] { md5, "IMPORT", - url.toString() }); + connectRemoteAction(new RemoteAction() { + @Override + public void action(ConnectActionClientObject action) + throws Exception { + Progress pg = pgF; - while (true) { - if (!RemoteLibraryServer.updateProgress(pg, rep)) { - break; - } + Object rep = action.send(new Object[] { subkey, "IMPORT", + url.toString() }); - rep = send(null); + while (true) { + if (!RemoteLibraryServer.updateProgress(pg, rep)) { + break; } - pg.done(); - luid[0] = (String) rep; + rep = action.send(null); } - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); - } - }.connect(); - } catch (IOException e) { - Instance.getTraceHandler().error(e); - } + pg.done(); + luid[0] = (String) rep; + } + }); if (luid[0] == null) { throw new IOException("Remote failure"); @@ -372,33 +411,26 @@ public class RemoteLibrary extends BasicLibrary { protected synchronized void changeSTA(final String luid, final String newSource, final String newTitle, final String newAuthor, Progress pg) throws IOException { - final Progress pgF = pg == null ? new Progress() : pg; - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - Progress pg = pgF; + final Progress pgF = pg == null ? new Progress() : pg; - Object rep = send(new Object[] { md5, "CHANGE_STA", luid, - newSource, newTitle, newAuthor }); - while (true) { - if (!RemoteLibraryServer.updateProgress(pg, rep)) { - break; - } + connectRemoteAction(new RemoteAction() { + @Override + public void action(ConnectActionClientObject action) + throws Exception { + Progress pg = pgF; - rep = send(null); + Object rep = action.send(new Object[] { subkey, "CHANGE_STA", + luid, newSource, newTitle, newAuthor }); + while (true) { + if (!RemoteLibraryServer.updateProgress(pg, rep)) { + break; } - } - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); + rep = action.send(null); } - }.connect(); - } catch (IOException e) { - Instance.getTraceHandler().error(e); - } + } + }); } @Override @@ -409,27 +441,22 @@ public class RemoteLibrary extends BasicLibrary { /** * Stop the server. + * + * @throws IOException + * in case of I/O error (including bad key) */ - public void exit() { - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - send(new Object[] { md5, "EXIT" }); - } - - @Override - protected void onError(Exception e) { - Instance.getTraceHandler().error(e); - } - }.connect(); - } catch (IOException e) { - Instance.getTraceHandler().error(e); - } + public void exit() throws IOException { + connectRemoteAction(new RemoteAction() { + @Override + public void action(ConnectActionClientObject action) + throws Exception { + action.send(new Object[] { subkey, "EXIT" }); + } + }); } @Override - public synchronized MetaData getInfo(String luid) { + public synchronized MetaData getInfo(String luid) throws IOException { List metas = getMetasList(luid, null); if (!metas.isEmpty()) { return metas.get(0); @@ -439,7 +466,7 @@ public class RemoteLibrary extends BasicLibrary { } @Override - protected List getMetas(Progress pg) { + protected List getMetas(Progress pg) throws IOException { return getMetasList("*", pg); } @@ -483,50 +510,78 @@ public class RemoteLibrary extends BasicLibrary { * @param pg * the optional progress * - * * @return the metas + * + * @throws IOException + * in case of I/O error or bad key (SSLException) */ - private List getMetasList(final String luid, Progress pg) { + private List getMetasList(final String luid, Progress pg) + throws IOException { final Progress pgF = pg; final List metas = new ArrayList(); - try { - new ConnectActionClientObject(host, port, true) { - @Override - public void action(Version serverVersion) throws Exception { - Progress pg = pgF; - if (pg == null) { - pg = new Progress(); - } - - Object rep = send(new Object[] { md5, "GET_METADATA", luid }); + connectRemoteAction(new RemoteAction() { + @Override + public void action(ConnectActionClientObject action) + throws Exception { + Progress pg = pgF; + if (pg == null) { + pg = new Progress(); + } - while (true) { - if (!RemoteLibraryServer.updateProgress(pg, rep)) { - break; - } + Object rep = action.send(new Object[] { subkey, "GET_METADATA", + luid }); - rep = send(null); + while (true) { + if (!RemoteLibraryServer.updateProgress(pg, rep)) { + break; } - if (rep instanceof MetaData[]) { - for (MetaData meta : (MetaData[]) rep) { - metas.add(meta); - } - } else if (rep != null) { - metas.add((MetaData) rep); + rep = action.send(null); + } + + if (rep instanceof MetaData[]) { + for (MetaData meta : (MetaData[]) rep) { + metas.add(meta); } + } else if (rep != null) { + metas.add((MetaData) rep); + } + } + }); + + return metas; + } + + private void connectRemoteAction(final RemoteAction runAction) + throws IOException { + final IOException[] err = new IOException[1]; + try { + final RemoteConnectAction[] array = new RemoteConnectAction[1]; + RemoteConnectAction ra = new RemoteConnectAction() { + @Override + public void action(Version serverVersion) throws Exception { + runAction.action(array[0]); } @Override protected void onError(Exception e) { - Instance.getTraceHandler().error(e); + if (!(e instanceof IOException)) { + Instance.getTraceHandler().error(e); + return; + } + + err[0] = (IOException) e; } - }.connect(); + }; + array[0] = ra; + ra.connect(); } catch (Exception e) { - Instance.getTraceHandler().error(e); + err[0] = (IOException) e; } - return metas; + if (err[0] != null) { + throw err[0]; + } } } diff --git a/src/be/nikiroo/fanfix/library/RemoteLibraryException.java b/src/be/nikiroo/fanfix/library/RemoteLibraryException.java new file mode 100644 index 0000000..4cbb631 --- /dev/null +++ b/src/be/nikiroo/fanfix/library/RemoteLibraryException.java @@ -0,0 +1,100 @@ +package be.nikiroo.fanfix.library; + +import java.io.IOException; + +/** + * Exceptions sent from remote to local. + * + * @author niki + */ +public class RemoteLibraryException extends IOException { + private static final long serialVersionUID = 1L; + + private boolean wrapped; + + @SuppressWarnings("unused") + private RemoteLibraryException() { + // for serialization purposes + } + + /** + * Wrap an {@link IOException} to allow it to pass across the network. + * + * @param cause + * the exception to wrap + * @param remote + * this exception is used to send the contained + * {@link IOException} to the other end of the network + */ + public RemoteLibraryException(IOException cause, boolean remote) { + this(null, cause, remote); + } + + /** + * Wrap an {@link IOException} to allow it to pass across the network. + * + * @param message + * the error message + * @param wrapped + * this exception is used to send the contained + * {@link IOException} to the other end of the network + */ + public RemoteLibraryException(String message, boolean wrapped) { + this(message, null, wrapped); + } + + /** + * Wrap an {@link IOException} to allow it to pass across the network. + * + * @param message + * the error message + * @param cause + * the exception to wrap + * @param wrapped + * this exception is used to send the contained + * {@link IOException} to the other end of the network + */ + public RemoteLibraryException(String message, IOException cause, + boolean wrapped) { + super(message, cause); + this.wrapped = wrapped; + } + + /** + * Return the actual exception we should return to the client code. It can + * be: + *
      + *
    • the cause if {@link RemoteLibraryException#isWrapped()} is + * TRUE
    • + *
    • this if {@link RemoteLibraryException#isWrapped()} is FALSE + * (
    • + *
    • this if the cause is NULL (so we never return NULL) + *
    • + *
    + * It is never NULL. + * + * @return the unwrapped exception or this, never NULL + */ + public synchronized IOException unwrapException() { + Throwable ex = super.getCause(); + if (!isWrapped() || !(ex instanceof IOException)) { + ex = this; + } + + return (IOException) ex; + } + + /** + * This exception is used to send the contained {@link IOException} to the + * other end of the network. + *

    + * In other words, do not use this exception in client code when it + * has reached the other end of the network, but use its cause instead (see + * {@link RemoteLibraryException#unwrapException()}). + * + * @return TRUE if it is + */ + public boolean isWrapped() { + return wrapped; + } +} diff --git a/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java b/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java index d739438..43f61b0 100644 --- a/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java +++ b/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java @@ -4,9 +4,14 @@ import java.io.IOException; 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 javax.net.ssl.SSLException; 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; @@ -19,45 +24,51 @@ import be.nikiroo.utils.serial.server.ConnectActionServerObject; import be.nikiroo.utils.serial.server.ServerObject; /** - * Create a new remote server that will listen for order on the given port. + * Create a new remote server that will listen for orders on the given port. + *

    + * The available commands are given as arrays of objects (first item is the + * command, the rest are the arguments). *

    - * The available commands are given as arrays of objects (first item is the key, - * second is the command, the rest are the arguments). + * All the commands are always prefixed by the subkey (which can be EMPTY if + * none). *

    - * The md5 is always a String (the MD5 hash of the access key), the commands are - * also Strings; the parameters vary depending upon the command. *

      - *
    • [md5] PING: will return PONG if the key is accepted
    • - *
    • [md5] GET_METADATA *: will return the metadata of all the stories in the - * library (array)
    • - * * - *
    • [md5] GET_METADATA [luid]: will return the metadata of the story of LUID - * luid
    • - *
    • [md5] GET_STORY [luid]: will return the given story if it exists (or NULL - * if not)
    • - *
    • [md5] SAVE_STORY [luid]: save the story (that must be sent just after the + *
    • PING: will return the mode if the key is accepted (mode can be: "r/o" or + * "r/w")
    • + *
    • GET_METADATA *: will return the metadata of all the stories in the + * library (array)
    • * + *
    • GET_METADATA [luid]: will return the metadata of the story of LUID luid
    • + *
    • GET_STORY [luid]: will return the given story if it exists (or NULL if + * not)
    • + *
    • SAVE_STORY [luid]: save the story (that must be sent just after the * command) with the given LUID, then return the LUID
    • - *
    • [md5] IMPORT [url]: save the story found at the given URL, then return - * the LUID
    • - *
    • [md5] DELETE_STORY [luid]: delete the story of LUID luid
    • - *
    • [md5] GET_COVER [luid]: return the cover of the story
    • - *
    • [md5] GET_CUSTOM_COVER ["SOURCE"|"AUTHOR"] [source]: return the cover for - * this source/author
    • - *
    • [md5] SET_COVER ["SOURCE"|"AUTHOR"] [value] [luid]: set the default cover - * for the given source/author to the cover of the story denoted by luid
    • - *
    • [md5] CHANGE_SOURCE [luid] [new source]: change the source of the story - * of LUID luid
    • - *
    • [md5] EXIT: stop the server
    • + *
    • IMPORT [url]: save the story found at the given URL, then return the LUID + *
    • + *
    • DELETE_STORY [luid]: delete the story of LUID luid
    • + *
    • GET_COVER [luid]: return the cover of the story
    • + *
    • GET_CUSTOM_COVER ["SOURCE"|"AUTHOR"] [source]: return the cover for this + * source/author
    • + *
    • SET_COVER ["SOURCE"|"AUTHOR"] [value] [luid]: set the default cover for + * the given source/author to the cover of the story denoted by luid
    • + *
    • CHANGE_SOURCE [luid] [new source]: change the source of the story of LUID + * luid
    • + *
    • EXIT: stop the server
    • *
    * * @author niki */ public class RemoteLibraryServer extends ServerObject { - private final String md5; + private Map commands = new HashMap(); + private Map times = new HashMap(); + private Map wls = new HashMap(); + private Map rws = new HashMap(); /** * Create a new remote server (will not be active until * {@link RemoteLibraryServer#start()} is called). + *

    + * 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 @@ -68,22 +79,28 @@ public class RemoteLibraryServer extends ServerObject { * in case of I/O error */ public RemoteLibraryServer(String key, int port) throws IOException { - super("Fanfix remote library", port, true); - this.md5 = StringUtils.getMd5Hash(key); - + super("Fanfix remote library", port, key); setTraceHandler(Instance.getTraceHandler()); } @Override protected Object onRequest(ConnectActionServerObject action, - Version clientVersion, Object data) throws Exception { - String md5 = ""; + Version clientVersion, Object data, long id) throws Exception { + long start = new Date().getTime(); + + // defaults are positive (as previous versions without the feature) + boolean rw = true; + boolean wl = true; + + String subkey = ""; String command = ""; Object[] args = new Object[0]; if (data instanceof Object[]) { Object[] dataArray = (Object[]) data; - if (dataArray.length >= 2) { - md5 = "" + dataArray[0]; + if (dataArray.length > 0) { + subkey = "" + dataArray[0]; + } + if (dataArray.length > 1) { command = "" + dataArray[1]; args = new Object[dataArray.length - 2]; @@ -93,38 +110,99 @@ public class RemoteLibraryServer extends ServerObject { } } - String trace = "[ " + command + "] "; - for (Object arg : args) { - trace += arg + " "; + List whitelist = Instance.getConfig().getList( + Config.SERVER_WHITELIST); + if (whitelist == null) { + whitelist = new ArrayList(); } - getTraceHandler().trace(trace); - if (!md5.equals(this.md5)) { - getTraceHandler().trace("Key rejected."); - return null; + if (whitelist.isEmpty()) { + wl = false; } - long start = new Date().getTime(); - Object rep = doRequest(action, command, args); + rw = Instance.getConfig().getBoolean(Config.SERVER_RW, rw); + if (!subkey.isEmpty()) { + List allowed = Instance.getConfig().getList( + Config.SERVER_ALLOWED_SUBKEYS); + if (allowed.contains(subkey)) { + if ((subkey + "|").contains("|rw|")) { + rw = true; + } + if ((subkey + "|").contains("|wl|")) { + wl = false; // |wl| = bypass whitelist + whitelist = new ArrayList(); + } + } + } + + String mode = display(wl, rw); + + String trace = mode + "[ " + command + "] "; + for (Object arg : args) { + trace += arg + " "; + } + long now = System.currentTimeMillis(); + System.out.println(StringUtils.fromTime(now) + ": " + trace); + + Object rep = null; + try { + rep = doRequest(action, command, args, rw, whitelist); + } catch (IOException e) { + rep = new RemoteLibraryException(e, true); + } - getTraceHandler().trace( - String.format("[>%s]: %d ms", command, - (new Date().getTime() - start))); + commands.put(id, command); + wls.put(id, wl); + rws.put(id, rw); + times.put(id, (new Date().getTime() - start)); return rep; } + private String display(boolean whitelist, boolean rw) { + String mode = ""; + if (!rw) { + mode += "RO: "; + } + if (whitelist) { + mode += "WL: "; + } + + return mode; + } + + @Override + protected void onRequestDone(long id, long bytesReceived, long bytesSent) { + boolean whitelist = wls.get(id); + boolean rw = rws.get(id); + wls.remove(id); + rws.remove(id); + + String rec = StringUtils.formatNumber(bytesReceived) + "b"; + String sent = StringUtils.formatNumber(bytesSent) + "b"; + long now = System.currentTimeMillis(); + System.out.println(StringUtils.fromTime(now) + + ": " + + String.format("%s[>%s]: (%s sent, %s rec) in %d ms", + display(whitelist, rw), commands.get(id), sent, rec, + times.get(id))); + + commands.remove(id); + times.remove(id); + } + private Object doRequest(ConnectActionServerObject action, String command, - Object[] args) throws NoSuchFieldException, NoSuchMethodException, + Object[] args, boolean rw, List whitelist) + throws NoSuchFieldException, NoSuchMethodException, ClassNotFoundException, IOException { if ("PING".equals(command)) { - return "PONG"; + return rw ? "r/w" : "r/o"; } else if ("GET_METADATA".equals(command)) { + List metas = new ArrayList(); + if ("*".equals(args[0])) { Progress pg = createPgForwarder(action); - List metas = new ArrayList(); - for (MetaData meta : Instance.getLibrary().getMetas(pg)) { MetaData light; if (meta.getCover() == null) { @@ -138,13 +216,41 @@ public class RemoteLibraryServer extends ServerObject { } forcePgDoneSent(pg); - return metas.toArray(new MetaData[] {}); + } else { + MetaData meta = Instance.getLibrary().getInfo((String) args[0]); + MetaData light; + if (meta.getCover() == null) { + light = meta; + } else { + light = meta.clone(); + light.setCover(null); + } + + metas.add(light); } - return new MetaData[] { Instance.getLibrary().getInfo( - (String) args[0]) }; + if (!whitelist.isEmpty()) { + for (int i = 0; i < metas.size(); i++) { + if (!whitelist.contains(metas.get(i).getSource())) { + metas.remove(i); + i--; + } + } + } + + return metas.toArray(new MetaData[0]); } else if ("GET_STORY".equals(command)) { MetaData meta = Instance.getLibrary().getInfo((String) args[0]); + if (meta == null) { + return null; + } + + if (!whitelist.isEmpty()) { + if (!whitelist.contains(meta.getSource())) { + return null; + } + } + meta = meta.clone(); meta.setCover(null); @@ -158,6 +264,11 @@ public class RemoteLibraryServer extends ServerObject { action.rec(); } } else if ("SAVE_STORY".equals(command)) { + if (!rw) { + throw new RemoteLibraryException("Read-Only remote library: " + + args[0], false); + } + List list = new ArrayList(); action.send(null); @@ -172,12 +283,22 @@ public class RemoteLibraryServer extends ServerObject { Instance.getLibrary().save(story, (String) args[0], null); return story.getMeta().getLuid(); } else if ("IMPORT".equals(command)) { + if (!rw) { + throw new RemoteLibraryException("Read-Only remote library: " + + args[0], false); + } + Progress pg = createPgForwarder(action); Story story = Instance.getLibrary().imprt( new URL((String) args[0]), pg); forcePgDoneSent(pg); return story.getMeta().getLuid(); } else if ("DELETE_STORY".equals(command)) { + if (!rw) { + throw new RemoteLibraryException("Read-Only remote library: " + + args[0], false); + } + Instance.getLibrary().delete((String) args[0]); } else if ("GET_COVER".equals(command)) { return Instance.getLibrary().getCover((String) args[0]); @@ -192,6 +313,11 @@ public class RemoteLibraryServer extends ServerObject { return null; } } else if ("SET_COVER".equals(command)) { + if (!rw) { + throw new RemoteLibraryException("Read-Only remote library: " + + args[0] + ", " + args[1], false); + } + if ("SOURCE".equals(args[0])) { Instance.getLibrary().setSourceCover((String) args[1], (String) args[2]); @@ -200,11 +326,21 @@ public class RemoteLibraryServer extends ServerObject { (String) args[2]); } } else if ("CHANGE_STA".equals(command)) { + if (!rw) { + throw new RemoteLibraryException("Read-Only remote library: " + + args[0] + ", " + args[1], false); + } + Progress pg = createPgForwarder(action); Instance.getLibrary().changeSTA((String) args[0], (String) args[1], (String) args[2], (String) args[3], pg); forcePgDoneSent(pg); } else if ("EXIT".equals(command)) { + if (!rw) { + throw new RemoteLibraryException( + "Read-Only remote library: EXIT", false); + } + stop(0, false); } @@ -213,7 +349,13 @@ public class RemoteLibraryServer extends ServerObject { @Override protected void onError(Exception e) { - getTraceHandler().error(e); + if (e instanceof SSLException) { + long now = System.currentTimeMillis(); + System.out.println(StringUtils.fromTime(now) + ": " + + "[Client connection refused (bad key)]"); + } else { + getTraceHandler().error(e); + } } /** @@ -308,8 +450,7 @@ public class RemoteLibraryServer extends ServerObject { * * @return the {@link Progress} */ - private static Progress createPgForwarder( - final ConnectActionServerObject action) { + private Progress createPgForwarder(final ConnectActionServerObject action) { final Boolean[] isDoneForwarded = new Boolean[] { false }; final Progress pg = new Progress() { @Override @@ -342,7 +483,7 @@ public class RemoteLibraryServer extends ServerObject { action.send(new Integer[] { min, max, relativeProgress }); action.rec(); } catch (Exception e) { - Instance.getTraceHandler().error(e); + getTraceHandler().error(e); } lastTime[0] = new Date().getTime(); @@ -356,14 +497,14 @@ public class RemoteLibraryServer extends ServerObject { } // with 30 seconds timeout - private static void forcePgDoneSent(Progress pg) { + private void forcePgDoneSent(Progress pg) { long start = new Date().getTime(); pg.done(); while (!pg.isDone() && new Date().getTime() - start < 30000) { try { Thread.sleep(100); } catch (InterruptedException e) { - Instance.getTraceHandler().error(e); + getTraceHandler().error(e); } } } diff --git a/src/be/nikiroo/fanfix/package-info.java b/src/be/nikiroo/fanfix/package-info.java index 6929cac..cfd9cbe 100644 --- a/src/be/nikiroo/fanfix/package-info.java +++ b/src/be/nikiroo/fanfix/package-info.java @@ -4,7 +4,7 @@ * files that you can read anywhere. *

    * It has support for a {@link be.nikiroo.fanfix.library.BasicLibrary} system, - * too. + * too, and can even offer its services over the network. * * @author niki */ diff --git a/src/be/nikiroo/fanfix/reader/BasicReader.java b/src/be/nikiroo/fanfix/reader/BasicReader.java index c2a650c..61769c0 100644 --- a/src/be/nikiroo/fanfix/reader/BasicReader.java +++ b/src/be/nikiroo/fanfix/reader/BasicReader.java @@ -54,7 +54,7 @@ public abstract class BasicReader implements Reader { } @Override - public synchronized Story getStory(Progress pg) { + public synchronized Story getStory(Progress pg) throws IOException { if (story == null) { story = getLibrary().getStory(meta.getLuid(), pg); } @@ -227,14 +227,19 @@ public abstract class BasicReader implements Reader { 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", format(meta.getWords())); + metaDesc.put("Number of images", count); } else { - metaDesc.put("Number of words", format(meta.getWords())); + metaDesc.put("Number of words", count); } metaDesc.put("Source", meta.getSource()); metaDesc.put("Subject", meta.getSubject()); @@ -351,46 +356,32 @@ public abstract class BasicReader implements Reader { } } - static private String format(long value) { - String display = ""; - String suffix = ""; - - if (value > 4000) { - value = value / 1000; - suffix = "k"; - } - - while (value > 0) { - if (!display.isEmpty()) { - display = "." + display; - } - display = (value % 1000) + display; - value = value / 1000; - } - - return display + suffix; - } - static private String formatDate(String date) { long ms = 0; - try { - ms = StringUtils.toTime(date); - } catch (ParseException e) { - } - - if (ms <= 0) { - SimpleDateFormat sdf = new SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ssSSS"); + if (date != null && !date.isEmpty()) { try { - ms = sdf.parse(date).getTime(); + 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 (ms > 0) { - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); - return sdf.format(new Date(ms)); + if (date == null) { + date = ""; } // :( diff --git a/src/be/nikiroo/fanfix/reader/Reader.java b/src/be/nikiroo/fanfix/reader/Reader.java index b001e30..3ecf247 100644 --- a/src/be/nikiroo/fanfix/reader/Reader.java +++ b/src/be/nikiroo/fanfix/reader/Reader.java @@ -6,6 +6,7 @@ import java.net.URL; import be.nikiroo.fanfix.data.MetaData; import be.nikiroo.fanfix.data.Story; import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix.supported.SupportType; import be.nikiroo.utils.Progress; /** @@ -70,8 +71,12 @@ public interface Reader { * the optional progress * * @return the {@link Story} + * + * @throws IOException + * in case of I/O error + * */ - public Story getStory(Progress pg); + public Story getStory(Progress pg) throws IOException; /** * The {@link BasicLibrary} to load the stories from (by default, takes the @@ -166,8 +171,81 @@ public interface Reader { * @param source * the type of {@link Story} to take into account, or NULL for * all + * + * @throws IOException + * in case of I/O error + */ + public void browse(String source) throws IOException; + + /** + * Display all supports that allow search operations. + * + * @param sync + * execute the process synchronously (wait until it is terminated + * before returning) + * + * @throws IOException + * in case of I/O error + */ + public void search(boolean sync) throws IOException; + + /** + * Search for the given terms and find stories that correspond if possible. + * + * @param searchOn + * the website to search on + * @param keywords + * the words to search for (cannot be NULL) + * @param page + * the page of results to show (0 = request the maximum number of + * pages, pages start at 1) + * @param item + * the item to select (0 = do not select a specific item but show + * all the page, items start at 1) + * @param sync + * execute the process synchronously (wait until it is terminated + * before returning) + * + * @throws IOException + * in case of I/O error + */ + public void search(SupportType searchOn, String keywords, int page, + int item, boolean sync) throws IOException; + + /** + * Search based upon a hierarchy of tags, or search for (sub)tags. + *

    + * We use the tags DisplayName. + *

    + * If no tag is given, the main tags will be shown. + *

    + * If a non-leaf tag is given, the subtags will be shown. + *

    + * If a leaf tag is given (or a full hierarchy ending with a leaf tag), + * stories will be shown. + *

    + * You can select the story you want with the item number. + * + * @param searchOn + * the website to search on + * @param page + * the page of results to show (0 = request the maximum number of + * pages, pages start at 1) + * @param item + * the item to select (0 = do not select a specific item but show + * all the page, items start at 1) + * @param sync + * execute the process synchronously (wait until it is terminated + * before returning) + * @param tags + * the tags indices to search for (this is a tag + * hierarchy, NOT a multiple tags choice) + * + * @throws IOException + * in case of I/O error */ - public void browse(String source); + public void searchTag(SupportType searchOn, int page, int item, + boolean sync, Integer... tags) throws IOException; /** * Open the {@link Story} with an external reader (the program should be diff --git a/src/be/nikiroo/fanfix/reader/android/AndroidReader.java b/src/be/nikiroo/fanfix/reader/android/AndroidReader.java deleted file mode 100644 index 7dcdd04..0000000 --- a/src/be/nikiroo/fanfix/reader/android/AndroidReader.java +++ /dev/null @@ -1,62 +0,0 @@ -package be.nikiroo.fanfix.reader.android; - -import android.app.Activity; -import android.content.Intent; -import android.net.Uri; - -import java.io.File; -import java.io.IOException; - -import be.nikiroo.fanfix.reader.BasicReader; - -public class AndroidReader extends BasicReader { - private Activity app; - - /** - * Do not use. - */ - private AndroidReader() { - // Required for reflection - } - - public AndroidReader(Activity app) { - this.app = app; - } - - @Override - public void read(boolean sync) throws IOException { - } - - @Override - public void browse(String source) { - } - - @Override - protected void start(File target, String program, boolean sync) throws IOException { - if (program == null) { - try { - Intent[] intents = new Intent[] { // - new Intent(Intent.ACTION_VIEW), // - new Intent(Intent.ACTION_OPEN_DOCUMENT) // - }; - - for (Intent intent : intents) { - intent.setDataAndType(Uri.parse(target.toURI().toString()), - "application/x-cbz"); - } - - Intent chooserIntent = Intent.createChooser(intents[0], - "Open CBZ in..."); - - // chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, - // intents); - - app.startActivity(chooserIntent); - } catch (UnsupportedOperationException e) { - super.start(target, program, sync); - } - } else { - super.start(target, program, sync); - } - } -} diff --git a/src/be/nikiroo/fanfix/reader/android/AndroidReaderActivity.java b/src/be/nikiroo/fanfix/reader/android/AndroidReaderActivity.java deleted file mode 100644 index 290b5ba..0000000 --- a/src/be/nikiroo/fanfix/reader/android/AndroidReaderActivity.java +++ /dev/null @@ -1,191 +0,0 @@ -package be.nikiroo.fanfix.reader.android; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.FragmentTransaction; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Bundle; -import android.os.Environment; -import android.text.InputType; -import android.view.View; -import android.widget.EditText; - -import java.io.File; -import java.io.IOException; -import java.net.URL; - -import be.nikiroo.fanfix.Instance; -import be.nikiroo.fanfix.data.MetaData; -import be.nikiroo.fanfix.reader.BasicReader; -import be.nikiroo.fanfix.reader.Reader; -import be.nikiroo.utils.TraceHandler; - -public class AndroidReaderActivity extends Activity implements - AndroidReaderBook.OnFragmentInteractionListener { - private static Reader reader = null; - - @Override - protected void onCreate(Bundle savedInstanceState) { - reader = config(); - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - } - - @Override - protected void onStart() { - super.onStart(); - refresh(); - } - - private void refresh() { - AndroidReaderGroup group = new AndroidReaderGroup(); - - FragmentTransaction trans = getFragmentManager().beginTransaction(); - trans.replace(R.id.Main_pnlStories, group); - trans.commit(); - getFragmentManager().executePendingTransactions(); - - group.fill(reader.getLibrary().getList(), reader); - } - - public void onAdd(View view) { - final View root = findViewById(R.id.Main); - - ask(this, - "Import new story", - "Enter the story URL (the program will then download it -- the interface will not be usable until it is downloaded", - "Download", new AnswerListener() { - @Override - public void onAnswer(final String answer) { - root.setEnabled(false); - new Thread(new Runnable() { - @Override - public void run() { - try { - URL url = new URL(answer); - reader.getLibrary().imprt(url, null); - } catch (Throwable e) { - // TODO: show error message correctly - String mess = ""; - for (String tab = ""; e != null - && e != e.getCause(); e = e - .getCause()) { - mess += tab + "[" - + e.getClass().getSimpleName() - + "] " + e.getMessage() + "\n"; - tab += "\t"; - } - - final String messf = mess; - AndroidReaderActivity.this - .runOnUiThread(new Runnable() { - @Override - public void run() { - ask(AndroidReaderActivity.this, - "Error", - "Cannot import URL: \n" - + messf, - "OK", null); - } - }); - - } - - AndroidReaderActivity.this - .runOnUiThread(new Runnable() { - @Override - public void run() { - refresh(); - root.setEnabled(true); - } - }); - } - }).start(); - } - }); - - /* - * Intent intent = new Intent(AndroidReaderActivity.this, SayIt.class); - * intent.putExtra(SayIt.MESSAGE, message); startActivity(intent); - */ - } - - @Override - public void onFragmentInteraction(MetaData meta) { - AndroidReader reader = new AndroidReader(this); - try { - reader.openExternal(Instance.getLibrary(), meta.getLuid()); - } catch (IOException e) { - e.printStackTrace(); - } - } - - private Reader config() { - if (reader != null) { - return reader; - } - - String internal = getExternalFilesDir(null).toString(); - File user = Environment - .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); - - try { - File parent = user.getParentFile(); - if (parent.exists() || parent.mkdirs()) { - File test = new File(parent, "test"); - if (test.exists() || (test.createNewFile() && test.delete())) { - user = parent; - } - } - } catch (Exception e) { - // Fall back to Documents/Books - } - - System.setProperty("DEBUG", "1"); - System.setProperty("fanfix.home", internal); - System.setProperty("fanfix.libdir", new File(user, "Books").toString()); - - Instance.resetConfig(false); - Instance.setTraceHandler(new TraceHandler(true, true, 2)); - - BasicReader.setDefaultReaderType(Reader.ReaderType.ANDROID); - return BasicReader.getReader(); - } - - public static void ask(Context context, String title, String message, - String okMessage, final AnswerListener listener) { - final EditText input = new EditText(context); - input.setFocusable(true); - input.setInputType(InputType.TYPE_CLASS_TEXT); - - AlertDialog.Builder alert = new AlertDialog.Builder(context); - alert.setTitle(title); - alert.setMessage(message); - alert.setCancelable(true); - alert.setView(input); - - if (listener != null) { - alert.setPositiveButton(okMessage, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - listener.onAnswer(input.getText().toString()); - } - }); - - alert.setOnCancelListener(new DialogInterface.OnCancelListener() { - @Override - public void onCancel(DialogInterface dialog) { - listener.onAnswer(null); - } - }); - } - - alert.show(); - } - - private interface AnswerListener { - public void onAnswer(String answer); - } -} \ No newline at end of file diff --git a/src/be/nikiroo/fanfix/reader/android/AndroidReaderBook.java b/src/be/nikiroo/fanfix/reader/android/AndroidReaderBook.java deleted file mode 100644 index 700a566..0000000 --- a/src/be/nikiroo/fanfix/reader/android/AndroidReaderBook.java +++ /dev/null @@ -1,122 +0,0 @@ -package be.nikiroo.fanfix.reader.android; - -import android.app.Activity; -import android.app.Fragment; -import android.graphics.Bitmap; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; - -import java.io.IOException; - -import be.nikiroo.fanfix.data.MetaData; -import be.nikiroo.fanfix.reader.Reader; -import be.nikiroo.utils.Image; -import be.nikiroo.utils.android.ImageUtilsAndroid; - -public class AndroidReaderBook extends Fragment { - private OnFragmentInteractionListener listener; - - /** - * This interface must be implemented by activities that contain this - * fragment to allow an interaction in this fragment to be communicated to - * the activity and potentially other fragments contained in that activity. - *

    - * See the Android Training lesson Communicating with Other Fragments for more information. - */ - public interface OnFragmentInteractionListener { - void onFragmentInteraction(MetaData meta); - } - - public AndroidReaderBook() { - // Required empty public constructor - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_android_reader_book, - container, false); - } - - @Override - public void onAttach(Activity context) { - super.onAttach(context); - if (context instanceof OnFragmentInteractionListener) { - listener = (OnFragmentInteractionListener) context; - } - } - - @Override - public void onDetach() { - super.onDetach(); - listener = null; - } - - public void fill(final MetaData meta, final Reader reader) { - ViewHolder viewHolder = new ViewHolder(getView()); - - viewHolder.title.setText(meta.getTitle()); - viewHolder.author.setText(meta.getAuthor()); - viewHolder.frame.setClickable(true); - viewHolder.frame.setFocusable(true); - viewHolder.frame.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - OnFragmentInteractionListener llistener = listener; - if (llistener != null) { - llistener.onFragmentInteraction(meta); - } - } - }); - - new AsyncTask() { - @Override - protected Image doInBackground(MetaData[] metas) { - if (metas[0].getCover() != null) { - return metas[0].getCover(); - } - - return reader.getLibrary().getCover(metas[0].getLuid()); - } - - @Override - protected void onPostExecute(Image coverImage) { - ViewHolder viewHolder = new ViewHolder(getView()); - - try { - if (coverImage != null) { - Bitmap coverBitmap = ImageUtilsAndroid - .fromImage(coverImage); - coverBitmap = Bitmap.createScaledBitmap(coverBitmap, - 128, 128, true); - viewHolder.cover.setImageBitmap(coverBitmap); - } - } catch (IOException e) { - e.printStackTrace(); - } - } - }.execute(meta); - } - - private class ViewHolder { - public FrameLayout frame; - public TextView title; - public TextView author; - public ImageView cover; - - public ViewHolder(View book) { - frame = book.findViewById(R.id.Book); - title = book.findViewById(R.id.Book_lblTitle); - author = book.findViewById(R.id.Book_lblAuthor); - cover = book.findViewById(R.id.Book_imgCover); - } - } -} diff --git a/src/be/nikiroo/fanfix/reader/android/AndroidReaderGroup.java b/src/be/nikiroo/fanfix/reader/android/AndroidReaderGroup.java deleted file mode 100644 index 2d48199..0000000 --- a/src/be/nikiroo/fanfix/reader/android/AndroidReaderGroup.java +++ /dev/null @@ -1,101 +0,0 @@ -package be.nikiroo.fanfix.reader.android; - -import android.app.Fragment; -import android.app.FragmentTransaction; -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.ListView; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import be.nikiroo.fanfix.data.MetaData; -import be.nikiroo.fanfix.reader.Reader; - -/** - * A simple {@link Fragment} subclass. Activities that contain this fragment - * must implement the {@link AndroidReaderGroup.OnFragmentInteractionListener} - * interface to handle interaction events. - */ -public class AndroidReaderGroup extends Fragment { - private OnFragmentInteractionListener listener; - private Map books = new HashMap(); - - public interface OnFragmentInteractionListener { - void onFragmentInteraction(MetaData meta); - } - - public AndroidReaderGroup() { - // Required empty public constructor - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_android_reader_group, - container, false); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - if (context instanceof OnFragmentInteractionListener) { - listener = (OnFragmentInteractionListener) context; - } - } - - @Override - public void onDetach() { - super.onDetach(); - listener = null; - } - - public void fill(final List metas, final Reader reader) { - final List datas = new ArrayList(metas); - - ListView list = getView().findViewById(R.id.Group_root); - list.setAdapter(new BaseAdapter() { - @Override - public int getCount() { - return datas.size(); - } - - @Override - public long getItemId(int position) { - return -1; // TODO: what is a "row id" in this context? - } - - @Override - public Object getItem(int position) { - return datas.get(position); - } - - @Override - public View getView(int position, View convertView, ViewGroup parent) { - AndroidReaderBook book = books.get(convertView); - if (book == null) { - book = new AndroidReaderBook(); - - FragmentTransaction trans = getFragmentManager() - .beginTransaction(); - trans.add(book, null); - trans.commit(); - getFragmentManager().executePendingTransactions(); - - books.put(book.getView(), book); - } - - MetaData meta = (MetaData) getItem(position); - book.fill(meta, reader); - - return book.getView(); - } - }); - } -} diff --git a/src/be/nikiroo/fanfix/reader/cli/CliReader.java b/src/be/nikiroo/fanfix/reader/cli/CliReader.java index 9ec37a5..2a085a7 100644 --- a/src/be/nikiroo/fanfix/reader/cli/CliReader.java +++ b/src/be/nikiroo/fanfix/reader/cli/CliReader.java @@ -10,6 +10,10 @@ import be.nikiroo.fanfix.data.MetaData; import be.nikiroo.fanfix.data.Paragraph; import be.nikiroo.fanfix.data.Story; import be.nikiroo.fanfix.reader.BasicReader; +import be.nikiroo.fanfix.searchable.BasicSearchable; +import be.nikiroo.fanfix.searchable.SearchableTag; +import be.nikiroo.fanfix.supported.SupportType; +import be.nikiroo.utils.StringUtils; /** * Command line {@link Story} reader. @@ -81,9 +85,8 @@ class CliReader extends BasicReader { } @Override - public void browse(String source) { - List stories; - stories = getLibrary().getListBySource(source); + public void browse(String source) throws IOException { + List stories = getLibrary().getListBySource(source); for (MetaData story : stories) { String author = ""; @@ -95,4 +98,153 @@ class CliReader extends BasicReader { + author); } } + + @Override + public void search(boolean sync) throws IOException { + for (SupportType type : SupportType.values()) { + if (BasicSearchable.getSearchable(type) != null) { + System.out.println(type); + } + } + } + + @Override + public void search(SupportType searchOn, String keywords, int page, + int item, boolean sync) throws IOException { + BasicSearchable search = BasicSearchable.getSearchable(searchOn); + + if (page == 0) { + System.out.println(search.searchPages(keywords)); + } else { + List metas = search.search(keywords, page); + + if (item == 0) { + System.out.println("Page " + page + " of stories for: " + + keywords); + displayStories(metas); + } else { + // ! 1-based index ! + if (item <= 0 || item > metas.size()) { + throw new IOException("Index out of bounds: " + item); + } + + MetaData meta = metas.get(item - 1); + displayStory(meta); + } + } + } + + @Override + public void searchTag(SupportType searchOn, int page, int item, + boolean sync, Integer... tags) throws IOException { + + BasicSearchable search = BasicSearchable.getSearchable(searchOn); + SearchableTag stag = search.getTag(tags); + + if (stag == null) { + // TODO i18n + System.out.println("Known tags: "); + int i = 1; + for (SearchableTag s : search.getTags()) { + System.out.println(String.format("%d: %s", i, s.getName())); + i++; + } + } else { + if (page <= 0) { + if (stag.isLeaf()) { + System.out.println(search.searchPages(stag)); + } else { + System.out.println(stag.getCount()); + } + } else { + List metas = null; + List subtags = null; + int count; + + if (stag.isLeaf()) { + metas = search.search(stag, page); + count = metas.size(); + } else { + subtags = stag.getChildren(); + count = subtags.size(); + } + + if (item > 0) { + if (item <= count) { + if (metas != null) { + MetaData meta = metas.get(item - 1); + displayStory(meta); + } else { + SearchableTag subtag = subtags.get(item - 1); + displayTag(subtag); + } + } else { + System.out.println("Invalid item: only " + count + + " items found"); + } + } else { + if (metas != null) { + // TODO i18n + System.out.println(String.format("Content of %s: ", + stag.getFqName())); + displayStories(metas); + } else { + // TODO i18n + System.out.println(String.format("Subtags of %s: ", + stag.getFqName())); + displayTags(subtags); + } + } + } + } + } + + private void displayTag(SearchableTag subtag) { + // TODO: i18n + String stories = "stories"; + String num = StringUtils.formatNumber(subtag.getCount()); + System.out.println(String.format("%s (%s), %s %s", subtag.getName(), + subtag.getFqName(), num, stories)); + } + + private void displayStory(MetaData meta) { + System.out.println(meta.getTitle()); + System.out.println(); + System.out.println(meta.getUrl()); + System.out.println(); + System.out.println("Tags: " + meta.getTags()); + System.out.println(); + for (Paragraph para : meta.getResume()) { + System.out.println(para.getContent()); + System.out.println(""); + } + } + + private void displayTags(List subtags) { + int i = 1; + for (SearchableTag subtag : subtags) { + String total = ""; + if (subtag.getCount() > 0) { + total = StringUtils.formatNumber(subtag.getCount()); + } + + if (total.isEmpty()) { + System.out + .println(String.format("%d: %s", i, subtag.getName())); + } else { + System.out.println(String.format("%d: %s (%s)", i, + subtag.getName(), total)); + } + + i++; + } + } + + private void displayStories(List metas) { + int i = 1; + for (MetaData meta : metas) { + System.out.println(i + ": " + meta.getTitle()); + i++; + } + } } diff --git a/src/be/nikiroo/fanfix/reader/tui/TuiReader.java b/src/be/nikiroo/fanfix/reader/tui/TuiReader.java index f94f783..bef84ea 100644 --- a/src/be/nikiroo/fanfix/reader/tui/TuiReader.java +++ b/src/be/nikiroo/fanfix/reader/tui/TuiReader.java @@ -7,6 +7,7 @@ import jexer.TApplication.BackendType; import be.nikiroo.fanfix.Instance; import be.nikiroo.fanfix.reader.BasicReader; import be.nikiroo.fanfix.reader.Reader; +import be.nikiroo.fanfix.supported.SupportType; /** * This {@link Reader}is based upon the TUI widget library 'jexer' @@ -70,4 +71,30 @@ class TuiReader extends BasicReader { Instance.getTraceHandler().error(e); } } + + @Override + public void search(boolean sync) throws IOException { + // TODO + if (sync) { + throw new java.lang.IllegalStateException("Not implemented yet."); + } + } + + @Override + public void search(SupportType searchOn, String keywords, int page, + int item, boolean sync) { + // TODO + if (sync) { + throw new java.lang.IllegalStateException("Not implemented yet."); + } + } + + @Override + public void searchTag(SupportType searchOn, int page, int item, + boolean sync, Integer... tags) { + // TODO + if (sync) { + throw new java.lang.IllegalStateException("Not implemented yet."); + } + } } diff --git a/src/be/nikiroo/fanfix/reader/tui/TuiReaderApplication.java b/src/be/nikiroo/fanfix/reader/tui/TuiReaderApplication.java index f08b84c..b6f31ff 100644 --- a/src/be/nikiroo/fanfix/reader/tui/TuiReaderApplication.java +++ b/src/be/nikiroo/fanfix/reader/tui/TuiReaderApplication.java @@ -25,6 +25,7 @@ import be.nikiroo.fanfix.library.BasicLibrary; import be.nikiroo.fanfix.reader.BasicReader; import be.nikiroo.fanfix.reader.Reader; import be.nikiroo.fanfix.reader.tui.TuiReaderMainWindow.Mode; +import be.nikiroo.fanfix.supported.SupportType; import be.nikiroo.utils.Progress; /** @@ -81,7 +82,7 @@ class TuiReaderApplication extends TApplication implements Reader { } @Override - public Story getStory(Progress pg) { + public Story getStory(Progress pg) throws IOException { return reader.getStory(pg); } @@ -112,7 +113,11 @@ class TuiReaderApplication extends TApplication implements Reader { @Override public void browse(String source) { - reader.browse(source); + try { + reader.browse(source); + } catch (IOException e) { + Instance.getTraceHandler().error(e); + } } @Override @@ -125,6 +130,23 @@ class TuiReaderApplication extends TApplication implements Reader { reader.setChapter(chapter); } + @Override + public void search(boolean sync) throws IOException { + reader.search(sync); + } + + @Override + public void search(SupportType searchOn, String keywords, int page, + int item, boolean sync) throws IOException { + reader.search(searchOn, keywords, page, item, sync); + } + + @Override + public void searchTag(SupportType searchOn, int page, int item, + boolean sync, Integer... tags) throws IOException { + reader.searchTag(searchOn, page, item, sync, tags); + } + /** * Open the given {@link Story} for reading. This may or may not start an * external program to read said {@link Story}. diff --git a/src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java b/src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java index 932cbcb..426355f 100644 --- a/src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java +++ b/src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java @@ -159,15 +159,24 @@ class TuiReaderMainWindow extends TWindow { } else if (smode.equals("Sources")) { selectTargets.clear(); selectTargets.add("(show all)"); - for (String source : reader.getLibrary().getSources()) { - selectTargets.add(source); + try { + for (String source : reader.getLibrary().getSources()) { + selectTargets.add(source); + } + } catch (IOException e) { + Instance.getTraceHandler().error(e); } + showTarget = true; } else { selectTargets.clear(); selectTargets.add("(show all)"); - for (String author : reader.getLibrary().getAuthors()) { - selectTargets.add(author); + try { + for (String author : reader.getLibrary().getAuthors()) { + selectTargets.add(author); + } + } catch (IOException e) { + Instance.getTraceHandler().error(e); } showTarget = true; @@ -231,12 +240,18 @@ class TuiReaderMainWindow extends TWindow { */ public void refreshStories() { List metas; - if (mode == Mode.SOURCE) { - metas = reader.getLibrary().getListBySource(target); - } else if (mode == Mode.AUTHOR) { - metas = reader.getLibrary().getListByAuthor(target); - } else { - metas = reader.getLibrary().getList(); + + try { + if (mode == Mode.SOURCE) { + metas = reader.getLibrary().getListBySource(target); + } else if (mode == Mode.AUTHOR) { + metas = reader.getLibrary().getListByAuthor(target); + } else { + metas = reader.getLibrary().getList(); + } + } catch (IOException e) { + Instance.getTraceHandler().error(e); + metas = new ArrayList(); } setMetas(metas); diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReader.java b/src/be/nikiroo/fanfix/reader/ui/GuiReader.java index f4a932b..b720af4 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReader.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReader.java @@ -25,6 +25,9 @@ import be.nikiroo.fanfix.library.BasicLibrary; import be.nikiroo.fanfix.library.CacheLibrary; import be.nikiroo.fanfix.reader.BasicReader; import be.nikiroo.fanfix.reader.Reader; +import be.nikiroo.fanfix.searchable.BasicSearchable; +import be.nikiroo.fanfix.searchable.SearchableTag; +import be.nikiroo.fanfix.supported.SupportType; import be.nikiroo.utils.Progress; import be.nikiroo.utils.Version; import be.nikiroo.utils.ui.UIUtils; @@ -218,6 +221,64 @@ class GuiReader extends BasicReader { } } + @Override + public void search(boolean sync) throws IOException { + GuiReaderSearchFrame search = new GuiReaderSearchFrame(this); + if (sync) { + sync(search); + } else { + search.setVisible(true); + } + } + + @Override + public void search(SupportType searchOn, String keywords, int page, + int item, boolean sync) { + GuiReaderSearchFrame search = new GuiReaderSearchFrame(this); + search.search(searchOn, keywords, page, item); + if (sync) { + sync(search); + } else { + search.setVisible(true); + } + } + + @Override + public void searchTag(final SupportType searchOn, final int page, + final int item, final boolean sync, final Integer... tags) { + + final GuiReaderSearchFrame search = new GuiReaderSearchFrame(this); + + final BasicSearchable searchable = BasicSearchable + .getSearchable(searchOn); + + Runnable action = new Runnable() { + @Override + public void run() { + SearchableTag tag = null; + try { + tag = searchable.getTag(tags); + } catch (IOException e) { + Instance.getTraceHandler().error(e); + } + + search.searchTag(searchOn, page, item, tag); + + if (sync) { + sync(search); + } else { + search.setVisible(true); + } + } + }; + + if (sync) { + action.run(); + } else { + new Thread(action).start(); + } + } + /** * Delete the {@link Story} from the cache if it is present, but NOT * from the main library. diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java index 23d4c31..f071be0 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java @@ -1,10 +1,13 @@ package be.nikiroo.fanfix.reader.ui; +import java.io.IOException; + import be.nikiroo.fanfix.bundles.StringIdGui; import be.nikiroo.fanfix.data.MetaData; import be.nikiroo.fanfix.data.Story; import be.nikiroo.fanfix.library.BasicLibrary; import be.nikiroo.utils.Image; +import be.nikiroo.utils.StringUtils; /** * Some meta information related to a "book" (which can either be a @@ -122,11 +125,22 @@ public class GuiReaderBookInfo { * the {@link BasicLibrary} to use to fetch the image * * @return the base image + * + * @throws IOException + * in case of I/O error */ - public Image getBaseImage(BasicLibrary lib) { + public Image getBaseImage(BasicLibrary lib) throws IOException { switch (type) { case STORY: - return lib.getCover(meta.getLuid()); + if (meta.getCover() != null) { + return meta.getCover(); + } + + if (meta.getLuid() != null) { + return lib.getCover(meta.getLuid()); + } + + return null; case SOURCE: return lib.getSourceCover(value); case AUTHOR: @@ -149,12 +163,15 @@ public class GuiReaderBookInfo { if (uid == null || uid.trim().isEmpty()) { uid = meta.getLuid(); } + if (uid == null || uid.trim().isEmpty()) { + uid = meta.getUrl(); + } GuiReaderBookInfo info = new GuiReaderBookInfo(Type.STORY, uid, meta.getTitle()); info.meta = meta; - info.count = formatNumber(meta.getWords()); + info.count = StringUtils.formatNumber(meta.getWords()); if (!info.count.isEmpty()) { info.count = GuiReader.trans( meta.isImageDocument() ? StringIdGui.BOOK_COUNT_IMAGES @@ -179,7 +196,13 @@ public class GuiReaderBookInfo { GuiReaderBookInfo info = new GuiReaderBookInfo(Type.SOURCE, "source_" + source, source); - info.count = formatNumber(lib.getListBySource(source).size()); + int size = 0; + try { + size = lib.getListBySource(source).size(); + } catch (IOException e) { + } + + info.count = StringUtils.formatNumber(size); if (!info.count.isEmpty()) { info.count = GuiReader.trans(StringIdGui.BOOK_COUNT_STORIES, info.count); @@ -203,7 +226,13 @@ public class GuiReaderBookInfo { GuiReaderBookInfo info = new GuiReaderBookInfo(Type.AUTHOR, "author_" + author, author); - info.count = formatNumber(lib.getListByAuthor(author).size()); + int size = 0; + try { + size = lib.getListByAuthor(author).size(); + } catch (IOException e) { + } + + info.count = StringUtils.formatNumber(size); if (!info.count.isEmpty()) { info.count = GuiReader.trans(StringIdGui.BOOK_COUNT_STORIES, info.count); @@ -211,26 +240,4 @@ public class GuiReaderBookInfo { return info; } - - /** - * Format a number for display (use the "k" notation if higher or equal to - * 4000). - * - * @param number - * the number to parse - * - * @return the displayable version of the number - */ - static private String formatNumber(long number) { - String displayNumber; - if (number >= 4000) { - displayNumber = "" + (number / 1000) + "k"; - } else if (number > 0) { - displayNumber = "" + number; - } else { - displayNumber = ""; - } - - return displayNumber; - } } diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java index 0bbf82e..f46ec1b 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java @@ -129,6 +129,24 @@ class GuiReaderCoverImager { return generateCoverIcon(lib, GuiReaderBookInfo.fromMeta(meta)); } + /** + * The width of a cover image. + * + * @return the width + */ + static public int getCoverWidth() { + return SPINE_WIDTH + COVER_WIDTH; + } + + /** + * The height of a cover image. + * + * @return the height + */ + static public int getCoverHeight() { + return COVER_HEIGHT + HOFFSET; + } + /** * Generate a cover icon based upon the given {@link GuiReaderBookInfo}. * @@ -158,9 +176,8 @@ class GuiReaderCoverImager { if (resizedImage == null) { try { Image cover = info.getBaseImage(lib); - resizedImage = new BufferedImage(SPINE_WIDTH + COVER_WIDTH, - SPINE_HEIGHT + COVER_HEIGHT + HOFFSET, - BufferedImage.TYPE_4BYTE_ABGR); + resizedImage = new BufferedImage(getCoverWidth(), + getCoverHeight(), BufferedImage.TYPE_4BYTE_ABGR); Graphics2D g = resizedImage.createGraphics(); try { diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java index e207023..a28dc8a 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java @@ -31,11 +31,14 @@ 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.fanfix.library.BasicLibrary.Status; import be.nikiroo.fanfix.library.LocalLibrary; import be.nikiroo.fanfix.output.BasicOutput.OutputType; import be.nikiroo.fanfix.reader.BasicReader; import be.nikiroo.fanfix.reader.ui.GuiReaderMainPanel.FrameHelper; import be.nikiroo.fanfix.reader.ui.GuiReaderMainPanel.StoryRunnable; +import be.nikiroo.fanfix.searchable.BasicSearchable; +import be.nikiroo.fanfix.supported.SupportType; import be.nikiroo.utils.Progress; import be.nikiroo.utils.Version; import be.nikiroo.utils.ui.ConfigEditor; @@ -78,7 +81,7 @@ class GuiReaderFrame extends JFrame implements FrameHelper { */ public GuiReaderFrame(GuiReader reader, String type) { super(getAppTitle(reader.getLibrary().getLibraryName())); - + this.reader = reader; mainPanel = new GuiReaderMainPanel(this, type); @@ -90,20 +93,25 @@ class GuiReaderFrame extends JFrame implements FrameHelper { @Override public JPopupMenu createBookPopup() { + Status status = reader.getLibrary().getStatus(); JPopupMenu popup = new JPopupMenu(); popup.add(createMenuItemOpenBook()); popup.addSeparator(); popup.add(createMenuItemExport()); - popup.add(createMenuItemMoveTo(true)); - popup.add(createMenuItemSetCoverForSource()); - popup.add(createMenuItemSetCoverForAuthor()); + if (status.isWritable()) { + popup.add(createMenuItemMoveTo()); + popup.add(createMenuItemSetCoverForSource()); + popup.add(createMenuItemSetCoverForAuthor()); + } popup.add(createMenuItemClearCache()); - popup.add(createMenuItemRedownload()); - popup.addSeparator(); - popup.add(createMenuItemRename(true)); - popup.add(createMenuItemSetAuthor(true)); - popup.addSeparator(); - popup.add(createMenuItemDelete()); + if (status.isWritable()) { + popup.add(createMenuItemRedownload()); + popup.addSeparator(); + popup.add(createMenuItemRename()); + popup.add(createMenuItemSetAuthor()); + popup.addSeparator(); + popup.add(createMenuItemDelete()); + } popup.addSeparator(); popup.add(createMenuItemProperties()); return popup; @@ -117,7 +125,7 @@ class GuiReaderFrame extends JFrame implements FrameHelper { } @Override - public void createMenu(boolean libOk) { + public void createMenu(Status status) { invalidate(); JMenuBar bar = new JMenuBar(); @@ -155,13 +163,15 @@ class GuiReaderFrame extends JFrame implements FrameHelper { file.add(createMenuItemOpenBook()); file.add(createMenuItemExport()); - file.add(createMenuItemMoveTo(libOk)); - file.addSeparator(); - file.add(imprt); - file.add(imprtF); - file.addSeparator(); - file.add(createMenuItemRename(libOk)); - file.add(createMenuItemSetAuthor(libOk)); + if (status.isWritable()) { + file.add(createMenuItemMoveTo()); + file.addSeparator(); + file.add(imprt); + file.add(imprtF); + file.addSeparator(); + file.add(createMenuItemRename()); + file.add(createMenuItemSetAuthor()); + } file.addSeparator(); file.add(createMenuItemProperties()); file.addSeparator(); @@ -181,6 +191,24 @@ class GuiReaderFrame extends JFrame implements FrameHelper { bar.add(edit); + JMenu search = new JMenu(GuiReader.trans(StringIdGui.MENU_SEARCH)); + search.setMnemonic(KeyEvent.VK_H); + for (final SupportType type : SupportType.values()) { + BasicSearchable searchable = BasicSearchable.getSearchable(type); + if (searchable != null) { + JMenuItem searchItem = new JMenuItem(type.getSourceName()); + searchItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + reader.search(type, null, 1, 0, false); + } + }); + search.add(searchItem); + } + } + + bar.add(search); + JMenu view = new JMenu(GuiReader.trans(StringIdGui.MENU_VIEW)); view.setMnemonic(KeyEvent.VK_V); JMenuItem vauthors = new JMenuItem( @@ -208,8 +236,12 @@ class GuiReaderFrame extends JFrame implements FrameHelper { bar.add(view); Map> groupedSources = new HashMap>(); - if (libOk) { - groupedSources = reader.getLibrary().getSourcesGrouped(); + if (status.isReady()) { + try { + groupedSources = reader.getLibrary().getSourcesGrouped(); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + } } JMenu sources = new JMenu(GuiReader.trans(StringIdGui.MENU_SOURCES)); sources.setMnemonic(KeyEvent.VK_S); @@ -217,8 +249,12 @@ class GuiReaderFrame extends JFrame implements FrameHelper { bar.add(sources); Map> goupedAuthors = new HashMap>(); - if (libOk) { - goupedAuthors = reader.getLibrary().getAuthorsGrouped(); + if (status.isReady()) { + try { + goupedAuthors = reader.getLibrary().getAuthorsGrouped(); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + } } JMenu authors = new JMenu(GuiReader.trans(StringIdGui.MENU_AUTHORS)); authors.setMnemonic(KeyEvent.VK_A); @@ -324,9 +360,13 @@ class GuiReaderFrame extends JFrame implements FrameHelper { final boolean listMode) { return new ActionListener() { @Override - public void actionPerformed(ActionEvent e) { + public void actionPerformed(ActionEvent ae) { mainPanel.removeBookPanes(); - mainPanel.addBookPane(type, listMode); + try { + mainPanel.addBookPane(type, listMode); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + } mainPanel.refreshBooks(); } }; @@ -421,7 +461,8 @@ class GuiReaderFrame extends JFrame implements FrameHelper { fc.showDialog(GuiReaderFrame.this, GuiReader.trans(StringIdGui.TITLE_SAVE)); if (fc.getSelectedFile() != null) { - final OutputType type = otherFilters.get(fc.getFileFilter()); + final OutputType type = otherFilters.get(fc + .getFileFilter()); final String path = fc.getSelectedFile() .getAbsolutePath() + type.getDefaultExtension(false); @@ -509,19 +550,18 @@ class GuiReaderFrame extends JFrame implements FrameHelper { /** * Create the "move to" menu item. * - * @param libOk - * the library can be queried - * * @return the item */ - private JMenuItem createMenuItemMoveTo(boolean libOk) { + private JMenuItem createMenuItemMoveTo() { JMenu changeTo = new JMenu( GuiReader.trans(StringIdGui.MENU_FILE_MOVE_TO)); changeTo.setMnemonic(KeyEvent.VK_M); Map> groupedSources = new HashMap>(); - if (libOk) { + try { groupedSources = reader.getLibrary().getSourcesGrouped(); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); } JMenuItem item = new JMenuItem( @@ -562,12 +602,9 @@ class GuiReaderFrame extends JFrame implements FrameHelper { /** * Create the "set author" menu item. * - * @param libOk - * the library can be queried - * * @return the item */ - private JMenuItem createMenuItemSetAuthor(boolean libOk) { + private JMenuItem createMenuItemSetAuthor() { JMenu changeTo = new JMenu( GuiReader.trans(StringIdGui.MENU_FILE_SET_AUTHOR)); changeTo.setMnemonic(KeyEvent.VK_A); @@ -580,34 +617,39 @@ class GuiReaderFrame extends JFrame implements FrameHelper { newItem.addActionListener(createMoveAction(ChangeAction.AUTHOR, null)); // Existing authors - if (libOk) { - Map> groupedAuthors = reader.getLibrary() - .getAuthorsGrouped(); - - if (groupedAuthors.size() > 1) { - for (String key : groupedAuthors.keySet()) { - JMenu group = new JMenu(key); - for (String value : groupedAuthors.get(key)) { - JMenuItem item = new JMenuItem( - value.isEmpty() ? GuiReader - .trans(StringIdGui.MENU_AUTHORS_UNKNOWN) - : value); - item.addActionListener(createMoveAction( - ChangeAction.AUTHOR, value)); - group.add(item); - } - changeTo.add(group); - } - } else if (groupedAuthors.size() == 1) { - for (String value : groupedAuthors.values().iterator().next()) { + Map> groupedAuthors; + + try { + groupedAuthors = reader.getLibrary().getAuthorsGrouped(); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + groupedAuthors = new HashMap>(); + + } + + if (groupedAuthors.size() > 1) { + for (String key : groupedAuthors.keySet()) { + JMenu group = new JMenu(key); + for (String value : groupedAuthors.get(key)) { JMenuItem item = new JMenuItem( value.isEmpty() ? GuiReader .trans(StringIdGui.MENU_AUTHORS_UNKNOWN) : value); item.addActionListener(createMoveAction( ChangeAction.AUTHOR, value)); - changeTo.add(item); + group.add(item); } + changeTo.add(group); + } + } else if (groupedAuthors.size() == 1) { + for (String value : groupedAuthors.values().iterator().next()) { + JMenuItem item = new JMenuItem( + value.isEmpty() ? GuiReader + .trans(StringIdGui.MENU_AUTHORS_UNKNOWN) + : value); + item.addActionListener(createMoveAction(ChangeAction.AUTHOR, + value)); + changeTo.add(item); } } @@ -617,13 +659,9 @@ class GuiReaderFrame extends JFrame implements FrameHelper { /** * Create the "rename" menu item. * - * @param libOk - * the library can be queried - * * @return the item */ - private JMenuItem createMenuItemRename( - @SuppressWarnings("unused") boolean libOk) { + private JMenuItem createMenuItemRename() { JMenuItem changeTo = new JMenuItem( GuiReader.trans(StringIdGui.MENU_FILE_RENAME)); changeTo.setMnemonic(KeyEvent.VK_R); @@ -693,7 +731,7 @@ class GuiReaderFrame extends JFrame implements FrameHelper { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { - createMenu(true); + createMenu(reader.getLibrary().getStatus()); } }); } @@ -718,21 +756,16 @@ class GuiReaderFrame extends JFrame implements FrameHelper { final GuiReaderBook selectedBook = mainPanel.getSelectedBook(); if (selectedBook != null) { final MetaData meta = selectedBook.getInfo().getMeta(); - mainPanel.imprt( - meta.getUrl(), - new StoryRunnable() { - @Override - public void run(Story story) { - MetaData newMeta = story.getMeta(); - if (!newMeta.getSource().equals( - meta.getSource())) { - reader.changeSource(newMeta.getLuid(), - meta.getSource()); - } - } - }, - GuiReader - .trans(StringIdGui.PROGRESS_CHANGE_SOURCE)); + mainPanel.imprt(meta.getUrl(), new StoryRunnable() { + @Override + public void run(Story story) { + MetaData newMeta = story.getMeta(); + if (!newMeta.getSource().equals(meta.getSource())) { + reader.changeSource(newMeta.getLuid(), + meta.getSource()); + } + } + }, GuiReader.trans(StringIdGui.PROGRESS_CHANGE_SOURCE)); } } }); @@ -848,7 +881,7 @@ class GuiReaderFrame extends JFrame implements FrameHelper { KeyEvent.VK_C); open.addActionListener(new ActionListener() { @Override - public void actionPerformed(ActionEvent e) { + public void actionPerformed(ActionEvent ae) { final GuiReaderBook selectedBook = mainPanel.getSelectedBook(); if (selectedBook != null) { BasicLibrary lib = reader.getLibrary(); @@ -856,7 +889,11 @@ class GuiReaderFrame extends JFrame implements FrameHelper { String source = selectedBook.getInfo().getMeta() .getSource(); - lib.setSourceCover(source, luid); + try { + lib.setSourceCover(source, luid); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + } GuiReaderBookInfo sourceInfo = GuiReaderBookInfo .fromSource(lib, source); @@ -880,7 +917,7 @@ class GuiReaderFrame extends JFrame implements FrameHelper { KeyEvent.VK_A); open.addActionListener(new ActionListener() { @Override - public void actionPerformed(ActionEvent e) { + public void actionPerformed(ActionEvent ae) { final GuiReaderBook selectedBook = mainPanel.getSelectedBook(); if (selectedBook != null) { BasicLibrary lib = reader.getLibrary(); @@ -888,7 +925,11 @@ class GuiReaderFrame extends JFrame implements FrameHelper { String author = selectedBook.getInfo().getMeta() .getAuthor(); - lib.setAuthorCover(author, luid); + try { + lib.setAuthorCover(author, luid); + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + } GuiReaderBookInfo authorInfo = GuiReaderBookInfo .fromAuthor(lib, author); diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java index ffbcda3..cc3f1e1 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java @@ -3,6 +3,8 @@ package be.nikiroo.fanfix.reader.ui; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; +import java.awt.Graphics; +import java.awt.Rectangle; import java.awt.event.ActionListener; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; @@ -29,10 +31,13 @@ public class GuiReaderGroup extends JPanel { private static final long serialVersionUID = 1L; private BookActionListener action; private Color backgroundColor; + private Color backgroundColorDef; + private Color backgroundColorDefPane; private GuiReader reader; private List infos; private List books; private JPanel pane; + private JLabel titleLabel; private boolean words; // words or authors (secondary info on books) private int itemsPerLine; @@ -43,21 +48,20 @@ public class GuiReaderGroup extends JPanel { * the {@link GuiReaderBook} used to probe some information about * the stories * @param title - * the title of this group + * the title of this group (can be NULL for "no title", an empty + * {@link String} will trigger a default title for empty groups) * @param backgroundColor * the background colour to use (or NULL for default) */ public GuiReaderGroup(GuiReader reader, String title, Color backgroundColor) { this.reader = reader; - this.backgroundColor = backgroundColor; this.pane = new JPanel(); - pane.setLayout(new WrapLayout(WrapLayout.LEADING, 5, 5)); - if (backgroundColor != null) { - pane.setBackground(backgroundColor); - setBackground(backgroundColor); - } + + this.backgroundColorDef = getBackground(); + this.backgroundColorDefPane = pane.getBackground(); + setBackground(backgroundColor); setLayout(new BorderLayout(0, 10)); @@ -68,18 +72,10 @@ public class GuiReaderGroup extends JPanel { add(pane, BorderLayout.CENTER); - if (title != null) { - if (title.isEmpty()) { - title = GuiReader.trans(StringIdGui.MENU_AUTHORS_UNKNOWN); - } - - JLabel label = new JLabel(); - label.setText(String.format("" - + "
    " - + "%s" + "" + "", title)); - label.setHorizontalAlignment(JLabel.CENTER); - add(label, BorderLayout.NORTH); - } + titleLabel = new JLabel(); + titleLabel.setHorizontalAlignment(JLabel.CENTER); + add(titleLabel, BorderLayout.NORTH); + setTitle(title); // Compute the number of items per line at each resize addComponentListener(new ComponentAdapter() { @@ -119,13 +115,79 @@ public class GuiReaderGroup extends JPanel { }); } + /** + * Note: this class supports NULL as a background colour, which will revert + * it to its default state. + *

    + * Note: this class' implementation will also set the main pane background + * colour at the same time. + *

    + * Sets the background colour of this component. The background colour is + * used only if the component is opaque, and only by subclasses of + * JComponent or ComponentUI implementations. + * Direct subclasses of JComponent must override + * paintComponent to honour this property. + *

    + * It is up to the look and feel to honour this property, some may choose to + * ignore it. + * + * @param backgroundColor + * the desired background Colour + * @see java.awt.Component#getBackground + * @see #setOpaque + * + * @beaninfo preferred: true bound: true attribute: visualUpdate true + * description: The background colour of the component. + */ + @Override + public void setBackground(Color backgroundColor) { + this.backgroundColor = backgroundColor; + + Color cme = backgroundColor == null ? backgroundColorDef + : backgroundColor; + Color cpane = backgroundColor == null ? backgroundColorDefPane + : backgroundColor; + + if (pane != null) { // can happen at theme setup time + pane.setBackground(cpane); + } + super.setBackground(cme); + } + + /** + * The title of this group (can be NULL for "no title", an empty + * {@link String} will trigger a default title for empty groups) + * + * @param title + * the title or NULL + */ + public void setTitle(String title) { + if (title != null) { + if (title.isEmpty()) { + title = GuiReader.trans(StringIdGui.MENU_AUTHORS_UNKNOWN); + } + + titleLabel.setText(String.format("" + + "
    " + + "%s" + "" + "", title)); + titleLabel.setVisible(true); + } else { + titleLabel.setVisible(false); + } + } + /** * Compute how many items can fit in a line so UP and DOWN can be used to go * up/down one line at a time. */ private void computeItemsPerLine() { - // TODO - itemsPerLine = 5; + itemsPerLine = 1; + + if (books != null && books.size() > 0) { + // this.pane holds all the books with a hgap of 5 px + int wbook = books.get(0).getWidth() + 5; + itemsPerLine = pane.getWidth() / wbook; + } } /** @@ -137,6 +199,30 @@ public class GuiReaderGroup extends JPanel { */ public void setActionListener(BookActionListener action) { this.action = action; + refreshBooks(); + } + + /** + * Clear all the books in this {@link GuiReaderGroup}. + */ + public void clear() { + refreshBooks(new ArrayList()); + } + + /** + * Refresh the list of {@link GuiReaderBook}s displayed in the control. + */ + public void refreshBooks() { + refreshBooks(infos, words); + } + + /** + * Refresh the list of {@link GuiReaderBook}s displayed in the control. + * + * @param infos + * the new list of infos + */ + public void refreshBooks(List infos) { refreshBooks(infos, words); } @@ -172,7 +258,7 @@ public class GuiReaderGroup extends JPanel { if (infos != null) { for (GuiReaderBookInfo info : infos) { boolean isCached = false; - if (info.getMeta() != null) { + if (info.getMeta() != null && info.getMeta().getLuid() != null) { isCached = reader.isCached(info.getMeta().getLuid()); } @@ -215,6 +301,8 @@ public class GuiReaderGroup extends JPanel { pane.repaint(); validate(); repaint(); + + computeItemsPerLine(); } /** @@ -242,12 +330,21 @@ public class GuiReaderGroup extends JPanel { repaint(); } + /** + * The number of books in this group. + * + * @return the count + */ + public int getBooksCount() { + return books.size(); + } + /** * Return the index of the currently selected book if any, -1 if none. * * @return the index or -1 */ - private int getSelectedBookIndex() { + public int getSelectedBookIndex() { int index = -1; for (int i = 0; i < books.size(); i++) { if (books.get(i).isSelected()) { @@ -264,12 +361,12 @@ public class GuiReaderGroup extends JPanel { * @param index * the index of the book to select, can be outside the bounds * (either all the items will be unselected or the first or last - * book will then be selected, see forceRange>/tt>) + * book will then be selected, see forceRange>) * @param forceRange * TRUE to constraint the index to the first/last element, FALSE * to unselect when outside the range */ - private void setSelectedBook(int index, boolean forceRange) { + public void setSelectedBook(int index, boolean forceRange) { int previousIndex = getSelectedBookIndex(); if (index >= books.size()) { @@ -288,7 +385,7 @@ public class GuiReaderGroup extends JPanel { books.get(previousIndex).setSelected(false); } - if (index >= 0) { + if (index >= 0 && !books.isEmpty()) { books.get(index).setSelected(true); } } @@ -361,4 +458,19 @@ public class GuiReaderGroup extends JPanel { e.consume(); } } + + @Override + public void paint(Graphics g) { + super.paint(g); + + Rectangle clip = g.getClipBounds(); + if (clip.getWidth() <= 0 || clip.getHeight() <= 0) { + return; + } + + if (!isEnabled()) { + g.setColor(new Color(128, 128, 128, 128)); + g.fillRect(clip.x, clip.y, clip.width, clip.height); + } + } } diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderMainPanel.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderMainPanel.java index cfd1e94..8593fe6 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderMainPanel.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderMainPanel.java @@ -85,10 +85,10 @@ class GuiReaderMainPanel extends JPanel { *

    * Will invalidate the layout. * - * @param libOk - * the library can be queried + * @param status + * the library status, must not be NULL */ - public void createMenu(boolean libOk); + public void createMenu(Status status); /** * Create a popup menu for a {@link GuiReaderBook} that represents a @@ -138,6 +138,7 @@ class GuiReaderMainPanel extends JPanel { pane = new JPanel(); pane.setLayout(new BoxLayout(pane, BoxLayout.PAGE_AXIS)); + JScrollPane scroll = new JScrollPane(pane); Integer icolor = Instance.getUiConfig().getColor( UiConfig.BACKGROUND_COLOR); @@ -145,9 +146,9 @@ class GuiReaderMainPanel extends JPanel { color = new Color(icolor); setBackground(color); pane.setBackground(color); + scroll.setBackground(color); } - JScrollPane scroll = new JScrollPane(pane); scroll.getVerticalScrollBar().setUnitIncrement(16); add(scroll, BorderLayout.CENTER); @@ -197,23 +198,28 @@ class GuiReaderMainPanel extends JPanel { final BasicLibrary lib = helper.getReader().getLibrary(); final Status status = lib.getStatus(); - if (status == Status.READY) { + if (status == Status.READ_WRITE) { lib.refresh(pg); } inUi(new Runnable() { @Override public void run() { - if (status == Status.READY) { - helper.createMenu(true); + if (status.isReady()) { + helper.createMenu(status); pane.setVisible(true); if (typeF == null) { - addBookPane(true, false); + try { + addBookPane(true, false); + } catch (IOException e) { + error(e.getLocalizedMessage(), + "IOException", e); + } } else { addBookPane(typeF, true); } } else { - helper.createMenu(false); + helper.createMenu(status); validate(); String desc = Instance.getTransGui().getStringX( @@ -255,8 +261,11 @@ class GuiReaderMainPanel extends JPanel { * @param listMode * TRUE to get a listing of all the sources or authors, FALSE to * get one icon per source or author + * + * @throws IOException + * in case of I/O error */ - public void addBookPane(boolean type, boolean listMode) { + public void addBookPane(boolean type, boolean listMode) throws IOException { this.currentType = type; BasicLibrary lib = helper.getReader().getLibrary(); if (type) { @@ -352,11 +361,17 @@ class GuiReaderMainPanel extends JPanel { List infos = new ArrayList(); List metas; - if (currentType) { - metas = lib.getListBySource(value); - } else { - metas = lib.getListByAuthor(value); + try { + if (currentType) { + metas = lib.getListBySource(value); + } else { + metas = lib.getListByAuthor(value); + } + } catch (IOException e) { + error(e.getLocalizedMessage(), "IOException", e); + metas = new ArrayList(); } + for (MetaData meta : metas) { infos.add(GuiReaderBookInfo.fromMeta(meta)); } diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderNavBar.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderNavBar.java new file mode 100644 index 0000000..099b3c8 --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderNavBar.java @@ -0,0 +1,339 @@ +package be.nikiroo.fanfix.reader.ui; + +import java.awt.Color; +import java.awt.LayoutManager; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; + +import be.nikiroo.fanfix.Instance; + +/** + * A Swing-based navigation bar, that displays first/previous/next/last page + * buttons. + * + * @author niki + */ +public class GuiReaderNavBar extends JPanel { + private static final long serialVersionUID = 1L; + + private JLabel label; + private int index = 0; + private int min = 0; + private int max = 0; + private JButton[] navButtons; + String extraLabel = null; + + private List listeners = new ArrayList(); + + /** + * Create a new navigation bar. + *

    + * The minimum must be lower or equal to the maximum. + *

    + * Note than a max of "-1" means "infinite". + * + * @param min + * the minimum page number (cannot be negative) + * @param max + * the maximum page number (cannot be lower than min, except if + * -1 (infinite)) + * + * @throws IndexOutOfBoundsException + * if min > max and max is not "-1" + */ + public GuiReaderNavBar(int min, int max) { + if (min > max && max != -1) { + throw new IndexOutOfBoundsException(String.format( + "min (%d) > max (%d)", min, max)); + } + + LayoutManager layout = new BoxLayout(this, BoxLayout.X_AXIS); + setLayout(layout); + + navButtons = new JButton[4]; + + navButtons[0] = createNavButton("<<", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + setIndex(GuiReaderNavBar.this.min); + fireEvent(); + } + }); + navButtons[1] = createNavButton(" < ", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + setIndex(index - 1); + fireEvent(); + } + }); + navButtons[2] = createNavButton(" > ", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + setIndex(index + 1); + fireEvent(); + } + }); + navButtons[3] = createNavButton(">>", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + setIndex(GuiReaderNavBar.this.max); + fireEvent(); + } + }); + + for (JButton navButton : navButtons) { + add(navButton); + } + + label = new JLabel(""); + add(label); + + this.min = min; + this.max = max; + this.index = min; + + updateEnabled(); + updateLabel(); + fireEvent(); + } + + /** + * The current index, must be between {@link GuiReaderNavBar#min} and + * {@link GuiReaderNavBar#max}, both inclusive. + * + * @return the index + */ + public int getIndex() { + return index; + } + + /** + * The current index, must be between {@link GuiReaderNavBar#min} and + * {@link GuiReaderNavBar#max}, both inclusive. + * + * @param index + * the new index + */ + public void setIndex(int index) { + if (index != this.index) { + if (index < min || (index > max && max != -1)) { + throw new IndexOutOfBoundsException(String.format( + "Index %d but min/max is [%d/%d]", index, min, max)); + } + + this.index = index; + updateLabel(); + } + + updateEnabled(); + } + + /** + * The minimun page number. Cannot be negative. + * + * @return the min + */ + public int getMin() { + return min; + } + + /** + * The minimum page number. Cannot be negative. + *

    + * May update the index if needed (if the index is < the new min). + *

    + * Will also (always) update the label and enable/disable the required + * buttons. + * + * @param min + * the new min + */ + public void setMin(int min) { + this.min = min; + if (index < min) { + index = min; + } + updateEnabled(); + updateLabel(); + + } + + /** + * The maximum page number. Cannot be lower than min, except if -1 + * (infinite). + * + * @return the max + */ + public int getMax() { + return max; + } + + /** + * The maximum page number. Cannot be lower than min, except if -1 + * (infinite). + *

    + * May update the index if needed (if the index is > the new max). + *

    + * Will also (always) update the label and enable/disable the required + * buttons. + * + * @param max + * the new max + */ + public void setMax(int max) { + this.max = max; + if (index > max && max != -1) { + index = max; + } + updateEnabled(); + updateLabel(); + } + + /** + * The current extra label to display with the default + * {@link GuiReaderNavBar#computeLabel(int, int, int)} implementation. + * + * @return the current label + */ + public String getExtraLabel() { + return extraLabel; + } + + /** + * The current extra label to display with the default + * {@link GuiReaderNavBar#computeLabel(int, int, int)} implementation. + * + * @param currentLabel + * the new current label + */ + public void setExtraLabel(String currentLabel) { + this.extraLabel = currentLabel; + updateLabel(); + } + + /** + * Add a listener that will be called on each page change. + * + * @param listener + * the new listener + */ + public void addActionListener(ActionListener listener) { + listeners.add(listener); + } + + /** + * Remove the given listener if possible. + * + * @param listener + * the listener to remove + * @return TRUE if it was removed, FALSE if it was not found + */ + public boolean removeActionListener(ActionListener listener) { + return listeners.remove(listener); + } + + /** + * Remove all the listeners. + */ + public void clearActionsListeners() { + listeners.clear(); + } + + /** + * Notify a change of page. + */ + public void fireEvent() { + for (ActionListener listener : listeners) { + try { + listener.actionPerformed(new ActionEvent(this, + ActionEvent.ACTION_FIRST, "page changed")); + } catch (Exception e) { + Instance.getTraceHandler().error(e); + } + } + } + + /** + * Create a single navigation button. + * + * @param text + * the text to display + * @param action + * the action to take on click + * @return the button + */ + private JButton createNavButton(String text, ActionListener action) { + JButton navButton = new JButton(text); + navButton.addActionListener(action); + navButton.setForeground(Color.BLUE); + return navButton; + } + + /** + * Update the label displayed in the UI. + */ + private void updateLabel() { + label.setText(computeLabel(index, min, max)); + } + + /** + * Update the navigation buttons "enabled" state according to the current + * index value. + */ + private void updateEnabled() { + navButtons[0].setEnabled(index > min); + navButtons[1].setEnabled(index > min); + navButtons[2].setEnabled(index < max || max == -1); + navButtons[3].setEnabled(index < max || max == -1); + } + + /** + * Return the label to display for the given index. + *

    + * Swing HTML (HTML3) is supported if surrounded by <HTML> and + * </HTML>. + *

    + * By default, return "Page 1/5: current_label" (with the current index and + * {@link GuiReaderNavBar#getCurrentLabel()}). + * + * @param index + * the new index number + * @param mix + * the minimum index (inclusive) + * @param max + * the maximum index (inclusive) + * @return the label + */ + protected String computeLabel(int index, + @SuppressWarnings("unused") int min, int max) { + + String base = "  Page %d "; + if (max >= 0) { + base += "/ %d"; + } + base += ""; + + String ifLabel = ": %s"; + + String display = base; + String label = getExtraLabel(); + if (label != null && !label.trim().isEmpty()) { + display += ifLabel; + } + + display = "" + display + ""; + + if (max >= 0) { + return String.format(display, index, max, label); + } + + return String.format(display, index, label); + } +} diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchAction.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchAction.java new file mode 100644 index 0000000..b3c8f8b --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchAction.java @@ -0,0 +1,91 @@ +package be.nikiroo.fanfix.reader.ui; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.IOException; +import java.net.URL; + +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JPanel; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.utils.Progress; +import be.nikiroo.utils.ui.ProgressBar; + +public class GuiReaderSearchAction extends JFrame { + private static final long serialVersionUID = 1L; + + private GuiReaderBookInfo info; + private ProgressBar pgBar; + + public GuiReaderSearchAction(BasicLibrary lib, GuiReaderBookInfo info) { + super(info.getMainInfo()); + this.setSize(800, 600); + this.info = info; + + setLayout(new BorderLayout()); + + JPanel main = new JPanel(new BorderLayout()); + JPanel props = new GuiReaderPropertiesPane(lib, info.getMeta()); + + main.add(props, BorderLayout.NORTH); + main.add(new GuiReaderViewerPanel(info.getMeta(), info.getMeta() + .isImageDocument()), BorderLayout.CENTER); + main.add(createImportButton(lib), BorderLayout.SOUTH); + + add(main, BorderLayout.CENTER); + + pgBar = new ProgressBar(); + pgBar.setVisible(false); + add(pgBar, BorderLayout.SOUTH); + + pgBar.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + pgBar.invalidate(); + pgBar.setProgress(null); + setEnabled(true); + validate(); + } + }); + + pgBar.addUpdateListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + pgBar.invalidate(); + validate(); + repaint(); + } + }); + } + + private Component createImportButton(final BasicLibrary lib) { + JButton imprt = new JButton("Import into library"); + imprt.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + final Progress pg = new Progress(); + pgBar.setProgress(pg); + + new Thread(new Runnable() { + @Override + public void run() { + try { + lib.imprt(new URL(info.getMeta().getUrl()), null); + } catch (IOException e) { + Instance.getTraceHandler().error(e); + } + + pg.done(); + } + }).start(); + } + }); + + return imprt; + } +} diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByNamePanel.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByNamePanel.java new file mode 100644 index 0000000..ebdb21a --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByNamePanel.java @@ -0,0 +1,246 @@ +package be.nikiroo.fanfix.reader.ui; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JButton; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.reader.ui.GuiReaderSearchByPanel.Waitable; +import be.nikiroo.fanfix.searchable.BasicSearchable; + +/** + * This panel represents a search panel that works for keywords and tags based + * searches. + * + * @author niki + */ +public class GuiReaderSearchByNamePanel extends JPanel { + private static final long serialVersionUID = 1L; + + private BasicSearchable searchable; + + private JTextField keywordsField; + private JButton submitKeywords; + + private int page; + private int maxPage; + private List stories = new ArrayList(); + private int storyItem; + + public GuiReaderSearchByNamePanel(final Waitable waitable) { + super(new BorderLayout()); + + keywordsField = new JTextField(); + add(keywordsField, BorderLayout.CENTER); + + submitKeywords = new JButton("Search"); + add(submitKeywords, BorderLayout.EAST); + + // should be done out of UI + final Runnable go = new Runnable() { + @Override + public void run() { + waitable.setWaiting(true); + try { + search(keywordsField.getText(), 1, 0); + waitable.fireEvent(); + } finally { + waitable.setWaiting(false); + } + } + }; + + keywordsField.addKeyListener(new KeyAdapter() { + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + new Thread(go).start(); + } else { + super.keyReleased(e); + } + } + }); + + submitKeywords.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + new Thread(go).start(); + } + }); + + setSearchable(null); + } + + /** + * The {@link BasicSearchable} object use for the searches themselves. + *

    + * Can be NULL, but no searches will work. + * + * @param searchable + * the new searchable + */ + public void setSearchable(BasicSearchable searchable) { + this.searchable = searchable; + page = 0; + maxPage = -1; + storyItem = 0; + stories = new ArrayList(); + updateKeywords(""); + } + + /** + * The currently displayed page of result for the current search (see the + * page parameter of + * {@link GuiReaderSearchByNamePanel#search(String, int, int)}). + * + * @return the currently displayed page of results + */ + public int getPage() { + return page; + } + + /** + * The number of pages of result for the current search (see the + * page parameter of + * {@link GuiReaderSearchByPanel#search(String, int, int)}). + *

    + * For an unknown number or when not applicable, -1 is returned. + * + * @return the number of pages of results or -1 + */ + public int getMaxPage() { + return maxPage; + } + + /** + * Return the keywords used for the current search. + * + * @return the keywords + */ + public String getCurrentKeywords() { + return keywordsField.getText(); + } + + /** + * The currently loaded stories (the result of the latest search). + * + * @return the stories + */ + public List getStories() { + return stories; + } + + /** + * Return the currently selected story (the item) if it was + * specified in the latest, or 0 if not. + *

    + * Note: this is thus a 1-based index, not a 0-based index. + * + * @return the item + */ + public int getStoryItem() { + return storyItem; + } + + /** + * Update the keywords displayed on screen. + * + * @param keywords + * the keywords + */ + private void updateKeywords(final String keywords) { + if (!keywords.equals(keywordsField.getText())) { + GuiReaderSearchFrame.inUi(new Runnable() { + @Override + public void run() { + keywordsField.setText(keywords); + } + }); + } + } + + /** + * Search for the given terms on the currently selected searchable. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param keywords + * the keywords to search for + * @param page + * the page of results to load + * @param item + * the item to select (or 0 for none by default) + * + * @throw IndexOutOfBoundsException if the page is out of bounds + */ + public void search(String keywords, int page, int item) { + List stories = new ArrayList(); + int storyItem = 0; + + updateKeywords(keywords); + + int maxPage = -1; + if (searchable != null) { + try { + maxPage = searchable.searchPages(keywords); + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + } + + if (page > 0) { + if (maxPage >= 0 && (page <= 0 || page > maxPage)) { + throw new IndexOutOfBoundsException("Page " + page + " out of " + + maxPage); + } + + if (searchable != null) { + try { + stories = searchable.search(keywords, page); + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + } + + if (item > 0 && item <= stories.size()) { + storyItem = item; + } else if (item > 0) { + GuiReaderSearchFrame.error(String.format( + "Story item does not exist: Search [%s], item %d", + keywords, item)); + } + } + + this.page = page; + this.maxPage = maxPage; + this.stories = stories; + this.storyItem = storyItem; + } + + /** + * Enables or disables this component, depending on the value of the + * parameter b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

    + * Disabling this component will also affect its children. + * + * @param b + * If true, this component is enabled; otherwise + * this component is disabled + */ + @Override + public void setEnabled(boolean b) { + super.setEnabled(b); + keywordsField.setEnabled(b); + submitKeywords.setEnabled(b); + } +} diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByPanel.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByPanel.java new file mode 100644 index 0000000..8f95d4c --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByPanel.java @@ -0,0 +1,281 @@ +package be.nikiroo.fanfix.reader.ui; + +import java.awt.BorderLayout; +import java.util.List; + +import javax.swing.JPanel; +import javax.swing.JTabbedPane; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; + +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.searchable.BasicSearchable; +import be.nikiroo.fanfix.searchable.SearchableTag; +import be.nikiroo.fanfix.supported.SupportType; + +/** + * This panel represents a search panel that works for keywords and tags based + * searches. + * + * @author niki + */ +public class GuiReaderSearchByPanel extends JPanel { + private static final long serialVersionUID = 1L; + + private Waitable waitable; + + private boolean searchByTags; + private JTabbedPane searchTabs; + private GuiReaderSearchByNamePanel byName; + private GuiReaderSearchByTagPanel byTag; + + /** + * This interface represents an item that wan be put in "wait" mode. It is + * supposed to be used for long running operations during which we want to + * disable UI interactions. + *

    + * It also allows reporting an event to the item. + * + * @author niki + */ + public interface Waitable { + /** + * Set the item in wait mode, blocking it from accepting UI input. + * + * @param waiting + * TRUE for wait more, FALSE to restore normal mode + */ + public void setWaiting(boolean waiting); + + /** + * Notify the {@link Waitable} that an event occured (i.e., new stories + * were found). + */ + public void fireEvent(); + } + + /** + * Create a new {@link GuiReaderSearchByPanel}. + * + * @param waitable + * the waitable we can wait on for long UI operations + */ + public GuiReaderSearchByPanel(Waitable waitable) { + setLayout(new BorderLayout()); + + this.waitable = waitable; + searchByTags = false; + + byName = new GuiReaderSearchByNamePanel(waitable); + byTag = new GuiReaderSearchByTagPanel(waitable); + + searchTabs = new JTabbedPane(); + searchTabs.addTab("By name", byName); + searchTabs.addTab("By tags", byTag); + searchTabs.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + searchByTags = (searchTabs.getSelectedComponent() == byTag); + } + }); + + add(searchTabs, BorderLayout.CENTER); + updateSearchBy(searchByTags); + } + + /** + * Set the new {@link SupportType}. + *

    + * This operation can be long and should be run outside the UI thread. + *

    + * Note that if a non-searchable {@link SupportType} is used, an + * {@link IllegalArgumentException} will be thrown. + * + * @param supportType + * the support mode, must be searchable or NULL + * + * @throws IllegalArgumentException + * if the {@link SupportType} is not NULL but not searchable + * (see {@link BasicSearchable#getSearchable(SupportType)}) + */ + public void setSupportType(SupportType supportType) { + BasicSearchable searchable = BasicSearchable.getSearchable(supportType); + if (searchable == null && supportType != null) { + throw new IllegalArgumentException("Unupported support type: " + + supportType); + } + + byName.setSearchable(searchable); + byTag.setSearchable(searchable); + } + + /** + * The currently displayed page of result for the current search (see the + * page parameter of + * {@link GuiReaderSearchByPanel#search(String, int, int)} or + * {@link GuiReaderSearchByPanel#searchTag(SupportType, int, int, SearchableTag)} + * ). + * + * @return the currently displayed page of results + */ + public int getPage() { + if (!searchByTags) { + return byName.getPage(); + } + + return byTag.getPage(); + } + + /** + * The number of pages of result for the current search (see the + * page parameter of + * {@link GuiReaderSearchByPanel#search(String, int, int)} or + * {@link GuiReaderSearchByPanel#searchTag(SupportType, int, int, SearchableTag)} + * ). + *

    + * For an unknown number or when not applicable, -1 is returned. + * + * @return the number of pages of results or -1 + */ + public int getMaxPage() { + if (!searchByTags) { + return byName.getMaxPage(); + } + + return byTag.getMaxPage(); + } + + /** + * Set the page of results to display for the current search. This will + * cause {@link Waitable#fireEvent()} to be called if needed. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param page + * the page of results to set + * + * @throw IndexOutOfBoundsException if the page is out of bounds + */ + public void setPage(int page) { + if (searchByTags) { + searchTag(byTag.getCurrentTag(), page, 0); + } else { + search(byName.getCurrentKeywords(), page, 0); + } + } + + /** + * The currently loaded stories (the result of the latest search). + * + * @return the stories + */ + public List getStories() { + if (!searchByTags) { + return byName.getStories(); + } + + return byTag.getStories(); + } + + /** + * Return the currently selected story (the item) if it was + * specified in the latest, or 0 if not. + *

    + * Note: this is thus a 1-based index, not a 0-based index. + * + * @return the item + */ + public int getStoryItem() { + if (!searchByTags) { + return byName.getStoryItem(); + } + + return byTag.getStoryItem(); + } + + /** + * Update the kind of searches to make: search by keywords or search by tags + * (it will impact what the user can see and interact with on the UI). + * + * @param byTag + * TRUE for tag-based searches, FALSE for keywords-based searches + */ + private void updateSearchBy(final boolean byTag) { + GuiReaderSearchFrame.inUi(new Runnable() { + @Override + public void run() { + if (!byTag) { + searchTabs.setSelectedIndex(0); + } else { + searchTabs.setSelectedIndex(1); + } + } + }); + } + + /** + * Search for the given terms on the currently selected searchable. This + * will cause {@link Waitable#fireEvent()} to be called if needed. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param keywords + * the keywords to search for + * @param page + * the page of results to load + * @param item + * the item to select (or 0 for none by default) + * + * @throw IndexOutOfBoundsException if the page is out of bounds + */ + public void search(final String keywords, final int page, final int item) { + updateSearchBy(false); + byName.search(keywords, page, item); + waitable.fireEvent(); + } + + /** + * Search for the given tag on the currently selected searchable. This will + * cause {@link Waitable#fireEvent()} to be called if needed. + *

    + * If the tag contains children tags, those will be displayed so you can + * select them; if the tag is a leaf tag, the linked stories will be + * displayed. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param tag + * the tag to search for, or NULL for base tags + * @param page + * the page of results to load + * @param item + * the item to select (or 0 for none by default) + * + * @throw IndexOutOfBoundsException if the page is out of bounds + */ + public void searchTag(final SearchableTag tag, final int page, + final int item) { + updateSearchBy(true); + byTag.searchTag(tag, page, item); + waitable.fireEvent(); + } + + /** + * Enables or disables this component, depending on the value of the + * parameter b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

    + * Disabling this component will also affect its children. + * + * @param b + * If true, this component is enabled; otherwise + * this component is disabled + */ + @Override + public void setEnabled(boolean b) { + super.setEnabled(b); + searchTabs.setEnabled(b); + byName.setEnabled(b); + byTag.setEnabled(b); + } +} diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByTagPanel.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByTagPanel.java new file mode 100644 index 0000000..260fc48 --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByTagPanel.java @@ -0,0 +1,458 @@ +package be.nikiroo.fanfix.reader.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.BoxLayout; +import javax.swing.JComboBox; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.ListCellRenderer; + +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.reader.ui.GuiReaderSearchByPanel.Waitable; +import be.nikiroo.fanfix.searchable.BasicSearchable; +import be.nikiroo.fanfix.searchable.SearchableTag; +import be.nikiroo.fanfix.supported.SupportType; + +/** + * This panel represents a search panel that works for keywords and tags based + * searches. + * + * @author niki + */ +// JCombobox not 1.6 compatible +@SuppressWarnings({ "unchecked", "rawtypes" }) +public class GuiReaderSearchByTagPanel extends JPanel { + private static final long serialVersionUID = 1L; + + private BasicSearchable searchable; + private Waitable waitable; + + private SearchableTag currentTag; + private JPanel tagBars; + private List combos; + + private int page; + private int maxPage; + private List stories = new ArrayList(); + private int storyItem; + + public GuiReaderSearchByTagPanel(Waitable waitable) { + setLayout(new BorderLayout()); + + this.waitable = waitable; + combos = new ArrayList(); + page = 0; + maxPage = -1; + + tagBars = new JPanel(); + tagBars.setLayout(new BoxLayout(tagBars, BoxLayout.Y_AXIS)); + add(tagBars, BorderLayout.NORTH); + } + + /** + * The {@link BasicSearchable} object use for the searches themselves. + *

    + * This operation can be long and should be run outside the UI thread. + *

    + * Can be NULL, but no searches will work. + * + * @param searchable + * the new searchable + */ + public void setSearchable(BasicSearchable searchable) { + this.searchable = searchable; + page = 0; + maxPage = -1; + storyItem = 0; + stories = new ArrayList(); + updateTags(null); + } + + /** + * The currently displayed page of result for the current search (see the + * page parameter of + * {@link GuiReaderSearchByTagPanel#searchTag(SupportType, int, int, SearchableTag)} + * ). + * + * @return the currently displayed page of results + */ + public int getPage() { + return page; + } + + /** + * The number of pages of result for the current search (see the + * page parameter of + * {@link GuiReaderSearchByPanel#searchTag(SupportType, int, int, SearchableTag)} + * ). + *

    + * For an unknown number or when not applicable, -1 is returned. + * + * @return the number of pages of results or -1 + */ + public int getMaxPage() { + return maxPage; + } + + /** + * Return the tag used for the current search. + * + * @return the tag (which can be NULL, for "base tags") + */ + public SearchableTag getCurrentTag() { + return currentTag; + } + + /** + * The currently loaded stories (the result of the latest search). + * + * @return the stories + */ + public List getStories() { + return stories; + } + + /** + * Return the currently selected story (the item) if it was + * specified in the latest, or 0 if not. + *

    + * Note: this is thus a 1-based index, not a 0-based index. + * + * @return the item + */ + public int getStoryItem() { + return storyItem; + } + + /** + * Update the tags displayed on screen and reset the tags bar. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param tag + * the tag to use, or NULL for base tags + */ + private void updateTags(final SearchableTag tag) { + final List parents = new ArrayList(); + SearchableTag parent = (tag == null) ? null : tag; + while (parent != null) { + parents.add(parent); + parent = parent.getParent(); + } + + List rootTags = new ArrayList(); + SearchableTag selectedRootTag = null; + selectedRootTag = parents.isEmpty() ? null : parents + .get(parents.size() - 1); + + if (searchable != null) { + try { + rootTags = searchable.getTags(); + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + } + + final List rootTagsF = rootTags; + final SearchableTag selectedRootTagF = selectedRootTag; + + GuiReaderSearchFrame.inUi(new Runnable() { + @Override + public void run() { + tagBars.invalidate(); + tagBars.removeAll(); + + addTagBar(rootTagsF, selectedRootTagF); + + for (int i = parents.size() - 1; i >= 0; i--) { + SearchableTag selectedChild = null; + if (i > 0) { + selectedChild = parents.get(i - 1); + } + + SearchableTag parent = parents.get(i); + addTagBar(parent.getChildren(), selectedChild); + } + + tagBars.validate(); + } + }); + } + + /** + * Add a tags bar (do not remove possible previous ones). + *

    + * Will always add an "empty" (NULL) tag as first option. + * + * @param tags + * the tags to display + * @param selected + * the selected tag if any, or NULL for none + */ + private void addTagBar(List tags, + final SearchableTag selected) { + tags.add(0, null); + + final int comboIndex = combos.size(); + + final JComboBox combo = new JComboBox( + tags.toArray(new SearchableTag[] {})); + combo.setSelectedItem(selected); + + final ListCellRenderer basic = combo.getRenderer(); + + combo.setRenderer(new ListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, + Object value, int index, boolean isSelected, + boolean cellHasFocus) { + + Object displayValue = value; + if (value instanceof SearchableTag) { + displayValue = ((SearchableTag) value).getName(); + } else { + displayValue = "Select a tag..."; + cellHasFocus = false; + isSelected = false; + } + + Component rep = basic.getListCellRendererComponent(list, + displayValue, index, isSelected, cellHasFocus); + + if (value == null) { + rep.setForeground(Color.GRAY); + } + + return rep; + } + }); + + combo.addActionListener(createComboTagAction(comboIndex)); + + combos.add(combo); + tagBars.add(combo); + } + + /** + * The action to do on {@link JComboBox} selection. + *

    + * The content of the action is: + *

      + *
    • Remove all tags bar below this one
    • + *
    • Load the subtags if any in anew tags bar
    • + *
    • Load the related stories if the tag was a leaf tag and notify the + * {@link Waitable} (via {@link Waitable#fireEvent()})
    • + *
    + * + * @param comboIndex + * the index of the related {@link JComboBox} + * + * @return the action + */ + private ActionListener createComboTagAction(final int comboIndex) { + return new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + List combos = GuiReaderSearchByTagPanel.this.combos; + if (combos == null || comboIndex < 0 + || comboIndex >= combos.size()) { + return; + } + + // Tag can be NULL + final SearchableTag tag = (SearchableTag) combos + .get(comboIndex).getSelectedItem(); + + while (comboIndex + 1 < combos.size()) { + JComboBox combo = combos.remove(comboIndex + 1); + tagBars.remove(combo); + } + + new Thread(new Runnable() { + @Override + public void run() { + waitable.setWaiting(true); + try { + final List children = getChildrenForTag(tag); + if (children != null) { + GuiReaderSearchFrame.inUi(new Runnable() { + @Override + public void run() { + addTagBar(children, tag); + } + }); + } + + if (tag != null && tag.isLeaf()) { + storyItem = 0; + try { + searchable.fillTag(tag); + page = 1; + stories = searchable.search(tag, 1); + maxPage = searchable.searchPages(tag); + currentTag = tag; + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + page = 0; + maxPage = -1; + stories = new ArrayList(); + } + + waitable.fireEvent(); + } + } finally { + waitable.setWaiting(false); + } + } + }).start(); + } + }; + } + + /** + * Get the children of the given tag (or the base tags if the given tag is + * NULL). + *

    + * This action will "fill" ({@link BasicSearchable#fillTag(SearchableTag)}) + * the given tag if needed first. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param tag + * the tag to search into or NULL for the base tags + * @return the children + */ + private List getChildrenForTag(final SearchableTag tag) { + List children = new ArrayList(); + if (tag == null) { + try { + List baseTags = searchable.getTags(); + children = baseTags; + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + } else { + try { + searchable.fillTag(tag); + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + + if (!tag.isLeaf()) { + children = tag.getChildren(); + } else { + children = null; + } + } + + return children; + } + + /** + * Search for the given tag on the currently selected searchable. + *

    + * If the tag contains children tags, those will be displayed so you can + * select them; if the tag is a leaf tag, the linked stories will be + * displayed. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param tag + * the tag to search for, or NULL for base tags + * @param page + * the page of results to load + * @param item + * the item to select (or 0 for none by default) + * + * @throw IndexOutOfBoundsException if the page is out of bounds + */ + public void searchTag(SearchableTag tag, int page, int item) { + List stories = new ArrayList(); + int storyItem = 0; + + currentTag = tag; + updateTags(tag); + + int maxPage = -1; + if (tag != null) { + try { + searchable.fillTag(tag); + + if (!tag.isLeaf()) { + List subtags = tag.getChildren(); + if (item > 0 && item <= subtags.size()) { + SearchableTag subtag = subtags.get(item - 1); + try { + tag = subtag; + searchable.fillTag(tag); + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + } else if (item > 0) { + GuiReaderSearchFrame.error(String.format( + "Tag item does not exist: Tag [%s], item %d", + tag.getFqName(), item)); + } + } + + maxPage = searchable.searchPages(tag); + if (page > 0 && tag.isLeaf()) { + if (maxPage >= 0 && (page <= 0 || page > maxPage)) { + throw new IndexOutOfBoundsException("Page " + page + + " out of " + maxPage); + } + + try { + stories = searchable.search(tag, page); + if (item > 0 && item <= stories.size()) { + storyItem = item; + } else if (item > 0) { + GuiReaderSearchFrame + .error(String + .format("Story item does not exist: Tag [%s], item %d", + tag.getFqName(), item)); + } + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + } + } + } catch (IOException e) { + GuiReaderSearchFrame.error(e); + maxPage = 0; + } + } + + this.stories = stories; + this.storyItem = storyItem; + this.page = page; + this.maxPage = maxPage; + } + + /** + * Enables or disables this component, depending on the value of the + * parameter b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

    + * Disabling this component will also affect its children. + * + * @param b + * If true, this component is enabled; otherwise + * this component is disabled + */ + @Override + public void setEnabled(boolean b) { + super.setEnabled(b); + tagBars.setEnabled(b); + for (JComboBox combo : combos) { + combo.setEnabled(b); + } + } +} diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchFrame.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchFrame.java new file mode 100644 index 0000000..5b99772 --- /dev/null +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchFrame.java @@ -0,0 +1,380 @@ +package be.nikiroo.fanfix.reader.ui; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.EventQueue; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JComboBox; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.reader.ui.GuiReaderBook.BookActionListener; +import be.nikiroo.fanfix.searchable.BasicSearchable; +import be.nikiroo.fanfix.searchable.SearchableTag; +import be.nikiroo.fanfix.supported.SupportType; + +/** + * This frame will allow you to search through the supported websites for new + * stories/comics. + * + * @author niki + */ +// JCombobox not 1.6 compatible +@SuppressWarnings({ "unchecked", "rawtypes" }) +public class GuiReaderSearchFrame extends JFrame { + private static final long serialVersionUID = 1L; + + private List supportTypes; + + private JComboBox comboSupportTypes; + private ActionListener comboSupportTypesListener; + private GuiReaderSearchByPanel searchPanel; + private GuiReaderNavBar navbar; + + private boolean seeWordcount; + private GuiReaderGroup books; + + public GuiReaderSearchFrame(final GuiReader reader) { + super("Browse stories"); + setLayout(new BorderLayout()); + setSize(800, 600); + + supportTypes = new ArrayList(); + supportTypes.add(null); + for (SupportType type : SupportType.values()) { + if (BasicSearchable.getSearchable(type) != null) { + supportTypes.add(type); + } + } + + comboSupportTypes = new JComboBox( + supportTypes.toArray(new SupportType[] {})); + + comboSupportTypesListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + final SupportType support = (SupportType) comboSupportTypes + .getSelectedItem(); + setWaiting(true); + new Thread(new Runnable() { + @Override + public void run() { + try { + updateSupportType(support); + } finally { + setWaiting(false); + } + } + }).start(); + } + }; + comboSupportTypes.addActionListener(comboSupportTypesListener); + + JPanel searchSites = new JPanel(new BorderLayout()); + searchSites.add(comboSupportTypes, BorderLayout.CENTER); + searchSites.add(new JLabel(" " + "Website : "), BorderLayout.WEST); + + searchPanel = new GuiReaderSearchByPanel( + new GuiReaderSearchByPanel.Waitable() { + @Override + public void setWaiting(boolean waiting) { + GuiReaderSearchFrame.this.setWaiting(waiting); + } + + @Override + public void fireEvent() { + updatePages(searchPanel.getPage(), + searchPanel.getMaxPage()); + List infos = new ArrayList(); + for (MetaData meta : searchPanel.getStories()) { + infos.add(GuiReaderBookInfo.fromMeta(meta)); + } + + int page = searchPanel.getPage(); + if (page <= 0) { + navbar.setMin(1); + navbar.setMax(1); + } else { + int max = searchPanel.getMaxPage(); + navbar.setMin(1); + navbar.setMax(max); + navbar.setIndex(page); + } + updateBooks(infos); + + // ! 1-based index ! + int item = searchPanel.getStoryItem(); + if (item > 0 && item <= books.getBooksCount()) { + books.setSelectedBook(item - 1, false); + } + } + }); + + JPanel top = new JPanel(new BorderLayout()); + top.add(searchSites, BorderLayout.NORTH); + top.add(searchPanel, BorderLayout.CENTER); + + add(top, BorderLayout.NORTH); + + books = new GuiReaderGroup(reader, null, null); + books.setActionListener(new BookActionListener() { + @Override + public void select(GuiReaderBook book) { + } + + @Override + public void popupRequested(GuiReaderBook book, Component target, + int x, int y) { + } + + @Override + public void action(GuiReaderBook book) { + new GuiReaderSearchAction(reader.getLibrary(), book.getInfo()) + .setVisible(true); + } + }); + JScrollPane scroll = new JScrollPane(books); + scroll.getVerticalScrollBar().setUnitIncrement(16); + add(scroll, BorderLayout.CENTER); + + navbar = new GuiReaderNavBar(-1, -1) { + private static final long serialVersionUID = 1L; + + @Override + protected String computeLabel(int index, int min, int max) { + if (index <= 0) { + return ""; + } + return super.computeLabel(index, min, max); + } + }; + + navbar.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + searchPanel.setPage(navbar.getIndex()); + } + }); + + add(navbar, BorderLayout.SOUTH); + } + + /** + * Update the {@link SupportType} currently displayed to the user. + *

    + * Will also cause a search for the new base tags of the given support if + * not NULL. + *

    + * This operation can be long and should be run outside the UI thread. + * + * @param supportType + * the new {@link SupportType} + */ + private void updateSupportType(final SupportType supportType) { + inUi(new Runnable() { + @Override + public void run() { + books.clear(); + + comboSupportTypes + .removeActionListener(comboSupportTypesListener); + comboSupportTypes.setSelectedItem(supportType); + comboSupportTypes.addActionListener(comboSupportTypesListener); + } + }); + + searchPanel.setSupportType(supportType); + } + + /** + * Update the pages and the lined buttons currently displayed on screen. + *

    + * Those are the same pages and maximum pages used by + * {@link GuiReaderSearchByPanel#search(String, int, int)} and + * {@link GuiReaderSearchByPanel#searchTag(SearchableTag, int, int)}. + * + * @param page + * the current page of results + * @param maxPage + * the maximum number of pages of results + */ + private void updatePages(final int page, final int maxPage) { + inUi(new Runnable() { + @Override + public void run() { + if (maxPage >= 1) { + navbar.setMin(1); + navbar.setMax(maxPage); + navbar.setIndex(page); + } else { + navbar.setMin(-1); + navbar.setMax(-1); + } + } + }); + } + + /** + * Update the currently displayed books. + * + * @param infos + * the new books + */ + private void updateBooks(final List infos) { + inUi(new Runnable() { + @Override + public void run() { + books.refreshBooks(infos, seeWordcount); + } + }); + } + + /** + * Search for the given terms on the currently selected searchable. This + * will update the displayed books if needed. + *

    + * This operation is asynchronous. + * + * @param keywords + * the keywords to search for + * @param page + * the page of results to load + * @param item + * the item to select (or 0 for none by default) + */ + public void search(final SupportType searchOn, final String keywords, + final int page, final int item) { + setWaiting(true); + new Thread(new Runnable() { + @Override + public void run() { + try { + updateSupportType(searchOn); + searchPanel.search(keywords, page, item); + } finally { + setWaiting(false); + } + } + }).start(); + } + + /** + * Search for the given tag on the currently selected searchable. This will + * update the displayed books if needed. + *

    + * If the tag contains children tags, those will be displayed so you can + * select them; if the tag is a leaf tag, the linked stories will be + * displayed. + *

    + * This operation is asynchronous. + * + * @param tag + * the tag to search for, or NULL for base tags + * @param page + * the page of results to load + * @param item + * the item to select (or 0 for none by default) + */ + public void searchTag(final SupportType searchOn, final int page, + final int item, final SearchableTag tag) { + setWaiting(true); + new Thread(new Runnable() { + @Override + public void run() { + try { + updateSupportType(searchOn); + searchPanel.searchTag(tag, page, item); + } finally { + setWaiting(false); + } + } + }).start(); + } + + /** + * Process the given action in the main Swing UI thread. + *

    + * The code will make sure the current thread is the main UI thread and, if + * not, will switch to it before executing the runnable. + *

    + * Synchronous operation. + * + * @param run + * the action to run + */ + static void inUi(final Runnable run) { + if (EventQueue.isDispatchThread()) { + run.run(); + } else { + try { + EventQueue.invokeAndWait(run); + } catch (InterruptedException e) { + error(e); + } catch (InvocationTargetException e) { + error(e); + } + } + } + + /** + * An error occurred, inform the user and/or log the error. + * + * @param e + * the error + */ + static void error(Exception e) { + Instance.getTraceHandler().error(e); + } + + /** + * An error occurred, inform the user and/or log the error. + * + * @param e + * the error message + */ + static void error(String e) { + Instance.getTraceHandler().error(e); + } + + /** + * Enables or disables this component, depending on the value of the + * parameter b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

    + * Disabling this component will also affect its children. + * + * @param b + * If true, this component is enabled; otherwise + * this component is disabled + */ + @Override + public void setEnabled(boolean b) { + super.setEnabled(b); + books.setEnabled(b); + searchPanel.setEnabled(b); + } + + /** + * Set the item in wait mode, blocking it from accepting UI input. + * + * @param waiting + * TRUE for wait more, FALSE to restore normal mode + */ + private void setWaiting(final boolean waiting) { + inUi(new Runnable() { + @Override + public void run() { + GuiReaderSearchFrame.this.setEnabled(!waiting); + } + }); + } +} diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewer.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewer.java index b57bdc4..bfb1892 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewer.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewer.java @@ -1,7 +1,6 @@ package be.nikiroo.fanfix.reader.ui; import java.awt.BorderLayout; -import java.awt.Color; import java.awt.Font; import java.awt.LayoutManager; import java.awt.event.ActionEvent; @@ -9,7 +8,6 @@ import java.awt.event.ActionListener; import javax.swing.BorderFactory; import javax.swing.BoxLayout; -import javax.swing.JButton; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JPanel; @@ -35,11 +33,9 @@ public class GuiReaderViewer extends JFrame { private Story story; private MetaData meta; private JLabel title; - private JLabel chapterLabel; private GuiReaderPropertiesPane descPane; - private int currentChapter = -42; // cover = -1 private GuiReaderViewerPanel mainPanel; - private JButton[] navButtons; + private GuiReaderNavBar navbar; /** * Create a new {@link Story} viewer. @@ -97,61 +93,46 @@ public class GuiReaderViewer extends JFrame { * initialise them. */ private void initGuiNavButtons() { - JPanel navButtonsPane = new JPanel(); - LayoutManager layout = new BoxLayout(navButtonsPane, BoxLayout.X_AXIS); - navButtonsPane.setLayout(layout); - - navButtons = new JButton[4]; + navbar = new GuiReaderNavBar(-1, story.getChapters().size() - 1) { + private static final long serialVersionUID = 1L; - navButtons[0] = createNavButton("<<", new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - setChapter(-1); - } - }); - navButtons[1] = createNavButton(" < ", new ActionListener() { @Override - public void actionPerformed(ActionEvent e) { - setChapter(currentChapter - 1); - } - }); - navButtons[2] = createNavButton(" > ", new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - setChapter(currentChapter + 1); + protected String computeLabel(int index, int min, int max) { + int chapter = index; + Chapter chap; + if (chapter < 0) { + chap = meta.getResume(); + descPane.setVisible(true); + } else { + chap = story.getChapters().get(chapter); + descPane.setVisible(false); + } + + String chapterDisplay = GuiReader.trans( + StringIdGui.CHAPTER_HTML_UNNAMED, chap.getNumber(), + story.getChapters().size()); + if (chap.getName() != null && !chap.getName().trim().isEmpty()) { + chapterDisplay = GuiReader.trans( + StringIdGui.CHAPTER_HTML_NAMED, chap.getNumber(), + story.getChapters().size(), chap.getName()); + } + + return "" + chapterDisplay + ""; } - }); - navButtons[3] = createNavButton(">>", new ActionListener() { + }; + + navbar.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { - setChapter(story.getChapters().size() - 1); + setChapter(navbar.getIndex()); } }); - for (JButton navButton : navButtons) { - navButtonsPane.add(navButton); - } - - add(navButtonsPane, BorderLayout.SOUTH); + JPanel navButtonsPane = new JPanel(); + LayoutManager layout = new BoxLayout(navButtonsPane, BoxLayout.X_AXIS); + navButtonsPane.setLayout(layout); - chapterLabel = new JLabel(""); - navButtonsPane.add(chapterLabel); - } - - /** - * Create a single navigation button. - * - * @param text - * the text to display - * @param action - * the action to take on click - * @return the button - */ - private JButton createNavButton(String text, ActionListener action) { - JButton navButton = new JButton(text); - navButton.addActionListener(action); - navButton.setForeground(Color.BLUE); - return navButton; + add(navbar, BorderLayout.SOUTH); } /** @@ -163,36 +144,15 @@ public class GuiReaderViewer extends JFrame { * the chapter number to set */ private void setChapter(int chapter) { - navButtons[0].setEnabled(chapter >= 0); - navButtons[1].setEnabled(chapter >= 0); - navButtons[2].setEnabled(chapter + 1 < story.getChapters().size()); - navButtons[3].setEnabled(chapter + 1 < story.getChapters().size()); - - if (chapter >= -1 && chapter < story.getChapters().size() - && chapter != currentChapter) { - currentChapter = chapter; - - Chapter chap; - if (chapter == -1) { - chap = meta.getResume(); - descPane.setVisible(true); - } else { - chap = story.getChapters().get(chapter); - descPane.setVisible(false); - } - - String chapterDisplay = GuiReader.trans( - StringIdGui.CHAPTER_HTML_UNNAMED, chap.getNumber(), story - .getChapters().size()); - if (chap.getName() != null && !chap.getName().trim().isEmpty()) { - chapterDisplay = GuiReader.trans( - StringIdGui.CHAPTER_HTML_NAMED, chap.getNumber(), story - .getChapters().size(), chap.getName()); - } - - chapterLabel.setText("" + chapterDisplay + ""); - - mainPanel.setChapter(chap); + Chapter chap; + if (chapter < 0) { + chap = meta.getResume(); + descPane.setVisible(true); + } else { + chap = story.getChapters().get(chapter); + descPane.setVisible(false); } + + mainPanel.setChapter(chap); } } diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerPanel.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerPanel.java index 08a9c9c..724f552 100644 --- a/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerPanel.java +++ b/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerPanel.java @@ -20,6 +20,7 @@ import javax.swing.SwingConstants; import be.nikiroo.fanfix.Instance; import be.nikiroo.fanfix.bundles.StringIdGui; 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.ui.ImageUtilsAwt; @@ -51,12 +52,24 @@ public class GuiReaderViewerPanel extends JPanel { * Create a new viewer. * * @param story - * the {@link Story} to work on. + * the {@link Story} to work on */ public GuiReaderViewerPanel(Story story) { + this(story.getMeta(), story.getMeta().isImageDocument()); + } + + /** + * Create a new viewer. + * + * @param meta + * the {@link MetaData} of the story to show + * @param isImageDocument + * TRUE if it is an image document, FALSE if not + */ + public GuiReaderViewerPanel(MetaData meta, boolean isImageDocument) { super(new BorderLayout()); - this.imageDocument = story.getMeta().isImageDocument(); + this.imageDocument = isImageDocument; this.text = new JEditorPane("text/html", ""); text.setEditable(false); @@ -102,7 +115,7 @@ public class GuiReaderViewerPanel extends JPanel { main.invalidate(); } - setChapter(story.getMeta().getResume()); + setChapter(meta.getResume()); } /** diff --git a/src/be/nikiroo/fanfix/searchable/BasicSearchable.java b/src/be/nikiroo/fanfix/searchable/BasicSearchable.java new file mode 100644 index 0000000..d38505e --- /dev/null +++ b/src/be/nikiroo/fanfix/searchable/BasicSearchable.java @@ -0,0 +1,276 @@ +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.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 + break; + case FANFICTION: + support = new Fanfiction(type); + break; + case MANGAFOX: + // TODO + break; + case E621: + // TODO + break; + case YIFFSTAR: + // TODO + break; + case E_HENTAI: + // TODO + 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..c2dfd5d --- /dev/null +++ b/src/be/nikiroo/fanfix/searchable/Fanfiction.java @@ -0,0 +1,415 @@ +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.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.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.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..3e2924f --- /dev/null +++ b/src/be/nikiroo/fanfix/searchable/MangaLel.java @@ -0,0 +1,192 @@ +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.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.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.getCache().open( + new URL(coverUrl), getSupport(), true); + try { + meta.setCover(new Image(in)); + } finally { + in.close(); + } + } catch (Exception e) { + // Happen often on MangaLEL... + Instance.getTraceHandler().trace( + "Cannot download cover for MangaLEL story in search mode: " + + meta.getTitle()); + } + } catch (Exception e) { + // no project id... cannot use the story :( + Instance.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 index 4337626..092f89e 100644 --- a/src/be/nikiroo/fanfix/supported/BasicSupport.java +++ b/src/be/nikiroo/fanfix/supported/BasicSupport.java @@ -38,13 +38,6 @@ public abstract class BasicSupport { private SupportType type; private URL currentReferer; // with only one 'r', as in 'HTTP'... - /** - * The name of this support class. - * - * @return the name - */ - protected abstract String getSourceName(); - /** * Check if the given resource is supported by this {@link BasicSupport}. * @@ -311,7 +304,7 @@ public abstract class BasicSupport { * @throws IOException * in case of I/O error */ - // ADD final when BasicSupport_Deprecated is gone + // TODO: ADD final when BasicSupport_Deprecated is gone public Story process(Progress pg) throws IOException { setCurrentReferer(source); login(); @@ -402,6 +395,29 @@ public abstract class BasicSupport { return story; } + /** + * Create a chapter from the given data. + * + * @param source + * the source URL for this content, which can be used to try and + * find images if images are present in the format [image-url] + * @param number + * the chapter number (0 = description) + * @param name + * the chapter name + * @param content + * the content of the chapter + * @return the {@link Chapter} + * + * @throws IOException + * in case of I/O error + */ + public Chapter makeChapter(URL source, int number, String name, + String content) throws IOException { + return BasicSupportPara.makeChapter(this, source, number, name, + content, isHtml(), null); + } + /** * Return a {@link BasicSupport} implementation supporting the given * resource if possible. @@ -441,10 +457,11 @@ public abstract class BasicSupport { * Return a {@link BasicSupport} implementation supporting the given type. * * @param type - * the type + * the type, must not be NULL * @param url * the {@link URL} to support (can be NULL to get an - * "abstract support") + * "abstract support"; if not NULL, will be used as the source + * URL) * * @return an implementation that supports it, or NULL */ diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java index f8ea9d4..d1dbc00 100644 --- a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java +++ b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java @@ -681,7 +681,6 @@ public abstract class BasicSupport_Deprecated extends BasicSupport { // try for files if (source != null) { try { - String relPath = null; String absPath = null; try { diff --git a/src/be/nikiroo/fanfix/supported/Cbz.java b/src/be/nikiroo/fanfix/supported/Cbz.java index 062adf0..3682afe 100644 --- a/src/be/nikiroo/fanfix/supported/Cbz.java +++ b/src/be/nikiroo/fanfix/supported/Cbz.java @@ -1,7 +1,6 @@ package be.nikiroo.fanfix.supported; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; @@ -21,8 +20,8 @@ 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.MarkableFileInputStream; import be.nikiroo.utils.Progress; +import be.nikiroo.utils.streams.MarkableFileInputStream; /** * Support class for CBZ files (works better with CBZ created with this program, @@ -36,11 +35,6 @@ class Cbz extends Epub { return url.toString().toLowerCase().endsWith(".cbz"); } - @Override - public String getSourceName() { - return "cbz"; - } - @Override protected String getDataPrefix() { return ""; @@ -83,8 +77,7 @@ class Cbz extends Epub { InputStream cbzIn = null; ZipInputStream zipIn = null; try { - cbzIn = new MarkableFileInputStream(new FileInputStream( - getSourceFileOriginal())); + cbzIn = new MarkableFileInputStream(getSourceFileOriginal()); zipIn = new ZipInputStream(cbzIn); for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn .getNextEntry()) { @@ -138,25 +131,27 @@ class Cbz extends Epub { story.setChapters(new ArrayList()); // Check if we can find non-images chapters, for hybrid-cbz support - 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 (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 (isImages != null && !isImages) { + story.getChapters().add(chap); + chap.setNumber(story.getChapters().size()); + } } } diff --git a/src/be/nikiroo/fanfix/supported/E621.java b/src/be/nikiroo/fanfix/supported/E621.java index 36b9dad..dfa9e5e 100644 --- a/src/be/nikiroo/fanfix/supported/E621.java +++ b/src/be/nikiroo/fanfix/supported/E621.java @@ -33,11 +33,6 @@ import be.nikiroo.utils.StringUtils; * @author niki */ class E621 extends BasicSupport_Deprecated { - @Override - public String getSourceName() { - return "e621.net"; - } - @Override protected MetaData getMeta(URL source, InputStream in) throws IOException { MetaData meta = new MetaData(); @@ -46,9 +41,9 @@ class E621 extends BasicSupport_Deprecated { meta.setAuthor(getAuthor(source, reset(in))); meta.setDate(""); meta.setTags(getTags(source, reset(in), false)); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(source.toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(source.toString()); meta.setLuid(""); meta.setLang("en"); diff --git a/src/be/nikiroo/fanfix/supported/EHentai.java b/src/be/nikiroo/fanfix/supported/EHentai.java index 9ed4e89..67585cd 100644 --- a/src/be/nikiroo/fanfix/supported/EHentai.java +++ b/src/be/nikiroo/fanfix/supported/EHentai.java @@ -26,11 +26,6 @@ import be.nikiroo.utils.StringUtils; * @author niki */ class EHentai extends BasicSupport_Deprecated { - @Override - public String getSourceName() { - return "e-hentai.org"; - } - @Override protected MetaData getMeta(URL source, InputStream in) throws IOException { MetaData meta = new MetaData(); @@ -39,9 +34,9 @@ class EHentai extends BasicSupport_Deprecated { meta.setAuthor(getAuthor(reset(in))); meta.setDate(getDate(reset(in))); meta.setTags(getTags(reset(in))); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(source.toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(source.toString()); meta.setLuid(""); meta.setLang(getLang(reset(in))); diff --git a/src/be/nikiroo/fanfix/supported/Epub.java b/src/be/nikiroo/fanfix/supported/Epub.java index e5261d3..ae26574 100644 --- a/src/be/nikiroo/fanfix/supported/Epub.java +++ b/src/be/nikiroo/fanfix/supported/Epub.java @@ -17,7 +17,7 @@ import be.nikiroo.fanfix.Instance; import be.nikiroo.fanfix.data.MetaData; import be.nikiroo.utils.IOUtils; import be.nikiroo.utils.Image; -import be.nikiroo.utils.MarkableFileInputStream; +import be.nikiroo.utils.streams.MarkableFileInputStream; import be.nikiroo.utils.StringUtils; /** @@ -34,11 +34,6 @@ class Epub extends InfoText { private URL fakeSource; private InputStream fakeIn; - @Override - public String getSourceName() { - return "epub"; - } - public File getSourceFileOriginal() { return super.getSourceFile(); } @@ -179,8 +174,7 @@ class Epub extends InfoText { } if (tmp.exists()) { - this.fakeIn = new MarkableFileInputStream(new FileInputStream( - tmp)); + this.fakeIn = new MarkableFileInputStream(tmp); } if (tmpInfo.exists()) { @@ -198,7 +192,7 @@ class Epub extends InfoText { meta = new MetaData(); meta.setLang("en"); meta.setTags(new ArrayList()); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUuid(url); meta.setUrl(url); meta.setTitle(title); diff --git a/src/be/nikiroo/fanfix/supported/Fanfiction.java b/src/be/nikiroo/fanfix/supported/Fanfiction.java index 9b749bc..64df8d3 100644 --- a/src/be/nikiroo/fanfix/supported/Fanfiction.java +++ b/src/be/nikiroo/fanfix/supported/Fanfiction.java @@ -32,11 +32,6 @@ class Fanfiction extends BasicSupport_Deprecated { return true; } - @Override - public String getSourceName() { - return "Fanfiction.net"; - } - @Override protected MetaData getMeta(URL source, InputStream in) throws IOException { MetaData meta = new MetaData(); @@ -45,9 +40,9 @@ class Fanfiction extends BasicSupport_Deprecated { meta.setAuthor(getAuthor(reset(in))); meta.setDate(getDate(reset(in))); meta.setTags(getTags(reset(in))); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(source.toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(source.toString()); meta.setLuid(""); meta.setLang("en"); // TODO! @@ -123,7 +118,7 @@ class Fanfiction extends BasicSupport_Deprecated { } } - return null; + return ""; } private String getAuthor(InputStream in) { diff --git a/src/be/nikiroo/fanfix/supported/Fimfiction.java b/src/be/nikiroo/fanfix/supported/Fimfiction.java index 792f66b..e96ac4f 100644 --- a/src/be/nikiroo/fanfix/supported/Fimfiction.java +++ b/src/be/nikiroo/fanfix/supported/Fimfiction.java @@ -30,11 +30,6 @@ class Fimfiction extends BasicSupport_Deprecated { return true; } - @Override - public String getSourceName() { - return "FimFiction.net"; - } - @Override protected MetaData getMeta(URL source, InputStream in) throws IOException { MetaData meta = new MetaData(); @@ -43,9 +38,9 @@ class Fimfiction extends BasicSupport_Deprecated { meta.setAuthor(getAuthor(reset(in))); meta.setDate(getDate(reset(in))); meta.setTags(getTags(reset(in))); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(source.toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(source.toString()); meta.setLuid(""); meta.setLang("en"); diff --git a/src/be/nikiroo/fanfix/supported/FimfictionApi.java b/src/be/nikiroo/fanfix/supported/FimfictionApi.java index a99986f..a64e4c0 100644 --- a/src/be/nikiroo/fanfix/supported/FimfictionApi.java +++ b/src/be/nikiroo/fanfix/supported/FimfictionApi.java @@ -84,11 +84,6 @@ class FimfictionApi extends BasicSupport { return true; } - @Override - public String getSourceName() { - return "FimFiction.net"; - } - /** * Extract the full JSON data we will later use to build the {@link Story}. * @@ -137,9 +132,9 @@ class FimfictionApi extends BasicSupport { meta.setAuthor(getKeyJson(json, 0, "type", "user", "name")); meta.setDate(getKeyJson(json, 0, "type", "story", "date_published")); meta.setTags(getTags()); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(getSource().toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(getSource().toString()); meta.setLuid(""); meta.setLang("en"); @@ -150,10 +145,11 @@ class FimfictionApi extends BasicSupport { String coverImageLink = getKeyJson(json, 0, "type", "story", "cover_image", "full"); if (!coverImageLink.trim().isEmpty()) { - InputStream in = null; + URL coverImageUrl = new URL(coverImageLink.trim()); + + InputStream in = Instance.getCache() + .open(coverImageUrl, this, true); try { - URL coverImageUrl = new URL(coverImageLink.trim()); - in = Instance.getCache().open(coverImageUrl, this, true); meta.setCover(new Image(in)); } finally { in.close(); diff --git a/src/be/nikiroo/fanfix/supported/Html.java b/src/be/nikiroo/fanfix/supported/Html.java index 5fe2839..c27dd32 100644 --- a/src/be/nikiroo/fanfix/supported/Html.java +++ b/src/be/nikiroo/fanfix/supported/Html.java @@ -14,11 +14,6 @@ import be.nikiroo.fanfix.Instance; * @author niki */ class Html extends InfoText { - @Override - public String getSourceName() { - return "html"; - } - @Override protected boolean supports(URL url) { try { diff --git a/src/be/nikiroo/fanfix/supported/InfoReader.java b/src/be/nikiroo/fanfix/supported/InfoReader.java index 817345e..57f021f 100644 --- a/src/be/nikiroo/fanfix/supported/InfoReader.java +++ b/src/be/nikiroo/fanfix/supported/InfoReader.java @@ -14,7 +14,7 @@ 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.MarkableFileInputStream; +import be.nikiroo.utils.streams.MarkableFileInputStream; // not complete: no "description" tag public class InfoReader { @@ -25,8 +25,7 @@ public class InfoReader { } if (infoFile.exists()) { - InputStream in = new MarkableFileInputStream(new FileInputStream( - infoFile)); + InputStream in = new MarkableFileInputStream(infoFile); try { return createMeta(infoFile.toURI().toURL(), in, withCover); } finally { diff --git a/src/be/nikiroo/fanfix/supported/InfoText.java b/src/be/nikiroo/fanfix/supported/InfoText.java index 37f447a..42e2c13 100644 --- a/src/be/nikiroo/fanfix/supported/InfoText.java +++ b/src/be/nikiroo/fanfix/supported/InfoText.java @@ -16,11 +16,6 @@ import be.nikiroo.fanfix.data.MetaData; * @author niki */ class InfoText extends Text { - @Override - public String getSourceName() { - return "info-text"; - } - protected File getInfoFile() { return new File(assureNoTxt(getSourceFile()).getPath() + ".info"); } diff --git a/src/be/nikiroo/fanfix/supported/MangaFox.java b/src/be/nikiroo/fanfix/supported/MangaFox.java index bd5816a..dae2d31 100644 --- a/src/be/nikiroo/fanfix/supported/MangaFox.java +++ b/src/be/nikiroo/fanfix/supported/MangaFox.java @@ -28,11 +28,6 @@ class MangaFox extends BasicSupport { return true; } - @Override - public String getSourceName() { - return "MangaFox.me"; - } - @Override protected MetaData getMeta() throws IOException { MetaData meta = new MetaData(); @@ -63,9 +58,9 @@ class MangaFox extends BasicSupport { meta.setDate(StringUtils.unhtml(table.get(0).text()).trim()); meta.setTags(explode(table.get(3).text())); } - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(getSource().toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(getSource().toString()); meta.setLuid(""); meta.setLang("en"); @@ -341,8 +336,8 @@ class MangaFox extends BasicSupport { */ private InputStream openEx(String url) throws IOException { try { - return Instance.getCache().open(new URL(url), this, true, - withoutQuery(url)); + return Instance.getCache().open(new URL(url), withoutQuery(url), + this, true); } catch (Exception e) { // second chance try { @@ -350,8 +345,8 @@ class MangaFox extends BasicSupport { } catch (InterruptedException ee) { } - return Instance.getCache().open(new URL(url), this, true, - withoutQuery(url)); + return Instance.getCache().open(new URL(url), withoutQuery(url), + this, true); } } diff --git a/src/be/nikiroo/fanfix/supported/MangaLel.java b/src/be/nikiroo/fanfix/supported/MangaLel.java index 0351622..1ba51bc 100644 --- a/src/be/nikiroo/fanfix/supported/MangaLel.java +++ b/src/be/nikiroo/fanfix/supported/MangaLel.java @@ -2,11 +2,11 @@ package be.nikiroo.fanfix.supported; import java.io.IOException; import java.io.InputStream; -import java.net.MalformedURLException; import java.net.URL; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.AbstractMap; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map.Entry; @@ -26,24 +26,17 @@ class MangaLel extends BasicSupport { return true; } - @Override - public String getSourceName() { - return "MangaLel.com"; - } - @Override protected MetaData getMeta() throws IOException { MetaData meta = new MetaData(); - String[] authorDateTag = getAuthorDateTag(); - meta.setTitle(getTitle()); - meta.setAuthor(authorDateTag[0]); - meta.setDate(authorDateTag[1]); - meta.setTags(explode(authorDateTag[2])); - meta.setSource(getSourceName()); + meta.setAuthor(getAuthor()); + meta.setDate(getDate()); + meta.setTags(getTags()); + meta.setSource(getType().getSourceName()); meta.setUrl(getSource().toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(getSource().toString()); meta.setLuid(""); meta.setLang("fr"); @@ -57,131 +50,153 @@ class MangaLel extends BasicSupport { private String getTitle() { Element doc = getSourceNode(); - Element h2 = doc.getElementsByClass("widget-title").first(); - if (h2 != null) { - return StringUtils.unhtml(h2.text()).trim(); + Element h4 = doc.getElementsByTag("h4").first(); + if (h4 != null) { + return StringUtils.unhtml(h4.text()).trim(); } return null; } - // 0 = author - // 1 = date - // 2 = tags - private String[] getAuthorDateTag() { - String[] tab = new String[3]; + private String getAuthor() { + Element doc = getSourceNode(); + Element tabEls = doc.getElementsByClass("presentation-projet").first(); + if (tabEls != null) { + String[] tab = tabEls.outerHtml().split("
    "); + return getVal(tab, 1); + } + return ""; + } + + private List getTags() { Element doc = getSourceNode(); - Element tabEls = doc.getElementsByClass("dl-horizontal").first(); - int prevOk = 0; - for (Element tabEl : tabEls.children()) { - String txt = tabEl.text().trim(); - if (prevOk > 0) { - if (tab[prevOk - 1] == null) { - tab[prevOk - 1] = ""; - } else { - tab[prevOk - 1] += ", "; - } + Element tabEls = doc.getElementsByClass("presentation-projet").first(); + if (tabEls != null) { + String[] tab = tabEls.outerHtml().split("
    "); + List tags = new ArrayList(); + for (String tag : getVal(tab, 3).split(" ")) { + tags.add(tag); + } + return tags; + } - tab[prevOk - 1] += txt; - prevOk = 0; - } else { - if (txt.equals("Auteur(s)") || txt.equals("Artist(s)")) { - prevOk = 1; - } else if (txt.equals("Date de sortie")) { - prevOk = 2; - } else if (txt.equals("Type") || txt.equals("Catégories")) { - prevOk = 3; - } else { - prevOk = 0; + return new ArrayList(); + + } + + private String getDate() { + Element doc = getSourceNode(); + Element table = doc.getElementsByClass("table").first(); + + // We take the first date we find + String value = ""; + if (table != null) { + Elements els; + els = table.getElementsByTag("tr"); + if (els.size() >= 2) { + els = els.get(1).getElementsByTag("td"); + if (els.size() >= 3) { + value = StringUtils.unhtml(els.get(2).text()).trim(); } } } - for (int i = 0; i < 3; i++) { - String list = ""; - for (String item : explode(tab[i])) { - if (!list.isEmpty()) { - list = list + ", "; - } - list += item; + if (!value.isEmpty()) { + try { + long time = StringUtils.toTime(value); + value = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + .format(time); + } catch (ParseException e) { } - tab[i] = list; } - return tab; + return value; } @Override protected String getDesc() { - String desc = null; - Element doc = getSourceNode(); - Element title = doc.getElementsByClass("well").first(); - if (title != null) { - desc = StringUtils.unhtml(title.text()).trim(); - if (desc.startsWith("Résumé")) { - desc = desc.substring("Résumé".length()).trim(); - } + Element tabEls = doc.getElementsByClass("presentation-projet").first(); + if (tabEls != null) { + String[] tab = tabEls.outerHtml().split("
    "); + return getVal(tab, 4); } - return desc; + return ""; } private Image getCover() { Element doc = getSourceNode(); - Element cover = doc.getElementsByClass("img-responsive").first(); + Element container = doc.getElementsByClass("container").first(); + + if (container != null) { + + Elements imgs = container.getElementsByTag("img"); + Element img = null; + if (imgs.size() >= 1) { + img = imgs.get(0); + if (img.hasClass("banniere-team-projet")) { + img = null; + if (imgs.size() >= 2) { + img = imgs.get(1); + } + } + } - if (cover != null) { - String coverUrl = cover.absUrl("src"); + if (img != null) { + String coverUrl = img.absUrl("src"); - InputStream coverIn; - try { - coverIn = Instance.getCache().open(new URL(coverUrl), this, - true); + InputStream coverIn; try { - return new Image(coverIn); - } finally { - coverIn.close(); + coverIn = Instance.getCache().open(new URL(coverUrl), this, + true); + try { + return new Image(coverIn); + } finally { + coverIn.close(); + } + } catch (IOException e) { + Instance.getTraceHandler().error(e); } - } catch (IOException e) { - Instance.getTraceHandler().error(e); } } return null; } - @Override - protected List> getChapters(Progress pg) { - List> urls = new ArrayList>(); + private String getVal(String[] tab, int i) { + String val = ""; - int i = 0; - Element doc = getSourceNode(); - Elements chapEls = doc.getElementsByClass("chapters").first() - .getElementsByTag("li"); - for (Element chapEl : chapEls) { - Element titleEl = chapEl.getElementsByTag("h5").first(); - String title = StringUtils.unhtml(titleEl.text()).trim(); + if (i < tab.length) { + val = StringUtils.unhtml(tab[i]); + int pos = val.indexOf(":"); + if (pos >= 0) { + val = val.substring(pos + 1).trim(); + } + } - // because Atril does not support strange file names - title = Integer.toString(chapEls.size() - i); + return val; + } - Element linkEl = chapEl.getElementsByTag("h5").first() - .getElementsByTag("a").first(); - String link = linkEl.absUrl("href"); + @Override + protected List> getChapters(Progress pg) + throws IOException { + List> urls = new ArrayList>(); - try { - urls.add(new AbstractMap.SimpleEntry(title, - new URL(link))); - } catch (MalformedURLException e) { - Instance.getTraceHandler().error(e); + Element doc = getSourceNode(); + Element table = doc.getElementsByClass("table").first(); + if (table != null) { + for (Element tr : table.getElementsByTag("tr")) { + Element a = tr.getElementsByTag("a").first(); + if (a != null) { + String name = StringUtils.unhtml(a.text()).trim(); + URL url = new URL(a.absUrl("href")); + urls.add(new AbstractMap.SimpleEntry(name, url)); + } } - - i++; } - Collections.reverse(urls); return urls; } @@ -197,13 +212,16 @@ class MangaLel extends BasicSupport { InputStream in = Instance.getCache().open(chapUrl, this, false); try { Element pageDoc = DataUtil.load(in, "UTF-8", chapUrl.toString()); - Elements linkEls = pageDoc.getElementsByClass("img-responsive"); + Element content = pageDoc.getElementById("content"); + Elements linkEls = content.getElementsByTag("img"); for (Element linkEl : linkEls) { - if (linkEl.hasAttr("data-src")) { - builder.append("["); - builder.append(linkEl.absUrl("data-src").trim()); - builder.append("]
    "); + if (linkEl.absUrl("src").isEmpty()) { + continue; } + + builder.append("["); + builder.append(linkEl.absUrl("src")); + builder.append("]
    "); } } finally { @@ -213,32 +231,11 @@ class MangaLel extends BasicSupport { return builder.toString(); } - /** - * Explode an HTML comma-separated list of values into a non-duplicate text - * {@link List} . - * - * @param values - * the comma-separated values in HTML format - * - * @return the full list with no duplicate in text format - */ - private List explode(String values) { - List list = new ArrayList(); - if (values != null && !values.isEmpty()) { - for (String auth : values.split(",")) { - String a = StringUtils.unhtml(auth).trim(); - if (!a.isEmpty() && !list.contains(a.trim())) { - list.add(a); - } - } - } - - return list; - } - @Override protected boolean supports(URL url) { - return "manga-lel.com".equals(url.getHost()) - || "www.manga-lel.com".equals(url.getHost()); + // URL structure (the projectId is the manga key): + // http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet=999 + + return "mangas-lecture-en-ligne.fr".equals(url.getHost()); } } diff --git a/src/be/nikiroo/fanfix/supported/SupportType.java b/src/be/nikiroo/fanfix/supported/SupportType.java index 7ded392..ba18949 100644 --- a/src/be/nikiroo/fanfix/supported/SupportType.java +++ b/src/be/nikiroo/fanfix/supported/SupportType.java @@ -35,8 +35,44 @@ public enum SupportType { HTML; /** - * A description of this support type (more information than the - * {@link BasicSupport#getSourceName()}). + * The name of this support type (a short version). + * + * @return the name + */ + public String getSourceName() { + switch (this) { + case CBZ: + return "cbz"; + case E621: + return "e621.net"; + case E_HENTAI: + return "e-hentai.org"; + case EPUB: + return "epub"; + case FANFICTION: + return "Fanfiction.net"; + case FIMFICTION: + return "FimFiction.net"; + case HTML: + return "html"; + case INFO_TEXT: + return "info-text"; + case MANGA_LEL: + return "MangaLEL"; + case MANGAFOX: + return "MangaFox.me"; + case TEXT: + return "text"; + case YIFFSTAR: + return "YiffStar"; + } + + return ""; + } + + /** + * A description of this support type (more information than + * {@link SupportType#getSourceName()}). * * @return the description */ @@ -51,20 +87,6 @@ public enum SupportType { return desc; } - /** - * The name of this support type (a short version). - * - * @return the name - */ - public String getSourceName() { - BasicSupport support = BasicSupport.getSupport(this, null); - if (support != null) { - return support.getSourceName(); - } - - return null; - } - @Override public String toString() { return super.toString().toLowerCase(); diff --git a/src/be/nikiroo/fanfix/supported/Text.java b/src/be/nikiroo/fanfix/supported/Text.java index cba23bf..5a4188a 100644 --- a/src/be/nikiroo/fanfix/supported/Text.java +++ b/src/be/nikiroo/fanfix/supported/Text.java @@ -19,7 +19,7 @@ import be.nikiroo.fanfix.bundles.Config; import be.nikiroo.fanfix.data.MetaData; import be.nikiroo.utils.Image; import be.nikiroo.utils.ImageUtils; -import be.nikiroo.utils.MarkableFileInputStream; +import be.nikiroo.utils.streams.MarkableFileInputStream; import be.nikiroo.utils.Progress; /** @@ -67,16 +67,11 @@ class Text extends BasicSupport { return false; } - @Override - public String getSourceName() { - return "text"; - } - @Override protected Document loadDocument(URL source) throws IOException { try { sourceFile = new File(source.toURI()); - in = new MarkableFileInputStream(new FileInputStream(sourceFile)); + in = new MarkableFileInputStream(sourceFile); } catch (URISyntaxException e) { throw new IOException("Cannot load the text document: " + source); } @@ -92,7 +87,7 @@ class Text extends BasicSupport { meta.setAuthor(getAuthor()); meta.setDate(getDate()); meta.setTags(new ArrayList()); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(getSourceFile().toURI().toURL().toString()); meta.setPublisher(""); meta.setUuid(getSourceFile().toString()); diff --git a/src/be/nikiroo/fanfix/supported/YiffStar.java b/src/be/nikiroo/fanfix/supported/YiffStar.java index 92d44fe..a17253a 100644 --- a/src/be/nikiroo/fanfix/supported/YiffStar.java +++ b/src/be/nikiroo/fanfix/supported/YiffStar.java @@ -26,12 +26,6 @@ import be.nikiroo.utils.StringUtils; * @author niki */ class YiffStar extends BasicSupport_Deprecated { - - @Override - public String getSourceName() { - return "YiffStar"; - } - @Override protected MetaData getMeta(URL source, InputStream in) throws IOException { MetaData meta = new MetaData(); @@ -40,9 +34,9 @@ class YiffStar extends BasicSupport_Deprecated { meta.setAuthor(getAuthor(reset(in))); meta.setDate(""); meta.setTags(getTags(reset(in))); - meta.setSource(getSourceName()); + meta.setSource(getType().getSourceName()); meta.setUrl(source.toString()); - meta.setPublisher(getSourceName()); + meta.setPublisher(getType().getSourceName()); meta.setUuid(source.toString()); meta.setLuid(""); meta.setLang("en"); diff --git a/src/be/nikiroo/fanfix/test/BasicSupportTest.java b/src/be/nikiroo/fanfix/test/BasicSupportTest.java index a3f5221..b731c44 100644 --- a/src/be/nikiroo/fanfix/test/BasicSupportTest.java +++ b/src/be/nikiroo/fanfix/test/BasicSupportTest.java @@ -394,11 +394,6 @@ class BasicSupportTest extends TestLauncher { } private class BasicSupportEmpty extends BasicSupport_Deprecated { - @Override - protected String getSourceName() { - return null; - } - @Override protected boolean supports(URL url) { return false;