Add 'src/be/nikiroo/utils/' from commit '46add0670fdee4bd936a13fe2448c5e20a7ffd0a'
authorNiki Roo <niki@nikiroo.be>
Fri, 20 Sep 2019 12:20:56 +0000 (14:20 +0200)
committerNiki Roo <niki@nikiroo.be>
Fri, 20 Sep 2019 12:20:56 +0000 (14:20 +0200)
git-subtree-dir: src/be/nikiroo/utils
git-subtree-mainline: c9994f27667bc421bcd448d39e55774fddf5c431
git-subtree-split: 46add0670fdee4bd936a13fe2448c5e20a7ffd0a

270 files changed:
LICENSE [new file with mode: 0644]
Makefile.base [new file with mode: 0644]
README-fr.md [new file with mode: 0644]
README.md [new file with mode: 0644]
TODO.md [new file with mode: 0644]
VERSION [new file with mode: 0644]
changelog-fr.md [new file with mode: 0644]
changelog.md [new file with mode: 0644]
configure.sh [new file with mode: 0755]
docs/android/android.md [new file with mode: 0644]
docs/android/screens/desc.jpg [new file with mode: 0755]
docs/android/screens/main_lib.jpg [new file with mode: 0755]
docs/android/screens/menu.jpg [new file with mode: 0755]
docs/android/screens/navigation.jpg [new file with mode: 0755]
docs/android/screens/options.jpg [new file with mode: 0755]
docs/android/screens/search.jpg [new file with mode: 0755]
docs/android/screens/viewer-image.jpg [new file with mode: 0755]
docs/android/screens/viewer-text.jpg [new file with mode: 0755]
docs/android/screens/viewer.jpg [new file with mode: 0755]
fanfix.sysv [new file with mode: 0755]
icons/fanfix-alt.png [new file with mode: 0644]
icons/fanfix.png [new file with mode: 0644]
icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png [new file with mode: 0644]
icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png [new file with mode: 0644]
icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png [new file with mode: 0644]
libs/jexer-0.0.4_README.md [new file with mode: 0644]
libs/jexer-0.3.1-Gitlab_2019-03-09-niki2-sources.jar [new file with mode: 0644]
libs/jsoup-1.10.3-sources.jar [new file with mode: 0644]
libs/licenses/jexer-0.0.4_LICENSE.txt [new file with mode: 0644]
libs/licenses/unbescape-1.1.4_LICENSE.txt [new file with mode: 0644]
libs/unbescape-1.1.4-sources.jar [new file with mode: 0644]
libs/unbescape-1.1.4_ChangeLog.txt [new file with mode: 0644]
res/drawable-v24/ic_launcher_foreground.xml [new file with mode: 0644]
res/drawable/ic_launcher_background.xml [new file with mode: 0644]
res/layout/activity_main.xml [new file with mode: 0644]
res/layout/fragment_android_reader_book.xml [new file with mode: 0644]
res/layout/fragment_android_reader_group.xml [new file with mode: 0644]
res/mipmap-hdpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-hdpi/ic_launcher_round.png [new file with mode: 0644]
res/mipmap-ldpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-ldpi/ic_launcher_round.png [new file with mode: 0644]
res/mipmap-mdpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-mdpi/ic_launcher_round.png [new file with mode: 0644]
res/mipmap-tvdpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-tvdpi/ic_launcher_round.png [new file with mode: 0644]
res/mipmap-xhdpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-xhdpi/ic_launcher_round.png [new file with mode: 0644]
res/mipmap-xxhdpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-xxhdpi/ic_launcher_round.png [new file with mode: 0644]
res/mipmap-xxxhdpi/ic_launcher.png [new file with mode: 0644]
res/mipmap-xxxhdpi/ic_launcher_round.png [new file with mode: 0644]
res/values/colors.xml [new file with mode: 0644]
res/values/strings.xml [new file with mode: 0644]
res/values/styles.xml [new file with mode: 0644]
screenshots/README-fr.md [new file with mode: 0644]
screenshots/README.md [new file with mode: 0644]
screenshots/fanfix-1.0.0.png [new file with mode: 0644]
screenshots/fanfix-1.3.2.png [new file with mode: 0644]
src/.gitattributes [new file with mode: 0644]
src/.gitignore [new file with mode: 0644]
src/be/nikiroo/fanfix/DataLoader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Instance.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Main.java [new file with mode: 0644]
src/be/nikiroo/fanfix/VersionCheck.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/Config.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/ConfigBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringId.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdGui.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/Target.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/UiConfig.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/UiConfigBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/UiConfigBundleDesc.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_core.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_core_fr.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_gui.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/ui_description.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Chapter.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/MetaData.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Paragraph.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Story.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/BasicLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/CacheLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/LocalLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/RemoteLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/RemoteLibraryException.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/RemoteLibraryServer.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/BasicOutput.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Cbz.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Epub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Html.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/InfoCover.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/InfoText.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/LaTeX.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Sysout.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Text.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/epub.style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/output/html.style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/output/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/BasicReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/Reader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/cli/CliReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/tui/TuiReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/tui/TuiReaderApplication.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/tui/TuiReaderStoryWindow.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderBook.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderMainPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderNavBar.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderPropertiesFrame.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderPropertiesPane.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchAction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByNamePanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchByTagPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchFrame.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderViewer.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerTextOutput.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/BasicSearchable.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/Fanfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/MangaLel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/SearchableTag.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupport.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupportHelper.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupportImages.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupportPara.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Cbz.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/E621.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/EHentai.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Epub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Fanfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Fimfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/FimfictionApi.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Html.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/InfoReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/InfoText.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/MangaFox.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/MangaLel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/SupportType.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Text.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/YiffStar.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/BasicSupportDeprecatedTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/ConversionTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/LibraryTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/Test.java [new file with mode: 0644]
src/be/nikiroo/jexer/TBrowsableWidget.java [new file with mode: 0644]
src/be/nikiroo/jexer/TSizeConstraint.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTable.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableCellRenderer.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableCellRendererText.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableCellRendererWidget.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableColumn.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableLine.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableModel.java [new file with mode: 0644]
src/be/nikiroo/utils/Cache.java [moved from Cache.java with 100% similarity]
src/be/nikiroo/utils/CacheMemory.java [moved from CacheMemory.java with 100% similarity]
src/be/nikiroo/utils/CryptUtils.java [moved from CryptUtils.java with 100% similarity]
src/be/nikiroo/utils/Downloader.java [moved from Downloader.java with 100% similarity]
src/be/nikiroo/utils/IOUtils.java [moved from IOUtils.java with 100% similarity]
src/be/nikiroo/utils/Image.java [moved from Image.java with 100% similarity]
src/be/nikiroo/utils/ImageUtils.java [moved from ImageUtils.java with 100% similarity]
src/be/nikiroo/utils/MarkableFileInputStream.java [moved from MarkableFileInputStream.java with 100% similarity]
src/be/nikiroo/utils/Progress.java [moved from Progress.java with 100% similarity]
src/be/nikiroo/utils/Proxy.java [moved from Proxy.java with 100% similarity]
src/be/nikiroo/utils/StringJustifier.java [moved from StringJustifier.java with 100% similarity]
src/be/nikiroo/utils/StringUtils.java [moved from StringUtils.java with 100% similarity]
src/be/nikiroo/utils/TempFiles.java [moved from TempFiles.java with 100% similarity]
src/be/nikiroo/utils/TraceHandler.java [moved from TraceHandler.java with 100% similarity]
src/be/nikiroo/utils/Version.java [moved from Version.java with 100% similarity]
src/be/nikiroo/utils/android/ImageUtilsAndroid.java [moved from android/ImageUtilsAndroid.java with 100% similarity]
src/be/nikiroo/utils/android/test/TestAndroid.java [moved from android/test/TestAndroid.java with 100% similarity]
src/be/nikiroo/utils/main/bridge.java [moved from main/bridge.java with 100% similarity]
src/be/nikiroo/utils/main/img2aa.java [moved from main/img2aa.java with 100% similarity]
src/be/nikiroo/utils/main/justify.java [moved from main/justify.java with 100% similarity]
src/be/nikiroo/utils/resources/Bundle.java [moved from resources/Bundle.java with 100% similarity]
src/be/nikiroo/utils/resources/BundleHelper.java [moved from resources/BundleHelper.java with 100% similarity]
src/be/nikiroo/utils/resources/Bundles.java [moved from resources/Bundles.java with 100% similarity]
src/be/nikiroo/utils/resources/FixedResourceBundleControl.java [moved from resources/FixedResourceBundleControl.java with 100% similarity]
src/be/nikiroo/utils/resources/Meta.java [moved from resources/Meta.java with 100% similarity]
src/be/nikiroo/utils/resources/MetaInfo.java [moved from resources/MetaInfo.java with 100% similarity]
src/be/nikiroo/utils/resources/TransBundle.java [moved from resources/TransBundle.java with 100% similarity]
src/be/nikiroo/utils/resources/TransBundle_ResourceList.java [moved from resources/TransBundle_ResourceList.java with 100% similarity]
src/be/nikiroo/utils/resources/package-info.java [moved from resources/package-info.java with 100% similarity]
src/be/nikiroo/utils/serial/CustomSerializer.java [moved from serial/CustomSerializer.java with 100% similarity]
src/be/nikiroo/utils/serial/Exporter.java [moved from serial/Exporter.java with 100% similarity]
src/be/nikiroo/utils/serial/Importer.java [moved from serial/Importer.java with 100% similarity]
src/be/nikiroo/utils/serial/SerialUtils.java [moved from serial/SerialUtils.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectAction.java [moved from serial/server/ConnectAction.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionClient.java [moved from serial/server/ConnectActionClient.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java [moved from serial/server/ConnectActionClientObject.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionClientString.java [moved from serial/server/ConnectActionClientString.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionServer.java [moved from serial/server/ConnectActionServer.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java [moved from serial/server/ConnectActionServerObject.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionServerString.java [moved from serial/server/ConnectActionServerString.java with 100% similarity]
src/be/nikiroo/utils/serial/server/Server.java [moved from serial/server/Server.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ServerBridge.java [moved from serial/server/ServerBridge.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ServerObject.java [moved from serial/server/ServerObject.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ServerString.java [moved from serial/server/ServerString.java with 100% similarity]
src/be/nikiroo/utils/streams/Base64.java [moved from streams/Base64.java with 100% similarity]
src/be/nikiroo/utils/streams/Base64InputStream.java [moved from streams/Base64InputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/Base64OutputStream.java [moved from streams/Base64OutputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/BufferedInputStream.java [moved from streams/BufferedInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/BufferedOutputStream.java [moved from streams/BufferedOutputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/MarkableFileInputStream.java [moved from streams/MarkableFileInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/NextableInputStream.java [moved from streams/NextableInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/NextableInputStreamStep.java [moved from streams/NextableInputStreamStep.java with 100% similarity]
src/be/nikiroo/utils/streams/ReplaceInputStream.java [moved from streams/ReplaceInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/ReplaceOutputStream.java [moved from streams/ReplaceOutputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/StreamUtils.java [moved from streams/StreamUtils.java with 100% similarity]
src/be/nikiroo/utils/test/TestCase.java [moved from test/TestCase.java with 100% similarity]
src/be/nikiroo/utils/test/TestLauncher.java [moved from test/TestLauncher.java with 100% similarity]
src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java [moved from test_code/BufferedInputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java [moved from test_code/BufferedOutputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/BundleTest.java [moved from test_code/BundleTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/CryptUtilsTest.java [moved from test_code/CryptUtilsTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/IOUtilsTest.java [moved from test_code/IOUtilsTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/NextableInputStreamTest.java [moved from test_code/NextableInputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/ProgressTest.java [moved from test_code/ProgressTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java [moved from test_code/ReplaceInputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java [moved from test_code/ReplaceOutputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/SerialServerTest.java [moved from test_code/SerialServerTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/SerialTest.java [moved from test_code/SerialTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/StringUtilsTest.java [moved from test_code/StringUtilsTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/TempFilesTest.java [moved from test_code/TempFilesTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/Test.java [moved from test_code/Test.java with 100% similarity]
src/be/nikiroo/utils/test_code/VersionTest.java [moved from test_code/VersionTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/bundle_test.properties [moved from test_code/bundle_test.properties with 100% similarity]
src/be/nikiroo/utils/ui/ConfigEditor.java [moved from ui/ConfigEditor.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItem.java [moved from ui/ConfigItem.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemBoolean.java [moved from ui/ConfigItemBoolean.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemBrowse.java [moved from ui/ConfigItemBrowse.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemColor.java [moved from ui/ConfigItemColor.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemCombobox.java [moved from ui/ConfigItemCombobox.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemInteger.java [moved from ui/ConfigItemInteger.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemLocale.java [moved from ui/ConfigItemLocale.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemPassword.java [moved from ui/ConfigItemPassword.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemString.java [moved from ui/ConfigItemString.java with 100% similarity]
src/be/nikiroo/utils/ui/ImageTextAwt.java [moved from ui/ImageTextAwt.java with 100% similarity]
src/be/nikiroo/utils/ui/ImageUtilsAwt.java [moved from ui/ImageUtilsAwt.java with 100% similarity]
src/be/nikiroo/utils/ui/ProgressBar.java [moved from ui/ProgressBar.java with 100% similarity]
src/be/nikiroo/utils/ui/UIUtils.java [moved from ui/UIUtils.java with 100% similarity]
src/be/nikiroo/utils/ui/WrapLayout.java [moved from ui/WrapLayout.java with 100% similarity]
src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java [moved from ui/test/ProgressBarManualTest.java with 100% similarity]
src/be/nikiroo/utils/ui/test/TestUI.java [moved from ui/test/TestUI.java with 100% similarity]
test/expected_test.story/cbz.cbz [new file with mode: 0644]
test/expected_test.story/epub.epub [new file with mode: 0644]
test/expected_test.story/html/html.info [new file with mode: 0644]
test/expected_test.story/html/html.txt [new file with mode: 0644]
test/expected_test.story/html/index.html [new file with mode: 0644]
test/expected_test.story/html/style.css [new file with mode: 0644]
test/expected_test.story/info_text [new file with mode: 0644]
test/expected_test.story/info_text.info [new file with mode: 0644]
test/expected_test.story/latex.tex [new file with mode: 0644]
test/expected_test.story/text.txt [new file with mode: 0644]
test/test.story [new file with mode: 0644]

diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..9cecc1d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    {one line to give the program's name and a brief idea of what it does.}
+    Copyright (C) {year}  {name of author}
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    {project}  Copyright (C) {year}  {fullname}
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/Makefile.base b/Makefile.base
new file mode 100644 (file)
index 0000000..0d365b8
--- /dev/null
@@ -0,0 +1,243 @@
+# Makefile base template
+# 
+# Version:
+# - 1.0.0: add a version comment
+# - 1.1.0: add 'help', 'sjar'
+# - 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):
+
+#MAIN = path to main java source to compile
+#MORE = path to supplementary needed resources not linked from MAIN
+#NAME = name of project (used for jar output file)
+#PREFIX = usually /usr/local (where to install the program)
+#TEST = path to main test source to compile
+#JAR_FLAGS += a list of things to pack, each usually prefixed with "-C bin/"
+#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"
+
+JAVAC = javac
+JAVAC_FLAGS += -encoding UTF-8 -d ./bin/ -cp ./src/
+JAVA = java
+JAVA_FLAGS += -cp ./bin/
+JAR = jar
+RJAR = java
+RJAR_FLAGS += -jar
+
+all: build jar man
+
+help:
+       @echo "Usual options:"
+       @echo "=============="
+       @echo " make            : to build the jar file and man pages IF possible"
+       @echo " make help       : to get this help screen"
+       @echo " make libs       : to update the libraries into src/"
+       @echo " make build      : to update the binaries (not the jar)"
+       @echo " make test       : to update the test binaries"
+       @echo " make build jar  : to update the binaries and jar file"
+       @echo " make sjar       : to create the sources jar file"
+       @echo " make clean      : to clean the directory of intermediate files"
+       @echo " make mrpropre   : to clean the directory of all outputs"
+       @echo " make run        : to run the program from the binaries"
+       @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 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 ifman man love
+
+bin:
+       @mkdir -p bin
+
+jar: $(NAME).jar
+
+sjar: $(NAME)-sources.jar
+
+build: resources
+       @echo Compiling program...
+       @echo " src/$(MAIN)"
+       @$(JAVAC) $(JAVAC_FLAGS) "src/$(MAIN).java"
+       @[ "$(MORE)" = "" ] || for sup in $(MORE); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+test: test-resources
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Compiling test program...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       @[ "$(TEST)"  = "" ] || for sup in $(TEST); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+clean:
+       rm -rf bin/
+       @echo Removing sources taken from libs...
+       @for lib in libs/*-sources.jar libs/*-sources.patch.jar; do \
+               if [ "$$lib" != 'libs/*-sources.jar' -a "$$lib" != 'libs/*-sources.patch.jar' ]; then \
+                       basename "$$lib"; \
+                       jar tf "$$lib" | while read -r ln; do \
+                               [ -f "src/$$ln" ] && rm "src/$$ln"; \
+                       done; \
+                       jar tf "$$lib" | tac | while read -r ln; do \
+                               [ -d "src/$$ln" ] && rmdir "src/$$ln" 2>/dev/null || true; \
+                       done; \
+               fi \
+       done
+
+mrproper: mrpropre
+
+mrpropre: clean
+       rm -f $(NAME).jar
+       rm -f $(NAME)-sources.jar
+       rm -f $(NAME).apk
+       rm -f $(NAME)-debug.apk
+       [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`.jar"
+       [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`-sources.jar"
+
+love:
+       @echo " ...not war."
+
+resources: libs
+       @echo Copying resources into bin/...
+       @cd src && find . | grep -v '\.java$$' | grep -v '/test/' | while read -r ln; do \
+               if [ -f "$$ln" ]; then \
+                       dir="`dirname "$$ln"`"; \
+                       mkdir -p "../bin/$$dir" ; \
+                       cp "$$ln" "../bin/$$ln" ; \
+               fi ; \
+       done
+       @cp VERSION bin/
+
+test-resources: resources
+       @echo Copying test resources into bin/...
+       @cd src && find . | grep -v '\.java$$' | grep '/test/' | while read -r ln; do \
+               if [ -f "$$ln" ]; then \
+                       dir="`dirname "$$ln"`"; \
+                       mkdir -p "../bin/$$dir" ; \
+                       cp "$$ln" "../bin/$$ln" ; \
+               fi ; \
+       done
+
+libs: bin
+       @[ -e bin/libs -o ! -d libs ] || echo Extracting sources from libs...
+       @[ -e bin/libs -o ! -d libs ] || (cd src && for lib in ../libs/*-sources.jar ../libs/*-sources.patch.jar; do \
+               if [ "$$lib" != '../libs/*-sources.jar' -a "$$lib" != '../libs/*-sources.patch.jar' ]; then \
+                       basename "$$lib"; \
+                       jar xf "$$lib"; \
+               fi \
+       done )
+       @[ ! -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 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 -C ./ *.md $(JAR_FLAGS)
+       @[ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`.jar"...
+       @[ ! -e VERSION ] || cp $(NAME).jar "$(NAME)-`cat VERSION`.jar"
+
+run: 
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Running "$(NAME)"...
+       $(JAVA) $(JAVA_FLAGS) $(MAIN)
+
+jrun:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       @echo Running "$(NAME).jar"...
+       $(RJAR) $(RJAR_FLAGS) $(NAME).jar
+
+run-test: 
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ] || echo You need to build the test sources
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ]
+       @echo Running tests for "$(NAME)"...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       [ "$(TEST)"  = "" ] || ( clear ; $(JAVA) $(JAVA_FLAGS) $(TEST) $(TEST_PARAMS) )
+
+install:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       mkdir -p "$(PREFIX)/lib" "$(PREFIX)/bin"
+       cp $(NAME).jar "$(PREFIX)/lib/"
+       echo "#!/bin/sh" > "$(PREFIX)/bin/$(NAME)"
+       echo "$(RJAR) $(RJAR_FLAGS) \"$(PREFIX)/lib/$(NAME).jar\" \"\$$@\"" >> "$(PREFIX)/bin/$(NAME)"
+       chmod a+rx "$(PREFIX)/bin/$(NAME)"
+       if [ -e "man/man1/$(NAME).1" ]; then \
+               cp -r man/ "$(PREFIX)"/share/; \
+       fi
+
+ifman:
+       @if pandoc -v >/dev/null 2>&1; then \
+               make man; \
+       else \
+               echo "man pages not generated: "'`'"pandoc' required"; \
+       fi
+
+man: 
+       @echo Checking for possible manual pages...
+       @if [ -e README.md ]; then \
+               echo Sources found for man pages; \
+               if pandoc -v >/dev/null 2>&1; then \
+                       ls README*.md 2>/dev/null \
+                                       | grep 'README\(-..\|\)\.md' \
+                                       | while read man; do \
+                               echo "  Processing page $$lang..."; \
+                               lang="`echo "$$man" \
+                                       | sed 's:README\.md:en:' \
+                                       | sed 's:README-\(.*\)\.md:\1:'`"; \
+                               mkdir -p man/"$$lang"/man1; \
+                               ( \
+                                       echo ".TH \"${NAME}\" 1 `\
+                                               date +%Y-%m-%d\
+                                               ` \"version `cat VERSION`\""; \
+                                       echo; \
+                                       UNAME="`echo "${NAME}" \
+                                               | sed 's:\(.*\):\U\1:g'`"; \
+                                       ( \
+                                               cat "$$man" | head -n1 \
+       | sed 's:.*(README\(-fr\|\)\.md).*::g'; \
+                                               cat "$$man" | tail -n+2; \
+                                       ) | sed 's:^#\(#.*\):\1:g' \
+       | sed 's:^\(#.*\):\U\1:g;s:# *'"$$UNAME"':# NAME\n'"${NAME}"' \\- :g' \
+       | sed 's:--:——:g' \
+       | pandoc -f markdown -t man | sed 's:——:--:g' ; \
+                               ) > man/"$$lang"/man1/"${NAME}.1"; \
+                       done; \
+                       mkdir -p "man/man1"; \
+                       cp man/en/man1/"${NAME}".1 man/man1/; \
+               else \
+                       echo "man pages generation: pandoc required" >&2; \
+                       false; \
+               fi; \
+       fi;
+
diff --git a/README-fr.md b/README-fr.md
new file mode 100644 (file)
index 0000000..777840a
--- /dev/null
@@ -0,0 +1,157 @@
+[English](README.md) Français
+
+# Fanfix
+Fanfix est un petit programme Java qui peut télécharger des histoires sur internet et les afficher hors ligne.
+
+## Synopsis
+
+- ```fanfix``` --import [*URL*]
+- ```fanfix``` --export [*id*] [*output_type*] [*target*]
+- ```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*]
+- ```fanfix``` --stop-server [*key*] [*port*]
+- ```fanfix``` --remote [*key*] [*host*] [*port*]
+- ```fanfix``` --help
+
+## Description
+
+(Si vous voulez juste voir les derniers changements, vous pouvez regarder le [Changelog](changelog-fr.md) -- remarquez que le programme affiche le changelog si une version plus récente est détectée depuis la version 1.4.0.)
+
+(Il y a aussi une [TODO list](TODO.md) sur le site parlant du futur du programme.)
+
+![Main GUI](screenshots/fanfix-1.3.2.png?raw=true "Main GUI")
+
+Une gallerie de screenshots est disponible [ici](screenshots/README-fr.md).
+
+Le fonctionnement du programme est assez simple : il converti une URL venant d'un site supporté en un fichier .epub pour les histoires ou .cbz pour les comics (d'autres options d'enregistrement sont disponibles, comme du texte simple, du HTML...)
+
+Pour vous aider à organiser vos histoires, il peut aussi servir de bibliothèque locale vous permettant :
+
+- d'importer une histoire depuis son URL (ou depuis un fichier)
+- d'exporter une histoire dans un des formats supportés vers un fichier
+- d'afficher une histoire en mode texte
+- d'afficher une histoire en mode GUI **nativement** ou **en appelant un programme natif pour lire le fichier** (potentiellement converti en HTML avant, pour que n'importe quel navigateur web puisse l'afficher)
+
+### Sites supportés
+
+Pour le moment, les sites suivants sont supportés :
+
+- http://FimFiction.net/ : fanfictions dévouées à la série My Little Pony
+- http://Fanfiction.net/ : fanfictions venant d'une multitude d'univers différents, depuis les shows télévisés aux livres en passant par les jeux-vidéos
+- http://mangafox.me/ : un site répertoriant une quantité non négligeable de mangas
+- 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 !
+- http://mangas-lecture-en-ligne.fr/ : un site proposant beaucoup de mangas, en français
+
+### Types de fichiers supportés
+
+Nous supportons les types de fichiers suivants (aussi bien en entrée qu'en sortie) :
+
+- epub : les fichiers .epub créés avec Fanfix (nous ne supportons pas les autres fichiers .epub, du moins pour le moment)
+- text : les histoires enregistrées en texte (.txt), avec quelques règles spécifiques :
+       - le titre doit être sur la première ligne
+       - l'auteur (précédé de rien, ```Par ```, ```De ``` ou ```©```) doit être sur la deuxième ligne, optionnellement suivi de la date de publication entre parenthèses (i.e., ```Par Quelqu'un (3 octobre 1998)```)
+       - les chapitres doivent être déclarés avec ```Chapitre x``` ou ```Chapitre x: NOM DU CHAPTITRE```, où ```x``` est le numéro du chapitre
+       - une description de l'histoire doit être donnée en tant que chaptire 0
+       - une image de couverture peut être présente avec le même nom de fichier que l'histoire, mais une extension .png, .jpeg ou .jpg
+- info_text : fort proche du format texte, mais avec un fichier .info accompagnant l'histoire pour y enregistrer quelques metadata (le fichier de metadata est supposé être créé par Fanfix, ou être compatible avec)
+- cbz : les fichiers .cbz (une collection d'images zipées), de préférence créés avec Fanfix (même si les autres .cbz sont aussi supportés, mais sans la majorité des metadata de Fanfix dans ce cas)
+- html : les fichiers HTML que vous pouvez ouvrir avec n'importe quel navigateur ; remarquez que Fanfix créera un répertoire pour y mettre les fichiers nécessaires, dont un fichier ```index.html``` pour afficher le tout -- nous ne supportons en entrée que les fichiers HTML créés par Fanfix
+
+### Plateformes supportées
+
+Toute plateforme supportant Java 1.6 devrait suffire.
+
+Le programme a été testé sur Linux (Debian, Slackware et Ubuntu), MacOS X et Windows pour le moment, mais n'hésitez pas à nous informer si vous l'essayez sur un autre système.
+
+Si vous avez des difficultés pour le compiler avec une version supportée de Java (1.6+), contactez-nous.
+
+## Options
+
+Vous pouvez démarrer le programme en mode graphique (comme dans le screenshot en haut) :
+
+- ```java -jar fanfix.jar```
+- ```fanfix``` (si vous avez utilisé *make install*)
+
+Les arguments suivants sont aussi supportés :
+
+- ```--import [URL]```: importer une histoire dans la librairie
+- ```--export [id] [output_type] [target]```: exporter l'histoire "id" vers le fichier donné
+- ```--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
+- ```--stop-server [key] [port]```: arrêter le serveur distant sur ce port (key doit avoir la même valeur)
+- ```--remote [key] [host] [port]```: contacter ce server au lieu de la librairie habituelle (key doit avoir la même valeur)
+- ```--help```: afficher la liste des options disponibles
+- ```--version```: retourne la version du programme
+
+### Environnement
+
+Certaines variables d'environnement sont reconnues par le programme :
+
+- ```LANG=en```: forcer la langue du programme en anglais
+- ```CONFIG_DIR=$HOME/.fanfix```: utilise ce répertoire pour les fichiers de configuration du programme (et copie les fichiers de configuration par défaut si besoin)
+- ```NOUTF=1```: essaye d'utiliser des caractères non-unicode quand possible (cela peut avoir un impact sur les fichiers générés, pas uniquement sur les messages à l'utilisateur)
+- ```DEBUG=1```: force l'option ```DEBUG=true``` du fichier de configuration (pour afficher plus d'information en cas d'erreur)
+
+## Compilation
+
+```./configure.sh && make```
+
+Vous pouvez aussi importer les sources java dans, par exemple, [Eclipse](https://eclipse.org/), et faire un JAR exécutable depuis celui-ci.
+
+Quelques tests unitaires sont disponibles :
+
+```./configure.sh && make build test run-test```
+
+Si vous faites tourner les tests unitaires, sachez que certains fichiers flags peuvent les impacter:
+
+- ```test/VERBOSE```      : active le mode verbeux pour les erreurs
+- ```test/OFFLINE```      : ne permet pas au programme de télécharger des données
+- ```test/URLS```         : permet au programme de tester des URLs
+- ```test/FORCE_REFRESH```: force le nettoyage du cache
+
+Notez que le répertoire ```test/CACHE``` peut rester en place; il contient tous les fichiers téléchargés au moins une fois depuis le réseau par les tests unitaires (si vous autorisez les tests d'URLs, lancez les tests au moins une fois pour peupler le CACHE, puis activez le mode OFFLINE, ça marchera toujours).
+
+Les fichiers de test seront:
+
+- ```test/*.url```  : des URLs à télécharger en fichier texte (le contenu du fichier est l'URL)
+- ```test/*.story```: des histoires en mode texte (le contenu du fichier est l'histoire)
+
+### Librairies dépendantes (incluses)
+
+Nécessaires :
+
+- ```libs/nikiroo-utils-sources.jar```: quelques utilitaires partagés
+- [```libs/unbescape-sources.jar```](https://github.com/unbescape/unbescape): une librairie sympathique pour convertir du texte depuis/vers beaucoup de formats ; utilisée ici pour la partie HTML
+- [```libs/jsoup-sources.jar```](https://jsoup.org/): une libraririe pour parser du HTML
+
+Optionnelles :
+
+- [```libs/jexer-sources.jar```](https://github.com/klamonte/jexer): une petite librairie qui offre des widgets en mode TUI
+- [```pandoc```](http://pandoc.org/): pour générer les man pages depuis les fichiers README
+
+Rien d'autre, si ce n'est Java 1.6+.
+
+À noter : ```make libs``` exporte ces librairies dans le répertoire src/.
+
+## Auteur
+
+Fanfix a été écrit par Niki Roo <niki@nikiroo.be>
+
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..43a0f40
--- /dev/null
+++ b/README.md
@@ -0,0 +1,158 @@
+English [Français](README-fr.md)
+
+# Fanfix
+Fanfix is a small Java program that can download stories from some supported websites and render them offline.
+
+## Synopsis
+
+- ```fanfix``` --import [*URL*]
+- ```fanfix``` --export [*id*] [*output_type*] [*target*]
+- ```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*]
+- ```fanfix``` --stop-server [*key*] [*port*]
+- ```fanfix``` --remote [*key*] [*host*] [*port*]
+- ```fanfix``` --help
+
+## Description
+
+(If you are interested in the recent changes, please check the [Changelog](changelog.md) -- note that starting from version 1.4.0, the changelog is checked at startup.)
+
+(A [TODO list](TODO.md) is also available to know what is expected to come in the future.)
+
+![Main GUI](screenshots/fanfix-1.3.2.png?raw=true "Main GUI")
+
+A screenshots cgallery an be found [here](screenshots/README.md).
+
+It will convert from a (supported) URL to an .epub file for stories or a .cbz file for comics (a few other output types are also available, like Plain Text, LaTeX, HTML...).
+
+To help organize your stories, it can also work as a local library so you can:
+
+- Import a story from its URL (or just from a file)
+- Export a story to a file (in any of the supported output types)
+- Display a story from the local library in text format in the console
+- Display a story from the local library graphically **natively** or **by calling a native program to handle it** (potentially converted into HTML before hand, so any browser can open it)
+
+### Supported websites
+
+Currently, the following websites are supported:
+
+- http://FimFiction.net/: fan fictions devoted to the My Little Pony show
+- http://Fanfiction.net/: fan fictions of many, many different universes, from TV shows to novels to games
+- http://mangafox.me/: a well filled repository of mangas, or, as their website states: most popular manga scanlations read online for free at mangafox, as well as a close-knit community to chat and make friends
+- https://e621.net/: 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!)
+- http://mangas-lecture-en-ligne.fr/: a website offering a lot of mangas (in French)
+
+### Support file types
+
+We support a few file types for local story conversion (both as input and as output):
+
+- epub: .epub files created by this program (we do not support "all" .epub files, at least for now)
+- text: local stories encoded in plain text format, with a few specific rules:
+       - the title must be on the first line
+       - the author (preceded by nothing, ```by ``` or ```©```) must be on the second line, possibly with the publication date in parenthesis (i.e., ```By Unknown (3rd October 1998)```)
+       - chapters must be declared with ```Chapter x``` or ```Chapter x: NAME OF THE CHAPTER```, where ```x``` is the chapter number
+       - a description of the story must be given as chapter number 0
+       - a cover image may be present with the same filename as the story, but a .png, .jpeg or .jpg extension
+- info_text: contains the same information as the text format, but with a companion .info file to store some metadata (the .info file is supposed to be created by Fanfix or compatible with it)
+- cbz: .cbz (collection of images) files, preferably created with Fanfix (but any .cbz file is supported, though without most of Fanfix metadata, obviously)
+- html: HTML files that you can open with any browser; note that it will create a directory structure with ```index.html``` as the main file -- we only support importing HTML files created by Fanfix
+
+### Supported platforms
+
+Any platform with at lest Java 1.6 on it should be ok.
+
+It has been tested on Linux (Debian, Slackware, Ubuntu), MacOS X and Windows for now, but feel free to inform us if you try it on another system.
+
+If you have any problems to compile it with a supported Java version (1.6+), please contact us.
+
+## Options
+
+You can start the program in GUI mode (as in the screenshot on top):
+
+- ```java -jar fanfix.jar```
+- ```fanfix``` (if you used *make install*)
+
+The following arguments are also allowed:
+
+- ```--import [URL]```: import the story at URL into the local library
+- ```--export [id] [output_type] [target]```: export the story denoted by ID to the target file
+- ```--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
+- ```--stop-server [key] [port]```: stop the remote server running on this port (key must be set to the same value)
+- ```--remote [key] [host] [port]```: contact this server instead of the usual library (key must be set to the same value)
+- ```--help```: display the available options
+- ```--version```: return the version of the program
+
+### Environment
+
+Some environment variables are recognized by the program:
+
+- ```LANG=en```: force the language to English
+- ```CONFIG_DIR=$HOME/.fanfix```: use the given directory as a config directory (and copy the default configuration if needed)
+- ```NOUTF=1```: try to fallback to non-unicode values when possible (can have an impact on the resulting files, not only on user messages)
+- ```DEBUG=1```: force the ```DEBUG=true``` option of the configuration file (to show more information on errors)
+
+## Compilation
+
+```./configure.sh && make```
+
+You can also import the java sources into, say, [Eclipse](https://eclipse.org/), and create a runnable JAR file from there.
+
+There are some unit tests you can run, too:
+
+```./configure.sh && make build test run-test```
+
+If you run the unit tests, note that some flag files can impact them:
+
+- ```test/VERBOSE```      : enable verbose mode
+- ```test/OFFLINE```      : to forbid any downloading
+- ```test/URLS```         : to allow testing URLs
+- ```test/FORCE_REFRESH```: to force a clear of the cache
+
+Note that ```test/CACHE``` can be kept, as it will contain all internet related files you need (if you allow URLs, run the test once which will populate the CACHE then go OFFLINE, it will still work).
+
+The test files will be:
+
+- ```test/*.url```  : URL to download in text format, content = URL
+- ```test/*.story```: text mode story, content = story
+
+
+### Dependant libraries (included)
+
+Required:
+
+- ```libs/nikiroo-utils-sources.jar```: some shared utility functions
+- [```libs/unbescape-sources.jar```](https://github.com/unbescape/unbescape): a nice library to escape/unescape a lot of text formats; used here for HTML
+- [```libs/jsoup-sources.jar```](https://jsoup.org/): a library to parse HTML
+
+Optional:
+
+- [```libs/jexer-sources.jar```](https://github.com/klamonte/jexer): a small library that offers TUI widgets
+- [```pandoc```](http://pandoc.org/): to generate the man pages from the README files
+
+Nothing else but Java 1.6+.
+
+Note that calling ```make libs``` will export the libraries into the src/ directory.
+
+## Author
+
+Fanfix was written by Niki Roo <niki@nikiroo.be>
+
diff --git a/TODO.md b/TODO.md
new file mode 100644 (file)
index 0000000..af17b53
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,95 @@
+My current planning for Fanfix (but not everything appears on this list):
+
+- [ ] Support new websites
+  - [x] YiffStar
+  - [ ] [Two Kinds](http://twokinds.keenspot.com/)
+  - [ ] [Slightly damned](http://www.sdamned.com/)
+  - [x] New API on FimFiction.net (faster)
+  - [ ] Others? Any ideas? I'm open for requests
+    - [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
+  - [x] Fix the UI, it is ugly
+  - [x] Work on the UI thread is BAD
+  - [x] Allow export
+  - [x] Allow delete/refresh
+  - [x] Show a list of types
+    - [x] ..in the menu
+    - [x] ..as a screen view
+  - [x] options screen
+  - [x] support progress events
+  - [x] Real menus
+    - [x] Store the long lists in [A-B], [BA-BB], ...
+- [ ] A TUI library
+  - [x] Choose an output (Jexer)
+  - [x] Implement it from --set-reader to the actual window
+  - [x] List the stories
+  - [x] Fix the UI layout
+  - [x] Status bar
+  - [x] Real menus
+    - [ ] Store the long lists in [A-B], [BA-BB], ...
+  - [x] Open a story in the reader and/or natively
+  - [ ] Update the screenshots
+  - [ ] Remember the current chapter and current read status of stories
+  - [ ] Support progress events
+  - [x] Add a properties pages
+  - [ ] Deal with comics
+    - [x] properties page
+    - [x] external launcher
+    - [ ] jexer sixels?
+- [x] Network support
+  - [x] A server that can send the stories
+  - [x] A network implementation of the Library
+  - [x] Write access to the library
+  - [x] Access rights (a simple "key")
+  - [x] More tests, especially with the GUI
+    - [x] ..even more
+  - [x] support progress events
+- [x] Check if it can work on Android
+  - [x] First checks: it should work, but with changes
+  - [x] Adapt work on images :(
+  - [x] Partial/Conditional compilation
+  - [x] APK export
+- [ ] Android
+  - [x] Android support
+  - [x] Show current stories
+  - [x] Download new stories
+  - [ ] 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
+  - [x] Make use of it in gui
+  - [ ] Make use of it in tui
+  - [ ] Use it for all user output
+  - [x] French translation
+  - [x] French manual/readme
+- [x] Install a mechanism to handle stories import/export progress update
+  - [x] Progress system
+  - [x] in support classes (import)
+  - [x] in output classes (export)
+- [x] Version
+  - [x] Use a version number
+  - [x] Show it in UI
+  - [x] A check-update feature
+    - [x] ..translated
+- [ ] Improve GUI library
+    - [x] Allow launching a custom application instead of Desktop.start
+    - [ ] Add the resume next to the cover icon if available (as an option)
+    - [ ] Add the resume in the Properties page (maybe a second tab?)
+- [ ] Bugs
+    - [x] Fix "Redownload also reset the source"
+    - [x] Fix "Redownload remote does not show the new item before restart of client app"
+    - [x] Fix eHentai "content warning" access (see 455)
+    - [ ] Fix the configuration system (for new or changed options, new or changed languages)
+    - [x] remote import also download the file in cache, why?
+    - [x] import file in remote mode tries to import remote file!!
+    - [ ] import file does not find author in cbz with SUMMARY file
+    - [x] import file:// creates a tmp without auto-deletion in /tmp/fanfic-...
+
diff --git a/VERSION b/VERSION
new file mode 100644 (file)
index 0000000..2468aa9
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+3.0.0-dev
diff --git a/changelog-fr.md b/changelog-fr.md
new file mode 100644 (file)
index 0000000..9409b81
--- /dev/null
@@ -0,0 +1,260 @@
+# Fanfix
+
+# Version 3.0.0
+
+- new: maintenant compatible Android (voir [companion project](https://gitlab.com/Rayman22/fanfix-android))
+- new: recherche d'histoires (pas encore toutes les sources)
+- new: support d'un proxy
+- fix: support des CBZ contenant du texte
+- fix: correction de DEBUG=0
+- fix: correction des histoires importées qui n'arrivent pas immédiatement à l'affichage
+- gui: correction pour le focus 
+- gui: fix pour la couleur d'arrière plan
+- gui: fix pour la navigation au clavier (haut et bas)
+- gui: configuration beaucoup plus facile
+- gui: peut maintenant télécharger toutes les histoires d'un groupe en cache en une fois
+- MangaLEL: site web changé
+- search: supporte MangaLEL
+- search: supporte Fanfiction.net
+- FimFictionAPI: correction d'une NPE
+- remote: changement du chiffrement because Google
+- remote: incompatible avec 2.x
+- remote: moins bonnes perfs mais meilleure utilisation de la mémoire
+- remote: le log inclus maintenant la date des évènements
+- remote: le mot de passe se configure maintenant dans le fichier de configuration
+
+# Version 2.0.3
+
+SoFurry: correction pour les histoires disponibles uniquement aux utilisateurs inscrits sur le site
+
+# Version 2.0.2
+
+- i18n: changer la langue dans les options fonctionne aussi quand $LANG existe
+- gui: traduction en français
+- gui: ReDownloader ne supprime plus le livre original
+- fix: corrections pour le visionneur interne
+- fix: quelques corrections pour les traductions
+
+# Version 2.0.1
+
+- 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
+
+- new: les sources peuvent contenir "/" (et utiliseront des sous-répertoires en fonction)
+- gui: nouvelle page pour voir les propriétés d'une histoire
+- gui: renommer les histoires, changer l'auteur
+- gui: permet de lister les auteurs ou les sources en mode "tout" ou "listing"
+- gui: lecteur intégré pour les histoires (texte et images)
+- tui: fonctionne maintenant assez bien que pour être déclaré stable
+- cli: permet maintenant de changer la source, le titre ou l'auteur
+- remote: fix de setSourceCover (ce n'était pas vu par le client)
+- remote: on peut maintenant importer un fichier local
+- remote: meilleures perfs
+- remote: incompatible avec 1.x
+- fix: deadlock dans certains cas rares (nikiroo-utils)
+- fix: le résumé n'était pas visibe dans certains cas
+- fix: update de nikiroo-utils, meilleures perfs pour le remote
+- fix: eHentai content warning
+
+# Version 1.8.1
+
+- e621: les images étaient rangées à l'envers pour les recherches (/post/)
+- e621: correction pour /post/search/
+- remote: correction de certains problèmes de timeout
+- remote: amélioration des perfs
+- fix: permettre les erreurs I/O pour les CBZ (ignore l'image)
+- fix: corriger le répertoire des covers par défaut
+
+# Version 1.8.0
+
+- FimfictionAPI: les noms des chapitres sont maintenant triés correctement
+- e621: supporte aussi les recherches (/post/)
+- remote: la cover est maintenant envoyée au client pour les imports
+- MangaLel: support pour MangaLel
+
+# Version 1.7.1
+
+- GUI: fichiers tmp supprimés trop vite en mode GUI
+- fix: une histoire sans cover pouvait planter le programme
+- ePub: erreur d'import pour un EPUB local sans cover
+
+# Version 1.7.0
+
+- new: utilisation de jsoup pour parser le HTML (pas encore partout)
+- update: mise à jour de nikiroo-utils
+- android: compatibilité Android
+- MangaFox: fix après une mise-à-jour du site
+- MangaFox: l'ordre des tomes n'était pas toujours bon
+- ePub: correction pour la compatibilité avec certains lecteurs ePub
+- remote: correction pour l'utilisation du cache
+- fix: TYPE= était parfois mauvais dans l'info-file
+- fix: les guillemets n'étaient pas toujours bien ordonnés
+- fix: amélioration du lanceur externe (lecteur natif)
+- test: plus de tests unitaires
+- doc: changelog disponible en français
+- doc: man pages (en, fr)
+- doc: SysV init script
+
+# Version 1.6.3
+
+- fix: corrections de bugs
+- remote: notification de l'état de progression des actions
+- remote: possibilité d'envoyer des histoires volumineuses
+- remote: détection de l'état du serveur
+- remote: import and change source on server
+- CBZ: meilleur support de certains CBZ (si SUMMARY ou URL est présent dans le CBZ)
+- Library: correction pour les pages de couvertures qui n'étaient pas toujours effacées quand l'histoire l'était
+- fix: correction pour certains cas où les images ne pouvaient pas être sauvées (quand on demande un jpeg mais que l'image n'est pas supportée, nous essayons maintenant ensuite en png)
+- remote: correction pour certaines images de couvertures qui n'étaient pas trouvées (nikiroo-utils)
+- remote: correction pour les images de couvertures qui n'étaient pas transmises
+
+## Version 1.6.2
+
+- GUI: amélioration des barres de progression
+- GUI: meilleures performances pour l'ouverture d'une histoire si le type de l'histoire est déjà le type demandé pour l'ouverture (CBZ -> CBZ ou HTML -> HTML par exemple)
+
+## Version 1.6.1
+
+- GUI: nouvelle option (désactivée par défaut) pour afficher un élément par source (type) sur la page de démarrage au lieu de tous les éléments triés par source (type)
+- fix: correction de la source (type) qui était remis à zéro après un re-téléchargement
+- GUI: affichage du nombre d'images présentes au lieu du nombre de mots pour les histoires en images
+
+## Version 1.6.0
+
+- TUI: un nouveau TUI (mode texte mais avec des fenêtres et des menus en texte) -- cette option n'est pas compilée par défaut (configure.sh)
+- remote: un serveur pour offrir les histoires téléchargées sur le réseau
+- remote: une Library qui reçoit les histoires depuis un serveur distant
+- update: mise à jour de nikiroo-utils
+- FimFiction: support for the new API
+- new: mise à jour du cache (effacer le cache actuel serait une bonne idée)
+- GUI: correction pour le déplacement d'une histoire qui n'est pas encore dans le cache
+
+## Version 1.5.3
+
+- FimFiction: correction pour les tags dans les metadata et la gestion des chapitres pour certaines histoires
+
+## Version 1.5.2
+
+- FimFiction: correction pour les tags dans les metadata
+
+## Version 1.5.1
+
+- FimFiction: mise à jour pour supporter FimFiction 4
+- eHentai: correction pour quelques metadata qui n'étaient pas reprises
+
+## Version 1.5.0
+
+- eHentai: nouveau site supporté sur demande (n'hésitez pas !) : e-hentai.org
+- Library: amélioration des performances quand on récupère une histoire (la page de couverture n'est plus chargée quand cela n'est pas nécessaire)
+- Library: correction pour les pages de couvertures qui n'étaient pas toujours effacées quand l'histoire l'était
+- GUI: amélioration des performances pour l'affichage des histoires (la page de couverture est re-dimensionnée en cache)
+- GUI: on peut maintenant éditer la source d'une histoire ("Déplacer vers...")
+
+## Version 1.4.2
+
+- GUI: nouveau menu Options pour configurer le programme (très minimaliste pour le moment)
+- new: gestion de la progression des actions plus fluide et avec plus de détails
+- fix: meilleur support des couvertures pour les fichiers en cache
+
+## Version 1.4.1
+
+- fix: correction de UpdateChecker qui affichait les nouveautés de TOUTES les versions du programme au lieu de se limiter aux versions plus récentes
+- fix: correction de la gestion de certains sauts de ligne pour le support HTML (entre autres, FanFiction.net)
+- GUI: les barres de progrès fonctionnent maintenant correctement
+- update: mise à jour de nikiroo-utils pour récupérer toutes les étapes dans les barres de progrès
+- ( --Fin des nouveautés de la version 1.4.1-- )
+
+## Version 1.4.0
+
+- new: sauvegarde du nombre de mots et de la date de création des histoires dans les fichiers mêmes
+- GUI: nouvelle option pour afficher le nombre de mots plutôt que le nom de l'auteur sous le nom de l'histoire
+- CBZ: la première page n'est plus doublée sur les sites n'offrant pas de page de couverture
+- GUI: recherche de mise à jour (le programme cherche maintenant si une mise à jour est disponible pour en informer l'utilisateur)
+
+## Version 1.3.1
+
+- GUI: on peut maintenant trier les histoires par auteur
+
+## Version 1.3.0
+
+- YiffStar: le site YiffStar (SoFurry.com) est maintenant supporté
+- new: support des sites avec login/password
+- GUI: les URLs copiées (ctrl+C) sont maintenant directement proposées par défaut quand on importe une histoire
+- GUI: la version est maintenant visible (elle peut aussi être récupérée avec --version)
+
+## Version 1.2.4
+
+- GUI: nouvelle option re-télécharger
+- GUI: les histoires sont maintenant triées (et ne changeront plus d'ordre après chaque re-téléchargement)
+- fix: corrections sur l'utilisation des guillemets
+- fix: corrections sur la détection des chapitres
+- new: de nouveaux tests unitaires
+
+## Version 1.2.3
+
+- HTML: les fichiers originaux (info_text) sont maintenant rajoutés quand on sauve
+- HTML: support d'un nouveau type de fichiers à l'import: HTML (si fait par Fanfix)
+
+## Version 1.2.2
+
+- GUI: nouvelle option "Sauver sous..."
+- GUI: corrections (rafraîchissement des icônes)
+- fix: correction de la gestion du caractère TAB dans les messages utilisateurs
+- GUI: LocalReader supporte maintenant "--read"
+- ePub: corrections sur le CSS
+
+## Version 1.2.1
+
+- GUI: de nouvelles fonctions ont été ajoutées dans le menu
+- GUI: popup avec un clic droit sur les histoires
+- GUI: corrections, particulièrement pour LocalLibrary
+- GUI: nouvelle icône (un rond vert) pour dénoter qu'une histoire est "cachée" (dans le LocalReader)
+
+## Version 1.2.0
+
+- GUI: système de notification de la progression des actions
+- ePub: changements sur le CSS
+- new: de nouveaux tests unitaires
+- GUI: de nouvelles fonctions ont été ajoutées dans le menu (supprimer, rafraîchir, un bouton exporter qui ne fonctionne pas encore)
+
+## Version 1.1.0
+
+- CLI: nouveau système de notification de la progression des actions
+- e621: correction pour les "pending pools" qui ne fonctionnaient pas avant
+- new: système de tests unitaires ajouté (pas encore de tests propres à Fanfix)
+
+## Version 1.0.0
+
+- GUI: état acceptable pour une 1.0.0 (l'export n'est encore disponible qu'en CLI)
+- fix: bugs fixés
+- GUI: (forte) amélioration
+- new: niveau fonctionnel acceptable pour une 1.0.0
+
+## Version 0.9.5
+
+- fix: bugs fixés
+- new: compatibilité avec WIN32 (testé sur Windows 10)
+
+## Version 0.9.4
+
+- fix: (beaucoup de) bugs fixés
+- new: amélioration des performances
+- new: moins de fichiers cache utilisés
+- GUI: amélioration (pas encore parfait, mais utilisable)
+
+## Version 0.9.3
+
+- fix: (beaucoup de) bugs fixés
+- GUI: première implémentation graphique (laide et buggée)
+
+## Version 0.9.2
+
+- new: version minimum de la JVM : Java 1.6 (tous les JAR binaires ont été compilés en Java 1.6)
+- fix: bugs fixés
+
+## Version 0.9.1
+
+- version initiale
+
diff --git a/changelog.md b/changelog.md
new file mode 100644 (file)
index 0000000..f3033b5
--- /dev/null
@@ -0,0 +1,260 @@
+# Fanfix
+
+# Version 3.0.0
+
+- 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
+- fix: fix imported stories that don't immediatly appear on screen
+- gui: focus fix
+- gui: bg colour fix
+- gui: fix keyboard navigation support (up and down)
+- gui: configuration is now much easier
+- gui: can now prefetch to cache all the sories of a group at once
+- 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: can 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.3
+
+SoFurry: fix for stories only available to registrated users
+
+# Version 2.0.2
+
+- i18n: setting the language in the option panel now works even with $LANG set
+- gui: translated into French
+- gui: ReDownload does not delete the original book anymore
+- gui: internal viewer fixes 
+- gui: some translation fixes
+
+# Version 2.0.1
+
+- core: a change of title/source/author was not always visible at runtime
+- gui: only reload the stoies when needed
+
+# Version 2.0.0
+
+- new: sources can contain "/" (and will use subdirectories)
+- gui: new Properties page for stories
+- gui: rename stories, change author
+- gui: allow "all" and "listing" modes for sources and authors
+- gui: integrated viewer for stories (both text and images)
+- tui: now working well enough to be considered stable
+- cli: now allow changing the source, title and author
+- remote: fix setSourceCover (was not seen by client)
+- remote: can now import local files into a remote library
+- remote: better perfs
+- remote: not compatible with 1.x
+- fix: deadlock in some rare cases (nikiroo-utils)
+- fix: the resume was not visible in some cases
+- fix: update nikiroo-utils, better remote perfs
+- fix: eHentai content warning
+
+# Version 1.8.1
+
+- e621: the images were stored in reverse order for searches (/post/)
+- e621: fix for /post/search/
+- remote: fix some timeout issues
+- remote: improve perfs
+- fix: allow I/O errors on CBZ files (skip image)
+- fix: fix the default covers directory
+
+# Version 1.8.0
+
+- FimfictionAPI: chapter names are now correctly ordered
+- e621: now supports searches (/post/)
+- remote: cover is now sent over the network for imported stories
+- MangaLel: new support for MangaLel
+
+# Version 1.7.1
+
+- GUI: tmp files deleted too soon in GUI mode
+- fix: unavailable cover could cause a crash
+- ePub: local EPUB import error when no cover
+
+# Version 1.7.0
+
+- new: use jsoup for parsing HTML (not everywhere yet)
+- update nikiroo-utils
+- android: Android compatibility
+- MangaFox: fix after website update
+- MangaFox: tomes order was not always correct
+- ePub: fix for compatibility with some ePub viewers
+- remote: cache usage fix
+- fix: TYPE= not always correct in info-file
+- fix: quotes error
+- fix: improve external launcher (native viewer)
+- test: more unit tests
+- doc: changelog available in French
+- doc: man pages (en, fr)
+- doc: SysV init script
+
+# Version 1.6.3
+
+- fix: bug fixes
+- remote: progress report
+- remote: can send large files
+- remote: detect server state
+- remote: import and change source on server
+- CBZ: better support for some CBZ files (if SUMMARY or URL files are present in it)
+- Library: fix cover images not deleted on story delete
+- fix: some images not supported because not jpeg-able (now try again in png)
+- remote: fix some covers not found over the wire (nikiroo-utils)
+- remote: fix cover image files not sent over the wire
+
+## Version 1.6.2
+
+- GUI: better progress bars
+- GUI: can now open files much quicker if they are stored in both library and cache with the same output type 
+
+## Version 1.6.1
+
+- GUI: new option (disabled by default) to show one item per source type instead of one item per story when showing ALL sources (which is also the start page)
+- fix: source/type reset when redownloading
+- GUI: show the number of images instead of the number of words for images documents 
+
+## Version 1.6.0
+
+- TUI: new TUI (text with windows and menus) -- not compiled by default (configure.sh)
+- remote: a server option to offer stories on the network
+- remote: a remote library to get said stories from the network
+- update to latest version of nikiroo-utils
+- FimFiction: support for the new API
+- new: cache update (you may want to clear your current cache)
+- GUI: bug fixed (moving an unopened book does not fail any more)
+
+## Version 1.5.3
+
+- FimFiction: Fix tags and chapter handling for some stories
+
+## Version 1.5.2
+
+- FimFiction: Fix tags metadata on FimFiction 4
+
+## Version 1.5.1
+
+- FimFiction: Update to FimFiction 4
+- eHentai: Fix some meta data that were missing
+
+## Version 1.5.0
+
+- eHentai: new website supported on request (do not hesitate!): e-hentai.org
+- Library: perf improvement when retrieving the stories (cover not loaded when not needed)
+- Library: fix the covers that were not always removed when deleting a story
+- GUI: perf improvement when displaying books (cover resized then cached)
+- GUI: sources are now editable ("Move to...")
+
+## Version 1.4.2
+
+- GUI: new Options menu to configure the program (minimalist for now)
+- new: improve progress reporting (smoother updates, more details)
+- fix: better cover support for local files
+
+## Version 1.4.1
+
+- fix: UpdateChecker which showed the changes of ALL versions instead of the newer ones only
+- fix: some bad line breaks on HTML support (including FanFiction.net)
+- GUI: progress bar now working correctly
+- update: nikiroo-utils update to show all steps in the progress bars
+- ( --End of changes for version 1.4.1-- )
+
+## Version 1.4.0
+
+- new: remember the word count and the date of creation of Fanfix stories
+- GUI: option to show the word count instead of the author below the book title
+- CBZ: do not include the first page twice anymore for no-cover websites
+- GUI: update version check (we now check for new versions)
+
+## Version 1.3.1
+
+- GUI: can now display books by Author
+
+## Version 1.3.0
+
+- YiffStar: YiffStar (SoFurry.com) is now supported
+- new: supports login/password websites
+- GUI: copied URLs (ctrl+C) are selected by default when importing a URL
+- GUI: version now visible (also with --version)
+
+## Version 1.2.4
+
+- GUI: new option: Re-download
+- GUI: books are now sorted (will not jump around after refresh/redownload)
+- fix: quote character handling
+- fix: chapter detection
+- new: more tests included
+
+## Version 1.2.3
+
+- HTML: include the original (info_text) files when saving
+- HTML: new input type supported: HTML files made by Fanfix
+
+## Version 1.2.2
+
+- GUI: new "Save as..." option
+- GUI: fixes (icon refresh)
+- fix: handling of TABs in user messages
+- GUI: LocalReader can now be used with --read
+- ePub: CSS style fixes
+
+## Version 1.2.1
+
+- GUI: some menu functions added
+- GUI: right-click popup menu added
+- GUI: fixes, especially for the LocalReader library
+- GUI: new green round icon to denote "cached" (into LocalReader library) files
+
+## Version 1.2.0
+
+- GUI: progress reporting system
+- ePub: CSS style changes
+- new: unit tests added
+- GUI: some menu functions added (delete, refresh, a place-holder for export)
+
+## Version 1.1.0
+
+- CLI: new Progress reporting system
+- e621: fix on "pending" pools, which were not downloaded before
+- new: unit tests system added (but no test yet, as all tests were moved into nikiroo-utils)
+
+## Version 1.0.0
+
+- GUI: it is now good enough to be released (export is still CLI-only though)
+- fix: bug fixes
+- GUI: improved (a lot)
+- new: should be good enough for 1.0.0
+
+## Version 0.9.5
+
+- fix: bug fixes
+- new: WIN32 compatibility (tested on Windows 10)
+
+## Version 0.9.4
+
+- fix: bug fixes (lots of)
+- new: perf improved
+- new: use less cache files
+- GUI: improvement (still not really OK, but OK enough I guess)
+
+## Version 0.9.3
+
+- fix: bug fixes (lots of)
+- GUI: first implementation (which is ugly and buggy -- the buggly GUI)
+
+## Version 0.9.2
+
+- new: minimum JVM version: Java 1.6 (all binary JAR files will be released in 1.6)
+- fix: bug fixes
+
+## Version 0.9.1
+
+- initial version
+
diff --git a/configure.sh b/configure.sh
new file mode 100755 (executable)
index 0000000..15ae180
--- /dev/null
@@ -0,0 +1,88 @@
+#!/bin/sh
+
+# default:
+PREFIX=/usr/local
+PROGS="java javac jar make sed"
+
+IMG=be/nikiroo/utils/ui/ImageUtilsAwt
+CLI=be/nikiroo/fanfix/reader/cli/CliReader
+TUI=be/nikiroo/fanfix/reader/tui/TuiReader
+GUI=be/nikiroo/fanfix/reader/ui/GuiReader
+JIMG=
+JCLI=
+JTUI="-C bin/ jexer"
+JGUI=
+
+valid=true
+while [ "$*" != "" ]; do
+       key=`echo "$1" | cut -f1 -d=`
+       val=`echo "$1" | cut -f2 -d=`
+       case "$key" in
+       --)
+       ;;
+       --help) #               This help message
+               echo The following arguments can be used:
+               cat "$0" | grep '^\s*--' | grep '#' | while read ln; do
+                       cmd=`echo "$ln" | cut -f1 -d')'`
+                       msg=`echo "$ln" | cut -f2 -d'#'`
+                       echo "  $cmd$msg"
+               done
+       ;;
+       --prefix) #=PATH        Change the prefix to the given path
+               PREFIX="$val"
+       ;;
+       --cli) #=no     Disable CLI support (System.out)
+               [ "$val" = no -o "$val" = false ] && CLI= && JCLI=
+       ;;
+       --tui) #=no     Enable TUI support (Jexer)
+               [ "$val" = no -o "$val" = false ] && TUI= && JTUI=
+       ;;
+       --gui) #=no     Disable GUI support (Swing)
+               [ "$val" = no -o "$val" = false ] && GUI= && JGUI=
+       ;;
+       *)
+               echo "Unsupported parameter: '$1'" >&2
+               echo >&2
+               sh "$0" --help >&2
+               valid=false
+       ;;
+       esac
+       shift
+done
+
+[ $valid = false ] && exit 1
+
+MESS="A required program cannot be found:"
+for prog in $PROGS; do
+       out="`whereis -b "$prog" 2>/dev/null`"
+       if [ "$out" = "$prog:" ]; then
+               echo "$MESS $prog" >&2
+               valid=false
+       fi
+done
+
+[ $valid = false ] && exit 2
+
+if [ "`whereis tput`" = "tput:" ]; then
+       ok='"[ ok ]"';
+       ko='"[ !! ]"';
+       cols=80;
+else
+       #ok='"`tput bold`[`tput setf 2` OK `tput init``tput bold`]`tput init`"';
+       #ko='"`tput bold`[`tput setf 4` !! `tput init``tput bold`]`tput init`"';
+       ok='"`tput bold`[`tput setaf 2` OK `tput init``tput bold`]`tput init`"';
+       ko='"`tput bold`[`tput setaf 1` !! `tput init``tput bold`]`tput init`"';
+       cols='"`tput cols`"';
+fi;
+
+echo "MAIN = be/nikiroo/fanfix/Main" > Makefile
+echo "MORE = $CLI $TUI $GUI $IMG" >> Makefile
+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 ./ 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/docs/android/android.md b/docs/android/android.md
new file mode 100644 (file)
index 0000000..f3d2775
--- /dev/null
@@ -0,0 +1,289 @@
+# Android UI mock-up
+
+## Concepts
+
+### Story
+
+We have **Stories** in Fanfix, which represent a story (an "epub", ...) or a comics (a "CBZ" file, ...).
+
+A story is written by an author, comes from a source and is dentified by a LUID (Local Unique ID).
+It can be a text story or an image story.
+
+The source can actually be changed by the user (this is the main sorting key).
+
+### Book
+
+We also have **Books**.
+
+Books can be used to display:
+
+- All the sources present in the library
+- All the authors known in the lbrary
+- Stories sharing a source or an author
+
+### All and Listing modes
+
+When representing sources or authors, books can be arranged in two modes:
+
+- "All" mode     : all the sources/authors are represented by a book and displayed in the UI
+- "Listing" mode : for each source/author, a group of books representing the corresponding story is present with the name of the source/author to group them (for instance, a JLabel on top of the group)
+
+Note: Listing mode can be left out of the Android version if needed (but the all mode is really needed).
+
+### Destination
+
+What I call here a destination is a specific group of books.
+
+Examples :
+
+- All the sources
+- All the books of author "Tulipe, F."
+- A listing of all the authors and their stories
+
+## Core
+
+### Library (main screen)
+
+![Main library](screens/main_lib.jpg)
+
+#### Header
+
+The header has a title, a navigation icon on the left and a search icon.
+
+Title can vary upon the current displayed books:
+
+- All sources
+- Sources listing
+- Source: xxx
+- All authors
+- Authors listing
+- Author: xxx
+
+The navigation icon open the Navigation drawer.
+
+##### Search
+
+![Search/Filter](screens/search.jpg)
+
+The search icon is actually a filter: it will hide all the books that don't contain the given text (search on LUID, title and author).
+
+#### List
+
+This list will hold books. Each item will be represented by :
+
+- a cover image (which is provided by fanfix.jar)
+- a main info, which can be:
+  - for stories, the story title
+  - for source books, the source name
+  - for author books, the author name
+- a secondary info, which can vary via a setting (see the Options page) between:
+  - author name (for a book representing an author, it is left blank)
+  - a count (a word count for text stories, an image count for images stories, a stories count for sources and authors books)
+
+#### UI
+
+Material.IO:
+
+- Title, navigation icon, search icon : [App bar top](https://material.io/design/components/app-bars-top.html)
+- List                                : [Cards](https://material.io/design/components/cards.html)
+
+A tap will open the target book in full-screen mode (i.e., the details about the card).
+
+On the detailed card, you will see the description (see Description Page) and 3 buttons :
+
+- Open
+- Delete
+- "..." for a menu
+
+### Navigation drawer
+
+![Navigation Drawer](screens/navigation.jpg)
+
+The navigation drawer will list 4 destinations:
+
+- All the sources
+- Listing of the sources
+- All the authors
+- Listing of the authors
+- By source
+
+...and 2 foldable "panels" with more destinations:
+
+- By source
+- By author
+
+Those subpanels will either contain the sources/authors **or** sub-subpanels with sources/authors.
+See fanfix.jar (BasicLibrary.getSourcesGrouped() and BasicLibrary.getAuthorsGrouped()).
+
+Note: if those last two cause problems, they can be removed; the first four options would be enough to cover the main use cases.
+
+#### UI
+
+Material.IO:
+
+- Navigation drawer: navigation drawer
+
+### Context menu
+
+![Context Menu](screens/menu.jpg)
+
+The context menu options are as follow for stories:
+
+- Open                : open the book (= internal or external reader)
+- Rename...           : ask the user for a new title for the story (default is current name)
+- Move to >>          : show a "new source..." option as well as the current ones fo quick select (BasicLibrary.getSourcesGrouped() will return all the sources on only one level if their number is small enough)
+  - *
+    - [new source...]
+  - [A-H]
+    - Anima
+    - Fanfiction.NET
+  - [I-Z]
+    - MangaFox
+- Change author to >> : show a "new author..." option as well as the current ones fo quick select (BasicLibrary.getAuthorsGrouped() will return all the authors on only one level if their number is small enough)
+  - *
+    - [new author...]
+  - [0-9]
+    - 5-stars
+  - [A-H]
+    - Albert
+    - Béatrice
+    - Hakan
+  - [I-Z]
+    - Irma
+    - Zoul
+- Delete              : delete the story
+- Redownload          : redownload the story (will **not** delete the original)
+- Properties          : open the properties page
+
+For other books (sources and authors):
+
+- Open: open the book (= go to the selected destination)
+
+#### UI
+
+Material.IO:
+
+- menu: [menu](https://developer.android.com/guide/topics/ui/menus.html)
+
+The menu will NOT use sublevels but link to a [list](https://material.io/design/components/lists.html) instead.
+
+### Description page
+
+![Description Page](screens/desc.jpg)
+
+#### Header
+
+Use the same cover image as the books, and the description key/values comes from BasicReader.getMetaDesc(MetaData).
+
+#### Description
+
+Simply display Story.getMeta().getResume(), without adding the chapter number (it is always 0 for description).
+
+An example can be seen in be.nikiroo.fanfix.ui.GuiReaderViewerTextOutput.java.
+
+### Options page
+
+![Options Page](screens/options.jpg)
+
+It consists of a "Remote Library" panel:
+
+- enable : an option to enable/disable the remote library (if disabled, use the local library instead)
+- server : (only enabled if the remote library is) the remote server host
+- port   : (only enabled if the remote library is) the remote server port
+- key    : (only enabled if the remote library is) the remote server secret key
+
+...and 5 other options:
+
+- Open CBZ files internally    : open CBZ files with the internal viewer
+- Open epub files internally   : open EPUB files with the internal viewer
+- Show handles on image viewer : can hide the handles used as cues in the image viewer to know where to click
+- Startup screen               : select the destination to show on application startup
+- Language                     : select the language to use
+
+#### Startup screen
+
+Can be:
+
+- Sources
+  - All
+  - Listing
+- Authors
+  - All
+  - Listing
+
+...but will have to be presented in a better way to the user (i.e., better names).
+
+#### UI
+
+Material.IO:
+
+- the page itself     : Temporary UI Region
+- the options         : Switch
+- the languages       : Exposed Dropdown Menu
+- the text fields     : the default for text fields
+- the scret key field : the default for passwords (with * * * )
+
+## Internal viewer
+
+The program will have an internal viewer that will be able to display the 2 kinds of stories (images and text).
+
+### Base viewer
+
+This is common to both of the viewer (this is **not** an architectural directives, I only speak about the concept here).
+
+![Base Viewer](screens/viewer.jpg)
+
+#### Header
+
+The title is the title of the story, shortened with "..." if too long.
+
+#### Content
+
+This area will host the text viewer or the image viewer.
+
+#### Navigator
+
+It contains 4 action buttons (first, previous, next and last chapter) and the title of the current chapter:
+
+- Descripton         : for the properties page (same layout as the actual Properties page)
+- Chapter X/Y: title : for the normal chapters (note that "Chapter X/Y" should be bold, and "X" should be coloured)
+
+#### UI
+
+Matrial.IO:
+
+- Header    : Header
+- Navigator : [Sheets bottom](https://material.io/design/components/sheets-bottom.html)
+
+### Text viewer
+
+![Text Viewer](screens/viewer-text.jpg)
+
+It will contain the content of the current chapter (Story.getChapters().get(index - 1)).
+
+Same layout as the Properties page uses for the resume, with just a small difference: the chapter name is now prefixed by "Chaper X: ".
+
+### Image viewer
+
+![Image Viewer](screens/viewer-image.jpg)
+
+#### Image
+
+Auto-zoom and fit (keep aspect ratio).
+
+#### Image counter
+
+Just display "Image X/Y"
+
+#### Handles
+
+This is a simple cue to show the user where to click.
+
+It can be hidden via the option "Show handles on image viewer" from the Options page.
+
+#### UI
+
+Pinch & Zoom should be allowed.
+
+Drag-to-pan should be allowed.
+
diff --git a/docs/android/screens/desc.jpg b/docs/android/screens/desc.jpg
new file mode 100755 (executable)
index 0000000..766b746
Binary files /dev/null and b/docs/android/screens/desc.jpg differ
diff --git a/docs/android/screens/main_lib.jpg b/docs/android/screens/main_lib.jpg
new file mode 100755 (executable)
index 0000000..e105824
Binary files /dev/null and b/docs/android/screens/main_lib.jpg differ
diff --git a/docs/android/screens/menu.jpg b/docs/android/screens/menu.jpg
new file mode 100755 (executable)
index 0000000..ea67163
Binary files /dev/null and b/docs/android/screens/menu.jpg differ
diff --git a/docs/android/screens/navigation.jpg b/docs/android/screens/navigation.jpg
new file mode 100755 (executable)
index 0000000..997cb32
Binary files /dev/null and b/docs/android/screens/navigation.jpg differ
diff --git a/docs/android/screens/options.jpg b/docs/android/screens/options.jpg
new file mode 100755 (executable)
index 0000000..b4ad836
Binary files /dev/null and b/docs/android/screens/options.jpg differ
diff --git a/docs/android/screens/search.jpg b/docs/android/screens/search.jpg
new file mode 100755 (executable)
index 0000000..f32257e
Binary files /dev/null and b/docs/android/screens/search.jpg differ
diff --git a/docs/android/screens/viewer-image.jpg b/docs/android/screens/viewer-image.jpg
new file mode 100755 (executable)
index 0000000..8f5c742
Binary files /dev/null and b/docs/android/screens/viewer-image.jpg differ
diff --git a/docs/android/screens/viewer-text.jpg b/docs/android/screens/viewer-text.jpg
new file mode 100755 (executable)
index 0000000..574f688
Binary files /dev/null and b/docs/android/screens/viewer-text.jpg differ
diff --git a/docs/android/screens/viewer.jpg b/docs/android/screens/viewer.jpg
new file mode 100755 (executable)
index 0000000..d8b130a
Binary files /dev/null and b/docs/android/screens/viewer.jpg differ
diff --git a/fanfix.sysv b/fanfix.sysv
new file mode 100755 (executable)
index 0000000..5ab6912
--- /dev/null
@@ -0,0 +1,91 @@
+#!/bin/sh
+#
+# fanfix       This starts the Fanfix remote service.
+#
+# description: Starts the Fanfix remote service
+#
+### BEGIN INIT INFO
+# Default-Start:  3 4 5
+# Short-Description: Fanfix service
+# Description: Starts the Fanfix remote service
+### END INIT INFO
+
+ENABLED=true
+USER=fanfix
+JAR=/path/to/fanfix.jar
+
+FPID=/tmp/fanfix.pid
+OUT=/var/log/fanfix
+ERR=/var/log/fanfix.err
+
+if [ "$ENABLED" != true ]; then
+       [ "$1" != status ]
+       exit $?
+fi
+
+if [ ! -e "$JAR" ]; then
+       echo "Canot find main jar file: $JAR" >&2
+       exit 4
+fi
+
+case "$1" in
+start)
+       if sh "$0" status --quiet; then
+               echo "Fanfix is already running." >&2
+               false
+       else
+               [ -e "$OUT" ] && mv "$OUT" "$OUT".previous
+               [ -e "$ERR" ] && mv "$ERR" "$ERR".previous
+               sudo -u "$USER" -- java -jar "$JAR" --server > "$OUT" 2> "$ERR" &
+               echo $! > "$FPID"
+       fi
+       
+       sleep 0.1
+       sh "$0" status --quiet
+;;
+stop)
+       if sh "$0" status --quiet; then
+               sudo -u "$USER" -- java -jar "$JAR" --stop-server
+       fi
+       
+       i=1
+       while [ $i -lt 100 ]; do
+               if sh "$0" status --quiet; then
+                       echo -n . >&2
+                       sleep 1
+               fi
+               i=`expr $i + 1`
+       done
+       echo >&2
+       
+       if sh "$0" status --quiet; then
+               echo "Process not responding, killing it..." >&2
+               kill "`cat "$FPID"`"
+               sleep 10
+               kill -9 "`cat "$FPID"`" 2>/dev/null
+       fi
+       
+       rm -f "$FPID"
+;;
+restart)
+       sh "$0" stop
+       sh "$0" start
+;;
+status)
+       if [ -e "$FPID" ]; then
+               if [ "$2" = "--quiet" ]; then
+                       ps "`cat "$FPID"`" >/dev/null
+               else
+                       ps "`cat "$FPID"`" >/dev/null \
+                               && echo service is running >&2
+               fi
+       else
+               false
+       fi
+;;
+*)
+       echo $"Usage: $0 {start|stop|status|restart}" >&2
+       false
+;;
+esac
+
diff --git a/icons/fanfix-alt.png b/icons/fanfix-alt.png
new file mode 100644 (file)
index 0000000..4ab0957
Binary files /dev/null and b/icons/fanfix-alt.png differ
diff --git a/icons/fanfix.png b/icons/fanfix.png
new file mode 100644 (file)
index 0000000..983b344
Binary files /dev/null and b/icons/fanfix.png differ
diff --git a/icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png b/icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png
new file mode 100644 (file)
index 0000000..1798dd3
Binary files /dev/null and b/icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png differ
diff --git a/icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png b/icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png
new file mode 100644 (file)
index 0000000..fb6fe0d
Binary files /dev/null and b/icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png differ
diff --git a/icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png b/icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png
new file mode 100644 (file)
index 0000000..a56a4d2
Binary files /dev/null and b/icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png differ
diff --git a/libs/jexer-0.0.4_README.md b/libs/jexer-0.0.4_README.md
new file mode 100644 (file)
index 0000000..7cfe9b4
--- /dev/null
@@ -0,0 +1,220 @@
+Jexer - Java Text User Interface library
+========================================
+
+This library implements a text-based windowing system reminiscient of
+Borland's [Turbo Vision](http://en.wikipedia.org/wiki/Turbo_Vision)
+system.  (For those wishing to use the actual C++ Turbo Vision
+library, see [Sergio Sigala's C++ version based on the public domain
+sources released by Borland.](http://tvision.sourceforge.net/) )
+
+Jexer currently supports three backends:
+
+* System.in/out to a command-line ECMA-48 / ANSI X3.64 type terminal
+  (tested on Linux + xterm).  I/O is handled through terminal escape
+  sequences generated by the library itself: ncurses is not required
+  or linked to.  xterm mouse tracking using UTF8 and SGR coordinates
+  are supported.  For the demo application, this is the default
+  backend on non-Windows/non-Mac platforms.
+
+* The same command-line ECMA-48 / ANSI X3.64 type terminal as above,
+  but to any general InputStream/OutputStream or Reader/Writer.  See
+  the file jexer.demos.Demo2 for an example of running the demo over a
+  TCP socket.  jexer.demos.Demo3 demonstrates how one might use a
+  character encoding than the default UTF-8.
+
+* Java Swing UI.  This backend can be selected by setting
+  jexer.Swing=true.  The default window size for Swing is 80x25, which
+  is set in jexer.session.SwingSession.  For the demo application,
+  this is the default backend on Windows and Mac platforms.
+
+Additional backends can be created by subclassing
+jexer.backend.Backend and passing it into the TApplication
+constructor.
+
+The Jexer homepage, which includes additional information and binary
+release downloads, is at: https://jexer.sourceforge.io .  The Jexer
+source code is hosted at: https://github.com/klamonte/jexer .
+
+
+
+License
+-------
+
+This project is licensed under the MIT License.  See the file LICENSE
+for the full license text.
+
+
+
+Acknowledgements
+----------------
+
+Jexer makes use of the Terminus TrueType font [made available
+here](http://files.ax86.net/terminus-ttf/) .
+
+
+
+Usage
+-----
+
+Simply subclass TApplication and then run it in a new thread:
+
+```Java
+import jexer.*;
+
+class MyApplication extends TApplication {
+
+    public MyApplication() throws Exception {
+        super(BackendType.SWING); // Could also use BackendType.XTERM
+
+        // Create standard menus for File and Window
+        addFileMenu();
+        addWindowMenu();
+
+        // Add a custom window, see below for its code.
+        addWindow(new MyWindow(this));
+    }
+
+    public static void main(String [] args) {
+        try {
+            MyApplication app = new MyApplication();
+            (new Thread(app)).start();
+        } catch (Throwable t) {
+            t.printStackTrace();
+        }
+    }
+}
+```
+
+Similarly, subclass TWindow and add some widgets:
+
+```Java
+class MyWindow extends TWindow {
+
+    public MyWindow(TApplication application) {
+        // See TWindow's API for several constructors.  This one uses the
+        // application, title, width, and height.  Note that the window width
+        // and height include the borders.  The widgets inside the window
+        // will see (0, 0) as the top-left corner inside the borders,
+        // i.e. what the window would see as (1, 1).
+        super(application, "My Window", 30, 20);
+
+        // See TWidget's API for convenience methods to add various kinds of
+        // widgets.  Note that ANY widget can be a container for other
+        // widgets: TRadioGroup for example has TRadioButtons as child
+        // widgets.
+
+        // We will add a basic label, text entry field, and button.
+        addLabel("This is a label", 5, 3);
+        addField(5, 5, 20, false, "enter text here");
+        // For the button, we will pop up a message box if the user presses
+        // it.
+        addButton("Press &Me!", 5, 8, new TAction() {
+            public void DO() {
+                MyWindow.this.messageBox("Box Title", "You pressed me, yay!");
+            }
+        } );
+    }
+}
+```
+
+Put these into a file, compile it with jexer.jar in the classpath, run
+it and you'll see an application like this:
+
+![The Example Code Above](/screenshots/readme_application.png?raw=true "The application in the text of README.md")
+
+See the files in jexer.demos for many more detailed examples showing
+all of the existing UI controls.  The demo can be run in three
+different ways:
+
+  * 'java -jar jexer.jar' .  This will use System.in/out with
+    xterm-like sequences on non-Windows platforms.  On Windows it will
+    use a Swing JFrame.
+
+  * 'java -Djexer.Swing=true -jar jexer.jar' .  This will always use
+    Swing on any platform.
+
+  * 'java -cp jexer.jar jexer.demos.Demo2 PORT' (where PORT is a
+    number to run the TCP daemon on).  This will use the telnet
+    protocol to establish an 8-bit clean channel and be aware of
+    screen size changes.
+
+
+
+More Screenshots
+----------------
+
+![Several Windows Open Including A Terminal](/screenshots/screenshot1.png?raw=true "Several Windows Open Including A Terminal")
+
+![Yo Dawg...](/screenshots/yodawg.png?raw=true "Yo Dawg, I heard you like text windowing systems, so I ran a text windowing system inside your text windowing system so you can have a terminal in your terminal.")
+
+
+
+System Properties
+-----------------
+
+The following properties control features of Jexer:
+
+  jexer.Swing
+  -----------
+
+  Used only by jexer.demos.Demo1.  If true, use the Swing interface
+  for the demo application.  Default: true on Windows platforms
+  (os.name starts with "Windows"), false on non-Windows platforms.
+
+  jexer.Swing.cursorStyle
+  -----------------------
+
+  Used by jexer.io.SwingScreen.  Selects the cursor style to draw.
+  Valid values are: underline, block, outline.  Default: underline.
+
+
+
+Known Issues / Arbitrary Decisions
+----------------------------------
+
+Some arbitrary design decisions had to be made when either the
+obviously expected behavior did not happen or when a specification was
+ambiguous.  This section describes such issues.
+
+  - See jexer.tterminal.ECMA48 for more specifics of terminal
+    emulation limitations.
+
+  - TTerminalWindow uses cmd.exe on Windows.  Output will not be seen
+    until enter is pressed, due to cmd.exe's use of line-oriented
+    input (see the ENABLE_LINE_INPUT flag for GetConsoleMode() and
+    SetConsoleMode()).
+
+  - TTerminalWindow launches 'script -fqe /dev/null' or 'script -q -F
+    /dev/null' on non-Windows platforms.  This is a workaround for the
+    C library behavior of checking for a tty: script launches $SHELL
+    in a pseudo-tty.  This works on Linux and Mac but might not on
+    other Posix-y platforms.
+
+  - Closing a TTerminalWindow without exiting the process inside it
+    may result in a zombie 'script' process.
+
+  - Java's InputStreamReader as used by the ECMA48 backend requires a
+    valid UTF-8 stream.  The default X10 encoding for mouse
+    coordinates outside (160,94) can corrupt that stream, at best
+    putting garbage keyboard events in the input queue but at worst
+    causing the backend reader thread to throw an Exception and exit
+    and make the entire UI unusable.  Mouse support therefore requires
+    a terminal that can deliver either UTF-8 coordinates (1005 mode)
+    or SGR coordinates (1006 mode).  Most modern terminals can do
+    this.
+
+  - jexer.session.TTYSession calls 'stty size' once every second to
+    check the current window size, performing the same function as
+    ioctl(TIOCGWINSZ) but without requiring a native library.
+
+  - jexer.io.ECMA48Terminal calls 'stty' to perform the equivalent of
+    cfmakeraw() when using System.in/out.  System.out is also
+    (blindly!)  put in 'stty sane cooked' mode when exiting.
+
+
+
+Roadmap
+-------
+
+Many tasks remain before calling this version 1.0.  See docs/TODO.md
+for the complete list of tasks.
diff --git a/libs/jexer-0.3.1-Gitlab_2019-03-09-niki2-sources.jar b/libs/jexer-0.3.1-Gitlab_2019-03-09-niki2-sources.jar
new file mode 100644 (file)
index 0000000..11786e2
Binary files /dev/null and b/libs/jexer-0.3.1-Gitlab_2019-03-09-niki2-sources.jar differ
diff --git a/libs/jsoup-1.10.3-sources.jar b/libs/jsoup-1.10.3-sources.jar
new file mode 100644 (file)
index 0000000..1fe0db4
Binary files /dev/null and b/libs/jsoup-1.10.3-sources.jar differ
diff --git a/libs/licenses/jexer-0.0.4_LICENSE.txt b/libs/licenses/jexer-0.0.4_LICENSE.txt
new file mode 100644 (file)
index 0000000..09bbfe0
--- /dev/null
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Kevin Lamonte
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/libs/licenses/unbescape-1.1.4_LICENSE.txt b/libs/licenses/unbescape-1.1.4_LICENSE.txt
new file mode 100644 (file)
index 0000000..d645695
--- /dev/null
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/libs/unbescape-1.1.4-sources.jar b/libs/unbescape-1.1.4-sources.jar
new file mode 100644 (file)
index 0000000..01ddb56
Binary files /dev/null and b/libs/unbescape-1.1.4-sources.jar differ
diff --git a/libs/unbescape-1.1.4_ChangeLog.txt b/libs/unbescape-1.1.4_ChangeLog.txt
new file mode 100644 (file)
index 0000000..9cec6ec
--- /dev/null
@@ -0,0 +1,32 @@
+1.1.4.RELEASE
+=============
+- Added ampersand (&) to the list of characters to be escaped in LEVEL 1 for JSON, JavaScript and CSS literals
+  in order to make escaped code safe against code injection attacks in XHTML scenarios (browsers using XHTML
+  processing mode) performed by means of including XHTML escape codes in literals.
+
+1.1.3.RELEASE
+=============
+- Improved performance of String-based unescape methods for HTML, XML, JS, JSON and others when the
+  text to be unescaped actually needs no unescaping.
+
+1.1.2.RELEASE
+=============
+- Added support for stream-based (String-to-Writer and Reader-to-Writer) escape and unescape operations.
+
+1.1.1.RELEASE
+=============
+- Fixed HTML unescape for codepoints > U+10FFFF (was throwing IllegalArgumentException).
+- Fixed HTML unescape for codepoints > Integer.MAX_VALUE (was throwing ArrayIndexOutOfBounds).
+- Simplified and improved performance of codepoint-computing code by using Character.codePointAt(...) instead
+  of a complex conditional structure based on Character.isHighSurrogate(...) and Character.isLowSurrogate(...).
+- [doc] Fixed description of MSExcel-compatible CSV files.
+
+
+1.1.0.RELEASE
+=============
+- Added URI/URL escape and unescape operations.
+
+
+1.0
+===
+- First release of unbescape.
diff --git a/res/drawable-v24/ic_launcher_foreground.xml b/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644 (file)
index 0000000..c7bd21d
--- /dev/null
@@ -0,0 +1,34 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path
+        android:fillType="evenOdd"
+        android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="78.5885"
+                android:endY="90.9159"
+                android:startX="48.7653"
+                android:startY="61.0927"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1" />
+</vector>
diff --git a/res/drawable/ic_launcher_background.xml b/res/drawable/ic_launcher_background.xml
new file mode 100644 (file)
index 0000000..01f0af0
--- /dev/null
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector
+    android:height="108dp"
+    android:width="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="#26A69A"
+          android:pathData="M0,0h108v108h-108z"/>
+    <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+    <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
+          android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+</vector>
diff --git a/res/layout/activity_main.xml b/res/layout/activity_main.xml
new file mode 100644 (file)
index 0000000..e576892
--- /dev/null
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/Main"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".AndroidReaderActivity">
+
+    <Button
+        android:id="@+id/Main_btnAdd"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="8dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        android:onClick="onAdd"
+        android:text="Add story"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.472"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <LinearLayout
+        android:id="@+id/Main_pnlStories"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginBottom="8dp"
+        android:layout_marginEnd="8dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        android:orientation="vertical"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/Main_btnAdd"></LinearLayout>
+
+</android.support.constraint.ConstraintLayout>
diff --git a/res/layout/fragment_android_reader_book.xml b/res/layout/fragment_android_reader_book.xml
new file mode 100644 (file)
index 0000000..3cd5423
--- /dev/null
@@ -0,0 +1,63 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/Book"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:clickable="true"
+    android:focusable="true"
+    android:onClick="onFrag">
+
+    <!-- TODO: Update blank fragment layout -->
+
+    <android.support.constraint.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:id="@+id/Book_lblTitle"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="8dp"
+            android:layout_marginStart="8dp"
+            android:layout_marginTop="8dp"
+            android:textStyle="bold"
+            app:layout_constraintEnd_toStartOf="@+id/Book_imgCover"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="@+id/Book_imgCover" />
+
+        <ImageView
+            android:id="@+id/Book_imgCover"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="8dp"
+            android:layout_marginEnd="8dp"
+            android:layout_marginTop="8dp"
+            android:src="@mipmap/ic_launcher"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+        <TextView
+            android:id="@+id/Book_lblAuthor"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="8dp"
+            android:layout_marginTop="8dp"
+            android:textColor="@android:color/darker_gray"
+            app:layout_constraintEnd_toStartOf="@+id/Book_imgCover"
+            app:layout_constraintStart_toEndOf="@+id/Book_lblBy"
+            app:layout_constraintTop_toBottomOf="@+id/Book_lblTitle" />
+
+        <TextView
+            android:id="@+id/Book_lblBy"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="8dp"
+            android:layout_marginTop="8dp"
+            android:text="By "
+            android:textColor="@android:color/darker_gray"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toBottomOf="@+id/Book_lblTitle" />
+    </android.support.constraint.ConstraintLayout>
+</FrameLayout>
diff --git a/res/layout/fragment_android_reader_group.xml b/res/layout/fragment_android_reader_group.xml
new file mode 100644 (file)
index 0000000..7d3b272
--- /dev/null
@@ -0,0 +1,14 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="be.nikiroo.fanfix.reader.android.AndroidReaderGroup">
+
+    <!-- TODO: Update blank fragment layout -->
+
+    <ListView
+        android:id="@+id/Group_root"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+</FrameLayout>
diff --git a/res/mipmap-hdpi/ic_launcher.png b/res/mipmap-hdpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..fb77539
Binary files /dev/null and b/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/res/mipmap-hdpi/ic_launcher_round.png b/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..fb77539
Binary files /dev/null and b/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/res/mipmap-ldpi/ic_launcher.png b/res/mipmap-ldpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..081acc5
Binary files /dev/null and b/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/res/mipmap-ldpi/ic_launcher_round.png b/res/mipmap-ldpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..081acc5
Binary files /dev/null and b/res/mipmap-ldpi/ic_launcher_round.png differ
diff --git a/res/mipmap-mdpi/ic_launcher.png b/res/mipmap-mdpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..088c92b
Binary files /dev/null and b/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/res/mipmap-mdpi/ic_launcher_round.png b/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..088c92b
Binary files /dev/null and b/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/res/mipmap-tvdpi/ic_launcher.png b/res/mipmap-tvdpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..eab0a4a
Binary files /dev/null and b/res/mipmap-tvdpi/ic_launcher.png differ
diff --git a/res/mipmap-tvdpi/ic_launcher_round.png b/res/mipmap-tvdpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..eab0a4a
Binary files /dev/null and b/res/mipmap-tvdpi/ic_launcher_round.png differ
diff --git a/res/mipmap-xhdpi/ic_launcher.png b/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..9fdb356
Binary files /dev/null and b/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/res/mipmap-xhdpi/ic_launcher_round.png b/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..9fdb356
Binary files /dev/null and b/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/res/mipmap-xxhdpi/ic_launcher.png b/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..469fd7d
Binary files /dev/null and b/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/res/mipmap-xxhdpi/ic_launcher_round.png b/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..469fd7d
Binary files /dev/null and b/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/res/mipmap-xxxhdpi/ic_launcher.png b/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644 (file)
index 0000000..f7bd70b
Binary files /dev/null and b/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/res/mipmap-xxxhdpi/ic_launcher_round.png b/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644 (file)
index 0000000..f7bd70b
Binary files /dev/null and b/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/res/values/colors.xml b/res/values/colors.xml
new file mode 100644 (file)
index 0000000..3ab3e9c
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="colorPrimary">#3F51B5</color>
+    <color name="colorPrimaryDark">#303F9F</color>
+    <color name="colorAccent">#FF4081</color>
+</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644 (file)
index 0000000..fd8ae42
--- /dev/null
@@ -0,0 +1,8 @@
+<resources>
+    <string name="app_name">Fanfix</string>
+    <string name="enter_text">testy</string>
+    <string name="clickit">Click here</string>
+
+    <!-- TODO: Remove or change this placeholder text -->
+    <string name="hello_blank_fragment">Hello blank fragment</string>
+</resources>
diff --git a/res/values/styles.xml b/res/values/styles.xml
new file mode 100644 (file)
index 0000000..ff6c9d2
--- /dev/null
@@ -0,0 +1,8 @@
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+    </style>
+
+</resources>
diff --git a/screenshots/README-fr.md b/screenshots/README-fr.md
new file mode 100644 (file)
index 0000000..7e77e61
--- /dev/null
@@ -0,0 +1,50 @@
+# Fanfix
+
+## Screenshots
+
+Fanfix peut utiliser plusieurs interfaces :
+
+- GUI: une interface basée sur Swing, pour afficher le programme sur votre PC graphiquement
+- TUI: une interface basée sur [jexer](https://gitlab.com/klamonte/jexer/), pour afficher des fenêtre et des boutons en mode texte
+- CLI: une interface purement en lignes de commandes, facile à automatiser dans un script ou pour utiliser dans un terminal texte
+
+Cette gallerie reprend des screenshots de plusieurs versions de Fanfix, mais les versions les plus récentes sont affichées en premier.
+
+### Version 2.0.0
+
+#### GUI
+
+![Fenêtre principale](fanfix-2.0.0-gui-library.png)
+
+![Propriétés d'une histoire](fanfix-2.0.0-gui-properties.png)
+
+![Menu](fanfix-2.0.0-gui-menu.png)
+
+#### TUI
+
+![Fenêtre principale](fanfix-2.0.0-tui-library.png)
+
+![Propriétés d'une histoire](fanfix-2.0.0-tui-properties.png)
+
+![Menu](fanfix-2.0.0-tui-menu.png)
+
+#### CLI
+
+![Fenêtre principale](fanfix-2.0.0-cli-library.png)
+
+![Propriétés d'une histoire](fanfix-2.0.0-cli-properties.png)
+
+![Menu](fanfix-2.0.0-cli-menu.png)
+
+### Version 1.3.2
+
+#### GUI
+
+![Fenêtre principale](fanfix-1.3.2.png)
+
+### Version 1.0.0
+
+#### GUI
+
+![Fenêtre principale](fanfix-1.0.0.png)
+
diff --git a/screenshots/README.md b/screenshots/README.md
new file mode 100644 (file)
index 0000000..d1500ae
--- /dev/null
@@ -0,0 +1,49 @@
+# Fanfix
+
+## Screenshots
+
+Fanfix can use different interfaces:
+
+- GUI: a Swing-based interface to display on your desktop
+- TUI: a [jexer](https://gitlab.com/klamonte/jexer/)-based interface to display in text mode, but still with window, buttons and other widgets
+- CLI: a fully automated CLI mode that can be used for scripts or to read on a terminal screen
+
+This screenshots gallery shows screenshots of different versions of Fanfix, but shows the more recent ones on top.
+
+### Version 2.0.0
+
+#### GUI
+
+![Main window](fanfix-2.0.0-gui-library.png)
+
+![Properties page](fanfix-2.0.0-gui-properties.png)
+
+![Menu](fanfix-2.0.0-gui-menu.png)
+
+#### TUI
+
+![Main window](fanfix-2.0.0-tui-library.png)
+
+![Properties page](fanfix-2.0.0-tui-properties.png)
+
+![Menu](fanfix-2.0.0-tui-menu.png)
+
+#### CLI
+
+![Main window](fanfix-2.0.0-cli-library.png)
+
+![Properties page](fanfix-2.0.0-cli-properties.png)
+
+![Menu](fanfix-2.0.0-cli-menu.png)
+
+### Version 1.3.2
+
+#### GUI
+
+![Main window](fanfix-1.3.2.png)
+
+### Version 1.0.0
+
+#### GUI
+
+![Main window](fanfix-1.0.0.png)
diff --git a/screenshots/fanfix-1.0.0.png b/screenshots/fanfix-1.0.0.png
new file mode 100644 (file)
index 0000000..d4d6c26
Binary files /dev/null and b/screenshots/fanfix-1.0.0.png differ
diff --git a/screenshots/fanfix-1.3.2.png b/screenshots/fanfix-1.3.2.png
new file mode 100644 (file)
index 0000000..6ea39ce
Binary files /dev/null and b/screenshots/fanfix-1.3.2.png differ
diff --git a/src/.gitattributes b/src/.gitattributes
new file mode 100644 (file)
index 0000000..409851f
--- /dev/null
@@ -0,0 +1,49 @@
+# Auto detect text files and perform LF normalization
+*            text=auto
+
+*.java       text diff=java
+*.properties text
+*.js         text
+*.css        text
+*.less       text
+*.html       text diff=html
+*.jsp        text diff=html
+*.jspx       text diff=html
+*.tag        text diff=html
+*.tagx       text diff=html
+*.tld        text
+*.xml        text
+*.gradle     text
+
+*.sql        text
+
+*.xsd        text
+*.dtd        text
+*.mod        text
+*.ent        text
+
+*.txt        text
+*.md         text
+*.markdown   text
+
+*.thtest     text
+*.thindex    text
+*.common     text
+
+*.odt        binary
+*.pdf        binary
+
+*.sh         text eol=lf
+*.bat        text eol=crlf
+
+*.ico        binary
+*.png        binary
+*.svg        binary
+*.woff       binary
+
+*.rar        binary
+*.zargo      binary
+*.zip        binary
+
+CNAME        text
+*.MF         text
diff --git a/src/.gitignore b/src/.gitignore
new file mode 100644 (file)
index 0000000..5c79834
--- /dev/null
@@ -0,0 +1,8 @@
+.classpath
+.project
+target/
+bin/
+.settings/
+.idea/
+*.iml
+
diff --git a/src/be/nikiroo/fanfix/DataLoader.java b/src/be/nikiroo/fanfix/DataLoader.java
new file mode 100644 (file)
index 0000000..3e0e770
--- /dev/null
@@ -0,0 +1,397 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Map;
+
+import be.nikiroo.fanfix.bundles.Config;
+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.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.TraceHandler;
+
+/**
+ * This cache will manage Internet (and local) downloads, as well as put the
+ * downloaded files into a cache.
+ * <p>
+ * As long the cached resource is not too old, it will use it instead of
+ * retrieving the file again.
+ * 
+ * @author niki
+ */
+public class DataLoader {
+       private Downloader downloader;
+       private Downloader downloaderNoCache;
+       private Cache cache;
+       private boolean offline;
+
+       /**
+        * Create a new {@link DataLoader} object.
+        * 
+        * @param dir
+        *            the directory to use as cache
+        * @param UA
+        *            the User-Agent to use to download the resources
+        * @param hoursChanging
+        *            the number of hours after which a cached file that is thought
+        *            to change ~often is considered too old (or -1 for
+        *            "never too old")
+        * @param hoursStable
+        *            the number of hours after which a LARGE cached file that is
+        *            thought to change rarely is considered too old (or -1 for
+        *            "never too old")
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public DataLoader(File dir, String UA, int hoursChanging, int hoursStable)
+                       throws IOException {
+               downloader = new Downloader(UA, new Cache(dir, hoursChanging,
+                               hoursStable));
+               downloaderNoCache = new Downloader(UA);
+
+               cache = downloader.getCache();
+       }
+
+       /**
+        * Create a new {@link DataLoader} object without disk cache (will keep a
+        * memory cache for manual cache operations).
+        * 
+        * @param UA
+        *            the User-Agent to use to download the resources
+        */
+       public DataLoader(String UA) {
+               downloader = new Downloader(UA);
+               downloaderNoCache = downloader;
+               cache = new CacheMemory();
+       }
+       
+       /**
+        * This {@link Downloader} is forbidden to try and connect to the network.
+        * <p>
+        * If TRUE, it will only check the cache (even in no-cache mode!).
+        * <p>
+        * Default is FALSE.
+        * 
+        * @return TRUE if offline
+        */
+       public boolean isOffline() {
+               return offline;
+       }
+       
+       /**
+        * This {@link Downloader} is forbidden to try and connect to the network.
+        * <p>
+        * If TRUE, it will only check the cache (even in no-cache mode!).
+        * <p>
+        * Default is FALSE.
+        * 
+        * @param offline TRUE for offline, FALSE for online
+        */
+       public void setOffline(boolean offline) {
+               this.offline = offline;
+               downloader.setOffline(offline);
+               downloaderNoCache.setOffline(offline);
+               
+               // If we don't, we cannot support no-cache using code in OFFLINE mode
+               if (offline) {
+                       downloaderNoCache.setCache(cache);
+               } else {
+                       downloaderNoCache.setCache(null);
+               }
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               downloader.setTraceHandler(tracer);
+               downloaderNoCache.setTraceHandler(tracer);
+               cache.setTraceHandler(tracer);
+               if (downloader.getCache() != null) {
+                       downloader.getCache().setTraceHandler(tracer);
+               }
+
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the opened resource, NOT NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, BasicSupport support, boolean stable)
+                       throws IOException {
+               return open(url, url, support, stable, null, null, null);
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param originalUrl
+        *            the original {@link URL} before any redirection occurs, which
+        *            is also used for the cache ID if needed (so we can retrieve
+        *            the content with this URL if needed)
+        * @param support
+        *            the support to use to download the resource
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the opened resource, NOT NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, URL originalUrl, BasicSupport support,
+                       boolean stable) throws IOException {
+               return open(url, originalUrl, support, stable, null, null, null);
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param originalUrl
+        *            the original {@link URL} before any redirection occurs, which
+        *            is also used for the cache ID if needed (so we can retrieve
+        *            the content with this URL if needed)
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorization (aka, "bearer XXXXXXX")
+        * 
+        * @return the opened resource, NOT NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, URL originalUrl, BasicSupport support,
+                       boolean stable, Map<String, String> postParams,
+                       Map<String, String> getParams, String oauth) throws IOException {
+
+               Map<String, String> cookiesValues = null;
+               URL currentReferer = url;
+
+               if (support != null) {
+                       cookiesValues = support.getCookies();
+                       currentReferer = support.getCurrentReferer();
+                       // priority: arguments
+                       if (oauth == null) {
+                               oauth = support.getOAuth();
+                       }
+               }
+
+               return downloader.open(url, originalUrl, currentReferer, cookiesValues,
+                               postParams, getParams, oauth, stable);
+       }
+
+       /**
+        * Open the given {@link URL} without using the cache, but still using and
+        * updating the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @param support
+        *            the {@link BasicSupport} used for the cookies
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorization (aka, "bearer XXXXXXX")
+        * 
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream openNoCache(URL url, BasicSupport support,
+                       Map<String, String> postParams, Map<String, String> getParams,
+                       String oauth) throws IOException {
+
+               Map<String, String> cookiesValues = null;
+               URL currentReferer = url;
+               if (support != null) {
+                       cookiesValues = support.getCookies();
+                       currentReferer = support.getCurrentReferer();
+                       // priority: arguments
+                       if (oauth == null) {
+                               oauth = support.getOAuth();
+                       }
+               }
+
+               return downloaderNoCache.open(url, currentReferer, cookiesValues,
+                               postParams, getParams, oauth);
+       }
+
+       /**
+        * Refresh the resource into cache if needed.
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void refresh(URL url, BasicSupport support, boolean stable)
+                       throws IOException {
+               if (!check(url, stable)) {
+                       open(url, url, support, stable, null, null, null).close();
+               }
+       }
+
+       /**
+        * Check the resource to see if it is in the cache.
+        * 
+        * @param url
+        *            the resource to check
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return TRUE if it is
+        * 
+        */
+       public boolean check(URL url, boolean stable) {
+               return downloader.getCache() != null
+                               && downloader.getCache().check(url, false, stable);
+       }
+
+       /**
+        * Save the given resource as an image on disk using the default image
+        * format for content or cover -- will automatically add the extension, too.
+        * 
+        * @param img
+        *            the resource
+        * @param target
+        *            the target file without extension
+        * @param cover
+        *            use the cover image format instead of the content image format
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void saveAsImage(Image img, File target, boolean cover)
+                       throws IOException {
+               String format;
+               if (cover) {
+                       format = Instance.getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
+                                       .toLowerCase();
+               } else {
+                       format = Instance.getConfig()
+                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_CONTENT).toLowerCase();
+               }
+               saveAsImage(img, new File(target.toString() + "." + format), format);
+       }
+
+       /**
+        * Save the given resource as an image on disk using the given image format
+        * for content, or with "png" format if it fails.
+        * 
+        * @param img
+        *            the resource
+        * @param target
+        *            the target file
+        * @param format
+        *            the file format ("png", "jpeg", "bmp"...)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void saveAsImage(Image img, File target, String format)
+                       throws IOException {
+               ImageUtils.getInstance().saveAsImage(img, target, format);
+       }
+
+       /**
+        * Manually add this item to the cache.
+        * 
+        * @param in
+        *            the input data
+        * @param uniqueID
+        *            a unique ID for this resource
+        * 
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void addToCache(InputStream in, String uniqueID) throws IOException {
+               cache.save(in, uniqueID);
+       }
+
+       /**
+        * Return the {@link InputStream} corresponding to the given unique ID, or
+        * NULL if none found.
+        * 
+        * @param uniqueID
+        *            the unique ID
+        * 
+        * @return the content or NULL
+        */
+       public InputStream getFromCache(String uniqueID) {
+               return cache.load(uniqueID, true, true);
+       }
+
+       /**
+        * Remove the given resource from the cache.
+        * 
+        * @param uniqueID
+        *            a unique ID used to locate the cached resource
+        * 
+        * @return TRUE if it was removed
+        */
+       public boolean removeFromCache(String uniqueID) {
+               return cache.remove(uniqueID);
+       }
+
+       /**
+        * Clean the cache (delete the cached items).
+        * 
+        * @param onlyOld
+        *            only clean the files that are considered too old
+        * 
+        * @return the number of cleaned items
+        */
+       public int cleanCache(boolean onlyOld) {
+               return cache.clean(onlyOld);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/Instance.java b/src/be/nikiroo/fanfix/Instance.java
new file mode 100644 (file)
index 0000000..561e2f6
--- /dev/null
@@ -0,0 +1,621 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.bundles.StringIdBundle;
+import be.nikiroo.fanfix.bundles.StringIdGuiBundle;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.bundles.UiConfigBundle;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.utils.Cache;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Proxy;
+import be.nikiroo.utils.TempFiles;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.resources.Bundles;
+
+/**
+ * Global state for the program (services and singletons).
+ * 
+ * @author niki
+ */
+public class Instance {
+       private static ConfigBundle config;
+       private static UiConfigBundle uiconfig;
+       private static StringIdBundle trans;
+       private static DataLoader cache;
+       private static StringIdGuiBundle transGui;
+       private static BasicLibrary lib;
+       private static File coverDir;
+       private static File readerTmp;
+       private static File remoteDir;
+       private static String configDir;
+       private static TraceHandler tracer;
+       private static TempFiles tempFiles;
+
+       private static boolean init;
+
+       /**
+        * Initialise the instance -- if already initialised, nothing will happen.
+        * <p>
+        * 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 <tt>force</tt>.
+        * <p>
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
+        * <p>
+        * Note: forcing the initialisation can be dangerous, so make sure to only
+        * make it under controlled circumstances -- for instance, at the start of
+        * the program, you could call {@link Instance#init()}, change some settings
+        * because you want to force those settings (it will also forbid users to
+        * change them!) and then call {@link Instance#init(boolean)} with
+        * <tt>force</tt> set to TRUE.
+        * 
+        * @param force
+        *            force the initialisation even if already initialised
+        */
+       static public void init(boolean force) {
+               if (init && !force) {
+                       return;
+               }
+
+               init = true;
+
+               // Before we can configure it:
+               Boolean debug = checkEnv("DEBUG");
+               boolean trace = debug != null && debug;
+               tracer = new TraceHandler(true, trace, trace);
+
+               // config dir:
+               configDir = getConfigDir();
+               if (!new File(configDir).exists()) {
+                       new File(configDir).mkdirs();
+               }
+
+               // Most of the rest is dependent upon this:
+               createConfigs(configDir, false);
+
+               // Proxy support
+               Proxy.use(Instance.getConfig().getString(Config.NETWORK_PROXY));
+
+               // update tracer:
+               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);
+
+               // default Library
+               remoteDir = new File(configDir, "remote");
+               lib = createDefaultLibrary(remoteDir);
+
+               // create cache and TMP
+               File tmp = getFile(Config.CACHE_DIR, new File(configDir, "tmp"));
+               if (!tmp.isAbsolute()) {
+                       tmp = new File(configDir, tmp.getPath());
+               }
+               Image.setTemporaryFilesRoot(new File(tmp.getParent(), "tmp.images"));
+
+               String ua = config.getString(Config.NETWORK_USER_AGENT, "");
+               try {
+                       int hours = config.getInteger(Config.CACHE_MAX_TIME_CHANGING, 0);
+                       int hoursLarge = config.getInteger(Config.CACHE_MAX_TIME_STABLE, 0);
+                       cache = new DataLoader(tmp, ua, hours, hoursLarge);
+               } catch (IOException e) {
+                       tracer.error(new IOException(
+                                       "Cannot create cache (will continue without cache)", e));
+                       cache = new DataLoader(ua);
+               }
+
+               cache.setTraceHandler(tracer);
+
+               // readerTmp / coverDir
+               readerTmp = getFile(UiConfig.CACHE_DIR_LOCAL_READER, new File(
+                               configDir, "tmp-reader"));
+
+               coverDir = getFile(Config.DEFAULT_COVERS_DIR, new File(configDir,
+                               "covers"));
+               coverDir.mkdirs();
+
+               try {
+                       tempFiles = new TempFiles("fanfix");
+               } catch (IOException e) {
+                       tracer.error(new IOException("Cannot create temporary directory", e));
+               }
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * <p>
+        * It is never NULL.
+        * 
+        * @return the traces handler (never NULL)
+        */
+       public static TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @param tracer
+        *            the new traces handler or NULL
+        */
+       public static void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               Instance.tracer = tracer;
+               cache.setTraceHandler(tracer);
+       }
+
+       /**
+        * Get the (unique) configuration service for the program.
+        * 
+        * @return the configuration service
+        */
+       public static ConfigBundle getConfig() {
+               return config;
+       }
+
+       /**
+        * Get the (unique) UI configuration service for the program.
+        * 
+        * @return the configuration service
+        */
+       public static UiConfigBundle getUiConfig() {
+               return uiconfig;
+       }
+
+       /**
+        * Reset the configuration.
+        * 
+        * @param resetTrans
+        *            also reset the translation files
+        */
+       public static void resetConfig(boolean resetTrans) {
+               String dir = Bundles.getDirectory();
+               Bundles.setDirectory(null);
+               try {
+                       try {
+                               ConfigBundle config = new ConfigBundle();
+                               config.updateFile(configDir);
+                       } catch (IOException e) {
+                               tracer.error(e);
+                       }
+                       try {
+                               UiConfigBundle uiconfig = new UiConfigBundle();
+                               uiconfig.updateFile(configDir);
+                       } catch (IOException e) {
+                               tracer.error(e);
+                       }
+
+                       if (resetTrans) {
+                               try {
+                                       StringIdBundle trans = new StringIdBundle(null);
+                                       trans.updateFile(configDir);
+                               } catch (IOException e) {
+                                       tracer.error(e);
+                               }
+                       }
+               } finally {
+                       Bundles.setDirectory(dir);
+               }
+       }
+
+       /**
+        * Get the (unique) {@link DataLoader} for the program.
+        * 
+        * @return the {@link DataLoader}
+        */
+       public static DataLoader getCache() {
+               return cache;
+       }
+
+       /**
+        * Get the (unique) {link StringIdBundle} for the program.
+        * <p>
+        * This is used for the translations of the core parts of Fanfix.
+        * 
+        * @return the {link StringIdBundle}
+        */
+       public static StringIdBundle getTrans() {
+               return trans;
+       }
+
+       /**
+        * Get the (unique) {link StringIdGuiBundle} for the program.
+        * <p>
+        * This is used for the translations of the GUI parts of Fanfix.
+        * 
+        * @return the {link StringIdGuiBundle}
+        */
+       public static StringIdGuiBundle getTransGui() {
+               return transGui;
+       }
+
+       /**
+        * Get the (unique) {@link LocalLibrary} for the program.
+        * 
+        * @return the {@link LocalLibrary}
+        */
+       public static BasicLibrary getLibrary() {
+               if (lib == null) {
+                       throw new NullPointerException("We don't have a library to return");
+               }
+
+               return lib;
+       }
+
+       /**
+        * Return the directory where to look for default cover pages.
+        * 
+        * @return the default covers directory
+        */
+       public static File getCoverDir() {
+               return coverDir;
+       }
+
+       /**
+        * Return the directory where to store temporary files for the local reader.
+        * 
+        * @return the directory
+        */
+       public static File getReaderDir() {
+               return readerTmp;
+       }
+
+       /**
+        * Return the directory where to store temporary files for the remote
+        * {@link LocalLibrary}.
+        * 
+        * @param host
+        *            the remote for this host
+        * 
+        * @return the directory
+        */
+       public static File getRemoteDir(String host) {
+               return getRemoteDir(remoteDir, host);
+       }
+
+       /**
+        * Return the directory where to store temporary files for the remote
+        * {@link LocalLibrary}.
+        * 
+        * @param remoteDir
+        *            the base remote directory
+        * @param host
+        *            the remote for this host
+        * 
+        * @return the directory
+        */
+       private static File getRemoteDir(File remoteDir, String host) {
+               remoteDir.mkdirs();
+
+               if (host != null) {
+                       return new File(remoteDir, host);
+               }
+
+               return remoteDir;
+       }
+
+       /**
+        * Check if we need to check that a new version of Fanfix is available.
+        * 
+        * @return TRUE if we need to
+        */
+       public static boolean isVersionCheckNeeded() {
+               try {
+                       long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0)
+                                       * 24 * 60 * 60 * 1000;
+                       if (wait >= 0) {
+                               String lastUpString = IOUtils.readSmallFile(new File(configDir,
+                                               "LAST_UPDATE"));
+                               long delay = new Date().getTime()
+                                               - Long.parseLong(lastUpString);
+                               if (delay > wait) {
+                                       return true;
+                               }
+                       } else {
+                               return false;
+                       }
+               } catch (Exception e) {
+                       // No file or bad file:
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Notify that we checked for a new version of Fanfix.
+        */
+       public static void setVersionChecked() {
+               try {
+                       IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE",
+                                       Long.toString(new Date().getTime()));
+               } catch (IOException e) {
+                       tracer.error(e);
+               }
+       }
+
+       /**
+        * The facility to use temporary files in this program.
+        * <p>
+        * <b>MUST</b> be closed at end of program.
+        * 
+        * @return the facility
+        */
+       public static TempFiles getTempFiles() {
+               return tempFiles;
+       }
+
+       /**
+        * The configuration directory (will check, in order of preference, the
+        * system properties, the environment and then defaults to
+        * {@link Instance#getHome()}/.fanfix).
+        * 
+        * @return the config directory
+        */
+       private static String getConfigDir() {
+               String configDir = System.getProperty("CONFIG_DIR");
+
+               if (configDir == null) {
+                       configDir = System.getenv("CONFIG_DIR");
+               }
+
+               if (configDir == null) {
+                       configDir = new File(getHome(), ".fanfix").getPath();
+               }
+
+               return configDir;
+       }
+
+       /**
+        * Create the config variables ({@link Instance#config},
+        * {@link Instance#uiconfig}, {@link Instance#trans} and
+        * {@link Instance#transGui}).
+        * 
+        * @param configDir
+        *            the directory where to find the configuration files
+        * @param refresh
+        *            TRUE to reset the configuration files from the default
+        *            included ones
+        */
+       private static void createConfigs(String configDir, boolean refresh) {
+               if (!refresh) {
+                       Bundles.setDirectory(configDir);
+               }
+
+               try {
+                       config = new ConfigBundle();
+                       config.updateFile(configDir);
+               } catch (IOException e) {
+                       tracer.error(e);
+               }
+
+               try {
+                       uiconfig = new UiConfigBundle();
+                       uiconfig.updateFile(configDir);
+               } catch (IOException e) {
+                       tracer.error(e);
+               }
+
+               // No updateFile for this one! (we do not want the user to have custom
+               // translations that won't accept updates from newer versions)
+               trans = new StringIdBundle(getLang());
+               transGui = new StringIdGuiBundle(getLang());
+
+               // Fix an old bug (we used to store custom translation files by
+               // default):
+               if (trans.getString(StringId.INPUT_DESC_CBZ) == null) {
+                       trans.deleteFile(configDir);
+               }
+
+               Boolean noutf = checkEnv("NOUTF");
+               if (noutf != null && noutf) {
+                       trans.setUnicode(false);
+                       transGui.setUnicode(false);
+               }
+
+               Bundles.setDirectory(configDir);
+       }
+
+       /**
+        * Create the default library as specified by the config.
+        * 
+        * @param remoteDir
+        *            the base remote directory if needed
+        * 
+        * @return the default {@link BasicLibrary}
+        */
+       private static BasicLibrary createDefaultLibrary(File remoteDir) {
+               BasicLibrary lib = null;
+
+               boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED,
+                               false);
+
+               if (useRemote) {
+                       String host = null;
+                       int port = -1;
+                       try {
+                               host = config.getString(Config.REMOTE_LIBRARY_HOST);
+                               port = config.getInteger(Config.REMOTE_LIBRARY_PORT, -1);
+                               String key = config.getString(Config.REMOTE_LIBRARY_KEY);
+
+                               tracer.trace("Selecting remote library " + host + ":" + port);
+                               lib = new RemoteLibrary(key, host, port);
+                               lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib);
+                       } catch (Exception e) {
+                               tracer.error(new IOException(
+                                               "Cannot create remote library for: " + host + ":"
+                                                               + port, e));
+                       }
+               } else {
+                       String libDir = System.getenv("BOOKS_DIR");
+                       if (libDir == null || libDir.isEmpty()) {
+                               libDir = config.getString(Config.LIBRARY_DIR, "$HOME/Books");
+                               if (!getFile(libDir).isAbsolute()) {
+                                       libDir = new File(configDir, libDir).getPath();
+                               }
+                       }
+                       try {
+                               lib = new LocalLibrary(getFile(libDir));
+                       } catch (Exception e) {
+                               tracer.error(new IOException(
+                                               "Cannot create library for directory: "
+                                                               + getFile(libDir), e));
+                       }
+               }
+
+               return lib;
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @return the path
+        */
+       private static File getFile(Config id, File def) {
+               String path = config.getString(id, def.getPath());
+               return getFile(path);
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @return the path
+        */
+       private static File getFile(UiConfig id, File def) {
+               String path = uiconfig.getString(id, def.getPath());
+               return getFile(path);
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @return the path
+        */
+       private static File getFile(String path) {
+               File file = null;
+               if (path != null && !path.isEmpty()) {
+                       path = path.replace('/', File.separatorChar);
+                       if (path.contains("$HOME")) {
+                               path = path.replace("$HOME", getHome());
+                       }
+
+                       file = new File(path);
+               }
+
+               return file;
+       }
+
+       /**
+        * Return the home directory from the environment (FANFIX_DIR) or the system
+        * properties.
+        * <p>
+        * The environment variable is tested first. Then, the custom property
+        * "fanfix.home" is tried, followed by the usual "user.home" then
+        * "java.io.tmp" if nothing else is found.
+        * 
+        * @return the home
+        */
+       private static String getHome() {
+               String home = System.getenv("FANFIX_DIR");
+               if (home != null && new File(home).isFile()) {
+                       home = null;
+               }
+
+               if (home == null || home.trim().isEmpty()) {
+                       home = System.getProperty("fanfix.home");
+                       if (home != null && new File(home).isFile()) {
+                               home = null;
+                       }
+               }
+
+               if (home == null || home.trim().isEmpty()) {
+                       home = System.getProperty("user.home");
+                       if (!new File(home).isDirectory()) {
+                               home = null;
+                       }
+               }
+
+               if (home == null || home.trim().isEmpty()) {
+                       home = System.getProperty("java.io.tmpdir");
+                       if (!new File(home).isDirectory()) {
+                               home = null;
+                       }
+               }
+
+               if (home == null) {
+                       home = "";
+               }
+
+               return home;
+       }
+
+       /**
+        * The language to use for the application (NULL = default system language).
+        * 
+        * @return the language
+        */
+       private static String getLang() {
+               String lang = config.getString(Config.LANG);
+
+               if (lang == null || lang.isEmpty()) {
+                       if (System.getenv("LANG") != null
+                                       && !System.getenv("LANG").isEmpty()) {
+                               lang = System.getenv("LANG");
+                       }
+               }
+
+               if (lang != null && lang.isEmpty()) {
+                       lang = null;
+               }
+
+               return lang;
+       }
+
+       /**
+        * Check that the given environment variable is "enabled".
+        * 
+        * @param key
+        *            the variable to check
+        * 
+        * @return TRUE if it is
+        */
+       private static Boolean checkEnv(String key) {
+               String value = System.getenv(key);
+               if (value != null) {
+                       value = value.trim().toLowerCase();
+                       if ("yes".equals(value) || "true".equals(value)
+                                       || "on".equals(value) || "1".equals(value)
+                                       || "y".equals(value)) {
+                               return true;
+                       }
+
+                       return false;
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/Main.java b/src/be/nikiroo/fanfix/Main.java
new file mode 100644 (file)
index 0000000..3d69cad
--- /dev/null
@@ -0,0 +1,913 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.fanfix.library.RemoteLibraryServer;
+import be.nikiroo.fanfix.output.BasicOutput;
+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;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ServerObject;
+
+/**
+ * Main program entry point.
+ * 
+ * @author niki
+ */
+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, SEARCH, SEARCH_TAG
+       }
+
+       /**
+        * Main program entry point.
+        * <p>
+        * Known environment variables:
+        * <ul>
+        * <li>NOUTF: if set to 1 or 'true', the program will prefer non-unicode
+        * {@link String}s when possible</li>
+        * <li>CONFIG_DIR: a path where to look for the <tt>.properties</tt> files
+        * before taking the usual ones; they will also be saved/updated into this
+        * path when the program starts</li>
+        * <li>DEBUG: if set to 1 or 'true', the program will override the DEBUG_ERR
+        * configuration value with 'true'</li>
+        * </ul>
+        * <p>
+        * <ul>
+        * <li>--import [URL]: import into library</li>
+        * <li>--export [id] [output_type] [target]: export story to target</li>
+        * <li>--convert [URL] [output_type] [target] (+info): convert URL into
+        * target</li>
+        * <li>--read [id] ([chapter number]): read the given story from the library
+        * </li>
+        * <li>--read-url [URL] ([chapter number]): convert on the fly and read the
+        * story, without saving it</li>
+        * <li>--search: list the supported websites (where)</li>
+        * <li>--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</li>
+        * <li>--search-tag [where]: list all the tags supported by this website</li>
+        * <li>--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</li>
+        * <li>--list ([type]): list the stories present in the library</li>
+        * <li>--set-source [id] [new source]: change the source of the given story</li>
+        * <li>--set-title [id] [new title]: change the title of the given story</li>
+        * <li>--set-author [id] [new author]: change the author of the given story</li>
+        * <li>--set-reader [reader type]: set the reader type to CLI, TUI or LOCAL
+        * for this command</li>
+        * <li>--version: get the version of the program</li>
+        * <li>--server: start the server mode (see config file for parameters)</li>
+        * <li>--stop-server: stop the running server on this port if any</li>
+        * <li>--remote [key] [host] [port]: use a the given remote library</li>
+        * </ul>
+        * 
+        * @param args
+        *            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;
+               String titleString = null;
+               String authorString = null;
+               String chapString = null;
+               String target = null;
+               String key = null;
+               MainAction action = MainAction.START;
+               Boolean plusInfo = null;
+               String host = null;
+               Integer port = null;
+               SupportType searchOn = null;
+               String search = null;
+               List<Integer> tags = new ArrayList<Integer>();
+               Integer page = null;
+               Integer item = null;
+
+               boolean noMoreActions = false;
+
+               int exitCode = 0;
+               for (int i = 0; exitCode == 0 && i < args.length; i++) {
+                       // Action (--) handling:
+                       if (!noMoreActions && args[i].startsWith("--")) {
+                               if (args[i].equals("--")) {
+                                       noMoreActions = true;
+                               } else {
+                                       try {
+                                               action = MainAction.valueOf(args[i].substring(2)
+                                                               .toUpperCase().replace("-", "_"));
+                                       } catch (Exception e) {
+                                               Instance.getTraceHandler().error(
+                                                               new IllegalArgumentException("Unknown action: "
+                                                                               + args[i], e));
+                                               exitCode = 255;
+                                       }
+                               }
+
+                               continue;
+                       }
+
+                       switch (action) {
+                       case IMPORT:
+                               if (urlString == null) {
+                                       urlString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case EXPORT:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else if (target == null) {
+                                       target = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case CONVERT:
+                               if (urlString == null) {
+                                       urlString = args[i];
+                               } else if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else if (target == null) {
+                                       target = args[i];
+                               } else if (plusInfo == null) {
+                                       if ("+info".equals(args[i])) {
+                                               plusInfo = true;
+                                       } else {
+                                               exitCode = 255;
+                                       }
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case LIST:
+                               if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SET_SOURCE:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SET_TITLE:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       titleString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SET_AUTHOR:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       authorString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case READ:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (chapString == null) {
+                                       chapString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case READ_URL:
+                               if (urlString == null) {
+                                       urlString = args[i];
+                               } else if (chapString == null) {
+                                       chapString = args[i];
+                               } else {
+                                       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;
+                       case SET_READER:
+                               exitCode = setReaderType(args[i]);
+                               action = MainAction.START;
+                               break;
+                       case START:
+                               exitCode = 255; // not supposed to be selected by user
+                               break;
+                       case VERSION:
+                               exitCode = 255; // no arguments for this option
+                               break;
+                       case SERVER:
+                               exitCode = 255; // no arguments for this option
+                               break;
+                       case STOP_SERVER:
+                               exitCode = 255; // no arguments for this option
+                               break;
+                       case REMOTE:
+                               if (key == null) {
+                                       key = args[i];
+                               } else if (host == null) {
+                                       host = args[i];
+                               } else if (port == null) {
+                                       port = Integer.parseInt(args[i]);
+
+                                       BasicLibrary lib = new RemoteLibrary(key, host, port);
+                                       lib = new CacheLibrary(Instance.getRemoteDir(host), lib);
+
+                                       BasicReader.setDefaultLibrary(lib);
+
+                                       action = MainAction.START;
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       }
+               }
+
+               final Progress mainProgress = new Progress(0, 80);
+               mainProgress.addProgressListener(new Progress.ProgressListener() {
+                       private int current = mainProgress.getMin();
+
+                       @Override
+                       public void progress(Progress progress, String name) {
+                               int diff = progress.getProgress() - current;
+                               current += diff;
+
+                               if (diff <= 0)
+                                       return;
+
+                               StringBuilder builder = new StringBuilder();
+                               for (int i = 0; i < diff; i++) {
+                                       builder.append('.');
+                               }
+
+                               System.err.print(builder.toString());
+
+                               if (progress.isDone()) {
+                                       System.err.println("");
+                               }
+                       }
+               });
+               Progress pg = new Progress();
+               mainProgress.addProgress(pg, mainProgress.getMax());
+
+               VersionCheck updates = VersionCheck.check();
+               if (updates.isNewVersionAvailable()) {
+                       // Sent to syserr so not to cause problem if one tries to capture a
+                       // story content in text mode
+                       System.err
+                                       .println("A new version of the program is available at https://github.com/nikiroo/fanfix/releases");
+                       System.err.println("");
+                       for (Version v : updates.getNewer()) {
+                               System.err.println("\tVersion " + v);
+                               System.err.println("\t-------------");
+                               System.err.println("");
+                               for (String it : updates.getChanges().get(v)) {
+                                       System.err.println("\t- " + it);
+                               }
+                               System.err.println("");
+                       }
+               }
+
+               if (exitCode == 0) {
+                       switch (action) {
+                       case IMPORT:
+                               exitCode = imprt(urlString, pg);
+                               updates.ok(); // we consider it read
+                               break;
+                       case EXPORT:
+                               exitCode = export(luid, sourceString, target, pg);
+                               updates.ok(); // we consider it read
+                               break;
+                       case CONVERT:
+                               exitCode = convert(urlString, sourceString, target,
+                                               plusInfo == null ? false : plusInfo, pg);
+                               updates.ok(); // we consider it read
+                               break;
+                       case LIST:
+                               if (BasicReader.getReader() == null) {
+                                       Instance.getTraceHandler()
+                                                       .error(new Exception(
+                                                                       "No reader type has been configured"));
+                                       exitCode = 10;
+                                       break;
+                               }
+                               exitCode = list(sourceString);
+                               break;
+                       case SET_SOURCE:
+                               try {
+                                       Instance.getLibrary().changeSource(luid, sourceString, pg);
+                               } catch (IOException e1) {
+                                       Instance.getTraceHandler().error(e1);
+                                       exitCode = 21;
+                               }
+                               break;
+                       case SET_TITLE:
+                               try {
+                                       Instance.getLibrary().changeTitle(luid, titleString, pg);
+                               } catch (IOException e1) {
+                                       Instance.getTraceHandler().error(e1);
+                                       exitCode = 22;
+                               }
+                               break;
+                       case SET_AUTHOR:
+                               try {
+                                       Instance.getLibrary().changeAuthor(luid, authorString, pg);
+                               } catch (IOException e1) {
+                                       Instance.getTraceHandler().error(e1);
+                                       exitCode = 23;
+                               }
+                               break;
+                       case READ:
+                               if (BasicReader.getReader() == null) {
+                                       Instance.getTraceHandler()
+                                                       .error(new Exception(
+                                                                       "No reader type has been configured"));
+                                       exitCode = 10;
+                                       break;
+                               }
+                               exitCode = read(luid, chapString, true);
+                               break;
+                       case READ_URL:
+                               if (BasicReader.getReader() == null) {
+                                       Instance.getTraceHandler()
+                                                       .error(new Exception(
+                                                                       "No reader type has been configured"));
+                                       exitCode = 10;
+                                       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);
+                               exitCode = 0;
+                               break;
+                       case SET_READER:
+                               exitCode = 255;
+                               break;
+                       case VERSION:
+                               System.out
+                                               .println(String.format("Fanfix version %s"
+                                                               + "%nhttps://github.com/nikiroo/fanfix/"
+                                                               + "%n\tWritten by Nikiroo",
+                                                               Version.getCurrentVersion()));
+                               updates.ok(); // we consider it read
+                               break;
+                       case START:
+                               if (BasicReader.getReader() == null) {
+                                       Instance.getTraceHandler()
+                                                       .error(new Exception(
+                                                                       "No reader type has been configured"));
+                                       exitCode = 10;
+                                       break;
+                               }
+                               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) {
+                                       System.err.println("No port configured in the config file");
+                                       exitCode = 15;
+                                       break;
+                               }
+                               try {
+                                       ServerObject server = new RemoteLibraryServer(key, port);
+                                       server.setTraceHandler(Instance.getTraceHandler());
+                                       server.run();
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(e);
+                               }
+                               return;
+                       case STOP_SERVER:
+                               key = Instance.getConfig().getString(Config.SERVER_KEY);
+                               port = Instance.getConfig().getInteger(Config.SERVER_PORT);
+                               if (port == null) {
+                                       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;
+                               }
+
+                               break;
+                       case REMOTE:
+                               exitCode = 255; // should not be reachable (REMOTE -> START)
+                               break;
+                       }
+               }
+
+               try {
+                       Instance.getTempFiles().close();
+               } catch (IOException e) {
+                       Instance.getTraceHandler()
+                                       .error(new IOException(
+                                                       "Cannot dispose of the temporary files", e));
+               }
+
+               if (exitCode == 255) {
+                       syntax(false);
+               }
+
+               System.exit(exitCode);
+       }
+
+       /**
+        * Import the given resource into the {@link LocalLibrary}.
+        * 
+        * @param urlString
+        *            the resource to import
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the exit return code (0 = success)
+        */
+       public static int imprt(String urlString, Progress pg) {
+               try {
+                       MetaData meta = Instance.getLibrary().imprt(
+                                       BasicReader.getUrl(urlString), pg);
+                       System.out.println(meta.getLuid() + ": \"" + meta.getTitle()
+                                       + "\" imported.");
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+                       return 1;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Export the {@link Story} from the {@link LocalLibrary} to the given
+        * target.
+        * 
+        * @param luid
+        *            the story LUID
+        * @param typeString
+        *            the {@link OutputType} to use
+        * @param target
+        *            the target
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the exit return code (0 = success)
+        */
+       public static int export(String luid, String typeString, String target,
+                       Progress pg) {
+               OutputType type = OutputType.valueOfNullOkUC(typeString, null);
+               if (type == null) {
+                       Instance.getTraceHandler().error(
+                                       new Exception(trans(StringId.OUTPUT_DESC, typeString)));
+                       return 1;
+               }
+
+               try {
+                       Instance.getLibrary().export(luid, type, target, pg);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+                       return 4;
+               }
+
+               return 0;
+       }
+
+       /**
+        * List the stories of the given source from the {@link LocalLibrary}
+        * (unless NULL is passed, in which case all stories will be listed).
+        * 
+        * @param source
+        *            the source to list the known stories of, or NULL to list all
+        *            stories
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int list(String source) {
+               BasicReader.setDefaultReaderType(ReaderType.CLI);
+               try {
+                       BasicReader.getReader().browse(source);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+                       return 66;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Start the current reader for this {@link Story}.
+        * 
+        * @param story
+        *            the LUID of the {@link Story} in the {@link LocalLibrary}
+        *            <b>or</b> the {@link Story} {@link URL}
+        * @param chapString
+        *            which {@link Chapter} to read (starting at 1), or NULL to get
+        *            the {@link Story} description
+        * @param library
+        *            TRUE if the source is the {@link Story} LUID, FALSE if it is a
+        *            {@link URL}
+        * 
+        * @return the exit return code (0 = success)
+        */
+       private static int read(String story, String chapString, boolean library) {
+               try {
+                       Reader reader = BasicReader.getReader();
+                       if (library) {
+                               reader.setMeta(story);
+                       } else {
+                               reader.setMeta(BasicReader.getUrl(story), null);
+                       }
+
+                       if (chapString != null) {
+                               try {
+                                       reader.setChapter(Integer.parseInt(chapString));
+                                       reader.read(true);
+                               } catch (NumberFormatException e) {
+                                       Instance.getTraceHandler().error(
+                                                       new IOException("Chapter number cannot be parsed: "
+                                                                       + chapString, e));
+                                       return 2;
+                               }
+                       } else {
+                               reader.read(true);
+                       }
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+                       return 1;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Convert the {@link Story} into another format.
+        * 
+        * @param urlString
+        *            the source {@link Story} to convert
+        * @param typeString
+        *            the {@link OutputType} to convert to
+        * @param target
+        *            the target file
+        * @param infoCover
+        *            TRUE to also export the cover and info file, even if the given
+        *            {@link OutputType} does not usually save them
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the exit return code (0 = success)
+        */
+       public static int convert(String urlString, String typeString,
+                       String target, boolean infoCover, Progress pg) {
+               int exitCode = 0;
+
+               Instance.getTraceHandler().trace("Convert: " + urlString);
+               String sourceName = urlString;
+               try {
+                       URL source = BasicReader.getUrl(urlString);
+                       sourceName = source.toString();
+                       if (source.toString().startsWith("file://")) {
+                               sourceName = sourceName.substring("file://".length());
+                       }
+
+                       OutputType type = OutputType.valueOfAllOkUC(typeString, null);
+                       if (type == null) {
+                               Instance.getTraceHandler().error(
+                                               new IOException(trans(StringId.ERR_BAD_OUTPUT_TYPE,
+                                                               typeString)));
+
+                               exitCode = 2;
+                       } else {
+                               try {
+                                       BasicSupport support = BasicSupport.getSupport(source);
+
+                                       if (support != null) {
+                                               Instance.getTraceHandler().trace(
+                                                               "Support found: " + support.getClass());
+                                               Progress pgIn = new Progress();
+                                               Progress pgOut = new Progress();
+                                               if (pg != null) {
+                                                       pg.setMax(2);
+                                                       pg.addProgress(pgIn, 1);
+                                                       pg.addProgress(pgOut, 1);
+                                               }
+
+                                               Story story = support.process(pgIn);
+                                               try {
+                                                       target = new File(target).getAbsolutePath();
+                                                       BasicOutput.getOutput(type, infoCover, infoCover)
+                                                                       .process(story, target, pgOut);
+                                               } catch (IOException e) {
+                                                       Instance.getTraceHandler().error(
+                                                                       new IOException(trans(StringId.ERR_SAVING,
+                                                                                       target), e));
+                                                       exitCode = 5;
+                                               }
+                                       } else {
+                                               Instance.getTraceHandler().error(
+                                                               new IOException(trans(
+                                                                               StringId.ERR_NOT_SUPPORTED, source)));
+
+                                               exitCode = 4;
+                                       }
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(
+                                                       new IOException(trans(StringId.ERR_LOADING,
+                                                                       sourceName), e));
+                                       exitCode = 3;
+                               }
+                       }
+               } catch (MalformedURLException e) {
+                       Instance.getTraceHandler()
+                                       .error(new IOException(trans(StringId.ERR_BAD_URL,
+                                                       sourceName), e));
+                       exitCode = 1;
+               }
+
+               return exitCode;
+       }
+
+       /**
+        * Simple shortcut method to call {link Instance#getTrans()#getString()}.
+        * 
+        * @param id
+        *            the ID to translate
+        * 
+        * @return the translated result
+        */
+       private static String trans(StringId id, Object... params) {
+               return Instance.getTrans().getString(id, params);
+       }
+
+       /**
+        * Display the correct syntax of the program to the user to stdout, or an
+        * error message if the syntax used was wrong on stderr.
+        * 
+        * @param showHelp
+        *            TRUE to show the syntax help, FALSE to show "syntax error"
+        */
+       private static void syntax(boolean showHelp) {
+               if (showHelp) {
+                       StringBuilder builder = new StringBuilder();
+                       for (SupportType type : SupportType.values()) {
+                               builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+                                               type.getDesc()));
+                               builder.append('\n');
+                       }
+
+                       String typesIn = builder.toString();
+                       builder.setLength(0);
+
+                       for (OutputType type : OutputType.values()) {
+                               builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+                                               type.getDesc(true)));
+                               builder.append('\n');
+                       }
+
+                       String typesOut = builder.toString();
+
+                       System.out.println(trans(StringId.HELP_SYNTAX, typesIn, typesOut));
+               } else {
+                       System.err.println(trans(StringId.ERR_SYNTAX));
+               }
+       }
+
+       /**
+        * Set the default reader type for this session only (it can be changed in
+        * the configuration file, too, but this value will override it).
+        * 
+        * @param readerTypeString
+        *            the type
+        */
+       private static int setReaderType(String readerTypeString) {
+               try {
+                       ReaderType readerType = ReaderType.valueOf(readerTypeString
+                                       .toUpperCase());
+                       BasicReader.setDefaultReaderType(readerType);
+                       return 0;
+               } catch (IllegalArgumentException e) {
+                       Instance.getTraceHandler().error(
+                                       new IOException("Unknown reader type: " + readerTypeString,
+                                                       e));
+                       return 1;
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/VersionCheck.java b/src/be/nikiroo/fanfix/VersionCheck.java
new file mode 100644 (file)
index 0000000..2c9a032
--- /dev/null
@@ -0,0 +1,174 @@
+package be.nikiroo.fanfix;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import be.nikiroo.utils.Version;
+
+/**
+ * Version checker: can check the current version of the program against a
+ * remote changelog, and list the missed updates and their description.
+ * 
+ * @author niki
+ */
+public class VersionCheck {
+       private static final String base = "https://github.com/nikiroo/fanfix/raw/master/changelog${LANG}.md";
+
+       private Version current;
+       private List<Version> newer;
+       private Map<Version, List<String>> changes;
+
+       /**
+        * Create a new {@link VersionCheck}.
+        * 
+        * @param current
+        *            the current version of the program
+        * @param newer
+        *            the list of available {@link Version}s newer the current one
+        * @param changes
+        *            the list of changes
+        */
+       private VersionCheck(Version current, List<Version> newer,
+                       Map<Version, List<String>> changes) {
+               this.current = current;
+               this.newer = newer;
+               this.changes = changes;
+       }
+
+       /**
+        * Check if there are more recent {@link Version}s of this program
+        * available.
+        * 
+        * @return TRUE if there is at least one
+        */
+       public boolean isNewVersionAvailable() {
+               return !newer.isEmpty();
+       }
+
+       /**
+        * The current {@link Version} of the program.
+        * 
+        * @return the current {@link Version}
+        */
+       public Version getCurrentVersion() {
+               return current;
+       }
+
+       /**
+        * The list of available {@link Version}s newer than the current one.
+        * 
+        * @return the newer {@link Version}s
+        */
+       public List<Version> getNewer() {
+               return newer;
+       }
+
+       /**
+        * The list of changes for each available {@link Version} newer than the
+        * current one.
+        * 
+        * @return the list of changes
+        */
+       public Map<Version, List<String>> getChanges() {
+               return changes;
+       }
+
+       /**
+        * Ignore the check result.
+        */
+       public void ignore() {
+
+       }
+
+       /**
+        * Accept the information, and do not check again until the minimum wait
+        * time has elapsed.
+        */
+       public void ok() {
+               Instance.setVersionChecked();
+       }
+
+       /**
+        * Check if there are available {@link Version}s of this program more recent
+        * than the current one.
+        * 
+        * @return a {@link VersionCheck}
+        */
+       public static VersionCheck check() {
+               Version current = Version.getCurrentVersion();
+               List<Version> newer = new ArrayList<Version>();
+               Map<Version, List<String>> changes = new HashMap<Version, List<String>>();
+
+               if (Instance.isVersionCheckNeeded()) {
+                       try {
+                               // Prepare the URLs according to the user's language
+                               Locale lang = Instance.getTrans().getLocale();
+                               String fr = lang.getLanguage();
+                               String BE = lang.getCountry().replace(".UTF8", "");
+                               String urlFrBE = base.replace("${LANG}", "-" + fr + "_" + BE);
+                               String urlFr = base.replace("${LANG}", "-" + fr);
+                               String urlDefault = base.replace("${LANG}", "");
+
+                               InputStream in = null;
+                               for (String url : new String[] { urlFrBE, urlFr, urlDefault }) {
+                                       try {
+                                               in = Instance.getCache()
+                                                               .open(new URL(url), null, false);
+                                               break;
+                                       } catch (IOException e) {
+                                       }
+                               }
+
+                               if (in == null) {
+                                       throw new IOException("No changelog found");
+                               }
+
+                               BufferedReader reader = new BufferedReader(
+                                               new InputStreamReader(in, "UTF-8"));
+                               try {
+                                       Version version = new Version();
+                                       for (String line = reader.readLine(); line != null; line = reader
+                                                       .readLine()) {
+                                               if (line.startsWith("## Version ")) {
+                                                       version = new Version(line.substring("## Version "
+                                                                       .length()));
+                                                       if (version.isNewerThan(current)) {
+                                                               newer.add(version);
+                                                               changes.put(version, new ArrayList<String>());
+                                                       } else {
+                                                               version = new Version();
+                                                       }
+                                               } else if (!version.isEmpty() && !newer.isEmpty()
+                                                               && !line.isEmpty()) {
+                                                       List<String> ch = changes.get(newer.get(newer
+                                                                       .size() - 1));
+                                                       if (!ch.isEmpty() && !line.startsWith("- ")) {
+                                                               int i = ch.size() - 1;
+                                                               ch.set(i, ch.get(i) + " " + line.trim());
+                                                       } else {
+                                                               ch.add(line.substring("- ".length()).trim());
+                                                       }
+                                               }
+                                       }
+                               } finally {
+                                       reader.close();
+                               }
+                       } catch (IOException e) {
+                               Instance.getTraceHandler()
+                                               .error(new IOException(
+                                                               "Cannot download latest changelist on github.com",
+                                                               e));
+                       }
+               }
+
+               return new VersionCheck(current, newer, changes);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Config.java b/src/be/nikiroo/fanfix/bundles/Config.java
new file mode 100644 (file)
index 0000000..7360f39
--- /dev/null
@@ -0,0 +1,164 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The configuration options.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum Config {
+       @Meta(description = "The language to use for in the program (example: en-GB, fr-BE...) or nothing for default system language (can be overwritten with the variable $LANG)",//
+       format = Format.LOCALE, list = { "en-GB", "fr-BE" })
+       LANG, //
+       @Meta(description = "The default reader type to use to read stories:\nCLI = simple output to console\nTUI = a Text User Interface with menus and windows, based upon Jexer\nGUI = a GUI with locally stored files, based upon Swing", //
+       format = Format.FIXED_LIST, list = { "CLI", "GUI", "TUI" }, def = "GUI")
+       READER_TYPE, //
+
+       @Meta(description = "File format options",//
+       group = true)
+       FILE_FORMAT, //
+       @Meta(description = "How to save non-images documents in the library",//
+       format = Format.FIXED_LIST, list = { "INFO_TEXT", "EPUB", "HTML", "TEXT" }, def = "INFO_TEXT")
+       FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "How to save images documents in the library",//
+       format = Format.FIXED_LIST, list = { "CBZ", "HTML" }, def = "CBZ")
+       FILE_FORMAT_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "How to save cover images",//
+       format = Format.FIXED_LIST, list = { "PNG", "JPG", "BMP" }, def = "PNG")
+       FILE_FORMAT_IMAGE_FORMAT_COVER, //
+       @Meta(description = "How to save content images",//
+       format = Format.FIXED_LIST, list = { "PNG", "JPG", "BMP" }, def = "JPG")
+       FILE_FORMAT_IMAGE_FORMAT_CONTENT, //
+
+       @Meta(description = "Cache management",//
+       group = true)
+       CACHE, //
+       @Meta(description = "The directory where to store temporary files; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "tmp/")
+       CACHE_DIR, //
+       @Meta(description = "The delay in hours after which a cached resource that is thought to change ~often is considered too old and triggers a refresh delay (or 0 for no cache, or -1 for infinite time)", //
+       format = Format.INT, def = "24")
+       CACHE_MAX_TIME_CHANGING, //
+       @Meta(description = "The delay in hours after which a cached resource that is thought to change rarely is considered too old and triggers a refresh delay (or 0 for no cache, or -1 for infinite time)", //
+       format = Format.INT, def = "720")
+       CACHE_MAX_TIME_STABLE, //
+
+       @Meta(description = "The directory where to get the default story covers; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "covers/")
+       DEFAULT_COVERS_DIR, //
+       @Meta(description = "The directory where to store the library (can be overriden by the envvironment variable \"BOOKS_DIR\"; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "$HOME/Books/")
+       LIBRARY_DIR, //
+
+       @Meta(description = "Remote library\nA remote library can be configured to fetch the stories from a remote Fanfix server",//
+       group = true)
+       REMOTE_LIBRARY, //
+       @Meta(description = "Use the remote Fanfix server configured here instead of the local library (if FALSE, the local library will be used instead)",//
+       format = Format.BOOLEAN, def = "false")
+       REMOTE_LIBRARY_ENABLED, //
+       @Meta(description = "The remote Fanfix server to connect to",//
+       format = Format.STRING)
+       REMOTE_LIBRARY_HOST, //
+       @Meta(description = "The port to use for the remote Fanfix server",//
+       format = Format.INT, def = "58365")
+       REMOTE_LIBRARY_PORT, //
+       @Meta(description = "The key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
+       format = Format.PASSWORD)
+       REMOTE_LIBRARY_KEY, //
+
+       @Meta(description = "Network configuration",//
+       group = true)
+       NETWORK, //
+       @Meta(description = "The user-agent to use to download files",//
+       def = "Mozilla/5.0 (X11; Linux x86_64; rv:44.0) Gecko/20100101 Firefox/44.0 -- ELinks/0.9.3 (Linux 2.6.11 i686; 80x24) -- Fanfix (https://github.com/nikiroo/fanfix/)")
+       NETWORK_USER_AGENT, //
+       @Meta(description = "The proxy server to use under the format 'user:pass@proxy:port', 'user@proxy:port', 'proxy:port' or ':' alone (system proxy); an empty String means no proxy",//
+       format = Format.STRING, def = "")
+       NETWORK_PROXY, //
+       @Meta(description = "If the last update check was done at least that many days ago, check for updates at startup (-1 for 'no checks')", //
+       format = Format.INT, def = "1")
+       NETWORK_UPDATE_INTERVAL, //
+
+       @Meta(description = "Remote Server configuration\nNote that the key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
+       group = true)
+       SERVER, //
+       @Meta(description = "The port on which we can start the server (must be a valid port, from 1 to 65535)", //
+       format = Format.INT, def = "58365")
+       SERVER_PORT, //
+       @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty (it is *still* encrypted, but with an empty, easy to guess key)",//
+       format = Format.PASSWORD, def = "")
+       SERVER_KEY, //
+       @Meta(description = "Allow write access to the clients (download story, move story...) without RW subkeys", //
+       format = Format.BOOLEAN, def = "true")
+       SERVER_RW, //
+       @Meta(description = "If not empty, only the EXACT listed sources will be available for clients without BL subkeys",//
+       array = true, format = Format.STRING, def = "")
+       SERVER_WHITELIST, //
+       @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey ", //
+       array = true, format = Format.STRING, def = "")
+       SERVER_ALLOWED_SUBKEYS, //
+
+       @Meta(description = "DEBUG options",//
+       group = true)
+       DEBUG, //
+       @Meta(description = "Show debug information on errors",//
+       format = Format.BOOLEAN, def = "false")
+       DEBUG_ERR, //
+       @Meta(description = "Show debug trace information",//
+       format = Format.BOOLEAN, def = "false")
+       DEBUG_TRACE, //
+
+       @Meta(description = "Internal configuration\nThose options are internal to the program and should probably not be changed",//
+       group = true)
+       CONF, //
+       @Meta(description = "LaTeX configuration",//
+       group = true)
+       CONF_LATEX_LANG, //
+       @Meta(description = "LaTeX output language (full name) for \"English\"",//
+       format = Format.STRING, def = "english")
+       CONF_LATEX_LANG_EN, //
+       @Meta(description = "LaTeX output language (full name) for \"French\"",//
+       format = Format.STRING, def = "french")
+       CONF_LATEX_LANG_FR, //
+       @Meta(description = "other 'by' prefixes before author name, used to identify the author",//
+       array = true, format = Format.STRING, def = "\"by\",\"par\",\"de\",\"©\",\"(c)\"")
+       CONF_BYS, //
+       @Meta(description = "List of languages codes used for chapter identification (should not be changed)", //
+       array = true, format = Format.STRING, def = "\"EN\",\"FR\"")
+       CONF_CHAPTER, //
+       @Meta(description = "Chapter identification string in English, used to identify a starting chapter in text mode",//
+       format = Format.STRING, def = "Chapter")
+       CONF_CHAPTER_EN, //
+       @Meta(description = "Chapter identification string in French, used to identify a starting chapter in text mode",//
+       format = Format.STRING, def = "Chapitre")
+       CONF_CHAPTER_FR, //
+
+       @Meta(description = "YiffStar/SoFurry credentials\nYou can give your YiffStar credentials here to have access to all the stories, though it should not be necessary anymore (some stories used to beblocked for anonymous viewers)",//
+       group = true)
+       LOGIN_YIFFSTAR, //
+       @Meta(description = "Your YiffStar/SoFurry login",//
+       format = Format.STRING)
+       LOGIN_YIFFSTAR_USER, //
+       @Meta(description = "Your YiffStar/SoFurry password",//
+       format = Format.PASSWORD)
+       LOGIN_YIFFSTAR_PASS, //
+
+       @Meta(description = "FimFiction APIKEY credentials\nFimFiction can be queried via an API, but requires an API key to do that. One has been created for this program, but if you have another API key you can set it here. You can also set a login and password instead, in that case, a new API key will be generated (and stored) if you still haven't set one.",//
+       group = true)
+       LOGIN_FIMFICTION_APIKEY, //
+       @Meta(description = "The login of the API key used to create a new token from FimFiction", //
+       format = Format.STRING)
+       LOGIN_FIMFICTION_APIKEY_CLIENT_ID, //
+       @Meta(description = "The password of the API key used to create a new token from FimFiction", //
+       format = Format.PASSWORD)
+       LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET, //
+       @Meta(description = "Do not use the new API, even if we have a token, and force HTML scraping",//
+       format = Format.BOOLEAN, def = "false")
+       LOGIN_FIMFICTION_APIKEY_FORCE_HTML, //
+       @Meta(description = "The token required to use the beta APIv2 from FimFiction (see APIKEY_CLIENT_* if you want to generate a new one from your own API key)", //
+       format = Format.PASSWORD, def = "Bearer WnZ5oHlzQoDocv1GcgHfcoqctHkSwL-D")
+       LOGIN_FIMFICTION_APIKEY_TOKEN, //
+}
diff --git a/src/be/nikiroo/fanfix/bundles/ConfigBundle.java b/src/be/nikiroo/fanfix/bundles/ConfigBundle.java
new file mode 100644 (file)
index 0000000..ce72b3d
--- /dev/null
@@ -0,0 +1,41 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * This class manages the configuration of the application.
+ * 
+ * @author niki
+ */
+public class ConfigBundle extends Bundle<Config> {
+       /**
+        * Create a new {@link ConfigBundle}.
+        */
+       public ConfigBundle() {
+               super(Config.class, Target.config5, null);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new ConfigBundle().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "Configuration options";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringId.java b/src/be/nikiroo/fanfix/bundles/StringId.java
new file mode 100644 (file)
index 0000000..9772248
--- /dev/null
@@ -0,0 +1,151 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.Meta;
+
+/**
+ * The {@link Enum} representing textual information to be translated to the
+ * user as a key.
+ * 
+ * Note that each key that should be translated <b>must</b> be annotated with a
+ * {@link Meta} annotation.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum StringId {
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need NO translation.
+        */
+       NULL, //
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need a real translation but still don't have a key.
+        */
+       DUMMY, //
+       @Meta(info = "%s = supported input, %s = supported output", description = "help message for the syntax")
+       HELP_SYNTAX, //
+       @Meta(description = "syntax error message")
+       ERR_SYNTAX, //
+       @Meta(info = "%s = support name, %s = support desc", description = "an input or output support type description")
+       ERR_SYNTAX_TYPE, //
+       @Meta(info = "%s = input string", description = "Error when retrieving data")
+       ERR_LOADING, //
+       @Meta(info = "%s = save target", description = "Error when saving to given target")
+       ERR_SAVING, //
+       @Meta(info = "%s = bad output format", description = "Error when unknown output format")
+       ERR_BAD_OUTPUT_TYPE, //
+       @Meta(info = "%s = input string", description = "Error when converting input to URL/File")
+       ERR_BAD_URL, //
+       @Meta(info = "%s = input url", description = "URL/File not supported")
+       ERR_NOT_SUPPORTED, //
+       @Meta(info = "%s = cover URL", description = "Failed to download cover : %s")
+       ERR_BS_NO_COVER, //
+       @Meta(def = "`", info = "single char", description = "Canonical OPEN SINGLE QUOTE char (for instance: ‘)")
+       OPEN_SINGLE_QUOTE, //
+       @Meta(def = "‘", info = "single char", description = "Canonical CLOSE SINGLE QUOTE char (for instance: ’)")
+       CLOSE_SINGLE_QUOTE, //
+       @Meta(def = "“", info = "single char", description = "Canonical OPEN DOUBLE QUOTE char (for instance: “)")
+       OPEN_DOUBLE_QUOTE, //
+       @Meta(def = "”", info = "single char", description = "Canonical CLOSE DOUBLE QUOTE char (for instance: ”)")
+       CLOSE_DOUBLE_QUOTE, //
+       @Meta(def = "Description", description = "Name of the description fake chapter")
+       DESCRIPTION, //
+       @Meta(def = "Chapter %d: %s", info = "%d = number, %s = name", description = "Name of a chapter with a name")
+       CHAPTER_NAMED, //
+       @Meta(def = "Chapter %d", info = "%d = number, %s = name", description = "Name of a chapter without name")
+       CHAPTER_UNNAMED, //
+       @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
+       INPUT_DESC, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_EPUB, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_TEXT, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_INFO_TEXT, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_FANFICTION, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_FIMFICTION, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_MANGAFOX, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_E621, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_E_HENTAI, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_YIFFSTAR, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_CBZ, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_HTML, //
+       @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
+       OUTPUT_DESC, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_EPUB, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_TEXT, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_INFO_TEXT, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_CBZ, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_HTML, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_LATEX, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_SYSOUT, //
+       @Meta(group = true, info = "%s = type", description = "Default description when the type is not known by i18n")
+       OUTPUT_DESC_SHORT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_EPUB, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_TEXT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_INFO_TEXT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_CBZ, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_LATEX, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_SYSOUT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_HTML, //
+       @Meta(info = "%s = the unknown 2-code language", description = "Error message for unknown 2-letter LaTeX language code")
+       LATEX_LANG_UNKNOWN, //
+       @Meta(def = "by", description = "'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection")
+       BY, //
+
+       ;
+
+       /**
+        * Write the header found in the configuration <tt>.properties</tt> file of
+        * this {@link Bundle}.
+        * 
+        * @param writer
+        *            the {@link Writer} to write the header in
+        * @param name
+        *            the file name
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public void writeHeader(Writer writer, String name)
+                       throws IOException {
+               writer.write("# " + name + " translation file (UTF-8)\n");
+               writer.write("# \n");
+               writer.write("# Note that any key can be doubled with a _NOUTF suffix\n");
+               writer.write("# to use when the NOUTF env variable is set to 1\n");
+               writer.write("# \n");
+               writer.write("# Also, the comments always refer to the key below them.\n");
+               writer.write("# \n");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdBundle.java b/src/be/nikiroo/fanfix/bundles/StringIdBundle.java
new file mode 100644 (file)
index 0000000..b9a0d79
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the translation resources of the application (Core).
+ * 
+ * @author niki
+ */
+public class StringIdBundle extends TransBundle<StringId> {
+       /**
+        * Create a translation service for the given language (will fall back to
+        * the default one if not found).
+        * 
+        * @param lang
+        *            the language to use
+        */
+       public StringIdBundle(String lang) {
+               super(StringId.class, Target.resources_core, lang);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new StringIdBundle(null).updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGui.java b/src/be/nikiroo/fanfix/bundles/StringIdGui.java
new file mode 100644 (file)
index 0000000..2c9d222
--- /dev/null
@@ -0,0 +1,199 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The {@link Enum} representing textual information to be translated to the
+ * user as a key.
+ * 
+ * Note that each key that should be translated <b>must</b> be annotated with a
+ * {@link Meta} annotation.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum StringIdGui {
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need NO translation.
+        */
+       NULL, //
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need a real translation but still don't have a key.
+        */
+       DUMMY, //
+       @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library", info = "%s = current Fanfix version")
+       // The titles/subtitles:
+       TITLE_LIBRARY, //
+       @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)", info = "%s = current Fanfix version, %s = library name")
+       TITLE_LIBRARY_WITH_NAME, //
+       @Meta(def = "Fanfix Configuration", format = Format.STRING, description = "the title of the configuration window of Fanfix, also the name of the menu button")
+       TITLE_CONFIG, //
+       @Meta(def = "This is where you configure the options of the program.", format = Format.STRING, description = "the subtitle of the configuration window of Fanfix")
+       SUBTITLE_CONFIG, //
+       @Meta(def = "UI Configuration", format = Format.STRING, description = "the title of the UI configuration window of Fanfix, also the name of the menu button")
+       TITLE_CONFIG_UI, //
+       @Meta(def = "This is where you configure the graphical appearence of the program.", format = Format.STRING, description = "the subtitle of the UI configuration window of Fanfix")
+       SUBTITLE_CONFIG_UI, //
+       @Meta(def = "Save", format = Format.STRING, description = "the title of the 'save to/export to' window of Fanfix")
+       TITLE_SAVE, //
+       @Meta(def = "Moving story", format = Format.STRING, description = "the title of the 'move to' window of Fanfix")
+       TITLE_MOVE_TO, //
+       @Meta(def = "Move to:", format = Format.STRING, description = "the subtitle of the 'move to' window of Fanfix")
+       SUBTITLE_MOVE_TO, //
+       @Meta(def = "Delete story", format = Format.STRING, description = "the title of the 'delete' window of Fanfix")
+       TITLE_DELETE, //
+       @Meta(def = "Delete %s: %s", format = Format.STRING, description = "the subtitle of the 'delete' window of Fanfix", info = "%s = LUID of the story, %s = title of the story")
+       SUBTITLE_DELETE, //
+       @Meta(def = "Library error", format = Format.STRING, description = "the title of the 'library error' dialogue")
+       TITLE_ERROR_LIBRARY, //
+       @Meta(def = "Importing from URL", format = Format.STRING, description = "the title of the 'import URL' dialogue")
+       TITLE_IMPORT_URL, //
+       @Meta(def = "URL of the story to import:", format = Format.STRING, description = "the subtitle of the 'import URL' dialogue")
+       SUBTITLE_IMPORT_URL, //
+       @Meta(def = "Error", format = Format.STRING, description = "the title of general error dialogues")
+       TITLE_ERROR, //
+       @Meta(def = "%s: %s", format = Format.STRING, description = "the title of a story for the properties dialogue, the viewers...", info = "%s = LUID of the story, %s = title of the story")
+       TITLE_STORY, //
+
+       //
+
+       @Meta(def = "A new version of the program is available at %s", format = Format.STRING, description = "HTML text used to notify of a new version", info = "%s = url link in HTML")
+       NEW_VERSION_AVAILABLE, //
+       @Meta(def = "Updates available", format = Format.STRING, description = "text used as title for the update dialogue")
+       NEW_VERSION_TITLE, //
+       @Meta(def = "Version %s", format = Format.STRING, description = "HTML text used to specify a newer version title and number, used for each version newer than the current one", info = "%s = the newer version number")
+       NEW_VERSION_VERSION, //
+       @Meta(def = "%s words", format = Format.STRING, description = "show the number of words of a book", info = "%s = the number")
+       BOOK_COUNT_WORDS, //
+       @Meta(def = "%s images", format = Format.STRING, description = "show the number of images of a book", info = "%s = the number")
+       BOOK_COUNT_IMAGES, //
+       @Meta(def = "%s stories", format = Format.STRING, description = "show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)", info = "%s = the number")
+       BOOK_COUNT_STORIES, //
+
+       // Menu (and popup) items:
+
+       @Meta(def = "File", format = Format.STRING, description = "the file menu")
+       MENU_FILE, //
+       @Meta(def = "Exit", format = Format.STRING, description = "the file/exit menu button")
+       MENU_FILE_EXIT, //
+       @Meta(def = "Import File...", format = Format.STRING, description = "the file/import_file menu button")
+       MENU_FILE_IMPORT_FILE, //
+       @Meta(def = "Import URL...", format = Format.STRING, description = "the file/import_url menu button")
+       MENU_FILE_IMPORT_URL, //
+       @Meta(def = "Save as...", format = Format.STRING, description = "the file/export menu button")
+       MENU_FILE_EXPORT, //
+       @Meta(def = "Move to", format = Format.STRING, description = "the file/move to menu button")
+       MENU_FILE_MOVE_TO, //
+       @Meta(def = "Set author", format = Format.STRING, description = "the file/set author menu button")
+       MENU_FILE_SET_AUTHOR, //
+       @Meta(def = "New source...", format = Format.STRING, description = "the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source")
+       MENU_FILE_MOVE_TO_NEW_TYPE, //
+       @Meta(def = "New author...", format = Format.STRING, description = "the file/move to/new author menu button, that will trigger a dialogue to create a new author")
+       MENU_FILE_MOVE_TO_NEW_AUTHOR, //
+       @Meta(def = "Rename...", format = Format.STRING, description = "the file/rename menu item, that will trigger a dialogue to ask for a new title for the story")
+       MENU_FILE_RENAME, //
+       @Meta(def = "Properties", format = Format.STRING, description = "the file/Properties menu item, that will trigger a dialogue to show the properties of the story")
+       MENU_FILE_PROPERTIES, //
+       @Meta(def = "Open", format = Format.STRING, description = "the file/open menu item, that will open the story or fake-story (an author or a source/type)")
+       MENU_FILE_OPEN, //
+       @Meta(def = "Edit", format = Format.STRING, description = "the edit menu")
+       MENU_EDIT, //
+       @Meta(def = "Download to cache", format = Format.STRING, description = "the edit/send to cache menu button, to download the story into the cache if not already done")
+       MENU_EDIT_DOWNLOAD_TO_CACHE, //
+       @Meta(def = "Clear cache", format = Format.STRING, description = "the clear cache menu button, to clear the cache for a single book")
+       MENU_EDIT_CLEAR_CACHE, //
+       @Meta(def = "Redownload", format = Format.STRING, description = "the edit/redownload menu button, to download the latest version of the book")
+       MENU_EDIT_REDOWNLOAD, //
+       @Meta(def = "Delete", format = Format.STRING, description = "the edit/delete menu button")
+       MENU_EDIT_DELETE, //
+       @Meta(def = "Set as cover for source", format = Format.STRING, description = "the edit/Set as cover for source menu button")
+       MENU_EDIT_SET_COVER_FOR_SOURCE, //
+       @Meta(def = "Set as cover for author", format = Format.STRING, description = "the edit/Set as cover for author menu button")
+       MENU_EDIT_SET_COVER_FOR_AUTHOR, //
+       @Meta(def = "Search", format = Format.STRING, description = "the search menu to open the earch stories on one of the searchable websites")
+       MENU_SEARCH,
+       @Meta(def = "View", format = Format.STRING, description = "the view menu")
+       MENU_VIEW, //
+       @Meta(def = "Word count", format = Format.STRING, description = "the view/word_count menu button, to show the word/image/story count as secondary info")
+       MENU_VIEW_WCOUNT, //
+       @Meta(def = "Author", format = Format.STRING, description = "the view/author menu button, to show the author as secondary info")
+       MENU_VIEW_AUTHOR, //
+       @Meta(def = "Sources", format = Format.STRING, description = "the sources menu, to select the books from a specific source; also used as a title for the source books")
+       MENU_SOURCES, //
+       @Meta(def = "Authors", format = Format.STRING, description = "the authors menu, to select the books of a specific author; also used as a title for the author books")
+       MENU_AUTHORS, //
+       @Meta(def = "Options", format = Format.STRING, description = "the options menu, to configure Fanfix from the GUI")
+       MENU_OPTIONS, //
+       @Meta(def = "All", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, by group (one book = one group)")
+       MENU_XXX_ALL_GROUPED, //
+       @Meta(def = "Listing", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)")
+       MENU_XXX_ALL_LISTING, //
+       @Meta(def = "[unknown]", format = Format.STRING, description = "a special menu button to select the books without author")
+       MENU_AUTHORS_UNKNOWN, //
+
+       // Progress names
+       @Meta(def = "Reload books", format = Format.STRING, description = "progress bar caption for the 'reload books' step of all outOfUi operations")
+       PROGRESS_OUT_OF_UI_RELOAD_BOOKS, //
+       @Meta(def = "Change the source of the book to %s", format = Format.STRING, description = "progress bar caption for the 'change source' step of the ReDownload operation", info = "%s = new source name")
+       PROGRESS_CHANGE_SOURCE, //
+
+       // Error messages
+       @Meta(def = "An error occured when contacting the library", format = Format.STRING, description = "default description if the error is not known")
+       ERROR_LIB_STATUS, //
+       @Meta(def = "You are not allowed to access this library", format = Format.STRING, description = "library access not allowed")
+       ERROR_LIB_STATUS_UNAUTHORIZED, //
+       @Meta(def = "Library not valid", format = Format.STRING, description = "the library is invalid (not correctly set up)")
+       ERROR_LIB_STATUS_INVALID, //
+       @Meta(def = "Library currently unavailable", format = Format.STRING, description = "the library is out of commission")
+       ERROR_LIB_STATUS_UNAVAILABLE, //
+       @Meta(def = "Cannot open the selected book", format = Format.STRING, description = "cannot open the book, internal or external viewer")
+       ERROR_CANNOT_OPEN, //
+       @Meta(def = "URL not supported: %s", format = Format.STRING, description = "URL is not supported by Fanfix", info = "%s = URL")
+       ERROR_URL_NOT_SUPPORTED, //
+       @Meta(def = "Failed to import %s:\n%s", format = Format.STRING, description = "cannot import the URL", info = "%s = URL, %s = reasons")
+       ERROR_URL_IMPORT_FAILED,
+
+       // Others
+       @Meta(def = "&nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters")
+       CHAPTER_HTML_UNNAMED, //
+       @Meta(def = "&nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>: %s", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters, %s = chapter name")
+       CHAPTER_HTML_NAMED, //
+       @Meta(def = "Image %d / %d", format = Format.STRING, description = "(NO html) the chapter progression value used on the viewers", info = "%d = current image number, %d = total images")
+       IMAGE_PROGRESSION, //
+       
+       ;
+
+       /**
+        * Write the header found in the configuration <tt>.properties</tt> file of
+        * this {@link Bundle}.
+        * 
+        * @param writer
+        *            the {@link Writer} to write the header in
+        * @param name
+        *            the file name
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public void writeHeader(Writer writer, String name)
+                       throws IOException {
+               writer.write("# " + name + " translation file (UTF-8)\n");
+               writer.write("# \n");
+               writer.write("# Note that any key can be doubled with a _NOUTF suffix\n");
+               writer.write("# to use when the NOUTF env variable is set to 1\n");
+               writer.write("# \n");
+               writer.write("# Also, the comments always refer to the key below them.\n");
+               writer.write("# \n");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java b/src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java
new file mode 100644 (file)
index 0000000..c036381
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the translation resources of the application (GUI).
+ * 
+ * @author niki
+ */
+public class StringIdGuiBundle extends TransBundle<StringIdGui> {
+       /**
+        * Create a translation service for the given language (will fall back to
+        * the default one if not found).
+        * 
+        * @param lang
+        *            the language to use
+        */
+       public StringIdGuiBundle(String lang) {
+               super(StringIdGui.class, Target.resources_gui, lang);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new StringIdGuiBundle(null).updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Target.java b/src/be/nikiroo/fanfix/bundles/Target.java
new file mode 100644 (file)
index 0000000..64284c6
--- /dev/null
@@ -0,0 +1,27 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * The type of configuration information the associated {@link Bundle} will
+ * convey.
+ * <p>
+ * Those values can change when the file is not compatible anymore.
+ * 
+ * @author niki
+ */
+public enum Target {
+       /**
+        * Configuration options that the user can change in the
+        * <tt>.properties</tt> file
+        */
+       config5,
+       /** Translation resources (Core) */
+       resources_core,
+       /** Translation resources (GUI) */
+       resources_gui,
+       /** UI resources (from colours to behaviour) */
+       ui,
+       /** Description of UI resources. */
+       ui_description,
+}
diff --git a/src/be/nikiroo/fanfix/bundles/UiConfig.java b/src/be/nikiroo/fanfix/bundles/UiConfig.java
new file mode 100644 (file)
index 0000000..0640db8
--- /dev/null
@@ -0,0 +1,37 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The configuration options.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum UiConfig {
+       @Meta(description = "The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "tmp-reader/")
+       CACHE_DIR_LOCAL_READER, //
+       @Meta(description = "How to save the cached stories for the GUI Reader (non-images documents) -- those files will be sent to the reader",//
+       format = Format.COMBO_LIST, list = { "INFO_TEXT", "EPUB", "HTML", "TEXT" }, def = "EPUB")
+       GUI_NON_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "How to save the cached stories for the GUI Reader (images documents) -- those files will be sent to the reader",//
+       format = Format.COMBO_LIST, list = { "CBZ", "HTML" }, def = "CBZ")
+       GUI_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "Use the internal reader for images documents",//
+       format = Format.BOOLEAN, def = "true")
+       IMAGES_DOCUMENT_USE_INTERNAL_READER, //
+       @Meta(description = "The external viewer for images documents (or empty to use the system default program for the given file type)",//
+       format = Format.STRING)
+       IMAGES_DOCUMENT_READER, //
+       @Meta(description = "Use the internal reader for non-images documents",//
+       format = Format.BOOLEAN, def = "true")
+       NON_IMAGES_DOCUMENT_USE_INTERNAL_READER, //
+       @Meta(description = "The external viewer for non-images documents (or empty to use the system default program for the given file type)",//
+       format = Format.STRING)
+       NON_IMAGES_DOCUMENT_READER, //
+       @Meta(description = "The background colour of the library if you don't like the default system one",//
+       format = Format.COLOR)
+       BACKGROUND_COLOR, //
+}
diff --git a/src/be/nikiroo/fanfix/bundles/UiConfigBundle.java b/src/be/nikiroo/fanfix/bundles/UiConfigBundle.java
new file mode 100644 (file)
index 0000000..8b2c008
--- /dev/null
@@ -0,0 +1,39 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * This class manages the configuration of UI of the application (colours and
+ * behaviour)
+ * 
+ * @author niki
+ */
+public class UiConfigBundle extends Bundle<UiConfig> {
+       public UiConfigBundle() {
+               super(UiConfig.class, Target.ui, new UiConfigBundleDesc());
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new UiConfigBundle().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "UI configuration options";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/UiConfigBundleDesc.java b/src/be/nikiroo/fanfix/bundles/UiConfigBundleDesc.java
new file mode 100644 (file)
index 0000000..da42950
--- /dev/null
@@ -0,0 +1,39 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the configuration of UI of the application (colours and
+ * behaviour)
+ * 
+ * @author niki
+ */
+public class UiConfigBundleDesc extends TransBundle<UiConfig> {
+       public UiConfigBundleDesc() {
+               super(UiConfig.class, Target.ui_description);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new UiConfigBundleDesc().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "UI configuration options description";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/package-info.java b/src/be/nikiroo/fanfix/bundles/package-info.java
new file mode 100644 (file)
index 0000000..80cdd15
--- /dev/null
@@ -0,0 +1,8 @@
+/**
+ * This package encloses the different 
+ * {@link be.nikiroo.utils.resources.Bundle} and their associated 
+ * {@link java.lang.Enum}s used by the application.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.bundles;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/bundles/resources_core.properties b/src/be/nikiroo/fanfix/bundles/resources_core.properties
new file mode 100644 (file)
index 0000000..d656ed6
--- /dev/null
@@ -0,0 +1,209 @@
+# United Kingdom (en_GB) resources_core translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# help message for the syntax
+# (FORMAT: STRING) 
+HELP_SYNTAX = Valid options:\n\
+\t--import [URL]: import into library\n\
+\t--export [id] [output_type] [target]: export story to target\n\
+\t--convert [URL] [output_type] [target] (+info): convert URL into target\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: 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\
+\t\tsame value)\n\
+\t--help: this help message\n\
+\t--version: return the version of the program\n\
+\n\
+Supported input types:\n\
+%s\n\
+\n\
+Supported output types:\n\
+%s
+# syntax error message
+# (FORMAT: STRING) 
+ERR_SYNTAX = Syntax error (try "--help")
+# an input or output support type description
+# (FORMAT: STRING) 
+ERR_SYNTAX_TYPE = > %s: %s
+# Error when retrieving data
+# (FORMAT: STRING) 
+ERR_LOADING = Error when retrieving data from: %s
+# Error when saving to given target
+# (FORMAT: STRING) 
+ERR_SAVING = Error when saving to target: %s
+# Error when unknown output format
+# (FORMAT: STRING) 
+ERR_BAD_OUTPUT_TYPE = Unknown output type: %s
+# Error when converting input to URL/File
+# (FORMAT: STRING) 
+ERR_BAD_URL = Cannot understand file or protocol: %s
+# URL/File not supported
+# (FORMAT: STRING) 
+ERR_NOT_SUPPORTED = URL not supported: %s
+# Failed to download cover : %s
+# (FORMAT: STRING) 
+ERR_BS_NO_COVER = Failed to download cover: %s
+# Canonical OPEN SINGLE QUOTE char (for instance: ‘)
+# (FORMAT: STRING) 
+OPEN_SINGLE_QUOTE = ‘
+# Canonical CLOSE SINGLE QUOTE char (for instance: ’)
+# (FORMAT: STRING) 
+CLOSE_SINGLE_QUOTE = ’
+# Canonical OPEN DOUBLE QUOTE char (for instance: “)
+# (FORMAT: STRING) 
+OPEN_DOUBLE_QUOTE = “
+# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
+# (FORMAT: STRING) 
+CLOSE_DOUBLE_QUOTE = ”
+# Name of the description fake chapter
+# (FORMAT: STRING) 
+DESCRIPTION = Description
+# Name of a chapter with a name
+# (FORMAT: STRING) 
+CHAPTER_NAMED = Chapter %d: %s
+# Name of a chapter without name
+# (FORMAT: STRING) 
+CHAPTER_UNNAMED = Chapter %d
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+INPUT_DESC = Unknown type: %s
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_EPUB = EPUB files created by this program (we do not support "all" EPUB files)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_TEXT = Stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
+\t\tline, possibly with the publication date in parenthesis\n\
+\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
+\t\tCHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, \n\
+\t\tJPEG or JPG extension.
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
+\tcompanion ".info" file to store some metadata
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FANFICTION = Fanfictions of many, many different universes, from TV shows to \n\
+\tnovels to games.
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FIMFICTION = Fanfictions devoted to the My Little Pony show
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_MANGAFOX = A well filled repository of mangas, or, as their website states: \n\
+\tMost popular manga scanlations read online for free at mangafox, \n\
+\tas well as a close-knit community to chat and make friends.
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E621 = Furry website supporting comics, including MLP
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E_HENTAI = Website offering many comics/mangas, mostly but not always NSFW
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_YIFFSTAR = A Furry website, story-oriented
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_CBZ = CBZ files coming from this very program
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_HTML = HTML files coming from this very program
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+OUTPUT_DESC = Unknown type: %s
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
+\t\tline, possibly with the publication date in parenthesis \n\
+\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
+\t\tCHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, JPEG \n\
+\t\tor JPG extension.
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
+\tcompanion ".info" file to store some metadata
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images \n\
+\tin PNG format by default)
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_HTML = HTML files (a directory containing the resources and "index.html")
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SYSOUT = A simple DEBUG console output
+# Default description when the type is not known by i18n
+# This item is used as a group, its content is not expected to be used.
+OUTPUT_DESC_SHORT = %s
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_EPUB = Electronic book (.epub)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_TEXT = Plain text (.txt)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_INFO_TEXT = Plain text and metadata
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_CBZ = Comic book (.cbz)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_LATEX = LaTeX (.tex)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_SYSOUT = Console output
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_HTML = HTML files with resources (directory, .html)
+# Error message for unknown 2-letter LaTeX language code
+# (FORMAT: STRING) 
+LATEX_LANG_UNKNOWN = Unknown language: %s
+# 'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection
+# (FORMAT: STRING) 
+BY = by
diff --git a/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties
new file mode 100644 (file)
index 0000000..9bf3626
--- /dev/null
@@ -0,0 +1,192 @@
+# français (fr) resources_core translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# help message for the syntax
+# (FORMAT: STRING) 
+HELP_SYNTAX = Options reconnues :\n\
+\t--import [URL]: importer une histoire dans la librairie\n\
+\t--export [id] [output_type] [target]: exporter l'histoire "id" vers le fichier donné\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: 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\
+\n\
+Types supportés en entrée :\n\
+%s\n\
+\n\
+Types supportés en sortie :\n\
+%s
+# syntax error message
+# (FORMAT: STRING) 
+ERR_SYNTAX = Erreur de syntaxe (essayez "--help")
+# an input or output support type description
+# (FORMAT: STRING) 
+ERR_SYNTAX_TYPE = > %s : %s
+# Error when retrieving data
+# (FORMAT: STRING) 
+ERR_LOADING = Erreur de récupération des données depuis : %s
+# Error when saving to given target
+# (FORMAT: STRING) 
+ERR_SAVING = Erreur lors de la sauvegarde sur : %s
+# Error when unknown output format
+# (FORMAT: STRING) 
+ERR_BAD_OUTPUT_TYPE = Type de sortie inconnu : %s
+# Error when converting input to URL/File
+# (FORMAT: STRING) 
+ERR_BAD_URL = Protocole ou type de fichier inconnu : %s
+# URL/File not supported
+# (FORMAT: STRING) 
+ERR_NOT_SUPPORTED = Site web non supporté : %s
+# Failed to download cover : %s
+# (FORMAT: STRING) 
+ERR_BS_NO_COVER = Échec de la récupération de la page de couverture : %s
+# Canonical OPEN SINGLE QUOTE char (for instance: ‘)
+# (FORMAT: STRING) 
+OPEN_SINGLE_QUOTE = ‘
+# Canonical CLOSE SINGLE QUOTE char (for instance: ’)
+# (FORMAT: STRING) 
+CLOSE_SINGLE_QUOTE = ’
+# Canonical OPEN DOUBLE QUOTE char (for instance: “)
+# (FORMAT: STRING) 
+OPEN_DOUBLE_QUOTE = “
+# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
+# (FORMAT: STRING) 
+CLOSE_DOUBLE_QUOTE = ”
+# Name of the description fake chapter
+# (FORMAT: STRING) 
+DESCRIPTION = Description
+# Name of a chapter with a name
+# (FORMAT: STRING) 
+CHAPTER_NAMED = Chapitre %d : %s
+# Name of a chapter without name
+# (FORMAT: STRING) 
+CHAPTER_UNNAMED = Chapitre %d
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+INPUT_DESC = Type d'entrée inconnu : %s
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_EPUB = Les fichiers .epub créés avec Fanfix (nous ne supportons pas les autres fichiers .epub, du moins pour le moment)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_TEXT = Les histoires enregistrées en texte (.txt), avec quelques règles spécifiques : \n\
+\tle titre doit être sur la première ligne\n\
+\tl'auteur (précédé de rien, "Par ", "De " ou "©") doit être sur la deuxième ligne, optionnellement suivi de la date de publication entre parenthèses (i.e., "Par Quelqu'un (3 octobre 1998)")\n\
+\tles chapitres doivent être déclarés avec "Chapitre x" ou "Chapitre x: NOM DU CHAPTITRE", où "x" est le numéro du chapitre\n\
+\tune description de l'histoire doit être donnée en tant que chaptire 0\n\
+\tune image de couverture peut être présente avec le même nom de fichier que l'histoire, mais une extension .png, .jpeg ou .jpg
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_INFO_TEXT = Fort proche du format texte, mais avec un fichier .info accompagnant l'histoire pour y enregistrer quelques metadata (le fichier de metadata est supposé être créé par Fanfix, ou être compatible avec)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FANFICTION = Fanfictions venant d'une multitude d'univers différents, depuis les shows télévisés aux livres en passant par les jeux-vidéos
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FIMFICTION = Fanfictions dévouées à la série My Little Pony
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_MANGAFOX = Un site répertoriant une quantité non négligeable de mangas
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E621 = Un site Furry proposant des comics, y compris de MLP
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E_HENTAI = Un site web proposant beaucoup de comics/mangas, souvent mais pas toujours NSFW
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_YIFFSTAR = Un site web Furry, orienté sur les histoires plutôt que les images
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_CBZ = Les fichiers .cbz (une collection d'images zipées), de préférence créés avec Fanfix (même si les autres .cbz sont aussi supportés, mais sans la majorité des metadata de Fanfix dans ce cas)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_HTML = Les fichiers HTML que vous pouvez ouvrir avec n'importe quel navigateur ; remarquez que Fanfix créera un répertoire pour y mettre les fichiers nécessaires, dont un fichier "index.html" pour afficher le tout -- nous ne supportons en entrée que les fichiers HTML créés par Fanfix
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+OUTPUT_DESC = Type de sortie inconnu : %s
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
+\t\tline, possibly with the publication date in parenthesis \n\
+\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
+\t\tCHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, JPEG \n\
+\t\tor JPG extension.
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
+\tcompanion ".info" file to store some metadata
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images \n\
+\tin PNG format by default)
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_HTML = HTML files (a directory containing the resources and "index.html")
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SYSOUT = A simple DEBUG console output
+# Default description when the type is not known by i18n
+# This item is used as a group, its content is not expected to be used.
+OUTPUT_DESC_SHORT = %s
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_EPUB = Electronic book (.epub)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_TEXT = Plain text (.txt)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_INFO_TEXT = Plain text and metadata
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_CBZ = Comic book (.cbz)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_LATEX = LaTeX (.tex)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_SYSOUT = Console output
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_HTML = HTML files with resources (directory, .html)
+# Error message for unknown 2-letter LaTeX language code
+# (FORMAT: STRING) 
+LATEX_LANG_UNKNOWN = Unknown language: %s
+# 'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection
+# (FORMAT: STRING) 
+BY = by
diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui.properties b/src/be/nikiroo/fanfix/bundles/resources_gui.properties
new file mode 100644 (file)
index 0000000..6d46af4
--- /dev/null
@@ -0,0 +1,199 @@
+# United Kingdom (en_GB) resources_gui translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# the title of the main window of Fanfix, the library
+# (FORMAT: STRING) 
+TITLE_LIBRARY = Fanfix %s
+# the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)
+# (FORMAT: STRING) 
+TITLE_LIBRARY_WITH_NAME = Fanfix %s
+# the title of the configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG = Fanfix Configuration
+# the subtitle of the configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG = This is where you configure the options of the program.
+# the title of the UI configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG_UI = UI Configuration
+# the subtitle of the UI configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG_UI = This is where you configure the graphical appearence of the program.
+# the title of the 'save to/export to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_SAVE = Save
+# the title of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_MOVE_TO = Moving story
+# the subtitle of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_MOVE_TO = Move to:
+# the title of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_DELETE = Delete story
+# the subtitle of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_DELETE = Delete %s: %s
+# the title of the 'library error' dialogue
+# (FORMAT: STRING) 
+TITLE_ERROR_LIBRARY = Library error
+# the title of the 'import URL' dialogue
+# (FORMAT: STRING) 
+TITLE_IMPORT_URL = Importing from URL
+# the subtitle of the 'import URL' dialogue
+# (FORMAT: STRING) 
+SUBTITLE_IMPORT_URL = URL of the story to import:
+# the title of general error dialogues
+# (FORMAT: STRING) 
+TITLE_ERROR = Error
+# the title of a story for the properties dialogue, the viewers...
+# (FORMAT: STRING) 
+TITLE_STORY = %s: %s
+# HTML text used to notify of a new version
+# (FORMAT: STRING) 
+NEW_VERSION_AVAILABLE = A new version of the program is available at %s
+# text used as title for the update dialogue
+# (FORMAT: STRING) 
+NEW_VERSION_TITLE = Updates available
+# HTML text used to specify a newer version title and number, used for each version newer than the current one
+# (FORMAT: STRING) 
+NEW_VERSION_VERSION = Version %s
+# show the number of words of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_WORDS = %s words
+# show the number of images of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_IMAGES = %s images
+# show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)
+# (FORMAT: STRING) 
+BOOK_COUNT_STORIES = %s stories
+# the file menu
+# (FORMAT: STRING) 
+MENU_FILE = File
+# the file/exit menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXIT = Exit
+# the file/import_file menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_FILE = Import File...
+# the file/import_url menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_URL = Import URL...
+# the file/export menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXPORT = Save as...
+# the file/move to menu button
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO = Move to
+# the file/set author menu button
+# (FORMAT: STRING) 
+MENU_FILE_SET_AUTHOR = Set author
+# the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_TYPE = New source...
+# the file/move to/new author menu button, that will trigger a dialogue to create a new author
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_AUTHOR = New author...
+# the file/rename menu item, that will trigger a dialogue to ask for a new title for the story
+# (FORMAT: STRING) 
+MENU_FILE_RENAME = Rename...
+# the file/Properties menu item, that will trigger a dialogue to show the properties of the story
+# (FORMAT: STRING) 
+MENU_FILE_PROPERTIES = Properties
+# the file/open menu item, that will open the story or fake-story (an author or a source/type)
+# (FORMAT: STRING) 
+MENU_FILE_OPEN = Open
+# the edit menu
+# (FORMAT: STRING) 
+MENU_EDIT = Edit
+# the edit/send to cache menu button, to download the story into the cache if not already done
+# (FORMAT: STRING) 
+MENU_EDIT_DOWNLOAD_TO_CACHE = Download to cache 
+# the clear cache menu button, to clear the cache for a single book
+# (FORMAT: STRING) 
+MENU_EDIT_CLEAR_CACHE = Clear cache
+# the edit/redownload menu button, to download the latest version of the book
+# (FORMAT: STRING) 
+MENU_EDIT_REDOWNLOAD = Redownload
+# the edit/delete menu button
+# (FORMAT: STRING) 
+MENU_EDIT_DELETE = Delete
+# the edit/Set as cover for source menu button
+# (FORMAT: STRING) 
+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
+# (FORMAT: STRING) 
+MENU_VIEW_WCOUNT = Word count
+# the view/author menu button, to show the author as secondary info
+# (FORMAT: STRING) 
+MENU_VIEW_AUTHOR = Author
+# the sources menu, to select the books from a specific source; also used as a title for the source books
+# (FORMAT: STRING) 
+MENU_SOURCES = Sources
+# the authors menu, to select the books of a specific author; also used as a title for the author books
+# (FORMAT: STRING) 
+MENU_AUTHORS = Authors
+# the options menu, to configure Fanfix from the GUI
+# (FORMAT: STRING) 
+MENU_OPTIONS = Options
+# a special menu button to select all the sources/types or authors, by group (one book = one group)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_GROUPED = All
+# a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_LISTING = Listing
+# a special menu button to select the books without author
+# (FORMAT: STRING) 
+MENU_AUTHORS_UNKNOWN = [unknown]
+# progress bar caption for the 'reload books' step of all outOfUi operations
+# (FORMAT: STRING) 
+PROGRESS_OUT_OF_UI_RELOAD_BOOKS = Reload books
+# progress bar caption for the 'change source' step of the ReDownload operation
+# (FORMAT: STRING) 
+PROGRESS_CHANGE_SOURCE = Change the source of the book to %s
+# default description if the error is not known
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS = An error occured when contacting the library
+# library access not allowed
+# (FORMAT: STRING) 
+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
+# the library is out of commission
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_UNAVAILABLE = Library currently unavailable
+# cannot open the book, internal or external viewer
+# (FORMAT: STRING) 
+ERROR_CANNOT_OPEN = Cannot open the selected book
+# URL is not supported by Fanfix
+# (FORMAT: STRING) 
+ERROR_URL_NOT_SUPPORTED = URL not supported: %s
+# cannot import the URL
+# (FORMAT: STRING) 
+ERROR_URL_IMPORT_FAILED = Failed to import %s:\n\
+%s
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_UNNAMED = &nbsp;&nbsp;<B>Chapter <SPAN COLOR='#7777DD'>%d</SPAN>/%d</B>
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_NAMED = &nbsp;&nbsp;<B>Chapter <SPAN COLOR='#7777DD'>%d</SPAN>/%d</B>: %s
+# (NO html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+IMAGE_PROGRESSION = Image %d / %d
diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties
new file mode 100644 (file)
index 0000000..1ede37c
--- /dev/null
@@ -0,0 +1,199 @@
+# français (fr) resources_gui translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# the title of the main window of Fanfix, the library
+# (FORMAT: STRING) 
+TITLE_LIBRARY = Fanfix %s
+# the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)
+# (FORMAT: STRING) 
+TITLE_LIBRARY_WITH_NAME = Fanfix %s
+# the title of the configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG = Configuration de Fanfix
+# the subtitle of the configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG = C'est ici que vous pouvez configurer les options du programme.
+# the title of the UI configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG_UI = Configuration de l'interface
+# the subtitle of the UI configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG_UI = C'est ici que vous pouvez configurer les options de l'apparence de l'application.
+# the title of the 'save to/export to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_SAVE = Sauver
+# the title of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_MOVE_TO = Déplacer le livre
+# the subtitle of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_MOVE_TO = Déplacer vers :
+# the title of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_DELETE = Supprimer le livre
+# the subtitle of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_DELETE = Supprimer %s : %s
+# the title of the 'library error' dialogue
+# (FORMAT: STRING) 
+TITLE_ERROR_LIBRARY = Erreur avec la librairie
+# the title of the 'import URL' dialogue
+# (FORMAT: STRING) 
+TITLE_IMPORT_URL = Importer depuis une URL
+# the subtitle of the 'import URL' dialogue
+# (FORMAT: STRING) 
+SUBTITLE_IMPORT_URL = L'URL du livre à importer
+# the title of general error dialogues
+# (FORMAT: STRING) 
+TITLE_ERROR = Error
+# the title of a story for the properties dialogue, the viewers...
+# (FORMAT: STRING) 
+TITLE_STORY = %s: %s
+# HTML text used to notify of a new version
+# (FORMAT: STRING) 
+NEW_VERSION_AVAILABLE = Une nouvelle version du programme est disponible sur %s
+# text used as title for the update dialogue
+# (FORMAT: STRING) 
+NEW_VERSION_TITLE = Mise-à-jour disponible
+# HTML text used to specify a newer version title and number, used for each version newer than the current one
+# (FORMAT: STRING) 
+NEW_VERSION_VERSION = Version %s
+# show the number of words of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_WORDS = %s mots
+# show the number of images of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_IMAGES = %s images
+# show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)
+# (FORMAT: STRING) 
+BOOK_COUNT_STORIES = %s livres
+# the file menu
+# (FORMAT: STRING) 
+MENU_FILE = Fichier
+# the file/exit menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXIT = Quiter
+# the file/import_file menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_FILE = Importer un fichier...
+# the file/import_url menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_URL = Importer une URL...
+# the file/export menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXPORT = Sauver sous...
+# the file/move to menu button
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO = Déplacer vers
+# the file/set author menu button
+# (FORMAT: STRING) 
+MENU_FILE_SET_AUTHOR = Changer l'auteur
+# the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_TYPE = Nouvelle source...
+# the file/move to/new author menu button, that will trigger a dialogue to create a new author
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_AUTHOR = Nouvel auteur...
+# the file/rename menu item, that will trigger a dialogue to ask for a new title for the story
+# (FORMAT: STRING) 
+MENU_FILE_RENAME = Renommer...
+# the file/Properties menu item, that will trigger a dialogue to show the properties of the story
+# (FORMAT: STRING) 
+MENU_FILE_PROPERTIES = Propriétés
+# the file/open menu item, that will open the story or fake-story (an author or a source/type)
+# (FORMAT: STRING) 
+MENU_FILE_OPEN = Ouvrir
+# the edit menu
+# (FORMAT: STRING) 
+MENU_EDIT = Edition
+# the edit/send to cache menu button, to download the story into the cache if not already done
+# (FORMAT: STRING) 
+MENU_EDIT_DOWNLOAD_TO_CACHE = Charger en cache 
+# the clear cache menu button, to clear the cache for a single book
+# (FORMAT: STRING) 
+MENU_EDIT_CLEAR_CACHE = Nettoyer le cache
+# the edit/redownload menu button, to download the latest version of the book
+# (FORMAT: STRING) 
+MENU_EDIT_REDOWNLOAD = Re-downloader
+# the edit/delete menu button
+# (FORMAT: STRING) 
+MENU_EDIT_DELETE = Supprimer
+# the edit/Set as cover for source menu button
+# (FORMAT: STRING) 
+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
+# (FORMAT: STRING) 
+MENU_VIEW_WCOUNT = Nombre de mots
+# the view/author menu button, to show the author as secondary info
+# (FORMAT: STRING) 
+MENU_VIEW_AUTHOR = Auteur
+# the sources menu, to select the books from a specific source; also used as a title for the source books
+# (FORMAT: STRING) 
+MENU_SOURCES = Sources
+# the authors menu, to select the books of a specific author; also used as a title for the author books
+# (FORMAT: STRING) 
+MENU_AUTHORS = Auteurs
+# the options menu, to configure Fanfix from the GUI
+# (FORMAT: STRING) 
+MENU_OPTIONS = Options
+# a special menu button to select all the sources/types or authors, by group (one book = one group)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_GROUPED = Tout
+# a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_LISTING = Listing
+# a special menu button to select the books without author
+# (FORMAT: STRING) 
+MENU_AUTHORS_UNKNOWN = [inconnu]
+# progress bar caption for the 'reload books' step of all outOfUi operations
+# (FORMAT: STRING) 
+PROGRESS_OUT_OF_UI_RELOAD_BOOKS = Recharger les livres
+# progress bar caption for the 'change source' step of the ReDownload operation
+# (FORMAT: STRING) 
+PROGRESS_CHANGE_SOURCE = Change la source du livre en %s
+# default description if the error is not known
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS = Une erreur est survenue en contactant la librairie
+# library access not allowed
+# (FORMAT: STRING) 
+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
+# the library is out of commission
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_UNAVAILABLE = Librairie indisponible
+# cannot open the book, internal or external viewer
+# (FORMAT: STRING) 
+ERROR_CANNOT_OPEN = Impossible d'ouvrir le livre sélectionné
+# URL is not supported by Fanfix
+# (FORMAT: STRING) 
+ERROR_URL_NOT_SUPPORTED = URL non supportée : %s
+# cannot import the URL
+# (FORMAT: STRING) 
+ERROR_URL_IMPORT_FAILED = Erreur lors de l'import de %s:\n\
+%s
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_UNNAMED = &nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_NAMED = &nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>: %s
+# (NO html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+IMAGE_PROGRESSION = Image %d / %d
diff --git a/src/be/nikiroo/fanfix/bundles/ui_description.properties b/src/be/nikiroo/fanfix/bundles/ui_description.properties
new file mode 100644 (file)
index 0000000..5cb2a9f
--- /dev/null
@@ -0,0 +1,35 @@
+# United Kingdom (en_GB) UI configuration options description translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# The directory where to store temporary files, defaults to directory 'tmp.reader' in the config directory (usually $HOME/.fanfix)
+# (FORMAT: DIRECTORY) absolute path, $HOME variable supported, / is always accepted as dir separator
+CACHE_DIR_LOCAL_READER = The directory where to store temporary files, defaults to directory 'tmp.reader' in the config directory (usually $HOME/.fanfix) -- this is an absolute path, $HOME variable supported, / is always accepted as dir separator
+# The type of output for the GUI Reader for non-images documents
+# (FORMAT: COMBO_LIST) One of the known output type
+# ALLOWED VALUES: "INFO_TEXT" "EPUB" "HTML" "TEXT"
+GUI_NON_IMAGES_DOCUMENT_TYPE = 
+# The type of output for the GUI Reader for images documents
+# (FORMAT: COMBO_LIST) 
+# ALLOWED VALUES: "CBZ" "HTML"
+GUI_IMAGES_DOCUMENT_TYPE = 
+# Use the internal reader for images documents -- this is TRUE by default
+# (FORMAT: BOOLEAN) 
+IMAGES_DOCUMENT_USE_INTERNAL_READER = 
+# The command launched for images documents -- default to the system default for the current file type
+# (FORMAT: STRING) A command to start
+IMAGES_DOCUMENT_READER = 
+# Use the internal reader for non images documents -- this is TRUE by default
+# (FORMAT: BOOLEAN) 
+NON_IMAGES_DOCUMENT_USE_INTERNAL_READER = 
+# The command launched for non images documents -- default to the system default for the current file type
+# (FORMAT: STRING) A command to start
+NON_IMAGES_DOCUMENT_READER = 
+# The background colour if you don't want the default system one
+# (FORMAT: COLOR) 
+BACKGROUND_COLOR = 
diff --git a/src/be/nikiroo/fanfix/data/Chapter.java b/src/be/nikiroo/fanfix/data/Chapter.java
new file mode 100644 (file)
index 0000000..d490058
--- /dev/null
@@ -0,0 +1,154 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * A chapter in the story (or the resume/description).
+ * 
+ * @author niki
+ */
+public class Chapter implements Iterable<Paragraph>, Cloneable, Serializable {
+       private static final long serialVersionUID = 1L;
+       
+       private String name;
+       private int number;
+       private List<Paragraph> paragraphs = new ArrayList<Paragraph>();
+       private List<Paragraph> empty = new ArrayList<Paragraph>();
+       private long words;
+
+       /**
+        * Empty constructor, not to use.
+        */
+       @SuppressWarnings("unused")
+       private Chapter() {
+               // for serialisation purposes
+       }
+
+       /**
+        * Create a new {@link Chapter} with the given information.
+        * 
+        * @param number
+        *            the chapter number, or 0 for the description/resume.
+        * @param name
+        *            the chapter name
+        */
+       public Chapter(int number, String name) {
+               this.number = number;
+               this.name = name;
+       }
+
+       /**
+        * The chapter name.
+        * 
+        * @return the name
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * The chapter name.
+        * 
+        * @param name
+        *            the name to set
+        */
+       public void setName(String name) {
+               this.name = name;
+       }
+
+       /**
+        * The chapter number, or 0 for the description/resume.
+        * 
+        * @return the number
+        */
+       public int getNumber() {
+               return number;
+       }
+
+       /**
+        * The chapter number, or 0 for the description/resume.
+        * 
+        * @param number
+        *            the number to set
+        */
+       public void setNumber(int number) {
+               this.number = number;
+       }
+
+       /**
+        * The included paragraphs.
+        * 
+        * @return the paragraphs
+        */
+       public List<Paragraph> getParagraphs() {
+               return paragraphs;
+       }
+
+       /**
+        * The included paragraphs.
+        * 
+        * @param paragraphs
+        *            the paragraphs to set
+        */
+       public void setParagraphs(List<Paragraph> paragraphs) {
+               this.paragraphs = paragraphs;
+       }
+
+       /**
+        * Get an iterator on the {@link Paragraph}s.
+        */
+       @Override
+       public Iterator<Paragraph> iterator() {
+               return paragraphs == null ? empty.iterator() : paragraphs.iterator();
+       }
+
+       /**
+        * The number of words (or images) in this {@link Chapter}.
+        * 
+        * @return the number of words
+        */
+       public long getWords() {
+               return words;
+       }
+
+       /**
+        * The number of words (or images) in this {@link Chapter}.
+        * 
+        * @param words
+        *            the number of words to set
+        */
+       public void setWords(long words) {
+               this.words = words;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               return "Chapter " + number + ": " + name;
+       }
+
+       @Override
+       public Chapter clone() {
+               Chapter chap = null;
+               try {
+                       chap = (Chapter) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               if (paragraphs != null) {
+                       chap.paragraphs = new ArrayList<Paragraph>();
+                       for (Paragraph para : paragraphs) {
+                               chap.paragraphs.add(para.clone());
+                       }
+               }
+
+               return chap;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/MetaData.java b/src/be/nikiroo/fanfix/data/MetaData.java
new file mode 100644 (file)
index 0000000..2c40beb
--- /dev/null
@@ -0,0 +1,484 @@
+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<MetaData>, Serializable {
+       private static final long serialVersionUID = 1L;
+
+       private String title;
+       private String author;
+       private String date;
+       private Chapter resume;
+       private List<String> tags;
+       private Image cover;
+       private String subject;
+       private String source;
+       private String url;
+       private String uuid;
+       private String luid;
+       private String lang;
+       private String publisher;
+       private String type;
+       private boolean imageDocument;
+       private long words;
+       private String creationDate;
+       private boolean fakeCover;
+
+       /**
+        * The title of the story.
+        * 
+        * @return the title
+        */
+       public String getTitle() {
+               return title;
+       }
+
+       /**
+        * The title of the story.
+        * 
+        * @param title
+        *            the title to set
+        */
+       public void setTitle(String title) {
+               this.title = title;
+       }
+
+       /**
+        * The author of the story.
+        * 
+        * @return the author
+        */
+       public String getAuthor() {
+               return author;
+       }
+
+       /**
+        * The author of the story.
+        * 
+        * @param author
+        *            the author to set
+        */
+       public void setAuthor(String author) {
+               this.author = author;
+       }
+
+       /**
+        * The story publication date.
+        * 
+        * @return the date
+        */
+       public String getDate() {
+               return date;
+       }
+
+       /**
+        * The story publication date.
+        * 
+        * @param date
+        *            the date to set
+        */
+       public void setDate(String date) {
+               this.date = date;
+       }
+
+       /**
+        * The tags associated with this story.
+        * 
+        * @return the tags
+        */
+       public List<String> getTags() {
+               return tags;
+       }
+
+       /**
+        * The tags associated with this story.
+        * 
+        * @param tags
+        *            the tags to set
+        */
+       public void setTags(List<String> tags) {
+               this.tags = tags;
+       }
+
+       /**
+        * The story resume (a.k.a. description).
+        * <p>
+        * This can be NULL if we don't have a resume for this {@link Story}.
+        * 
+        * @return the resume
+        */
+       public Chapter getResume() {
+               return resume;
+       }
+
+       /**
+        * The story resume (a.k.a. description).
+        * 
+        * @param resume
+        *            the resume to set
+        */
+       public void setResume(Chapter resume) {
+               this.resume = resume;
+       }
+
+       /**
+        * The cover image of the story if any (can be NULL).
+        * 
+        * @return the cover
+        */
+       public Image getCover() {
+               return cover;
+       }
+
+       /**
+        * The cover image of the story if any (can be NULL).
+        * 
+        * @param cover
+        *            the cover to set
+        */
+       public void setCover(Image cover) {
+               this.cover = cover;
+       }
+
+       /**
+        * The subject of the story (or instance, if it is a fanfiction, what is the
+        * original work; if it is a technical text, what is the technical
+        * subject...).
+        * 
+        * @return the subject
+        */
+       public String getSubject() {
+               return subject;
+       }
+
+       /**
+        * The subject of the story (for instance, if it is a fanfiction, what is
+        * the original work; if it is a technical text, what is the technical
+        * subject...).
+        * 
+        * @param subject
+        *            the subject to set
+        */
+       public void setSubject(String subject) {
+               this.subject = subject;
+       }
+
+       /**
+        * The source of this story (which online library it was downloaded from).
+        * 
+        * @return the source
+        */
+       public String getSource() {
+               return source;
+       }
+
+       /**
+        * The source of this story (which online library it was downloaded from).
+        * 
+        * @param source
+        *            the source to set
+        */
+       public void setSource(String source) {
+               this.source = source;
+       }
+
+       /**
+        * The original URL from which this {@link Story} was imported.
+        * 
+        * @return the url
+        */
+       public String getUrl() {
+               return url;
+       }
+
+       /**
+        * The original URL from which this {@link Story} was imported.
+        * 
+        * @param url
+        *            the new url to set
+        */
+       public void setUrl(String url) {
+               this.url = url;
+       }
+
+       /**
+        * A unique value representing the story (it is often a URL).
+        * 
+        * @return the uuid
+        */
+       public String getUuid() {
+               return uuid;
+       }
+
+       /**
+        * A unique value representing the story (it is often a URL).
+        * 
+        * @param uuid
+        *            the uuid to set
+        */
+       public void setUuid(String uuid) {
+               this.uuid = uuid;
+       }
+
+       /**
+        * A unique value representing the story in the local library.
+        * 
+        * @return the luid
+        */
+       public String getLuid() {
+               return luid;
+       }
+
+       /**
+        * A unique value representing the story in the local library.
+        * 
+        * @param luid
+        *            the luid to set
+        */
+       public void setLuid(String luid) {
+               this.luid = luid;
+       }
+
+       /**
+        * The 2-letter code language of this story.
+        * 
+        * @return the lang
+        */
+       public String getLang() {
+               return lang;
+       }
+
+       /**
+        * The 2-letter code language of this story.
+        * 
+        * @param lang
+        *            the lang to set
+        */
+       public void setLang(String lang) {
+               this.lang = lang;
+       }
+
+       /**
+        * The story publisher (other the same as the source).
+        * 
+        * @return the publisher
+        */
+       public String getPublisher() {
+               return publisher;
+       }
+
+       /**
+        * The story publisher (other the same as the source).
+        * 
+        * @param publisher
+        *            the publisher to set
+        */
+       public void setPublisher(String publisher) {
+               this.publisher = publisher;
+       }
+
+       /**
+        * The output type this {@link Story} is in.
+        * 
+        * @return the type the type
+        */
+       public String getType() {
+               return type;
+       }
+
+       /**
+        * The output type this {@link Story} is in.
+        * 
+        * @param type
+        *            the new type to set
+        */
+       public void setType(String type) {
+               this.type = type;
+       }
+
+       /**
+        * Document catering mostly to image files.
+        * 
+        * @return the imageDocument state
+        */
+       public boolean isImageDocument() {
+               return imageDocument;
+       }
+
+       /**
+        * Document catering mostly to image files.
+        * 
+        * @param imageDocument
+        *            the imageDocument state to set
+        */
+       public void setImageDocument(boolean imageDocument) {
+               this.imageDocument = imageDocument;
+       }
+
+       /**
+        * The number of words in the related {@link Story}.
+        * 
+        * @return the number of words
+        */
+       public long getWords() {
+               return words;
+       }
+
+       /**
+        * The number of words in the related {@link Story}.
+        * 
+        * @param words
+        *            the number of words to set
+        */
+       public void setWords(long words) {
+               this.words = words;
+       }
+
+       /**
+        * The (Fanfix) {@link Story} creation date.
+        * 
+        * @return the creationDate
+        */
+       public String getCreationDate() {
+               return creationDate;
+       }
+
+       /**
+        * The (Fanfix) {@link Story} creation date.
+        * 
+        * @param creationDate
+        *            the creationDate to set
+        */
+       public void setCreationDate(String creationDate) {
+               this.creationDate = creationDate;
+       }
+
+       /**
+        * The cover in this {@link MetaData} object is "fake", in the sens that it
+        * comes from the actual content images.
+        * 
+        * @return TRUE for a fake cover
+        */
+       public boolean isFakeCover() {
+               return fakeCover;
+       }
+
+       /**
+        * The cover in this {@link MetaData} object is "fake", in the sens that it
+        * comes from the actual content images
+        * 
+        * @param fakeCover
+        *            TRUE for a fake cover
+        */
+       public void setFakeCover(boolean fakeCover) {
+               this.fakeCover = fakeCover;
+       }
+
+       @Override
+       public int compareTo(MetaData o) {
+               if (o == null) {
+                       return 1;
+               }
+
+               String id = (getUuid() == null ? "" : getUuid())
+                               + (getLuid() == null ? "" : getLuid());
+               String oId = (getUuid() == null ? "" : o.getUuid())
+                               + (o.getLuid() == null ? "" : o.getLuid());
+
+               return id.compareTo(oId);
+       }
+
+       @Override
+       public boolean equals(Object obj) {
+               if (!(obj instanceof MetaData)) {
+                       return false;
+               }
+
+               return compareTo((MetaData) obj) == 0;
+       }
+
+       @Override
+       public int hashCode() {
+               String uuid = getUuid();
+               if (uuid == null) {
+                       uuid = "" + title + author + source;
+               }
+
+               return uuid.hashCode();
+       }
+
+       @Override
+       public MetaData clone() {
+               MetaData meta = null;
+               try {
+                       meta = (MetaData) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               if (tags != null) {
+                       meta.tags = new ArrayList<String>(tags);
+               }
+
+               if (resume != null) {
+                       meta.resume = resume.clone();
+               }
+
+               return meta;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        * <p>
+        * This is not efficient, nor intended to be.
+        */
+       @Override
+       public String toString() {
+               String title = "";
+               if (getTitle() != null) {
+                       title = getTitle();
+               }
+
+               StringBuilder tags = new StringBuilder();
+               if (getTags() != null) {
+                       for (String tag : getTags()) {
+                               if (tags.length() > 0) {
+                                       tags.append(", ");
+                               }
+                               tags.append(tag);
+                       }
+               }
+
+               String resume = "";
+               if (getResume() != null) {
+                       for (Paragraph para : getResume()) {
+                               resume += "\n\t";
+                               resume += para.toString().substring(0,
+                                               Math.min(para.toString().length(), 120));
+                       }
+                       resume += "\n";
+               }
+
+               String cover = "none";
+               if (getCover() != null) {
+                       cover = StringUtils.formatNumber(getCover().getSize())
+                                       + "bytes";
+               }
+
+               return String.format(
+                               "Meta %s:\n\tTitle: [%s]\n\tAuthor: [%s]\n\tDate: [%s]\n\tTags: [%s]"
+                                               + "\n\tResume: [%s]\n\tCover: [%s]", luid, title,
+                               getAuthor(), getDate(), tags.toString(), resume, cover);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/Paragraph.java b/src/be/nikiroo/fanfix/data/Paragraph.java
new file mode 100644 (file)
index 0000000..9adc51c
--- /dev/null
@@ -0,0 +1,172 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+
+import be.nikiroo.utils.Image;
+
+/**
+ * A paragraph in a chapter of the story.
+ * 
+ * @author niki
+ */
+public class Paragraph implements Cloneable, Serializable {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * A paragraph type, that will dictate how the paragraph will be handled.
+        * 
+        * @author niki
+        */
+       public enum ParagraphType {
+               /** Normal paragraph (text) */
+               NORMAL,
+               /** Blank line */
+               BLANK,
+               /** A Break paragraph, i.e.: HR (Horizontal Line) or '* * *' or whatever */
+               BREAK,
+               /** Quotation (dialogue) */
+               QUOTE,
+               /** An image (no text) */
+               IMAGE, ;
+
+               /**
+                * This paragraph type is of a text kind (quote or not).
+                * 
+                * @param allowEmpty
+                *            allow empty text as text, too (blanks, breaks...)
+                * @return TRUE if it is
+                */
+               public boolean isText(boolean allowEmpty) {
+                       return (this == NORMAL || this == QUOTE)
+                                       || (allowEmpty && (this == BLANK || this == BREAK));
+               }
+       }
+
+       private ParagraphType type;
+       private String content;
+       private Image contentImage;
+       private long words;
+
+       /**
+        * Empty constructor, not to use.
+        */
+       @SuppressWarnings("unused")
+       private Paragraph() {
+               // for serialisation purposes
+       }
+
+       /**
+        * Create a new {@link Paragraph} with the given image.
+        * 
+        * @param contentImage
+        *            the image
+        */
+       public Paragraph(Image contentImage) {
+               this(ParagraphType.IMAGE, null, 1);
+               this.contentImage = contentImage;
+       }
+
+       /**
+        * Create a new {@link Paragraph} with the given values.
+        * 
+        * @param type
+        *            the {@link ParagraphType}
+        * @param content
+        *            the content of this paragraph
+        * @param words
+        *            the number of words (or images)
+        */
+       public Paragraph(ParagraphType type, String content, long words) {
+               this.type = type;
+               this.content = content;
+               this.words = words;
+       }
+
+       /**
+        * The {@link ParagraphType}.
+        * 
+        * @return the type
+        */
+       public ParagraphType getType() {
+               return type;
+       }
+
+       /**
+        * The {@link ParagraphType}.
+        * 
+        * @param type
+        *            the type to set
+        */
+       public void setType(ParagraphType type) {
+               this.type = type;
+       }
+
+       /**
+        * The content of this {@link Paragraph} if it is not an image.
+        * 
+        * @return the content
+        */
+       public String getContent() {
+               return content;
+       }
+
+       /**
+        * The content of this {@link Paragraph}.
+        * 
+        * @param content
+        *            the content to set
+        */
+       public void setContent(String content) {
+               this.content = content;
+       }
+
+       /**
+        * The content of this {@link Paragraph} if it is an image.
+        * 
+        * @return the content
+        */
+       public Image getContentImage() {
+               return contentImage;
+       }
+
+       /**
+        * The number of words (or images) in this {@link Paragraph}.
+        * 
+        * @return the number of words
+        */
+       public long getWords() {
+               return words;
+       }
+
+       /**
+        * The number of words (or images) in this {@link Paragraph}.
+        * 
+        * @param words
+        *            the number of words to set
+        */
+       public void setWords(long words) {
+               this.words = words;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               return String.format("%s: [%s]", "" + type, content == null ? "N/A"
+                               : content);
+       }
+
+       @Override
+       public Paragraph clone() {
+               Paragraph para = null;
+               try {
+                       para = (Paragraph) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               return para;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/Story.java b/src/be/nikiroo/fanfix/data/Story.java
new file mode 100644 (file)
index 0000000..fc3f909
--- /dev/null
@@ -0,0 +1,101 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * The main data class, where the whole story resides.
+ * 
+ * @author niki
+ */
+public class Story implements Iterable<Chapter>, Cloneable, Serializable {
+       private static final long serialVersionUID = 1L;
+       
+       private MetaData meta;
+       private List<Chapter> chapters = new ArrayList<Chapter>();
+       private List<Chapter> empty = new ArrayList<Chapter>();
+
+       /**
+        * The metadata about this {@link Story}.
+        * 
+        * @return the meta
+        */
+       public MetaData getMeta() {
+               return meta;
+       }
+
+       /**
+        * The metadata about this {@link Story}.
+        * 
+        * @param meta
+        *            the meta to set
+        */
+       public void setMeta(MetaData meta) {
+               this.meta = meta;
+       }
+
+       /**
+        * The chapters of the story.
+        * 
+        * @return the chapters
+        */
+       public List<Chapter> getChapters() {
+               return chapters;
+       }
+
+       /**
+        * The chapters of the story.
+        * 
+        * @param chapters
+        *            the chapters to set
+        */
+       public void setChapters(List<Chapter> chapters) {
+               this.chapters = chapters;
+       }
+
+       /**
+        * Get an iterator on the {@link Chapter}s.
+        */
+       @Override
+       public Iterator<Chapter> iterator() {
+               return chapters == null ? empty.iterator() : chapters.iterator();
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        * <p>
+        * This is not efficient, nor intended to be.
+        */
+       @Override
+       public String toString() {
+               if (getMeta() != null)
+                       return "Story: [\n" + getMeta().toString() + "\n]";
+               return "Story: [ no metadata found ]";
+       }
+
+       @Override
+       public Story clone() {
+               Story story = null;
+               try {
+                       story = (Story) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               if (meta != null) {
+                       story.meta = meta.clone();
+               }
+
+               if (chapters != null) {
+                       story.chapters = new ArrayList<Chapter>();
+                       for (Chapter chap : chapters) {
+                               story.chapters.add(chap.clone());
+                       }
+               }
+
+               return story;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/package-info.java b/src/be/nikiroo/fanfix/data/package-info.java
new file mode 100644 (file)
index 0000000..57db36b
--- /dev/null
@@ -0,0 +1,9 @@
+/**
+ * This package contains the data structure used by the program, without the 
+ * logic behind them.
+ * <p>
+ * All the classes inside are serializable.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.data;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/BasicLibrary.java b/src/be/nikiroo/fanfix/library/BasicLibrary.java
new file mode 100644 (file)
index 0000000..099859d
--- /dev/null
@@ -0,0 +1,1086 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Manage a library of Stories: import, export, list, modify.
+ * <p>
+ * Each {@link Story} object will be associated with a (local to the library)
+ * unique ID, the LUID, which will be used to identify the {@link Story}.
+ * <p>
+ * Most of the {@link BasicLibrary} functions work on a partial (cover
+ * <b>MAY</b> not be included) {@link MetaData} object.
+ * 
+ * @author niki
+ */
+abstract public class BasicLibrary {
+       /**
+        * A {@link BasicLibrary} status.
+        * 
+        * @author niki
+        */
+       public enum Status {
+               /** The library is ready and r/w. */
+               READ_WRITE,
+               /** The library is ready, but read-only. */
+               READ_ONLY,
+               /** The library is invalid (not correctly set up). */
+               INVALID,
+               /** You are not allowed to access this library. */
+               UNAUTHORIZED,
+               /** The library is currently out of commission. */
+               UNAVAILABLE;
+
+               /**
+                * The library is available (you can query it).
+                * <p>
+                * It does <b>not</b> specify if it is read-only or not.
+                * 
+                * @return TRUE if it is
+                */
+               public boolean isReady() {
+                       return (this == READ_WRITE || this == READ_ONLY);
+               }
+
+               /**
+                * This library can be modified (= you are allowed to modify it).
+                * 
+                * @return TRUE if it is
+                */
+               public boolean isWritable() {
+                       return (this == READ_WRITE);
+               }
+       }
+
+       /**
+        * Return a name for this library (the UI may display this).
+        * <p>
+        * Must not be NULL.
+        * 
+        * @return the name, or an empty {@link String} if none
+        */
+       public String getLibraryName() {
+               return "";
+       }
+
+       /**
+        * The library status.
+        * 
+        * @return the current status
+        */
+       public Status getStatus() {
+               return Status.READ_WRITE;
+       }
+
+       /**
+        * Retrieve the main {@link File} corresponding to the given {@link Story},
+        * which can be passed to an external reader or instance.
+        * <p>
+        * Do <b>NOT</b> alter this file.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the corresponding {@link Story}
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract File getFile(String luid, Progress pg) throws IOException;
+
+       /**
+        * Return the cover image associated to this story.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * 
+        * @return the cover image
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract Image getCover(String luid) throws IOException;
+
+       /**
+        * Return the cover image associated to this source.
+        * <p>
+        * By default, return the custom cover if any, and if not, return the cover
+        * of the first story with this source.
+        * 
+        * @param source
+        *            the source
+        * 
+        * @return the cover image or NULL
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Image getSourceCover(String source) throws IOException {
+               Image custom = getCustomSourceCover(source);
+               if (custom != null) {
+                       return custom;
+               }
+
+               List<MetaData> metas = getListBySource(source);
+               if (metas.size() > 0) {
+                       return getCover(metas.get(0).getLuid());
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the cover image associated to this author.
+        * <p>
+        * By default, return the custom cover if any, and if not, return the cover
+        * of the first story with this author.
+        * 
+        * @param author
+        *            the author
+        * 
+        * @return the cover image or NULL
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Image getAuthorCover(String author) throws IOException {
+               Image custom = getCustomAuthorCover(author);
+               if (custom != null) {
+                       return custom;
+               }
+
+               List<MetaData> metas = getListByAuthor(author);
+               if (metas.size() > 0) {
+                       return getCover(metas.get(0).getLuid());
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the custom cover image associated to this source.
+        * <p>
+        * By default, return NULL.
+        * 
+        * @param source
+        *            the source to look for
+        * 
+        * @return the custom cover or NULL if none
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       @SuppressWarnings("unused")
+       public Image getCustomSourceCover(String source) throws IOException {
+               return null;
+       }
+
+       /**
+        * Return the custom cover image associated to this author.
+        * <p>
+        * By default, return NULL.
+        * 
+        * @param author
+        *            the author to look for
+        * 
+        * @return the custom cover or NULL if none
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       @SuppressWarnings("unused")
+       public Image getCustomAuthorCover(String author) throws IOException {
+               return null;
+       }
+
+       /**
+        * Set the source cover to the given story cover.
+        * 
+        * @param source
+        *            the source to change
+        * @param luid
+        *            the story LUID
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract void setSourceCover(String source, String luid)
+                       throws IOException;
+
+       /**
+        * Set the author cover to the given story cover.
+        * 
+        * @param author
+        *            the author to change
+        * @param luid
+        *            the story LUID
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract void setAuthorCover(String author, String luid)
+                       throws IOException;
+
+       /**
+        * Return the list of stories (represented by their {@link MetaData}, which
+        * <b>MAY</b> not have the cover included).
+        * 
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the list (can be empty but not NULL)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       protected abstract List<MetaData> getMetas(Progress pg) throws IOException;
+
+       /**
+        * Invalidate the {@link Story} cache (when the content should be re-read
+        * because it was changed).
+        */
+       protected void invalidateInfo() {
+               invalidateInfo(null);
+       }
+
+       /**
+        * Invalidate the {@link Story} cache (when the content is removed).
+        * <p>
+        * All the cache can be deleted if NULL is passed as meta.
+        * 
+        * @param luid
+        *            the LUID of the {@link Story} to clear from the cache, or NULL
+        *            for all stories
+        */
+       protected abstract void invalidateInfo(String luid);
+
+       /**
+        * Invalidate the {@link Story} cache (when the content has changed, but we
+        * already have it) with the new given meta.
+        * 
+        * @param meta
+        *            the {@link Story} to clear from the cache
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       protected abstract void updateInfo(MetaData meta) throws IOException;
+
+       /**
+        * Return the next LUID that can be used.
+        * 
+        * @return the next luid
+        */
+       protected abstract int getNextId();
+
+       /**
+        * Delete the target {@link Story}.
+        * 
+        * @param luid
+        *            the LUID of the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} wa not found
+        */
+       protected abstract void doDelete(String luid) throws IOException;
+
+       /**
+        * Actually save the story to the back-end.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the saved {@link Story} (which may have changed, especially
+        *         regarding the {@link MetaData})
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract Story doSave(Story story, Progress pg)
+                       throws IOException;
+
+       /**
+        * Refresh the {@link BasicLibrary}, that is, make sure all metas are
+        * loaded.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        */
+       public void refresh(Progress pg) {
+               try {
+                       getMetas(pg);
+               } catch (IOException e) {
+                       // We will let it fail later
+               }
+       }
+
+       /**
+        * List all the known types (sources) of stories.
+        * 
+        * @return the sources
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized List<String> getSources() throws IOException {
+               List<String> list = new ArrayList<String>();
+               for (MetaData meta : getMetas(null)) {
+                       String storySource = meta.getSource();
+                       if (!list.contains(storySource)) {
+                               list.add(storySource);
+                       }
+               }
+
+               Collections.sort(list);
+               return list;
+       }
+
+       /**
+        * List all the known types (sources) of stories, grouped by directory
+        * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
+        * <p>
+        * Note that an empty item in the list means a non-grouped source (type) --
+        * e.g., you could have for Source_1:
+        * <ul>
+        * <li><tt></tt>: empty, so source is "Source_1"</li>
+        * <li><tt>a</tt>: empty, so source is "Source_1/a"</li>
+        * <li><tt>b</tt>: empty, so source is "Source_1/b"</li>
+        * </ul>
+        * 
+        * @return the grouped list
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized Map<String, List<String>> getSourcesGrouped()
+                       throws IOException {
+               Map<String, List<String>> map = new TreeMap<String, List<String>>();
+               for (String source : getSources()) {
+                       String name;
+                       String subname;
+
+                       int pos = source.indexOf('/');
+                       if (pos > 0 && pos < source.length() - 1) {
+                               name = source.substring(0, pos);
+                               subname = source.substring(pos + 1);
+
+                       } else {
+                               name = source;
+                               subname = "";
+                       }
+
+                       List<String> list = map.get(name);
+                       if (list == null) {
+                               list = new ArrayList<String>();
+                               map.put(name, list);
+                       }
+                       list.add(subname);
+               }
+
+               return map;
+       }
+
+       /**
+        * List all the known authors of stories.
+        * 
+        * @return the authors
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized List<String> getAuthors() throws IOException {
+               List<String> list = new ArrayList<String>();
+               for (MetaData meta : getMetas(null)) {
+                       String storyAuthor = meta.getAuthor();
+                       if (!list.contains(storyAuthor)) {
+                               list.add(storyAuthor);
+                       }
+               }
+
+               Collections.sort(list);
+               return list;
+       }
+
+       /**
+        * Return the list of authors, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of author is not too high, only one group with an empty
+        * name and all the authors will be returned.
+        * <p>
+        * If not, the authors will be separated into groups:
+        * <ul>
+        * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0-9</tt>: any authors whose name starts with a number</li>
+        * <li><tt>A-C</tt> (for instance): any author whose name starts with
+        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+        * </ul>
+        * Note that the letters used in the groups can vary (except <tt>*</tt> and
+        * <tt>0-9</tt>, which may only be present or not).
+        * 
+        * @return the authors' names, grouped by letter(s)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Map<String, List<String>> getAuthorsGrouped() throws IOException {
+               int MAX = 20;
+
+               Map<String, List<String>> groups = new TreeMap<String, List<String>>();
+               List<String> authors = getAuthors();
+
+               // If all authors fit the max, just report them as is
+               if (authors.size() <= MAX) {
+                       groups.put("", authors);
+                       return groups;
+               }
+
+               // Create groups A to Z, which can be empty here
+               for (char car = 'A'; car <= 'Z'; car++) {
+                       groups.put(Character.toString(car), getAuthorsGroup(authors, car));
+               }
+
+               // Collapse them
+               List<String> keys = new ArrayList<String>(groups.keySet());
+               for (int i = 0; i + 1 < keys.size(); i++) {
+                       String keyNow = keys.get(i);
+                       String keyNext = keys.get(i + 1);
+
+                       List<String> now = groups.get(keyNow);
+                       List<String> next = groups.get(keyNext);
+
+                       int currentTotal = now.size() + next.size();
+                       if (currentTotal <= MAX) {
+                               String key = keyNow.charAt(0) + "-"
+                                               + keyNext.charAt(keyNext.length() - 1);
+
+                               List<String> all = new ArrayList<String>();
+                               all.addAll(now);
+                               all.addAll(next);
+
+                               groups.remove(keyNow);
+                               groups.remove(keyNext);
+                               groups.put(key, all);
+
+                               keys.set(i, key); // set the new key instead of key(i)
+                               keys.remove(i + 1); // remove the next, consumed key
+                               i--; // restart at key(i)
+                       }
+               }
+
+               // Add "special" groups
+               groups.put("*", getAuthorsGroup(authors, '*'));
+               groups.put("0-9", getAuthorsGroup(authors, '0'));
+
+               // Prune empty groups
+               keys = new ArrayList<String>(groups.keySet());
+               for (String key : keys) {
+                       if (groups.get(key).isEmpty()) {
+                               groups.remove(key);
+                       }
+               }
+
+               return groups;
+       }
+
+       /**
+        * Get all the authors that start with the given character:
+        * <ul>
+        * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0</tt>: any authors whose name starts with a number</li>
+        * <li><tt>A</tt> (any capital latin letter): any author whose name starts
+        * with <tt>A</tt></li>
+        * </ul>
+        * 
+        * @param authors
+        *            the full list of authors
+        * @param car
+        *            the starting character, <tt>*</tt>, <tt>0</tt> or a capital
+        *            letter
+        * 
+        * @return the authors that fulfil the starting letter
+        */
+       private List<String> getAuthorsGroup(List<String> authors, char car) {
+               List<String> accepted = new ArrayList<String>();
+               for (String author : authors) {
+                       char first = '*';
+                       for (int i = 0; first == '*' && i < author.length(); i++) {
+                               String san = StringUtils.sanitize(author, true, true);
+                               char c = san.charAt(i);
+                               if (c >= '0' && c <= '9') {
+                                       first = '0';
+                               } else if (c >= 'a' && c <= 'z') {
+                                       first = (char) (c - 'a' + 'A');
+                               } else if (c >= 'A' && c <= 'Z') {
+                                       first = c;
+                               }
+                       }
+
+                       if (first == car) {
+                               accepted.add(author);
+                       }
+               }
+
+               return accepted;
+       }
+
+       /**
+        * List all the stories in the {@link BasicLibrary}.
+        * <p>
+        * Cover images <b>MAYBE</b> not included.
+        * 
+        * @return the stories
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized List<MetaData> getList() throws IOException {
+               return getMetas(null);
+       }
+
+       /**
+        * List all the stories of the given source type in the {@link BasicLibrary}
+        * , or all the stories if NULL is passed as a type.
+        * <p>
+        * Cover images not included.
+        * 
+        * @param type
+        *            the type of story to retrieve, or NULL for all
+        * 
+        * @return the stories
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized List<MetaData> getListBySource(String type)
+                       throws IOException {
+               List<MetaData> list = new ArrayList<MetaData>();
+               for (MetaData meta : getMetas(null)) {
+                       String storyType = meta.getSource();
+                       if (type == null || type.equalsIgnoreCase(storyType)) {
+                               list.add(meta);
+                       }
+               }
+
+               Collections.sort(list);
+               return list;
+       }
+
+       /**
+        * List all the stories of the given author in the {@link BasicLibrary}, or
+        * all the stories if NULL is passed as an author.
+        * <p>
+        * Cover images not included.
+        * 
+        * @param author
+        *            the author of the stories to retrieve, or NULL for all
+        * 
+        * @return the stories
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized List<MetaData> getListByAuthor(String author)
+                       throws IOException {
+               List<MetaData> list = new ArrayList<MetaData>();
+               for (MetaData meta : getMetas(null)) {
+                       String storyAuthor = meta.getAuthor();
+                       if (author == null || author.equalsIgnoreCase(storyAuthor)) {
+                               list.add(meta);
+                       }
+               }
+
+               Collections.sort(list);
+               return list;
+       }
+
+       /**
+        * Retrieve a {@link MetaData} corresponding to the given {@link Story},
+        * cover image <b>MAY</b> not be included.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * 
+        * @return the corresponding {@link Story}
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized MetaData getInfo(String luid) throws IOException {
+               if (luid != null) {
+                       for (MetaData meta : getMetas(null)) {
+                               if (luid.equals(meta.getLuid())) {
+                                       return meta;
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Retrieve a specific {@link Story}.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the corresponding {@link Story} or NULL if not found
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized Story getStory(String luid, Progress pg)
+                       throws IOException {
+               Progress pgMetas = new Progress();
+               Progress pgStory = new Progress();
+               if (pg != null) {
+                       pg.setMinMax(0, 100);
+                       pg.addProgress(pgMetas, 10);
+                       pg.addProgress(pgStory, 90);
+               }
+
+               MetaData meta = null;
+               for (MetaData oneMeta : getMetas(pgMetas)) {
+                       if (oneMeta.getLuid().equals(luid)) {
+                               meta = oneMeta;
+                               break;
+                       }
+               }
+
+               pgMetas.done();
+
+               Story story = getStory(luid, meta, pgStory);
+               pgStory.done();
+
+               return story;
+       }
+
+       /**
+        * Retrieve a specific {@link Story}.
+        * 
+        * @param luid
+        *            the meta of the story
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the corresponding {@link Story} or NULL if not found
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized Story getStory(String luid,
+                       @SuppressWarnings("javadoc") MetaData meta, Progress pg)
+                       throws IOException {
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgGet = new Progress();
+               Progress pgProcess = new Progress();
+
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgGet, 1);
+               pg.addProgress(pgProcess, 1);
+
+               Story story = null;
+               File file = getFile(luid, pgGet);
+               pgGet.done();
+               try {
+                       SupportType type = SupportType.valueOfAllOkUC(meta.getType());
+                       URL url = file.toURI().toURL();
+                       if (type != null) {
+                               story = BasicSupport.getSupport(type, url) //
+                                               .process(pgProcess);
+
+                               // Because we do not want to clear the meta cache:
+                               meta.setCover(story.getMeta().getCover());
+                               meta.setResume(story.getMeta().getResume());
+                               story.setMeta(meta);
+                               //
+                       } else {
+                               throw new IOException("Unknown type: " + meta.getType());
+                       }
+               } catch (IOException e) {
+                       // We should not have not-supported files in the
+                       // library
+                       Instance.getTraceHandler().error(
+                                       new IOException(String.format(
+                                                       "Cannot load file of type '%s' from library: %s",
+                                                       meta.getType(), file), e));
+               } finally {
+                       pgProcess.done();
+                       pg.done();
+               }
+
+               return story;
+       }
+
+       /**
+        * Import the {@link Story} at the given {@link URL} into the
+        * {@link BasicLibrary}.
+        * 
+        * @param url
+        *            the {@link URL} to import
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the imported Story {@link MetaData}
+        * 
+        * @throws UnknownHostException
+        *             if the host is not supported
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public MetaData imprt(URL url, Progress pg) throws IOException {
+               if (pg == null)
+                       pg = new Progress();
+
+               pg.setMinMax(0, 1000);
+               Progress pgProcess = new Progress();
+               Progress pgSave = new Progress();
+               pg.addProgress(pgProcess, 800);
+               pg.addProgress(pgSave, 200);
+
+               BasicSupport support = BasicSupport.getSupport(url);
+               if (support == null) {
+                       throw new UnknownHostException("" + url);
+               }
+
+               Story story = save(support.process(pgProcess), pgSave);
+               pg.done();
+
+               return story.getMeta();
+       }
+
+       /**
+        * Import the story from one library to another, and keep the same LUID.
+        * 
+        * @param other
+        *            the other library to import from
+        * @param luid
+        *            the Library UID
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void imprt(BasicLibrary other, String luid, Progress pg)
+                       throws IOException {
+               Progress pgGetStory = new Progress();
+               Progress pgSave = new Progress();
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgGetStory, 1);
+               pg.addProgress(pgSave, 1);
+
+               Story story = other.getStory(luid, pgGetStory);
+               if (story != null) {
+                       story = this.save(story, luid, pgSave);
+                       pg.done();
+               } else {
+                       pg.done();
+                       throw new IOException("Cannot find story in Library: " + luid);
+               }
+       }
+
+       /**
+        * Export the {@link Story} to the given target in the given format.
+        * 
+        * @param luid
+        *            the {@link Story} ID
+        * @param type
+        *            the {@link OutputType} to transform it to
+        * @param target
+        *            the target to save to
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the saved resource (the main saved {@link File})
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File export(String luid, OutputType type, String target, Progress pg)
+                       throws IOException {
+               Progress pgGetStory = new Progress();
+               Progress pgOut = new Progress();
+               if (pg != null) {
+                       pg.setMax(2);
+                       pg.addProgress(pgGetStory, 1);
+                       pg.addProgress(pgOut, 1);
+               }
+
+               BasicOutput out = BasicOutput.getOutput(type, false, false);
+               if (out == null) {
+                       throw new IOException("Output type not supported: " + type);
+               }
+
+               Story story = getStory(luid, pgGetStory);
+               if (story == null) {
+                       throw new IOException("Cannot find story to export: " + luid);
+               }
+
+               return out.process(story, target, pgOut);
+       }
+
+       /**
+        * Save a {@link Story} to the {@link BasicLibrary}.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the same {@link Story}, whose LUID may have changed
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story save(Story story, Progress pg) throws IOException {
+               return save(story, null, pg);
+       }
+
+       /**
+        * Save a {@link Story} to the {@link BasicLibrary} -- the LUID <b>must</b>
+        * be correct, or NULL to get the next free one.
+        * <p>
+        * Will override any previous {@link Story} with the same LUID.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * @param luid
+        *            the <b>correct</b> LUID or NULL to get the next free one
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the same {@link Story}, whose LUID may have changed
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public synchronized Story save(Story story, String luid, Progress pg)
+                       throws IOException {
+
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": saving story " + luid);
+
+               // Do not change the original metadata, but change the original story
+               MetaData meta = story.getMeta().clone();
+               story.setMeta(meta);
+
+               if (luid == null || luid.isEmpty()) {
+                       meta.setLuid(String.format("%03d", getNextId()));
+               } else {
+                       meta.setLuid(luid);
+               }
+
+               if (luid != null && getInfo(luid) != null) {
+                       delete(luid);
+               }
+
+               story = doSave(story, pg);
+
+               updateInfo(story.getMeta());
+
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": story saved (" + luid
+                                               + ")");
+
+               return story;
+       }
+
+       /**
+        * Delete the given {@link Story} from this {@link BasicLibrary}.
+        * 
+        * @param luid
+        *            the LUID of the target {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public synchronized void delete(String luid) throws IOException {
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": deleting story " + luid);
+
+               doDelete(luid);
+               invalidateInfo(luid);
+
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": story deleted (" + luid
+                                               + ")");
+       }
+
+       /**
+        * Change the type (source) of the given {@link Story}.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newSource
+        *            the new source
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       public synchronized void changeSource(String luid, String newSource,
+                       Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               changeSTA(luid, newSource, meta.getTitle(), meta.getAuthor(), pg);
+       }
+
+       /**
+        * Change the title (name) of the given {@link Story}.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newTitle
+        *            the new title
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       public synchronized void changeTitle(String luid, String newTitle,
+                       Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               changeSTA(luid, meta.getSource(), newTitle, meta.getAuthor(), pg);
+       }
+
+       /**
+        * Change the author of the given {@link Story}.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newAuthor
+        *            the new author
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       public synchronized void changeAuthor(String luid, String newAuthor,
+                       Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               changeSTA(luid, meta.getSource(), meta.getTitle(), newAuthor, pg);
+       }
+
+       /**
+        * Change the Source, Title and Author of the {@link Story} in one single
+        * go.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newSource
+        *            the new source
+        * @param newTitle
+        *            the new title
+        * @param newAuthor
+        *            the new author
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       protected synchronized void changeSTA(String luid, String newSource,
+                       String newTitle, String newAuthor, Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               meta.setSource(newSource);
+               meta.setTitle(newTitle);
+               meta.setAuthor(newAuthor);
+               saveMeta(meta, pg);
+
+               invalidateInfo(luid);
+       }
+
+       /**
+        * Save back the current state of the {@link MetaData} (LUID <b>MUST NOT</b>
+        * change) for this {@link Story}.
+        * <p>
+        * By default, delete the old {@link Story} then recreate a new
+        * {@link Story}.
+        * <p>
+        * Note that this behaviour can lead to data loss in case of problems!
+        * 
+        * @param meta
+        *            the new {@link MetaData} (LUID <b>MUST NOT</b> change)
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       protected synchronized void saveMeta(MetaData meta, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgGet = new Progress();
+               Progress pgSet = new Progress();
+               pg.addProgress(pgGet, 50);
+               pg.addProgress(pgSet, 50);
+
+               Story story = getStory(meta.getLuid(), pgGet);
+               if (story == null) {
+                       throw new IOException("Story not found: " + meta.getLuid());
+               }
+
+               // TODO: this is not safe!
+               delete(meta.getLuid());
+               story.setMeta(meta);
+               save(story, meta.getLuid(), pgSet);
+
+               pg.done();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/CacheLibrary.java b/src/be/nikiroo/fanfix/library/CacheLibrary.java
new file mode 100644 (file)
index 0000000..e8743b6
--- /dev/null
@@ -0,0 +1,391 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * This library will cache another pre-existing {@link BasicLibrary}.
+ * 
+ * @author niki
+ */
+public class CacheLibrary extends BasicLibrary {
+       private List<MetaData> metas;
+       private BasicLibrary lib;
+       private LocalLibrary cacheLib;
+
+       /**
+        * Create a cache library around the given one.
+        * <p>
+        * It will return the same result, but those will be saved to disk at the
+        * same time to be fetched quicker the next time.
+        * 
+        * @param cacheDir
+        *            the cache directory where to save the files to disk
+        * @param lib
+        *            the original library to wrap
+        */
+       public CacheLibrary(File cacheDir, BasicLibrary lib) {
+               this.cacheLib = new LocalLibrary(cacheDir, Instance.getUiConfig()
+                               .getString(UiConfig.GUI_NON_IMAGES_DOCUMENT_TYPE), Instance
+                               .getUiConfig().getString(UiConfig.GUI_IMAGES_DOCUMENT_TYPE),
+                               true);
+               this.lib = lib;
+       }
+
+       @Override
+       public String getLibraryName() {
+               return lib.getLibraryName();
+       }
+
+       @Override
+       public Status getStatus() {
+               return lib.getStatus();
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               if (metas == null) {
+                       metas = lib.getMetas(pg);
+               }
+
+               pg.done();
+               return metas;
+       }
+
+       @Override
+       public synchronized MetaData getInfo(String luid) throws IOException {
+               MetaData info = cacheLib.getInfo(luid);
+               if (info == null) {
+                       info = lib.getInfo(luid);
+               }
+
+               return info;
+       }
+
+       @Override
+       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgImport = new Progress();
+               Progress pgGet = new Progress();
+
+               pg.setMinMax(0, 4);
+               pg.addProgress(pgImport, 3);
+               pg.addProgress(pgGet, 1);
+
+               if (!isCached(luid)) {
+                       try {
+                               cacheLib.imprt(lib, luid, pgImport);
+                               updateInfo(cacheLib.getInfo(luid));
+                               pgImport.done();
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(e);
+                       }
+
+                       pgImport.done();
+                       pgGet.done();
+               }
+
+               String type = cacheLib.getOutputType(meta.isImageDocument());
+               MetaData cachedMeta = meta.clone();
+               cachedMeta.setType(type);
+
+               return cacheLib.getStory(luid, cachedMeta, pg);
+       }
+
+       @Override
+       public synchronized File getFile(final String luid, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgGet = new Progress();
+               Progress pgRecall = new Progress();
+
+               pg.setMinMax(0, 5);
+               pg.addProgress(pgGet, 4);
+               pg.addProgress(pgRecall, 1);
+
+               if (!isCached(luid)) {
+                       getStory(luid, pgGet);
+                       pgGet.done();
+               }
+
+               File file = cacheLib.getFile(luid, pgRecall);
+               pgRecall.done();
+
+               pg.done();
+               return file;
+       }
+
+       @Override
+       public Image getCover(final String luid) throws IOException {
+               if (isCached(luid)) {
+                       return cacheLib.getCover(luid);
+               }
+
+               // We could update the cache here, but it's not easy
+               return lib.getCover(luid);
+       }
+
+       @Override
+       public Image getSourceCover(String source) throws IOException {
+               Image custom = getCustomSourceCover(source);
+               if (custom != null) {
+                       return custom;
+               }
+
+               Image cached = cacheLib.getSourceCover(source);
+               if (cached != null) {
+                       return cached;
+               }
+
+               return lib.getSourceCover(source);
+       }
+
+       @Override
+       public Image getAuthorCover(String author) throws IOException {
+               Image custom = getCustomAuthorCover(author);
+               if (custom != null) {
+                       return custom;
+               }
+
+               Image cached = cacheLib.getAuthorCover(author);
+               if (cached != null) {
+                       return cached;
+               }
+
+               return lib.getAuthorCover(author);
+       }
+
+       @Override
+       public Image getCustomSourceCover(String source) throws IOException {
+               Image custom = cacheLib.getCustomSourceCover(source);
+               if (custom == null) {
+                       custom = lib.getCustomSourceCover(source);
+                       if (custom != null) {
+                               cacheLib.setSourceCover(source, custom);
+                       }
+               }
+
+               return custom;
+       }
+
+       @Override
+       public Image getCustomAuthorCover(String author) throws IOException {
+               Image custom = cacheLib.getCustomAuthorCover(author);
+               if (custom == null) {
+                       custom = lib.getCustomAuthorCover(author);
+                       if (custom != null) {
+                               cacheLib.setAuthorCover(author, custom);
+                       }
+               }
+
+               return custom;
+       }
+
+       @Override
+       public void setSourceCover(String source, String luid) throws IOException {
+               lib.setSourceCover(source, luid);
+               cacheLib.setSourceCover(source, getCover(luid));
+       }
+
+       @Override
+       public void setAuthorCover(String author, String luid) throws IOException {
+               lib.setAuthorCover(author, luid);
+               cacheLib.setAuthorCover(author, getCover(luid));
+       }
+
+       @Override
+       protected void updateInfo(MetaData meta) throws IOException {
+               if (meta != null && metas != null) {
+                       boolean changed = false;
+                       for (int i = 0; i < metas.size(); i++) {
+                               if (metas.get(i).getLuid().equals(meta.getLuid())) {
+                                       metas.set(i, meta);
+                                       changed = true;
+                               }
+                       }
+
+                       if (!changed) {
+                               metas.add(meta);
+                       }
+               }
+
+               cacheLib.updateInfo(meta);
+               lib.updateInfo(meta);
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               if (luid == null) {
+                       metas = null;
+               } else if (metas != null) {
+                       for (int i = 0; i < metas.size(); i++) {
+                               if (metas.get(i).getLuid().equals(luid)) {
+                                       metas.remove(i--);
+                               }
+                       }
+               }
+
+               cacheLib.invalidateInfo(luid);
+               lib.invalidateInfo(luid);
+       }
+
+       @Override
+       public synchronized Story save(Story story, String luid, Progress pg)
+                       throws IOException {
+               Progress pgLib = new Progress();
+               Progress pgCacheLib = new Progress();
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgLib, 1);
+               pg.addProgress(pgCacheLib, 1);
+
+               story = lib.save(story, luid, pgLib);
+               story = cacheLib.save(story, story.getMeta().getLuid(), pgCacheLib);
+
+               updateInfo(story.getMeta());
+
+               return story;
+       }
+
+       @Override
+       public synchronized void delete(String luid) throws IOException {
+               if (isCached(luid)) {
+                       cacheLib.delete(luid);
+               }
+               lib.delete(luid);
+
+               invalidateInfo(luid);
+       }
+
+       @Override
+       protected synchronized void changeSTA(String luid, String newSource,
+                       String newTitle, String newAuthor, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgCache = new Progress();
+               Progress pgOrig = new Progress();
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgCache, 1);
+               pg.addProgress(pgOrig, 1);
+
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               if (isCached(luid)) {
+                       cacheLib.changeSTA(luid, newSource, newTitle, newAuthor, pgCache);
+               }
+               pgCache.done();
+
+               lib.changeSTA(luid, newSource, newTitle, newAuthor, pgOrig);
+               pgOrig.done();
+
+               meta.setSource(newSource);
+               meta.setTitle(newTitle);
+               meta.setAuthor(newAuthor);
+               pg.done();
+
+               invalidateInfo(luid);
+       }
+
+       /**
+        * Check if the {@link Story} denoted by this Library UID is present in the
+        * cache.
+        * 
+        * @param luid
+        *            the Library UID
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isCached(String luid) {
+               try {
+                       return cacheLib.getInfo(luid) != null;
+               } catch (IOException e) {
+                       return false;
+               }
+       }
+
+       /**
+        * Clear the {@link Story} from the cache.
+        * <p>
+        * The next time we try to retrieve the {@link Story}, it may be required to
+        * cache it again.
+        * 
+        * @param luid
+        *            the story to clear
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void clearFromCache(String luid) throws IOException {
+               if (isCached(luid)) {
+                       cacheLib.delete(luid);
+               }
+       }
+
+       @Override
+       public MetaData imprt(URL url, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgImprt = new Progress();
+               Progress pgCache = new Progress();
+               pg.setMinMax(0, 10);
+               pg.addProgress(pgImprt, 7);
+               pg.addProgress(pgCache, 3);
+
+               MetaData meta = lib.imprt(url, pgImprt);
+               updateInfo(meta);
+               
+               clearFromCache(meta.getLuid());
+               
+               pg.done();
+               return meta;
+       }
+
+       // All the following methods are only used by Save and Delete in
+       // BasicLibrary:
+
+       @Override
+       protected int getNextId() {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/LocalLibrary.java b/src/be/nikiroo/fanfix/library/LocalLibrary.java
new file mode 100644 (file)
index 0000000..ffcd8af
--- /dev/null
@@ -0,0 +1,689 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.output.InfoCover;
+import be.nikiroo.fanfix.supported.InfoReader;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This {@link BasicLibrary} will store the stories locally on disk.
+ * 
+ * @author niki
+ */
+public class LocalLibrary extends BasicLibrary {
+       private int lastId;
+       private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
+       private Map<String, Image> sourceCovers;
+       private Map<String, Image> authorCovers;
+
+       private File baseDir;
+       private OutputType text;
+       private OutputType image;
+
+       /**
+        * Create a new {@link LocalLibrary} with the given back-end directory.
+        * 
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        */
+       public LocalLibrary(File baseDir) {
+               this(baseDir, Instance.getConfig().getString(
+                               Config.FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE), Instance.getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE), false);
+       }
+
+       /**
+        * Create a new {@link LocalLibrary} with the given back-end directory.
+        * 
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        * @param text
+        *            the {@link OutputType} to use for non-image documents
+        * @param image
+        *            the {@link OutputType} to use for image documents
+        * @param defaultIsHtml
+        *            if the given text or image is invalid, use HTML by default (if
+        *            not, it will be INFO_TEXT/CBZ by default)
+        */
+       public LocalLibrary(File baseDir, String text, String image,
+                       boolean defaultIsHtml) {
+               this(baseDir, OutputType.valueOfAllOkUC(text,
+                               defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
+                               OutputType.valueOfAllOkUC(image,
+                                               defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
+       }
+
+       /**
+        * Create a new {@link LocalLibrary} with the given back-end directory.
+        * 
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        * @param text
+        *            the {@link OutputType} to use for non-image documents
+        * @param image
+        *            the {@link OutputType} to use for image documents
+        */
+       public LocalLibrary(File baseDir, OutputType text, OutputType image) {
+               this.baseDir = baseDir;
+               this.text = text;
+               this.image = image;
+
+               this.lastId = 0;
+               this.stories = null;
+               this.sourceCovers = null;
+
+               baseDir.mkdirs();
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) {
+               return new ArrayList<MetaData>(getStories(pg).keySet());
+       }
+
+       @Override
+       public File getFile(String luid, Progress pg) throws IOException {
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": get file for " + luid);
+
+               File file = null;
+               String mess = "no file found for ";
+
+               MetaData meta = getInfo(luid);
+               File[] files = getStories(pg).get(meta);
+               if (files != null) {
+                       mess = "file retrieved for ";
+                       file = files[1];
+               }
+
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": " + mess + luid + " ("
+                                               + meta.getTitle() + ")");
+
+               return file;
+       }
+
+       @Override
+       public Image getCover(String luid) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta != null) {
+                       if (meta.getCover() != null) {
+                               return meta.getCover();
+                       }
+
+                       File[] files = getStories(null).get(meta);
+                       if (files != null) {
+                               File infoFile = files[0];
+
+                               try {
+                                       meta = InfoReader.readMeta(infoFile, true);
+                                       return meta.getCover();
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(e);
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected synchronized void updateInfo(MetaData meta) {
+               invalidateInfo();
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               stories = null;
+               sourceCovers = null;
+       }
+
+       @Override
+       protected synchronized int getNextId() {
+               getStories(null); // make sure lastId is set
+               return ++lastId;
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               for (File file : getRelatedFiles(luid)) {
+                       // TODO: throw an IOException if we cannot delete the files?
+                       IOUtils.deltree(file);
+                       file.getParentFile().delete();
+               }
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               MetaData meta = story.getMeta();
+
+               File expectedTarget = getExpectedFile(meta);
+               expectedTarget.getParentFile().mkdirs();
+
+               BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
+               it.process(story, expectedTarget.getPath(), pg);
+
+               return story;
+       }
+
+       @Override
+       protected synchronized void saveMeta(MetaData meta, Progress pg)
+                       throws IOException {
+               File newDir = getExpectedDir(meta.getSource());
+               if (!newDir.exists()) {
+                       newDir.mkdirs();
+               }
+
+               List<File> relatedFiles = getRelatedFiles(meta.getLuid());
+               for (File relatedFile : relatedFiles) {
+                       // TODO: this is not safe at all.
+                       // We should copy all the files THEN delete them
+                       // Maybe also adding some rollback cleanup if possible
+                       if (relatedFile.getName().endsWith(".info")) {
+                               try {
+                                       String name = relatedFile.getName().replaceFirst(
+                                                       "\\.info$", "");
+                                       relatedFile.delete();
+                                       InfoCover.writeInfo(newDir, name, meta);
+                                       relatedFile.getParentFile().delete();
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(e);
+                               }
+                       } else {
+                               relatedFile.renameTo(new File(newDir, relatedFile.getName()));
+                               relatedFile.getParentFile().delete();
+                       }
+               }
+
+               invalidateInfo();
+       }
+
+       @Override
+       public synchronized Image getCustomSourceCover(String source) {
+               if (sourceCovers == null) {
+                       sourceCovers = new HashMap<String, Image>();
+               }
+
+               Image img = sourceCovers.get(source);
+               if (img != null) {
+                       return img;
+               }
+
+               File coverDir = getExpectedDir(source);
+               if (coverDir.isDirectory()) {
+                       File cover = new File(coverDir, ".cover.png");
+                       if (cover.exists()) {
+                               InputStream in;
+                               try {
+                                       in = new FileInputStream(cover);
+                                       try {
+                                               sourceCovers.put(source, new Image(in));
+                                       } finally {
+                                               in.close();
+                                       }
+                               } catch (FileNotFoundException e) {
+                                       e.printStackTrace();
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(
+                                                       new IOException(
+                                                                       "Cannot load the existing custom source cover: "
+                                                                                       + cover, e));
+                               }
+                       }
+               }
+
+               return sourceCovers.get(source);
+       }
+
+       @Override
+       public synchronized Image getCustomAuthorCover(String author) {
+               if (authorCovers == null) {
+                       authorCovers = new HashMap<String, Image>();
+               }
+
+               Image img = authorCovers.get(author);
+               if (img != null) {
+                       return img;
+               }
+
+               File cover = getAuthorCoverFile(author);
+               if (cover.exists()) {
+                       InputStream in;
+                       try {
+                               in = new FileInputStream(cover);
+                               try {
+                                       authorCovers.put(author, new Image(in));
+                               } finally {
+                                       in.close();
+                               }
+                       } catch (FileNotFoundException e) {
+                               e.printStackTrace();
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(
+                                               new IOException(
+                                                               "Cannot load the existing custom author cover: "
+                                                                               + cover, e));
+                       }
+               }
+
+               return authorCovers.get(author);
+       }
+
+       @Override
+       public void setSourceCover(String source, String luid) throws IOException {
+               setSourceCover(source, getCover(luid));
+       }
+
+       @Override
+       public void setAuthorCover(String author, String luid) throws IOException {
+               setAuthorCover(author, getCover(luid));
+       }
+
+       /**
+        * Set the source cover to the given story cover.
+        * 
+        * @param source
+        *            the source to change
+        * @param coverImage
+        *            the cover image
+        */
+       synchronized void setSourceCover(String source, Image coverImage) {
+               File dir = getExpectedDir(source);
+               dir.mkdirs();
+               File cover = new File(dir, ".cover");
+               try {
+                       Instance.getCache().saveAsImage(coverImage, cover, true);
+                       if (sourceCovers != null) {
+                               sourceCovers.put(source, coverImage);
+                       }
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Set the author cover to the given story cover.
+        * 
+        * @param author
+        *            the author to change
+        * @param coverImage
+        *            the cover image
+        */
+       synchronized void setAuthorCover(String author, Image coverImage) {
+               File cover = getAuthorCoverFile(author);
+               cover.getParentFile().mkdirs();
+               try {
+                       Instance.getCache().saveAsImage(coverImage, cover, true);
+                       if (authorCovers != null) {
+                               authorCovers.put(author, coverImage);
+                       }
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public void imprt(BasicLibrary other, String luid, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               // Check if we can simply copy the files instead of the whole process
+               if (other instanceof LocalLibrary) {
+                       LocalLibrary otherLocalLibrary = (LocalLibrary) other;
+
+                       MetaData meta = otherLocalLibrary.getInfo(luid);
+                       String expectedType = ""
+                                       + (meta != null && meta.isImageDocument() ? image : text);
+                       if (meta != null && meta.getType().equals(expectedType)) {
+                               File from = otherLocalLibrary.getExpectedDir(meta.getSource());
+                               File to = this.getExpectedDir(meta.getSource());
+                               List<File> relatedFiles = otherLocalLibrary
+                                               .getRelatedFiles(luid);
+                               if (!relatedFiles.isEmpty()) {
+                                       pg.setMinMax(0, relatedFiles.size());
+                               }
+
+                               for (File relatedFile : relatedFiles) {
+                                       File target = new File(relatedFile.getAbsolutePath()
+                                                       .replace(from.getAbsolutePath(),
+                                                                       to.getAbsolutePath()));
+                                       if (!relatedFile.equals(target)) {
+                                               target.getParentFile().mkdirs();
+                                               InputStream in = null;
+                                               try {
+                                                       in = new FileInputStream(relatedFile);
+                                                       IOUtils.write(in, target);
+                                               } catch (IOException e) {
+                                                       if (in != null) {
+                                                               try {
+                                                                       in.close();
+                                                               } catch (Exception ee) {
+                                                               }
+                                                       }
+
+                                                       pg.done();
+                                                       throw e;
+                                               }
+                                       }
+
+                                       pg.add(1);
+                               }
+
+                               invalidateInfo();
+                               pg.done();
+                               return;
+                       }
+               }
+
+               super.imprt(other, luid, pg);
+       }
+
+       /**
+        * Return the {@link OutputType} for this {@link Story}.
+        * 
+        * @param meta
+        *            the {@link Story} {@link MetaData}
+        * 
+        * @return the type
+        */
+       private OutputType getOutputType(MetaData meta) {
+               if (meta != null && meta.isImageDocument()) {
+                       return image;
+               }
+
+               return text;
+       }
+
+       /**
+        * Return the default {@link OutputType} for this kind of {@link Story}.
+        * 
+        * @param imageDocument
+        *            TRUE for images document, FALSE for text documents
+        * 
+        * @return the type
+        */
+       public String getOutputType(boolean imageDocument) {
+               if (imageDocument) {
+                       return image.toString();
+               }
+
+               return text.toString();
+       }
+
+       /**
+        * Get the target {@link File} related to the given <tt>.info</tt>
+        * {@link File} and {@link MetaData}.
+        * 
+        * @param meta
+        *            the meta
+        * @param infoFile
+        *            the <tt>.info</tt> {@link File}
+        * 
+        * @return the target {@link File}
+        */
+       private File getTargetFile(MetaData meta, File infoFile) {
+               // Replace .info with whatever is needed:
+               String path = infoFile.getPath();
+               path = path.substring(0, path.length() - ".info".length());
+               String newExt = getOutputType(meta).getDefaultExtension(true);
+
+               return new File(path + newExt);
+       }
+
+       /**
+        * The target (full path) where the {@link Story} related to this
+        * {@link MetaData} should be located on disk for a new {@link Story}.
+        * 
+        * @param key
+        *            the {@link Story} {@link MetaData}
+        * 
+        * @return the target
+        */
+       private File getExpectedFile(MetaData key) {
+               String title = key.getTitle();
+               if (title == null) {
+                       title = "";
+               }
+               title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
+               if (title.length() > 40) {
+                       title = title.substring(0, 40);
+               }
+               return new File(getExpectedDir(key.getSource()), key.getLuid() + "_"
+                               + title);
+       }
+
+       /**
+        * The directory (full path) where the new {@link Story} related to this
+        * {@link MetaData} should be located on disk.
+        * 
+        * @param source
+        *            the type (source)
+        * 
+        * @return the target directory
+        */
+       private File getExpectedDir(String source) {
+               String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
+
+               while (sanitizedSource.startsWith("/")
+                               || sanitizedSource.startsWith("_")) {
+                       if (sanitizedSource.length() > 1) {
+                               sanitizedSource = sanitizedSource.substring(1);
+                       } else {
+                               sanitizedSource = "";
+                       }
+               }
+
+               sanitizedSource = sanitizedSource.replace("/", File.separator);
+
+               if (sanitizedSource.isEmpty()) {
+                       sanitizedSource = "_EMPTY";
+               }
+
+               return new File(baseDir, sanitizedSource);
+       }
+
+       /**
+        * Return the full path to the file to use for the custom cover of this
+        * author.
+        * <p>
+        * One or more of the parent directories <b>MAY</b> not exist.
+        * 
+        * @param author
+        *            the author
+        * 
+        * @return the custom cover file
+        */
+       private File getAuthorCoverFile(String author) {
+               File aDir = new File(baseDir, "_AUTHORS");
+               String hash = StringUtils.getMd5Hash(author);
+               String ext = Instance.getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
+               return new File(aDir, hash + "." + ext.toLowerCase());
+       }
+
+       /**
+        * Return the list of files/directories on disk for this {@link Story}.
+        * <p>
+        * If the {@link Story} is not found, and empty list is returned.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * 
+        * @return the list of {@link File}s
+        * 
+        * @throws IOException
+        *             if the {@link Story} was not found
+        */
+       private List<File> getRelatedFiles(String luid) throws IOException {
+               List<File> files = new ArrayList<File>();
+
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               File infoFile = getStories(null).get(meta)[0];
+               File targetFile = getStories(null).get(meta)[1];
+
+               files.add(infoFile);
+               files.add(targetFile);
+
+               String readerExt = getOutputType(meta).getDefaultExtension(true);
+               String fileExt = getOutputType(meta).getDefaultExtension(false);
+
+               String path = targetFile.getAbsolutePath();
+               if (readerExt != null && !readerExt.equals(fileExt)) {
+                       path = path.substring(0, path.length() - readerExt.length())
+                                       + fileExt;
+                       File relatedFile = new File(path);
+
+                       if (relatedFile.exists()) {
+                               files.add(relatedFile);
+                       }
+               }
+
+               String coverExt = "."
+                               + Instance.getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
+                                               .toLowerCase();
+               File coverFile = new File(path + coverExt);
+               if (!coverFile.exists()) {
+                       coverFile = new File(path.substring(0,
+                                       path.length() - fileExt.length())
+                                       + coverExt);
+               }
+
+               if (coverFile.exists()) {
+                       files.add(coverFile);
+               }
+
+               return files;
+       }
+
+       /**
+        * Fill the list of stories by reading the content of the local directory
+        * {@link LocalLibrary#baseDir}.
+        * <p>
+        * Will use a cached list when possible (see
+        * {@link BasicLibrary#invalidateInfo()}).
+        * 
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the list of stories (for each item, the first {@link File} is the
+        *         info file, the second file is the target {@link File})
+        */
+       private synchronized Map<MetaData, File[]> getStories(Progress pg) {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               if (stories == null) {
+                       stories = new HashMap<MetaData, File[]>();
+
+                       lastId = 0;
+
+                       File[] dirs = baseDir.listFiles(new FileFilter() {
+                               @Override
+                               public boolean accept(File file) {
+                                       return file != null && file.isDirectory();
+                               }
+                       });
+
+                       if (dirs != null) {
+                               Progress pgDirs = new Progress(0, 100 * dirs.length);
+                               pg.addProgress(pgDirs, 100);
+
+                               for (File dir : dirs) {
+                                       Progress pgFiles = new Progress();
+                                       pgDirs.addProgress(pgFiles, 100);
+                                       pgDirs.setName("Loading from: " + dir.getName());
+
+                                       addToStories(pgFiles, dir);
+
+                                       pgFiles.setName(null);
+                               }
+
+                               pgDirs.setName("Loading directories");
+                       }
+               }
+
+               pg.done();
+               return stories;
+       }
+
+       private void addToStories(Progress pgFiles, File dir) {
+               File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
+                       @Override
+                       public boolean accept(File file) {
+                               boolean info = file != null && file.isFile()
+                                               && file.getPath().toLowerCase().endsWith(".info");
+                               boolean dir = file != null && file.isDirectory();
+                               boolean isExpandedHtml = new File(file, "index.html").isFile();
+                               return info || (dir && !isExpandedHtml);
+                       }
+               });
+
+               if (pgFiles != null) {
+                       pgFiles.setMinMax(0, infoFilesAndSubdirs.length);
+               }
+
+               for (File infoFileOrSubdir : infoFilesAndSubdirs) {
+                       if (pgFiles != null) {
+                               pgFiles.setName(infoFileOrSubdir.getName());
+                       }
+
+                       if (infoFileOrSubdir.isDirectory()) {
+                               addToStories(null, infoFileOrSubdir);
+                       } else {
+                               try {
+                                       MetaData meta = InfoReader
+                                                       .readMeta(infoFileOrSubdir, false);
+                                       try {
+                                               int id = Integer.parseInt(meta.getLuid());
+                                               if (id > lastId) {
+                                                       lastId = id;
+                                               }
+
+                                               stories.put(meta, new File[] { infoFileOrSubdir,
+                                                               getTargetFile(meta, infoFileOrSubdir) });
+                                       } catch (Exception e) {
+                                               // not normal!!
+                                               throw new IOException("Cannot understand the LUID of "
+                                                               + infoFileOrSubdir + ": " + meta.getLuid(), e);
+                                       }
+                               } catch (IOException e) {
+                                       // We should not have not-supported files in the
+                                       // library
+                                       Instance.getTraceHandler().error(
+                                                       new IOException("Cannot load file from library: "
+                                                                       + infoFileOrSubdir, e));
+                               }
+                       }
+
+                       if (pgFiles != null) {
+                               pgFiles.add(1);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/RemoteLibrary.java b/src/be/nikiroo/fanfix/library/RemoteLibrary.java
new file mode 100644 (file)
index 0000000..ce4305a
--- /dev/null
@@ -0,0 +1,579 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ConnectActionClientObject;
+
+/**
+ * This {@link BasicLibrary} will access a remote server to list the available
+ * stories, and download the ones you try to load to the local directory
+ * specified in the configuration.
+ * 
+ * @author niki
+ */
+public class RemoteLibrary extends BasicLibrary {
+       interface RemoteAction {
+               public void action(ConnectActionClientObject action) throws Exception;
+       }
+
+       class RemoteConnectAction extends ConnectActionClientObject {
+               public RemoteConnectAction() throws IOException {
+                       super(host, port, key);
+               }
+
+               @Override
+               public Object send(Object data) throws IOException,
+                               NoSuchFieldException, NoSuchMethodException,
+                               ClassNotFoundException {
+                       Object rep = super.send(data);
+                       if (rep instanceof RemoteLibraryException) {
+                               RemoteLibraryException remoteEx = (RemoteLibraryException) rep;
+                               throw remoteEx.unwrapException();
+                       }
+
+                       return rep;
+               }
+       }
+
+       private String host;
+       private int port;
+       private final String key;
+       private final String subkey;
+
+       // informative only (server will make the actual checks)
+       private boolean rw;
+
+       /**
+        * Create a {@link RemoteLibrary} linked to the given server.
+        * <p>
+        * Note that the key is structured:
+        * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>|<b>wl</b>)(|<b>rw</b>)</tt>
+        * <p>
+        * Note that anything before the first pipe (<tt>|</tt>) character is
+        * considered to be the encryption key, anything after that character is
+        * called the subkey (including the other pipe characters and flags!).
+        * <p>
+        * This is important because the subkey (including the pipe characters and
+        * flags) must be present as-is in the server configuration file to be
+        * allowed.
+        * <ul>
+        * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
+        * server</li>
+        * <li><b><i>yyy</i></b>: the secondary key</li>
+        * <li><b>rw</b>: flag to allow read and write access if it is not the
+        * default on this server</li>
+        * <li><b>wl</b>: flag to allow access to all the stories (bypassing the
+        * whitelist if it exists)</li>
+        * </ul>
+        * <p>
+        * Some examples:
+        * <ul>
+        * <li><b>my_key</b>: normal connection, will take the default server
+        * options</li>
+        * <li><b>my_key|agzyzz|wl</b>: will ask to bypass the white list (if it
+        * exists)</li>
+        * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
+        * is read-only)</li>
+        * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
+        * list bypass</li>
+        * </ul>
+        * 
+        * @param key
+        *            the key that will allow us to exchange information with the
+        *            server
+        * @param host
+        *            the host to contact or NULL for localhost
+        * @param port
+        *            the port to contact it on
+        */
+       public RemoteLibrary(String key, String host, int port) {
+               int index = -1;
+               if (key != null) {
+                       index = key.indexOf('|');
+               }
+
+               if (index >= 0) {
+                       this.key = key.substring(0, index);
+                       this.subkey = key.substring(index + 1);
+               } else {
+                       this.key = key;
+                       this.subkey = "";
+               }
+
+               this.host = host;
+               this.port = port;
+       }
+
+       @Override
+       public String getLibraryName() {
+               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;
+
+               try {
+                       new RemoteConnectAction() {
+                               @Override
+                               public void action(Version serverVersion) throws Exception {
+                                       Object rep = send(new Object[] { subkey, "PING" });
+
+                                       if ("r/w".equals(rep)) {
+                                               rw = true;
+                                               result[0] = Status.READ_WRITE;
+                                       } else if ("r/o".equals(rep)) {
+                                               rw = false;
+                                               result[0] = Status.READ_ONLY;
+                                       } else {
+                                               result[0] = Status.UNAUTHORIZED;
+                                       }
+                               }
+
+                               @Override
+                               protected void onError(Exception e) {
+                                       if (e instanceof SSLException) {
+                                               result[0] = Status.UNAUTHORIZED;
+                                       } else {
+                                               result[0] = Status.UNAVAILABLE;
+                                       }
+                               }
+                       }.connect();
+               } catch (UnknownHostException e) {
+                       result[0] = Status.INVALID;
+               } catch (IllegalArgumentException e) {
+                       result[0] = Status.INVALID;
+               } catch (Exception e) {
+                       result[0] = Status.UNAVAILABLE;
+               }
+
+               return result[0];
+       }
+
+       @Override
+       public Image getCover(final String luid) throws IOException {
+               final Image[] result = new Image[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Object rep = action.send(new Object[] { subkey, "GET_COVER",
+                                               luid });
+                               result[0] = (Image) rep;
+                       }
+               });
+
+               return result[0];
+       }
+
+       @Override
+       public Image getCustomSourceCover(final String source) throws IOException {
+               return getCustomCover(source, "SOURCE");
+       }
+
+       @Override
+       public Image getCustomAuthorCover(final String author) throws IOException {
+               return getCustomCover(author, "AUTHOR");
+       }
+
+       // type: "SOURCE" or "AUTHOR"
+       private Image getCustomCover(final String source, final String type)
+                       throws IOException {
+               final Image[] result = new Image[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Object rep = action.send(new Object[] { subkey,
+                                               "GET_CUSTOM_COVER", type, source });
+                               result[0] = (Image) rep;
+                       }
+               });
+
+               return result[0];
+       }
+
+       @Override
+       public synchronized Story getStory(final String luid, Progress pg)
+                       throws IOException {
+               final Progress pgF = pg;
+               final Story[] result = new Story[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+                               if (pg == null) {
+                                       pg = new Progress();
+                               }
+
+                               Object rep = action.send(new Object[] { subkey, "GET_STORY",
+                                               luid });
+
+                               MetaData meta = null;
+                               if (rep instanceof MetaData) {
+                                       meta = (MetaData) rep;
+                                       if (meta.getWords() <= Integer.MAX_VALUE) {
+                                               pg.setMinMax(0, (int) meta.getWords());
+                                       }
+                               }
+
+                               List<Object> list = new ArrayList<Object>();
+                               for (Object obj = action.send(null); obj != null; obj = action
+                                               .send(null)) {
+                                       list.add(obj);
+                                       pg.add(1);
+                               }
+
+                               result[0] = RemoteLibraryServer.rebuildStory(list);
+                               pg.done();
+                       }
+               });
+
+               return result[0];
+       }
+
+       @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();
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               pg.setMinMax(0, 10);
+               pg.addProgress(pgSave, 9);
+               pg.addProgress(pgRefresh, 1);
+
+               final Progress pgF = pgSave;
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+                               if (story.getMeta().getWords() <= Integer.MAX_VALUE) {
+                                       pg.setMinMax(0, (int) story.getMeta().getWords());
+                               }
+
+                               action.send(new Object[] { subkey, "SAVE_STORY", luid });
+
+                               List<Object> list = RemoteLibraryServer.breakStory(story);
+                               for (Object obj : list) {
+                                       action.send(obj);
+                                       pg.add(1);
+                               }
+
+                               luidSaved[0] = (String) action.send(null);
+
+                               pg.done();
+                       }
+               });
+
+               // because the meta changed:
+               MetaData meta = getInfo(luidSaved[0]);
+               if (story.getMeta().getClass() != null) {
+                       // If already available locally:
+                       meta.setCover(story.getMeta().getCover());
+               } else {
+                       // If required:
+                       meta.setCover(getCover(meta.getLuid()));
+               }
+               story.setMeta(meta);
+
+               pg.done();
+
+               return story;
+       }
+
+       @Override
+       public synchronized void delete(final String luid) throws IOException {
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               action.send(new Object[] { subkey, "DELETE_STORY", luid });
+                       }
+               });
+       }
+
+       @Override
+       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)
+                       throws IOException {
+               setCover(author, luid, "AUTHOR");
+       }
+
+       // type = "SOURCE" | "AUTHOR"
+       private void setCover(final String value, final String luid,
+                       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
+       // Could work (more slowly) without it
+       public MetaData imprt(final URL url, Progress pg) throws IOException {
+               // Import the file locally if it is actually a file
+               
+               if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
+                       return super.imprt(url, pg);
+               }
+
+               // Import it remotely if it is an URL
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               final Progress pgF = pg;
+               final String[] luid = new String[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+
+                               Object rep = action.send(new Object[] { subkey, "IMPORT",
+                                               url.toString() });
+
+                               while (true) {
+                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
+                                               break;
+                                       }
+
+                                       rep = action.send(null);
+                               }
+
+                               pg.done();
+                               luid[0] = (String) rep;
+                       }
+               });
+
+               if (luid[0] == null) {
+                       throw new IOException("Remote failure");
+               }
+
+               pg.done();
+               return getInfo(luid[0]);
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       protected synchronized void changeSTA(final String luid,
+                       final String newSource, final String newTitle,
+                       final String newAuthor, Progress pg) throws IOException {
+
+               final Progress pgF = pg == null ? new Progress() : pg;
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+
+                               Object rep = action.send(new Object[] { subkey, "CHANGE_STA",
+                                               luid, newSource, newTitle, newAuthor });
+                               while (true) {
+                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
+                                               break;
+                                       }
+
+                                       rep = action.send(null);
+                               }
+                       }
+               });
+       }
+
+       @Override
+       public synchronized File getFile(final String luid, Progress pg) {
+               throw new java.lang.InternalError(
+                               "Operation not supportorted on remote Libraries");
+       }
+
+       /**
+        * Stop the server.
+        * 
+        * @throws IOException
+        *             in case of I/O error (including bad key)
+        */
+       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) throws IOException {
+               List<MetaData> metas = getMetasList(luid, null);
+               if (!metas.isEmpty()) {
+                       return metas.get(0);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
+               return getMetasList("*", pg);
+       }
+
+       @Override
+       protected void updateInfo(MetaData meta) {
+               // Will be taken care of directly server side
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               // Will be taken care of directly server side
+       }
+
+       // The following methods are only used by Save and Delete in BasicLibrary:
+
+       @Override
+       protected int getNextId() {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       //
+
+       /**
+        * Return the meta of the given story or a list of all known metas if the
+        * luid is "*".
+        * <p>
+        * Will not get the covers.
+        * 
+        * @param luid
+        *            the luid of the story or *
+        * @param pg
+        *            the optional progress
+        * 
+        * @return the metas
+        * 
+        * @throws IOException
+        *             in case of I/O error or bad key (SSLException)
+        */
+       private List<MetaData> getMetasList(final String luid, Progress pg)
+                       throws IOException {
+               final Progress pgF = pg;
+               final List<MetaData> metas = new ArrayList<MetaData>();
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+                               if (pg == null) {
+                                       pg = new Progress();
+                               }
+
+                               Object rep = action.send(new Object[] { subkey, "GET_METADATA",
+                                               luid });
+
+                               while (true) {
+                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
+                                               break;
+                                       }
+
+                                       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) {
+                                       if (!(e instanceof IOException)) {
+                                               Instance.getTraceHandler().error(e);
+                                               return;
+                                       }
+
+                                       err[0] = (IOException) e;
+                               }
+                       };
+                       array[0] = ra;
+                       ra.connect();
+               } catch (Exception e) {
+                       err[0] = (IOException) e;
+               }
+
+               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 (file)
index 0000000..4cbb631
--- /dev/null
@@ -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:
+        * <ul>
+        * <li>the <tt>cause</tt> if {@link RemoteLibraryException#isWrapped()} is
+        * TRUE</li>
+        * <li><tt>this</tt> if {@link RemoteLibraryException#isWrapped()} is FALSE
+        * (</li>
+        * <li><tt>this</tt> if the <tt>cause</tt> is NULL (so we never return NULL)
+        * </li>
+        * </ul>
+        * It is never NULL.
+        * 
+        * @return the unwrapped exception or <tt>this</tt>, 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.
+        * <p>
+        * In other words, do not use <tt>this</tt> 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
new file mode 100644 (file)
index 0000000..dc9688c
--- /dev/null
@@ -0,0 +1,511 @@
+package be.nikiroo.fanfix.library;
+
+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;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Progress.ProgressListener;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ConnectActionServerObject;
+import be.nikiroo.utils.serial.server.ServerObject;
+
+/**
+ * Create a new remote server that will listen for orders on the given port.
+ * <p>
+ * The available commands are given as arrays of objects (first item is the
+ * command, the rest are the arguments).
+ * <p>
+ * All the commands are always prefixed by the subkey (which can be EMPTY if
+ * none).
+ * <p>
+ * <ul>
+ * <li>PING: will return the mode if the key is accepted (mode can be: "r/o" or
+ * "r/w")</li>
+ * <li>GET_METADATA *: will return the metadata of all the stories in the
+ * library (array)</li> *
+ * <li>GET_METADATA [luid]: will return the metadata of the story of LUID luid</li>
+ * <li>GET_STORY [luid]: will return the given story if it exists (or NULL if
+ * not)</li>
+ * <li>SAVE_STORY [luid]: save the story (that must be sent just after the
+ * command) with the given LUID, then return the LUID</li>
+ * <li>IMPORT [url]: save the story found at the given URL, then return the LUID
+ * </li>
+ * <li>DELETE_STORY [luid]: delete the story of LUID luid</li>
+ * <li>GET_COVER [luid]: return the cover of the story</li>
+ * <li>GET_CUSTOM_COVER ["SOURCE"|"AUTHOR"] [source]: return the cover for this
+ * source/author</li>
+ * <li>SET_COVER ["SOURCE"|"AUTHOR"] [value] [luid]: set the default cover for
+ * the given source/author to the cover of the story denoted by luid</li>
+ * <li>CHANGE_SOURCE [luid] [new source]: change the source of the story of LUID
+ * luid</li>
+ * <li>EXIT: stop the server</li>
+ * </ul>
+ * 
+ * @author niki
+ */
+public class RemoteLibraryServer extends ServerObject {
+       private Map<Long, String> commands = new HashMap<Long, String>();
+       private Map<Long, Long> times = new HashMap<Long, Long>();
+       private Map<Long, Boolean> wls = new HashMap<Long, Boolean>();
+       private Map<Long, Boolean> rws = new HashMap<Long, Boolean>();
+
+       /**
+        * Create a new remote server (will not be active until
+        * {@link RemoteLibraryServer#start()} is called).
+        * <p>
+        * Note: the key we use here is the encryption key (it must not contain a
+        * subkey).
+        * 
+        * @param key
+        *            the key that will restrict access to this server
+        * @param port
+        *            the port to listen on
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public RemoteLibraryServer(String key, int port) throws IOException {
+               super("Fanfix remote library", port, key);
+               setTraceHandler(Instance.getTraceHandler());
+       }
+
+       @Override
+       protected Object onRequest(ConnectActionServerObject action,
+                       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 > 0) {
+                               subkey = "" + dataArray[0];
+                       }
+                       if (dataArray.length > 1) {
+                               command = "" + dataArray[1];
+
+                               args = new Object[dataArray.length - 2];
+                               for (int i = 2; i < dataArray.length; i++) {
+                                       args[i - 2] = dataArray[i];
+                               }
+                       }
+               }
+
+               List<String> whitelist = Instance.getConfig().getList(
+                               Config.SERVER_WHITELIST);
+               if (whitelist == null) {
+                       whitelist = new ArrayList<String>();
+               }
+
+               if (whitelist.isEmpty()) {
+                       wl = false;
+               }
+
+               rw = Instance.getConfig().getBoolean(Config.SERVER_RW, rw);
+               if (!subkey.isEmpty()) {
+                       List<String> 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>();
+                               }
+                       }
+               }
+
+               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);
+               }
+
+               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, boolean rw, List<String> whitelist)
+                       throws NoSuchFieldException, NoSuchMethodException,
+                       ClassNotFoundException, IOException {
+               if ("PING".equals(command)) {
+                       return rw ? "r/w" : "r/o";
+               } else if ("GET_METADATA".equals(command)) {
+                       List<MetaData> metas = new ArrayList<MetaData>();
+
+                       if ("*".equals(args[0])) {
+                               Progress pg = createPgForwarder(action);
+
+                               for (MetaData meta : Instance.getLibrary().getMetas(pg)) {
+                                       MetaData light;
+                                       if (meta.getCover() == null) {
+                                               light = meta;
+                                       } else {
+                                               light = meta.clone();
+                                               light.setCover(null);
+                                       }
+
+                                       metas.add(light);
+                               }
+
+                               forcePgDoneSent(pg);
+                       } 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);
+                       }
+
+                       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);
+
+                       action.send(meta);
+                       action.rec();
+
+                       Story story = Instance.getLibrary()
+                                       .getStory((String) args[0], null);
+                       for (Object obj : breakStory(story)) {
+                               action.send(obj);
+                               action.rec();
+                       }
+               } else if ("SAVE_STORY".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException("Read-Only remote library: "
+                                               + args[0], false);
+                       }
+
+                       List<Object> list = new ArrayList<Object>();
+
+                       action.send(null);
+                       Object obj = action.rec();
+                       while (obj != null) {
+                               list.add(obj);
+                               action.send(null);
+                               obj = action.rec();
+                       }
+
+                       Story story = rebuildStory(list);
+                       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);
+                       MetaData meta = Instance.getLibrary().imprt(
+                                       new URL((String) args[0]), pg);
+                       forcePgDoneSent(pg);
+                       return meta.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]);
+               } else if ("GET_CUSTOM_COVER".equals(command)) {
+                       if ("SOURCE".equals(args[0])) {
+                               return Instance.getLibrary().getCustomSourceCover(
+                                               (String) args[1]);
+                       } else if ("AUTHOR".equals(args[0])) {
+                               return Instance.getLibrary().getCustomAuthorCover(
+                                               (String) args[1]);
+                       } else {
+                               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]);
+                       } else if ("AUTHOR".equals(args[0])) {
+                               Instance.getLibrary().setAuthorCover((String) args[1],
+                                               (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);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected void onError(Exception e) {
+               if (e instanceof SSLException) {
+                       long now = System.currentTimeMillis();
+                       System.out.println(StringUtils.fromTime(now) + ": "
+                                       + "[Client connection refused (bad key)]");
+               } else {
+                       getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Break a story in multiple {@link Object}s for easier serialisation.
+        * 
+        * @param story
+        *            the {@link Story} to break
+        * 
+        * @return the list of {@link Object}s
+        */
+       static List<Object> breakStory(Story story) {
+               List<Object> list = new ArrayList<Object>();
+
+               story = story.clone();
+               list.add(story);
+
+               if (story.getMeta().isImageDocument()) {
+                       for (Chapter chap : story) {
+                               list.add(chap);
+                               list.addAll(chap.getParagraphs());
+                               chap.setParagraphs(new ArrayList<Paragraph>());
+                       }
+                       story.setChapters(new ArrayList<Chapter>());
+               }
+
+               return list;
+       }
+
+       /**
+        * Rebuild a story from a list of broke up {@link Story} parts.
+        * 
+        * @param list
+        *            the list of {@link Story} parts
+        * 
+        * @return the reconstructed {@link Story}
+        */
+       static Story rebuildStory(List<Object> list) {
+               Story story = null;
+               Chapter chap = null;
+
+               for (Object obj : list) {
+                       if (obj instanceof Story) {
+                               story = (Story) obj;
+                       } else if (obj instanceof Chapter) {
+                               chap = (Chapter) obj;
+                               story.getChapters().add(chap);
+                       } else if (obj instanceof Paragraph) {
+                               chap.getParagraphs().add((Paragraph) obj);
+                       }
+               }
+
+               return story;
+       }
+
+       /**
+        * Update the {@link Progress} with the adequate {@link Object} received
+        * from the network via {@link RemoteLibraryServer}.
+        * 
+        * @param pg
+        *            the {@link Progress} to update
+        * @param rep
+        *            the object received from the network
+        * 
+        * @return TRUE if it was a progress event, FALSE if not
+        */
+       static boolean updateProgress(Progress pg, Object rep) {
+               if (rep instanceof Integer[]) {
+                       Integer[] a = (Integer[]) rep;
+                       if (a.length == 3) {
+                               int min = a[0];
+                               int max = a[1];
+                               int progress = a[2];
+
+                               if (min >= 0 && min <= max) {
+                                       pg.setMinMax(min, max);
+                                       pg.setProgress(progress);
+
+                                       return true;
+                               }
+                       }
+               }
+
+               return false;
+       }
+
+       /**
+        * Create a {@link Progress} that will forward its progress over the
+        * network.
+        * 
+        * @param action
+        *            the {@link ConnectActionServerObject} to use to forward it
+        * 
+        * @return the {@link Progress}
+        */
+       private Progress createPgForwarder(final ConnectActionServerObject action) {
+               final Boolean[] isDoneForwarded = new Boolean[] { false };
+               final Progress pg = new Progress() {
+                       @Override
+                       public boolean isDone() {
+                               return isDoneForwarded[0];
+                       }
+               };
+
+               final Integer[] p = new Integer[] { -1, -1, -1 };
+               final Long[] lastTime = new Long[] { new Date().getTime() };
+               pg.addProgressListener(new ProgressListener() {
+                       @Override
+                       public void progress(Progress progress, String name) {
+                               int min = pg.getMin();
+                               int max = pg.getMax();
+                               int relativeProgress = min
+                                               + (int) Math.round(pg.getRelativeProgress()
+                                                               * (max - min));
+
+                               // Do not re-send the same value twice over the wire,
+                               // unless more than 2 seconds have elapsed (to maintain the
+                               // connection)
+                               if ((p[0] != min || p[1] != max || p[2] != relativeProgress)
+                                               || (new Date().getTime() - lastTime[0] > 2000)) {
+                                       p[0] = min;
+                                       p[1] = max;
+                                       p[2] = relativeProgress;
+
+                                       try {
+                                               action.send(new Integer[] { min, max, relativeProgress });
+                                               action.rec();
+                                       } catch (Exception e) {
+                                               getTraceHandler().error(e);
+                                       }
+
+                                       lastTime[0] = new Date().getTime();
+                               }
+
+                               isDoneForwarded[0] = (pg.getProgress() >= pg.getMax());
+                       }
+               });
+
+               return pg;
+       }
+
+       // with 30 seconds timeout
+       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) {
+                               getTraceHandler().error(e);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/package-info.java b/src/be/nikiroo/fanfix/library/package-info.java
new file mode 100644 (file)
index 0000000..1bb63ea
--- /dev/null
@@ -0,0 +1,9 @@
+/**
+ * This package offer a Libraries to store stories into.
+ * <p>
+ * It currently has a local library and a remote one, as well as the required 
+ * remote server. 
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.library;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/output/BasicOutput.java b/src/be/nikiroo/fanfix/output/BasicOutput.java
new file mode 100644 (file)
index 0000000..15d8cc1
--- /dev/null
@@ -0,0 +1,553 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+
+/**
+ * This class is the base class used by the other output classes. It can be used
+ * outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * 
+ * @author niki
+ */
+public abstract class BasicOutput {
+       /**
+        * The supported output types for which we can get a {@link BasicOutput}
+        * object.
+        * 
+        * @author niki
+        */
+       public enum OutputType {
+               /** EPUB files created with this program */
+               EPUB,
+               /** Pure text file with some rules */
+               TEXT,
+               /** TEXT but with associated .info file */
+               INFO_TEXT,
+               /** DEBUG output to console */
+               SYSOUT,
+               /** ZIP with (PNG) images */
+               CBZ,
+               /** LaTeX file with "book" template */
+               LATEX,
+               /** HTML files in a dedicated directory */
+               HTML,
+
+               ;
+
+               @Override
+               public String toString() {
+                       return super.toString().toLowerCase();
+               }
+
+               /**
+                * A description of this output type.
+                * 
+                * @param longDesc
+                *            TRUE for the long description, FALSE for the short one
+                * 
+                * @return the description
+                */
+               public String getDesc(boolean longDesc) {
+                       StringId id = longDesc ? StringId.OUTPUT_DESC
+                                       : StringId.OUTPUT_DESC_SHORT;
+
+                       String desc = Instance.getTrans().getStringX(id, this.name());
+
+                       if (desc == null) {
+                               desc = Instance.getTrans().getString(id, this.toString());
+                       }
+
+                       if (desc == null || desc.isEmpty()) {
+                               desc = this.toString();
+                       }
+
+                       return desc;
+               }
+
+               /**
+                * The default extension to add to the output files.
+                * 
+                * @param readerTarget
+                *            TRUE to point to the main {@link Story} entry point for a
+                *            reader (for instance, the main entry point if this
+                *            {@link Story} is in a directory bundle), FALSE to point to
+                *            the main file even if it is a directory for instance
+                * 
+                * @return the extension
+                */
+               public String getDefaultExtension(boolean readerTarget) {
+                       BasicOutput output = BasicOutput.getOutput(this, false, false);
+                       if (output != null) {
+                               return output.getDefaultExtension(readerTarget);
+                       }
+
+                       return null;
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String)} after conversion to upper
+                * case.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfUC(String typeName) {
+                       return OutputType.valueOf(typeName == null ? null : typeName
+                                       .toUpperCase());
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String)} after conversion to upper
+                * case but return def for NULL and empty instead of raising an
+                * exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * @param def
+                *            the default value
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfNullOkUC(String typeName, OutputType def) {
+                       if (typeName == null || typeName.isEmpty()) {
+                               return def;
+                       }
+
+                       return OutputType.valueOfUC(typeName);
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String)} after conversion to upper
+                * case but return def in case of error instead of raising an exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * @param def
+                *            the default value
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfAllOkUC(String typeName, OutputType def) {
+                       try {
+                               return OutputType.valueOfUC(typeName);
+                       } catch (Exception e) {
+                               return def;
+                       }
+               }
+       }
+
+       /** The creator name (this program, by me!) */
+       static protected final String EPUB_CREATOR = "Fanfix "
+                       + Version.getCurrentVersion() + " (by Niki)";
+
+       /** The current best name for an image */
+       private String imageName;
+       private File targetDir;
+       private String targetName;
+       private OutputType type;
+       private boolean writeCover;
+       private boolean writeInfo;
+       private Progress storyPg;
+       private Progress chapPg;
+
+       /**
+        * Process the {@link Story} into the given target.
+        * 
+        * @param story
+        *            the {@link Story} to export
+        * @param target
+        *            the target where to save to (will not necessary be taken as is
+        *            by the processor, for instance an extension can be added)
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the actual main target saved, which can be slightly different
+        *         that the input one
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File process(Story story, String target, Progress pg)
+                       throws IOException {
+               storyPg = pg;
+
+               File targetDir = null;
+               String targetName = null;
+               if (target != null) {
+                       target = new File(target).getAbsolutePath();
+                       targetDir = new File(target).getParentFile();
+                       targetName = new File(target).getName();
+
+                       String ext = getDefaultExtension(false);
+                       if (ext != null && !ext.isEmpty()) {
+                               if (targetName.toLowerCase().endsWith(ext)) {
+                                       targetName = targetName.substring(0, targetName.length()
+                                                       - ext.length());
+                               }
+                       }
+               }
+
+               return process(story, targetDir, targetName);
+       }
+
+       /**
+        * Process the {@link Story} into the given target.
+        * <p>
+        * This method is expected to be overridden in most cases.
+        * 
+        * @param story
+        *            the {@link Story} to export
+        * @param targetDir
+        *            the target dir where to save to
+        * @param targetName
+        *            the target filename (will not necessary be taken as is by the
+        *            processor, for instance an extension can be added)
+        * 
+        * 
+        * @return the actual main target saved, which can be slightly different
+        *         that the input one
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               this.targetDir = targetDir;
+               this.targetName = targetName;
+
+               writeStory(story);
+
+               return null;
+       }
+
+       /**
+        * The output type.
+        * 
+        * @return the type
+        */
+       public OutputType getType() {
+               return type;
+       }
+
+       /**
+        * Enable the creation of a .info file next to the resulting processed file.
+        * 
+        * @return TRUE to enable it
+        */
+       protected boolean isWriteInfo() {
+               return writeInfo;
+       }
+
+       /**
+        * Enable the creation of a cover file next to the resulting processed file
+        * if possible.
+        * 
+        * @return TRUE to enable it
+        */
+       protected boolean isWriteCover() {
+               return writeCover;
+       }
+
+       /**
+        * The output type.
+        * 
+        * @param type
+        *            the new type
+        * @param writeCover
+        *            TRUE to enable the creation of a cover if possible
+        * @param writeInfo
+        *            TRUE to enable the creation of a .info file
+        * 
+        * @return this
+        */
+       protected BasicOutput setType(OutputType type, boolean writeInfo,
+                       boolean writeCover) {
+               this.type = type;
+               this.writeInfo = writeInfo;
+               this.writeCover = writeCover;
+
+               return this;
+       }
+
+       /**
+        * The default extension to add to the output files.
+        * 
+        * @param readerTarget
+        *            TRUE to point to the main {@link Story} entry point for a
+        *            reader (for instance, the main entry point if this
+        *            {@link Story} is in a directory bundle), FALSE to point to the
+        *            main file even if it is a directory for instance
+        * 
+        * @return the extension
+        */
+       public String getDefaultExtension(
+                       @SuppressWarnings("unused") boolean readerTarget) {
+               return "";
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeStoryHeader(Story story) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeStoryFooter(Story story) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+       }
+
+       protected void writeStory(Story story) throws IOException {
+               if (storyPg == null) {
+                       storyPg = new Progress(0, story.getChapters().size() + 2);
+               } else {
+                       storyPg.setMinMax(0, story.getChapters().size() + 2);
+               }
+
+               String chapterNameNum = String.format("%03d", 0);
+               String paragraphNumber = String.format("%04d", 0);
+               imageName = paragraphNumber + "_" + chapterNameNum;
+
+               if (story.getMeta() != null) {
+                       story.getMeta().setType("" + getType());
+               }
+
+               if (isWriteCover()) {
+                       InfoCover.writeCover(targetDir, targetName, story.getMeta());
+               }
+               if (isWriteInfo()) {
+                       InfoCover.writeInfo(targetDir, targetName, story.getMeta());
+               }
+
+               storyPg.setProgress(1);
+
+               List<Progress> chapPgs = new ArrayList<Progress>(story.getChapters()
+                               .size());
+               for (Chapter chap : story) {
+                       chapPg = new Progress(0, chap.getParagraphs().size());
+                       storyPg.addProgress(chapPg, 1);
+                       chapPgs.add(chapPg);
+                       chapPg = null;
+               }
+
+               writeStoryHeader(story);
+               for (int i = 0; i < story.getChapters().size(); i++) {
+                       chapPg = chapPgs.get(i);
+                       writeChapter(story.getChapters().get(i));
+                       chapPg.setProgress(chapPg.getMax());
+                       chapPg = null;
+               }
+               writeStoryFooter(story);
+
+               storyPg.setProgress(storyPg.getMax());
+               storyPg = null;
+       }
+
+       protected void writeChapter(Chapter chap) throws IOException {
+               String chapterNameNum;
+               if (chap.getName() == null || chap.getName().isEmpty()) {
+                       chapterNameNum = String.format("%03d", chap.getNumber());
+               } else {
+                       chapterNameNum = String.format("%03d", chap.getNumber()) + "_"
+                                       + chap.getName().replace(" ", "_");
+               }
+
+               int num = 0;
+               String paragraphNumber = String.format("%04d", num++);
+               imageName = chapterNameNum + "_" + paragraphNumber;
+
+               writeChapterHeader(chap);
+               int i = 1;
+               for (Paragraph para : chap) {
+                       paragraphNumber = String.format("%04d", num++);
+                       imageName = chapterNameNum + "_" + paragraphNumber;
+                       writeParagraph(para);
+                       if (chapPg != null) {
+                               chapPg.setProgress(i++);
+                       }
+               }
+               writeChapterFooter(chap);
+       }
+
+       protected void writeParagraph(Paragraph para) throws IOException {
+               writeParagraphHeader(para);
+               writeTextLine(para.getType(), para.getContent());
+               writeParagraphFooter(para);
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+       }
+
+       /**
+        * Return the current best guess for an image name, based upon the current
+        * {@link Chapter} and {@link Paragraph}.
+        * 
+        * @param prefix
+        *            add the original target name as a prefix
+        * 
+        * @return the guessed name
+        */
+       protected String getCurrentImageBestName(boolean prefix) {
+               if (prefix) {
+                       return targetName + "_" + imageName;
+               }
+
+               return imageName;
+       }
+
+       /**
+        * Return the given word or sentence as <b>bold</b>.
+        * 
+        * @param word
+        *            the input
+        * 
+        * @return the bold output
+        */
+       protected String enbold(String word) {
+               return word;
+       }
+
+       /**
+        * Return the given word or sentence as <i>italic</i>.
+        * 
+        * @param word
+        *            the input
+        * 
+        * @return the italic output
+        */
+       protected String italize(String word) {
+               return word;
+       }
+
+       /**
+        * Decorate the given text with <b>bold</b> and <i>italic</i> words,
+        * according to {@link BasicOutput#enbold(String)} and
+        * {@link BasicOutput#italize(String)}.
+        * 
+        * @param text
+        *            the input
+        * 
+        * @return the decorated output
+        */
+       protected String decorateText(String text) {
+               StringBuilder builder = new StringBuilder();
+
+               int bold = -1;
+               int italic = -1;
+               char prev = '\0';
+               for (char car : text.toCharArray()) {
+                       switch (car) {
+                       case '*':
+                               if (bold >= 0 && prev != ' ') {
+                                       String data = builder.substring(bold);
+                                       builder.setLength(bold);
+                                       builder.append(enbold(data));
+                                       bold = -1;
+                               } else if (bold < 0
+                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
+                                       bold = builder.length();
+                               } else {
+                                       builder.append(car);
+                               }
+
+                               break;
+                       case '_':
+                               if (italic >= 0 && prev != ' ') {
+                                       String data = builder.substring(italic);
+                                       builder.setLength(italic);
+                                       builder.append(enbold(data));
+                                       italic = -1;
+                               } else if (italic < 0
+                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
+                                       italic = builder.length();
+                               } else {
+                                       builder.append(car);
+                               }
+
+                               break;
+                       default:
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (bold >= 0) {
+                       builder.insert(bold, '*');
+               }
+
+               if (italic >= 0) {
+                       builder.insert(italic, '_');
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Return a {@link BasicOutput} object compatible with the given
+        * {@link OutputType}.
+        * 
+        * @param type
+        *            the type
+        * @param writeCover
+        *            TRUE to enable the creation of a cover if possible to be saved
+        *            next to the main target file
+        * @param writeInfo
+        *            TRUE to enable the creation of a .info file to be saved next
+        *            to the main target file
+        * 
+        * @return the {@link BasicOutput}
+        */
+       public static BasicOutput getOutput(OutputType type, boolean writeInfo,
+                       boolean writeCover) {
+               if (type != null) {
+                       switch (type) {
+                       case EPUB:
+                               return new Epub().setType(type, writeInfo, writeCover);
+                       case TEXT:
+                               return new Text().setType(type, writeInfo, true);
+                       case INFO_TEXT:
+                               return new InfoText().setType(type, true, true);
+                       case SYSOUT:
+                               return new Sysout().setType(type, false, false);
+                       case CBZ:
+                               return new Cbz().setType(type, writeInfo, writeCover);
+                       case LATEX:
+                               return new LaTeX().setType(type, writeInfo, writeCover);
+                       case HTML:
+                               return new Html().setType(type, writeInfo, writeCover);
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Cbz.java b/src/be/nikiroo/fanfix/output/Cbz.java
new file mode 100644 (file)
index 0000000..3d90082
--- /dev/null
@@ -0,0 +1,101 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+
+class Cbz extends BasicOutput {
+       private File dir;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               File target = new File(targetDir, targetName);
+
+               dir = Instance.getTempFiles().createTempDir("fanfic-reader-cbz-dir");
+               try {
+                       // will also save the images! (except the cover -> false)
+                       BasicOutput
+                                       .getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
+                                       // Force cover to FALSE:
+                                       .setType(OutputType.TEXT, isWriteInfo(), false)
+                                       .process(story, dir, targetNameOrig);
+
+                       try {
+                               super.process(story, targetDir, targetNameOrig);
+                       } finally {
+                       }
+
+                       InfoCover.writeInfo(dir, targetNameOrig, story.getMeta());
+                       if (story.getMeta() != null && !story.getMeta().isFakeCover()) {
+                               InfoCover.writeCover(dir, targetNameOrig, story.getMeta());
+                       }
+
+                       IOUtils.writeSmallFile(dir, "version", "3.0");
+
+                       IOUtils.zip(dir, target, true);
+               } finally {
+                       IOUtils.deltree(dir);
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".cbz";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               MetaData meta = story.getMeta();
+
+               StringBuilder builder = new StringBuilder();
+               if (meta != null && meta.getResume() != null) {
+                       for (Paragraph para : story.getMeta().getResume()) {
+                               builder.append(para.getContent());
+                               builder.append("\n");
+                       }
+               }
+
+               BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(new File(dir, "URL")), "UTF-8"));
+               try {
+                       if (meta != null) {
+                               writer.write(meta.getUrl());
+                       }
+               } finally {
+                       writer.close();
+               }
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(new File(dir, "SUMMARY")), "UTF-8"));
+               try {
+                       String title = "";
+                       if (meta != null && meta.getTitle() != null) {
+                               title = meta.getTitle();
+                       }
+
+                       writer.write(title);
+                       if (meta != null && meta.getAuthor() != null) {
+                               writer.write("\n©");
+                               writer.write(meta.getAuthor());
+                       }
+                       writer.write("\n\n");
+                       writer.write(builder.toString());
+               } finally {
+                       writer.close();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Epub.java b/src/be/nikiroo/fanfix/output/Epub.java
new file mode 100644 (file)
index 0000000..b7401d3
--- /dev/null
@@ -0,0 +1,517 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.StringUtils;
+
+class Epub extends BasicOutput {
+       private File tmpDir;
+       private BufferedWriter writer;
+       private boolean inDialogue = false;
+       private boolean inNormal = false;
+       private File images;
+       private boolean nextParaIsCover = true;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               tmpDir = Instance.getTempFiles().createTempDir("fanfic-reader-epub");
+               tmpDir.delete();
+
+               if (!tmpDir.mkdir()) {
+                       throw new IOException(
+                                       "Cannot create a temporary directory: no space left on device?");
+               }
+
+               super.process(story, targetDir, targetNameOrig);
+
+               File epub = null;
+               try {
+                       // "Originals"
+                       File data = new File(tmpDir, "DATA");
+                       data.mkdir();
+                       BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(),
+                                       isWriteCover()).process(story, data, targetNameOrig);
+                       InfoCover.writeInfo(data, targetNameOrig, story.getMeta());
+                       IOUtils.writeSmallFile(data, "version", "3.0");
+
+                       // zip/epub
+                       epub = new File(targetDir, targetName);
+                       IOUtils.zip(tmpDir, epub, true);
+
+                       OutputStream out = new FileOutputStream(epub);
+                       try {
+                               ZipOutputStream zip = new ZipOutputStream(out);
+                               try {
+                                       // "mimetype" MUST be the first element and not compressed
+                                       zip.setLevel(ZipOutputStream.STORED);
+                                       File mimetype = new File(tmpDir, "mimetype");
+                                       IOUtils.writeSmallFile(tmpDir, "mimetype",
+                                                       "application/epub+zip");
+                                       ZipEntry entry = new ZipEntry("mimetype");
+                                       entry.setExtra(new byte[] {});
+                                       zip.putNextEntry(entry);
+                                       FileInputStream in = new FileInputStream(mimetype);
+                                       try {
+                                               IOUtils.write(in, zip);
+                                       } finally {
+                                               in.close();
+                                       }
+                                       IOUtils.deltree(mimetype);
+                                       zip.setLevel(ZipOutputStream.DEFLATED);
+                                       //
+
+                                       IOUtils.zip(zip, "", tmpDir, true);
+                               } finally {
+                                       zip.close();
+                               }
+                       } finally {
+                               out.close();
+                       }
+               } finally {
+                       IOUtils.deltree(tmpDir);
+                       tmpDir = null;
+               }
+
+               return epub;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".epub";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               File ops = new File(tmpDir, "OPS");
+               ops.mkdirs();
+               File css = new File(ops, "css");
+               css.mkdirs();
+               images = new File(ops, "images");
+               images.mkdirs();
+               File metaInf = new File(tmpDir, "META-INF");
+               metaInf.mkdirs();
+
+               // META-INF
+               String containerContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+                               + "<container xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\" version=\"1.0\">\n"
+                               + "\t<rootfiles>\n"
+                               + "\t\t<rootfile full-path=\"OPS/epb.opf\" media-type=\"application/oebps-package+xml\"/>\n"
+                               + "\t</rootfiles>\n" + "</container>\n";
+
+               IOUtils.writeSmallFile(metaInf, "container.xml", containerContent);
+
+               // OPS/css
+               InputStream inStyle = getClass().getResourceAsStream("epub.style.css");
+               if (inStyle == null) {
+                       throw new IOException("Cannot find style.css resource");
+               }
+               try {
+                       IOUtils.write(inStyle, new File(css, "style.css"));
+               } finally {
+                       inStyle.close();
+               }
+
+               // OPS/images
+               if (story.getMeta() != null && story.getMeta().getCover() != null) {
+                       File file = new File(images, "cover");
+                       try {
+                               Instance.getCache().saveAsImage(story.getMeta().getCover(),
+                                               file, true);
+                       } catch (Exception e) {
+                               Instance.getTraceHandler().error(e);
+                       }
+               }
+
+               // OPS/* except chapters
+               IOUtils.writeSmallFile(ops, "epb.ncx", generateNcx(story));
+               IOUtils.writeSmallFile(ops, "epb.opf", generateOpf(story));
+               IOUtils.writeSmallFile(ops, "title.xhtml", generateTitleXml(story));
+
+               // Resume
+               if (story.getMeta() != null && story.getMeta().getResume() != null) {
+                       writeChapter(story.getMeta().getResume());
+               }
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String filename = String.format("%s%03d%s", "chapter-",
+                               chap.getNumber(), ".xhtml");
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(new File(tmpDir + File.separator + "OPS",
+                                               filename)), "UTF-8"));
+               inDialogue = false;
+               inNormal = false;
+               try {
+                       String title = "Chapter " + chap.getNumber();
+                       String nameOrNum = Integer.toString(chap.getNumber());
+                       if (chap.getName() != null && !chap.getName().isEmpty()) {
+                               title += ": " + chap.getName();
+                               nameOrNum = chap.getName();
+                       }
+
+                       writer.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+                       writer.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
+                       writer.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
+                       writer.write("\n<head>");
+                       writer.write("\n        <title>" + StringUtils.xmlEscape(title)
+                                       + "</title>");
+                       writer.write("\n        <link rel='stylesheet' href='css/style.css' type='text/css'/>");
+                       writer.write("\n</head>");
+                       writer.write("\n<body>");
+                       writer.write("\n        <h2>");
+                       writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
+                                       + chap.getNumber() + "</span>:</span> ");
+                       writer.write("\n                <span class='chaptitle'>"
+                                       + StringUtils.xmlEscape(nameOrNum) + "</span>");
+                       writer.write("\n        </h2>");
+                       writer.write("\n        ");
+                       writer.write("\n        <div class='chapter_content'>\n");
+               } catch (Exception e) {
+                       writer.close();
+                       throw new IOException(e);
+               }
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               try {
+                       if (inDialogue) {
+                               writer.write("          </div>\n");
+                               inDialogue = false;
+                       }
+                       if (inNormal) {
+                               writer.write("          </div>\n");
+                               inNormal = false;
+                       }
+                       writer.write("  </div>\n</body>\n</html>\n");
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
+                       writer.write("          <div class='dialogues'>\n");
+                       inDialogue = true;
+               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+
+               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
+                       writer.write("          <div class='normals'>\n");
+                       inNormal = true;
+               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               switch (para.getType()) {
+               case BLANK:
+                       writer.write("          <div class='blank'></div>");
+                       break;
+               case BREAK:
+                       writer.write("          <hr class='break'/>");
+                       break;
+               case NORMAL:
+                       writer.write("          <span class='normal'>");
+                       break;
+               case QUOTE:
+                       writer.write("                  <div class='dialogue'>&mdash; ");
+                       break;
+               case IMAGE:
+                       File file = new File(images, getCurrentImageBestName(false));
+                       Instance.getCache().saveAsImage(para.getContentImage(), file,
+                                       nextParaIsCover);
+                       writer.write("                  <img alt='page image' class='page-image' src='images/"
+                                       + getCurrentImageBestName(false) + "'/>");
+                       break;
+               }
+
+               nextParaIsCover = false;
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               switch (para.getType()) {
+               case NORMAL:
+                       writer.write("</span>\n");
+                       break;
+               case QUOTE:
+                       writer.write("</div>\n");
+                       break;
+               default:
+                       writer.write("\n");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case QUOTE:
+               case NORMAL:
+                       writer.write(decorateText(StringUtils.xmlEscape(line)));
+                       break;
+               default:
+                       break;
+               }
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "<strong>" + word + "</strong>";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "<emph>" + word + "</emph>";
+       }
+
+       private String generateNcx(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String uuid = "";
+               String author = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       uuid = meta.getUuid();
+                       author = meta.getAuthor();
+                       title = meta.getTitle();
+               }
+
+               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+               builder.append("\n<!DOCTYPE ncx");
+               builder.append("\nPUBLIC \"-//NISO//DTD ncx 2005-1//EN\" \"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd\">");
+               builder.append("\n<ncx xmlns=\"http://www.daisy.org/z3986/2005/ncx/\" version=\"2005-1\">");
+               builder.append("\n      <head>");
+               builder.append("\n              <!--The following four metadata items are required for all");
+               builder.append("\n                  NCX documents, including those conforming to the relaxed");
+               builder.append("\n                  constraints of OPS 2.0-->");
+               builder.append("\n              <meta name=\"dtb:uid\" content=\""
+                               + StringUtils.xmlEscapeQuote(uuid) + "\"/>");
+               builder.append("\n              <meta name=\"dtb:depth\" content=\"1\"/>");
+               builder.append("\n              <meta name=\"dtb:totalPageCount\" content=\"0\"/>");
+               builder.append("\n              <meta name=\"dtb:maxPageNumber\" content=\"0\"/>");
+               builder.append("\n              <meta name=\"epub-creator\" content=\""
+                               + StringUtils.xmlEscapeQuote(EPUB_CREATOR) + "\"/>");
+               builder.append("\n      </head>");
+               builder.append("\n      <docTitle>");
+               builder.append("\n              <text>" + StringUtils.xmlEscape(title) + "</text>");
+               builder.append("\n      </docTitle>");
+               builder.append("\n      <docAuthor>");
+
+               builder.append("\n              <text>" + StringUtils.xmlEscape(author) + "</text>");
+               builder.append("\n      </docAuthor>");
+               builder.append("\n      <navMap>");
+               builder.append("\n              <navPoint id=\"navpoint-1\" playOrder=\"1\">");
+               builder.append("\n                      <navLabel>");
+               builder.append("\n                              <text>Title Page</text>");
+               builder.append("\n                      </navLabel>");
+               builder.append("\n                      <content src=\"title.xhtml\"/>");
+               builder.append("\n              </navPoint>");
+
+               int navPoint = 2; // 1 is above
+
+               if (story.getMeta() != null & story.getMeta().getResume() != null) {
+                       Chapter chap = story.getMeta().getResume();
+                       generateNcx(chap, builder, navPoint++);
+               }
+
+               for (Chapter chap : story) {
+                       generateNcx(chap, builder, navPoint++);
+               }
+
+               builder.append("\n      </navMap>");
+               builder.append("\n</ncx>\n");
+
+               return builder.toString();
+       }
+
+       private void generateNcx(Chapter chap, StringBuilder builder, int navPoint) {
+               String name;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       name = Instance.getTrans().getString(StringId.CHAPTER_NAMED,
+                                       chap.getNumber(), chap.getName());
+               } else {
+                       name = Instance.getTrans().getString(StringId.CHAPTER_UNNAMED,
+                                       chap.getNumber());
+               }
+
+               String nnn = String.format("%03d", (navPoint - 2));
+
+               builder.append("\n              <navPoint id=\"navpoint-" + navPoint
+                               + "\" playOrder=\"" + navPoint + "\">");
+               builder.append("\n                      <navLabel>");
+               builder.append("\n                              <text>" + name + "</text>");
+               builder.append("\n                      </navLabel>");
+               builder.append("\n                      <content src=\"chapter-" + nnn + ".xhtml\"/>");
+               builder.append("\n              </navPoint>\n");
+       }
+
+       private String generateOpf(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String uuid = "";
+               String author = "";
+               String date = "";
+               String publisher = "";
+               String subject = "";
+               String source = "";
+               String lang = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       uuid = meta.getUuid();
+                       author = meta.getAuthor();
+                       date = meta.getDate();
+                       publisher = meta.getPublisher();
+                       subject = meta.getSubject();
+                       source = meta.getSource();
+                       lang = meta.getLang();
+               }
+
+               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+               builder.append("\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"BookId\" version=\"2.0\">");
+               builder.append("\n   <metadata xmlns:opf=\"http://www.idpf.org/2007/opf\"");
+               builder.append("\n             xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
+               builder.append("\n      <dc:title>" + StringUtils.xmlEscape(title)
+                               + "</dc:title>");
+               builder.append("\n      <dc:creator opf:role=\"aut\" opf:file-as=\""
+                               + StringUtils.xmlEscapeQuote(author) + "\">"
+                               + StringUtils.xmlEscape(author) + "</dc:creator>");
+               builder.append("\n      <dc:date opf:event=\"original-publication\">"
+                               + StringUtils.xmlEscape(date) + "</dc:date>");
+               builder.append("\n      <dc:publisher>"
+                               + StringUtils.xmlEscape(publisher) + "</dc:publisher>");
+               builder.append("\n      <dc:date opf:event=\"epub-publication\"></dc:date>");
+               builder.append("\n      <dc:subject>" + StringUtils.xmlEscape(subject)
+                               + "</dc:subject>");
+               builder.append("\n      <dc:source>" + StringUtils.xmlEscape(source)
+                               + "</dc:source>");
+               builder.append("\n      <dc:rights>Not for commercial use.</dc:rights>");
+               builder.append("\n      <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"
+                               + StringUtils.xmlEscape(uuid) + "</dc:identifier>");
+               builder.append("\n      <dc:language>" + StringUtils.xmlEscape(lang)
+                               + "</dc:language>");
+               builder.append("\n   </metadata>");
+               builder.append("\n   <manifest>");
+               builder.append("\n      <!-- Content Documents -->");
+               builder.append("\n      <item id=\"titlepage\" href=\"title.xhtml\" media-type=\"application/xhtml+xml\"/>");
+               for (int i = 0; i <= story.getChapters().size(); i++) {
+                       String name = String.format("%s%03d", "chapter-", i);
+                       builder.append("\n      <item id=\""
+                                       + StringUtils.xmlEscapeQuote(name) + "\" href=\""
+                                       + StringUtils.xmlEscapeQuote(name)
+                                       + ".xhtml\" media-type=\"application/xhtml+xml\"/>");
+               }
+
+               builder.append("\n      <!-- CSS Style Sheets -->");
+               builder.append("\n      <item id=\"style-css\" href=\"css/style.css\" media-type=\"text/css\"/>");
+
+               builder.append("\n      <!-- Images -->");
+
+               if (story.getMeta() != null && story.getMeta().getCover() != null) {
+                       String format = Instance.getConfig()
+                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
+                                       .toLowerCase();
+                       builder.append("\n      <item id=\"cover\" href=\"images/cover."
+                                       + format + "\" media-type=\"image/png\"/>");
+               }
+
+               builder.append("\n      <!-- NCX -->");
+               builder.append("\n      <item id=\"ncx\" href=\"epb.ncx\" media-type=\"application/x-dtbncx+xml\"/>");
+               builder.append("\n   </manifest>");
+               builder.append("\n   <spine toc=\"ncx\">");
+               builder.append("\n      <itemref idref=\"titlepage\" linear=\"yes\"/>");
+               for (int i = 0; i <= story.getChapters().size(); i++) {
+                       String name = String.format("%s%03d", "chapter-", i);
+                       builder.append("\n      <itemref idref=\""
+                                       + StringUtils.xmlEscapeQuote(name) + "\" linear=\"yes\"/>");
+               }
+               builder.append("\n   </spine>");
+               builder.append("\n</package>\n");
+
+               return builder.toString();
+       }
+
+       private String generateTitleXml(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String tags = "";
+               String author = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       if (meta.getTags() != null) {
+                               for (String tag : meta.getTags()) {
+                                       if (!tags.isEmpty()) {
+                                               tags += ", ";
+                                       }
+                                       tags += tag;
+                               }
+
+                               if (!tags.isEmpty()) {
+                                       tags = "(" + tags + ")";
+                               }
+                       }
+                       author = meta.getAuthor();
+               }
+
+               String format = Instance.getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+
+               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+               builder.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
+               builder.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
+               builder.append("\n<head>");
+               builder.append("\n      <title>" + StringUtils.xmlEscape(title) + "</title>");
+               builder.append("\n      <link rel=\"stylesheet\" href=\"css/style.css\" type=\"text/css\"/>");
+               builder.append("\n</head>");
+               builder.append("\n<body>");
+               builder.append("\n      <div class=\"titlepage\">");
+               builder.append("\n              <h1>" + StringUtils.xmlEscape(title) + "</h1>");
+               builder.append("\n                      <div class=\"type\">"
+                               + StringUtils.xmlEscape(tags) + "</div>");
+               builder.append("\n              <div class=\"cover\">");
+               builder.append("\n                      <img alt=\"cover image\" src=\"images/cover."
+                               + format + "\"></img>");
+               builder.append("\n              </div>");
+               builder.append("\n              <div class=\"author\">"
+                               + StringUtils.xmlEscape(author) + "</div>");
+               builder.append("\n      </div>");
+               builder.append("\n</body>");
+               builder.append("\n</html>\n");
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Html.java b/src/be/nikiroo/fanfix/output/Html.java
new file mode 100644 (file)
index 0000000..ca802a5
--- /dev/null
@@ -0,0 +1,265 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.StringUtils;
+
+class Html extends BasicOutput {
+       private File dir;
+       protected BufferedWriter writer;
+       private boolean inDialogue = false;
+       private boolean inNormal = false;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+
+               File target = new File(targetDir, targetName);
+               target.mkdir();
+               dir = target;
+
+               target = new File(targetDir, targetName + getDefaultExtension(true));
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(target), "UTF-8"));
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               // write a copy of the originals inside
+               InfoCover.writeInfo(dir, targetName, story.getMeta());
+               InfoCover.writeCover(dir, targetName, story.getMeta());
+               BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
+                               .process(story, dir, targetNameOrig);
+
+               if (story.getMeta().getCover() != null) {
+                       Instance.getCache().saveAsImage(story.getMeta().getCover(),
+                                       new File(dir, "cover"), true);
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               if (readerTarget) {
+                       return File.separator + "index.html";
+               }
+
+               return "";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String title = "";
+               String tags = "";
+               String author = "";
+               Chapter resume = null;
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       resume = meta.getResume();
+                       if (meta.getTags() != null) {
+                               for (String tag : meta.getTags()) {
+                                       if (!tags.isEmpty()) {
+                                               tags += ", ";
+                                       }
+                                       tags += tag;
+                               }
+
+                               if (!tags.isEmpty()) {
+                                       tags = "(" + tags + ")";
+                               }
+                       }
+                       author = meta.getAuthor();
+               }
+
+               String format = Instance.getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+
+               InputStream inStyle = getClass().getResourceAsStream("html.style.css");
+               if (inStyle == null) {
+                       throw new IOException("Cannot find style.css resource");
+               }
+               try {
+                       IOUtils.write(inStyle, new File(dir, "style.css"));
+               } finally {
+                       inStyle.close();
+               }
+
+               writer.write("<!DOCTYPE html>");
+               writer.write("\n<html>");
+               writer.write("\n<head>");
+               writer.write("\n        <meta http-equiv='content-type' content='text/html; charset=utf-8'>");
+               writer.write("\n        <meta name='viewport' content='width=device-width, initial-scale=1.0'>");
+               writer.write("\n        <link rel='stylesheet' type='text/css' href='style.css'>");
+               writer.write("\n        <title>" + StringUtils.xmlEscape(title) + "</title>");
+               writer.write("\n</head>");
+               writer.write("\n<body>\n");
+
+               writer.write("\n        <div class=\"titlepage\">");
+               writer.write("\n                <h1>" + StringUtils.xmlEscape(title) + "</h1>");
+               writer.write("\n                        <div class=\"type\">" + StringUtils.xmlEscape(tags)
+                               + "</div>");
+               writer.write("\n                <div class=\"cover\">");
+               writer.write("\n                        <img src=\"cover." + format + "\"></img>");
+               writer.write("\n                </div>");
+               writer.write("\n                <div class=\"author\">"
+                               + StringUtils.xmlEscape(author) + "</div>");
+               writer.write("\n        </div>");
+
+               writer.write("\n        <hr/><br/>");
+
+               if (resume != null) {
+                       for (Paragraph para : resume) {
+                               writeParagraph(para);
+                       }
+                       if (inDialogue) {
+                               writer.write("          </div>\n");
+                               inDialogue = false;
+                       }
+                       if (inNormal) {
+                               writer.write("          </div>\n");
+                               inNormal = false;
+                       }
+               }
+
+               writer.write("\n        <br/>");
+       }
+
+       @Override
+       protected void writeStoryFooter(Story story) throws IOException {
+               writer.write("</body>\n");
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String nameOrNumber;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       nameOrNumber = chap.getName();
+               } else {
+                       nameOrNumber = Integer.toString(chap.getNumber());
+               }
+
+               writer.write("\n        <h2>");
+               writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
+                               + chap.getNumber() + "</span>:</span> ");
+               writer.write("\n                <span class='chaptitle'>"
+                               + StringUtils.xmlEscape(nameOrNumber) + "</span>");
+               writer.write("\n        </h2>");
+               writer.write("\n        ");
+               writer.write("\n        <div class='chapter_content'>\n");
+
+               inDialogue = false;
+               inNormal = false;
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               if (inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+               if (inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               writer.write("\n        </div>");
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
+                       writer.write("          <div class='dialogues'>\n");
+                       inDialogue = true;
+               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+
+               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
+                       writer.write("          <div class='normals'>\n");
+                       inNormal = true;
+               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               switch (para.getType()) {
+               case BLANK:
+                       writer.write("          <div class='blank'></div>");
+                       break;
+               case BREAK:
+                       writer.write("          <hr class='break'/>");
+                       break;
+               case NORMAL:
+                       writer.write("          <span class='normal'>");
+                       break;
+               case QUOTE:
+                       writer.write("                  <div class='dialogue'>&mdash; ");
+                       break;
+               case IMAGE:
+                       // TODO
+                       writer.write("<a href='"
+                                       + StringUtils.xmlEscapeQuote(para.getContent()) + "'>"
+                                       + StringUtils.xmlEscape(para.getContent()) + "</a>");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               switch (para.getType()) {
+               case NORMAL:
+                       writer.write("</span>\n");
+                       break;
+               case QUOTE:
+                       writer.write("</div>\n");
+                       break;
+               default:
+                       writer.write("\n");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case QUOTE:
+               case NORMAL:
+                       writer.write(decorateText(StringUtils.xmlEscape(line)));
+                       break;
+               default:
+                       break;
+               }
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "<strong>" + word + "</strong>";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "<emph>" + word + "</emph>";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/InfoCover.java b/src/be/nikiroo/fanfix/output/InfoCover.java
new file mode 100644 (file)
index 0000000..6bfa4dd
--- /dev/null
@@ -0,0 +1,92 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+
+public class InfoCover {
+       public static void writeInfo(File targetDir, String targetName,
+                       MetaData meta) throws IOException {
+               File info = new File(targetDir, targetName + ".info");
+
+               BufferedWriter infoWriter = null;
+               try {
+                       infoWriter = new BufferedWriter(new OutputStreamWriter(
+                                       new FileOutputStream(info), "UTF-8"));
+
+                       if (meta != null) {
+                               String tags = "";
+                               if (meta.getTags() != null) {
+                                       for (String tag : meta.getTags()) {
+                                               if (!tags.isEmpty()) {
+                                                       tags += ", ";
+                                               }
+                                               tags += tag;
+                                       }
+                               }
+
+                               writeMeta(infoWriter, "TITLE", meta.getTitle());
+                               writeMeta(infoWriter, "AUTHOR", meta.getAuthor());
+                               writeMeta(infoWriter, "DATE", meta.getDate());
+                               writeMeta(infoWriter, "SUBJECT", meta.getSubject());
+                               writeMeta(infoWriter, "SOURCE", meta.getSource());
+                               writeMeta(infoWriter, "URL", meta.getUrl());
+                               writeMeta(infoWriter, "TAGS", tags);
+                               writeMeta(infoWriter, "UUID", meta.getUuid());
+                               writeMeta(infoWriter, "LUID", meta.getLuid());
+                               writeMeta(infoWriter, "LANG", meta.getLang() == null ? ""
+                                               : meta.getLang().toLowerCase());
+                               writeMeta(infoWriter, "IMAGES_DOCUMENT",
+                                               meta.isImageDocument() ? "true" : "false");
+                               writeMeta(infoWriter, "TYPE", meta.getType());
+                               if (meta.getCover() != null) {
+                                       String format = Instance.getConfig()
+                                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+                                       writeMeta(infoWriter, "COVER", targetName + "." + format);
+                               } else {
+                                       writeMeta(infoWriter, "COVER", "");
+                               }
+                               writeMeta(infoWriter, "EPUBCREATOR", BasicOutput.EPUB_CREATOR);
+                               writeMeta(infoWriter, "PUBLISHER", meta.getPublisher());
+                               writeMeta(infoWriter, "WORDCOUNT",
+                                               Long.toString(meta.getWords()));
+                               writeMeta(infoWriter, "CREATION_DATE", meta.getCreationDate());
+                               writeMeta(infoWriter, "FAKE_COVER",
+                                               Boolean.toString(meta.isFakeCover()));
+                       }
+               } finally {
+                       if (infoWriter != null) {
+                               infoWriter.close();
+                       }
+               }
+       }
+
+       public static void writeCover(File targetDir, String targetName,
+                       MetaData meta) {
+               if (meta != null && meta.getCover() != null) {
+                       try {
+                               Instance.getCache().saveAsImage(meta.getCover(),
+                                               new File(targetDir, targetName), true);
+                       } catch (IOException e) {
+                               // Allow to continue without cover
+                               Instance.getTraceHandler().error(
+                                               new IOException("Failed to save the cover image", e));
+                       }
+               }
+       }
+
+       private static void writeMeta(BufferedWriter writer, String key,
+                       String value) throws IOException {
+               if (value == null) {
+                       value = "";
+               }
+
+               writer.write(String.format("%s=\"%s\"\n", key, value.replace("\"", "'")));
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/InfoText.java b/src/be/nikiroo/fanfix/output/InfoText.java
new file mode 100644 (file)
index 0000000..cce715d
--- /dev/null
@@ -0,0 +1,74 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+class InfoText extends Text {
+       // quote chars
+       private char openQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return "";
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               writer.write("\n");
+
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       writer.write(Instance.getTrans().getString(StringId.CHAPTER_NAMED,
+                                       chap.getNumber(), chap.getName()));
+               } else {
+                       writer.write(Instance.getTrans().getString(
+                                       StringId.CHAPTER_UNNAMED, chap.getNumber()));
+               }
+
+               writer.write("\n\n");
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case NORMAL:
+               case QUOTE:
+                       StringBuilder builder = new StringBuilder();
+                       for (char car : line.toCharArray()) {
+                               if (car == '—') {
+                                       builder.append("---");
+                               } else if (car == '–') {
+                                       builder.append("--");
+                               } else if (car == openDoubleQuote) {
+                                       builder.append("\"");
+                               } else if (car == closeDoubleQuote) {
+                                       builder.append("\"");
+                               } else if (car == openQuote) {
+                                       builder.append("'");
+                               } else if (car == closeQuote) {
+                                       builder.append("'");
+                               } else {
+                                       builder.append(car);
+                               }
+                       }
+
+                       line = builder.toString();
+                       break;
+               default:
+                       break;
+               }
+
+               super.writeTextLine(type, line);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/LaTeX.java b/src/be/nikiroo/fanfix/output/LaTeX.java
new file mode 100644 (file)
index 0000000..321556f
--- /dev/null
@@ -0,0 +1,185 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+
+class LaTeX extends BasicOutput {
+       protected BufferedWriter writer;
+       private boolean lastWasQuote = false;
+
+       // quote chars
+       private char openQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               File target = new File(targetDir, targetName);
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(target), "UTF-8"));
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".tex";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String date = "";
+               String author = "";
+               String title = "\\title{}";
+               String lang = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = "\\title{" + latexEncode(meta.getTitle()) + "}";
+                       date = "\\date{" + latexEncode(meta.getDate()) + "}";
+                       author = "\\author{" + latexEncode(meta.getAuthor()) + "}";
+                       lang = meta.getLang().toLowerCase();
+                       if (lang != null && !lang.isEmpty()) {
+                               lang = Instance.getConfig().getStringX(Config.CONF_LATEX_LANG, lang);
+                               if (lang == null) {
+                                       System.err.println(Instance.getTrans().getString(
+                                                       StringId.LATEX_LANG_UNKNOWN, lang));
+                               }
+                       }
+               }
+
+               writer.append("%\n");
+               writer.append("% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.\n");
+               writer.append("%\n\n");
+               writer.append("\\documentclass[a4paper]{book}\n");
+               if (lang != null && !lang.isEmpty()) {
+                       writer.append("\\usepackage[" + lang + "]{babel}\n");
+               }
+               writer.append("\\usepackage[utf8]{inputenc}\n");
+               writer.append("\\usepackage[T1]{fontenc}\n");
+               writer.append("\\usepackage{lmodern}\n");
+               writer.append("\\newcommand{\\br}{\\vspace{10 mm}}\n");
+               writer.append("\\newcommand{\\say}{--- \\noindent\\emph}\n");
+               writer.append("\\hyphenpenalty=1000\n");
+               writer.append("\\tolerance=5000\n");
+               writer.append("\\begin{document}\n");
+               if (story.getMeta() != null && story.getMeta().getDate() != null)
+                       writer.append(date + "\n");
+               writer.append(title + "\n");
+               writer.append(author + "\n");
+               writer.append("\\maketitle\n");
+               writer.append("\n");
+
+               // TODO: cover
+       }
+
+       @Override
+       protected void writeStoryFooter(Story story) throws IOException {
+               writer.append("\\end{document}\n");
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               writer.append("\n\n\\chapter{" + latexEncode(chap.getName()) + "}"
+                               + "\n");
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               writer.write("\n");
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "\\textsc{" + word + "}";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "\\emph{" + word + "}";
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+
+               line = decorateText(latexEncode(line));
+
+               switch (type) {
+               case BLANK:
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case BREAK:
+                       writer.write("\n\\br");
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case NORMAL:
+                       writer.write(line);
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case QUOTE:
+                       writer.write("\n\\say{" + line + "}\n");
+                       if (lastWasQuote) {
+                               writer.write("\n\\noindent{}");
+                       }
+                       lastWasQuote = true;
+                       break;
+               case IMAGE:
+                       // TODO
+                       break;
+               }
+       }
+
+       private String latexEncode(String input) {
+               StringBuilder builder = new StringBuilder();
+               for (char car : input.toCharArray()) {
+                       // TODO: check restricted chars?
+                       if (car == '^' || car == '$' || car == '\\' || car == '#'
+                                       || car == '%') {
+                               builder.append('\\');
+                               builder.append(car);
+                       } else if (car == openQuote) {
+                               builder.append('`');
+                       } else if (car == closeQuote) {
+                               builder.append('\'');
+                       } else if (car == openDoubleQuote) {
+                               builder.append("``");
+                       } else if (car == closeDoubleQuote) {
+                               builder.append("''");
+                       } else {
+                               builder.append(car);
+                       }
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Sysout.java b/src/be/nikiroo/fanfix/output/Sysout.java
new file mode 100644 (file)
index 0000000..f6cd789
--- /dev/null
@@ -0,0 +1,22 @@
+package be.nikiroo.fanfix.output;
+
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+
+class Sysout extends BasicOutput {
+       @Override
+       protected void writeStoryHeader(Story story) {
+               System.out.println(story);
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) {
+               System.out.println(chap);
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) {
+               System.out.println(para);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Text.java b/src/be/nikiroo/fanfix/output/Text.java
new file mode 100644 (file)
index 0000000..4a45e54
--- /dev/null
@@ -0,0 +1,138 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+
+class Text extends BasicOutput {
+       protected BufferedWriter writer;
+       protected File targetDir;
+       private boolean nextParaIsCover = true;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               this.targetDir = targetDir;
+
+               File target = new File(targetDir, targetName);
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(target), "UTF-8"));
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".txt";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String title = "";
+               String author = null;
+               String date = null;
+
+               MetaData meta = story.getMeta();
+               if (meta != null) {
+                       title = meta.getTitle() == null ? "" : meta.getTitle();
+                       author = meta.getAuthor();
+                       date = meta.getDate();
+               }
+
+               writer.write(title);
+               writer.write("\n");
+               if (author != null && !author.isEmpty()) {
+                       writer.write(Instance.getTrans().getString(StringId.BY) + " "
+                                       + author);
+               }
+               if (date != null && !date.isEmpty()) {
+                       writer.write(" (");
+                       writer.write(date);
+                       writer.write(")");
+               }
+               writer.write("\n");
+
+               // resume:
+               if (meta != null && meta.getResume() != null) {
+                       writeChapter(meta.getResume());
+               }
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String txt;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       txt = Instance.getTrans().getString(StringId.CHAPTER_NAMED,
+                                       chap.getNumber(), chap.getName());
+               } else {
+                       txt = Instance.getTrans().getString(StringId.CHAPTER_UNNAMED,
+                                       chap.getNumber());
+               }
+
+               writer.write("\n" + txt + "\n");
+               for (int i = 0; i < txt.length(); i++) {
+                       writer.write("—");
+               }
+               writer.write("\n\n");
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               writer.write("\n");
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.IMAGE) {
+                       File file = new File(targetDir, getCurrentImageBestName(true));
+                       try {
+                               Instance.getCache().saveAsImage(para.getContentImage(), file,
+                                               nextParaIsCover);
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(
+                                               new IOException("Cannot save an image", e));
+                       }
+               }
+
+               nextParaIsCover = false;
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case BLANK:
+                       break;
+               case BREAK:
+                       writer.write("\n* * *\n");
+                       break;
+               case NORMAL:
+               case QUOTE:
+                       writer.write(line);
+                       break;
+               case IMAGE:
+                       writer.write("[" + getCurrentImageBestName(true) + "]");
+                       break;
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/epub.style.css b/src/be/nikiroo/fanfix/output/epub.style.css
new file mode 100644 (file)
index 0000000..2c4a961
--- /dev/null
@@ -0,0 +1,110 @@
+html {
+       text-align: justify;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       NO_text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+}
+
+.normal {
+       /* Can be removed if you want a more "compact" view */
+       display: block;
+}
+
+.blank {
+       /* Can be removed if you want a more "compact" view */
+       height: 24px;
+       width: 100%;
+}
+
+hr.break {
+       /* Can be removed if you want a more "compact" view */
+       margin-top: 48px;
+       margin-bottom: 48px;
+}
+
+.dialogues {
+}
+
+.dialogue {
+       font-style: italic;
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/output/html.style.css b/src/be/nikiroo/fanfix/output/html.style.css
new file mode 100644 (file)
index 0000000..6b6d0d2
--- /dev/null
@@ -0,0 +1,112 @@
+html {
+       text-align: justify;
+       max-width: 800px;
+       margin: auto;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       NO_text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+}
+
+.normal {
+       /* Can be removed if you want a more "compact" view */
+       display: block;
+}
+
+.blank {
+       /* Can be removed if you want a more "compact" view */
+       height: 24px;
+       width: 100%;
+}
+
+hr.break {
+       /* Can be removed if you want a more "compact" view */
+       margin-top: 48px;
+       margin-bottom: 48px;
+}
+
+.dialogues {
+}
+
+.dialogue {
+       font-style: italic;
+}
diff --git a/src/be/nikiroo/fanfix/output/package-info.java b/src/be/nikiroo/fanfix/output/package-info.java
new file mode 100644 (file)
index 0000000..8314c80
--- /dev/null
@@ -0,0 +1,12 @@
+/**
+ * This package contains all the output processors.
+ * <p>
+ * Of those, only {@link be.nikiroo.fanfix.output.BasicOutput} is public,
+ * but it contains a method 
+ * ({@link be.nikiroo.fanfix.output.BasicOutput#getOutput(be.nikiroo.fanfix.output.BasicOutput.OutputType, boolean,boolean)})
+ * to get all the other 
+ * {@link be.nikiroo.fanfix.output.BasicOutput.OutputType}s.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.output;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/package-info.java b/src/be/nikiroo/fanfix/package-info.java
new file mode 100644 (file)
index 0000000..cfd9cbe
--- /dev/null
@@ -0,0 +1,11 @@
+/**
+ * Fanfix is a program that can support a few different websites from 
+ * which to retrieve stories, then process them into <tt>epub</tt> (or other)
+ * files that you can read anywhere.
+ * <p>
+ * It has support for a {@link be.nikiroo.fanfix.library.BasicLibrary} system, 
+ * too, and can even offer its services over the network.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/reader/BasicReader.java b/src/be/nikiroo/fanfix/reader/BasicReader.java
new file mode 100644 (file)
index 0000000..61769c0
--- /dev/null
@@ -0,0 +1,390 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+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.LocalLibrary;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.serial.SerialUtils;
+
+/**
+ * The class that handles the different {@link Story} readers you can use.
+ * <p>
+ * All the readers should be accessed via {@link BasicReader#getReader()}.
+ * 
+ * @author niki
+ */
+public abstract class BasicReader implements Reader {
+       private static BasicLibrary defaultLibrary = Instance.getLibrary();
+       private static ReaderType defaultType = ReaderType.GUI;
+
+       private BasicLibrary lib;
+       private MetaData meta;
+       private Story story;
+       private int chapter;
+
+       /**
+        * Take the default reader type configuration from the config file.
+        */
+       static {
+               String typeString = Instance.getConfig().getString(Config.READER_TYPE);
+               if (typeString != null && !typeString.isEmpty()) {
+                       try {
+                               ReaderType type = ReaderType.valueOf(typeString.toUpperCase());
+                               defaultType = type;
+                       } catch (IllegalArgumentException e) {
+                               // Do nothing
+                       }
+               }
+       }
+
+       @Override
+       public synchronized Story getStory(Progress pg) throws IOException {
+               if (story == null) {
+                       story = getLibrary().getStory(meta.getLuid(), pg);
+               }
+
+               return story;
+       }
+
+       @Override
+       public BasicLibrary getLibrary() {
+               if (lib == null) {
+                       lib = defaultLibrary;
+               }
+
+               return lib;
+       }
+
+       @Override
+       public void setLibrary(BasicLibrary lib) {
+               this.lib = lib;
+       }
+
+       @Override
+       public synchronized MetaData getMeta() {
+               return meta;
+       }
+
+       @Override
+       public synchronized void setMeta(MetaData meta) throws IOException {
+               setMeta(meta == null ? null : meta.getLuid()); // must check the library
+       }
+
+       @Override
+       public synchronized void setMeta(String luid) throws IOException {
+               story = null;
+               meta = getLibrary().getInfo(luid);
+
+               if (meta == null) {
+                       throw new IOException("Cannot retrieve story from library: " + luid);
+               }
+       }
+
+       @Override
+       public synchronized void setMeta(URL url, Progress pg) throws IOException {
+               BasicSupport support = BasicSupport.getSupport(url);
+               if (support == null) {
+                       throw new IOException("URL not supported: " + url.toString());
+               }
+
+               story = support.process(pg);
+               if (story == null) {
+                       throw new IOException(
+                                       "Cannot retrieve story from external source: "
+                                                       + url.toString());
+               }
+
+               meta = story.getMeta();
+       }
+
+       @Override
+       public int getChapter() {
+               return chapter;
+       }
+
+       @Override
+       public void setChapter(int chapter) {
+               this.chapter = chapter;
+       }
+
+       /**
+        * Return a new {@link BasicReader} ready for use if one is configured.
+        * <p>
+        * Can return NULL if none are configured.
+        * 
+        * @return a {@link BasicReader}, or NULL if none configured
+        */
+       public static Reader getReader() {
+               try {
+                       if (defaultType != null) {
+                               return (Reader) SerialUtils.createObject(defaultType
+                                               .getTypeName());
+                       }
+               } catch (Exception e) {
+                       Instance.getTraceHandler().error(
+                                       new Exception("Cannot create a reader of type: "
+                                                       + defaultType + " (Not compiled in?)", e));
+               }
+
+               return null;
+       }
+
+       /**
+        * The default {@link Reader.ReaderType} used when calling
+        * {@link BasicReader#getReader()}.
+        * 
+        * @return the default type
+        */
+       public static ReaderType getDefaultReaderType() {
+               return defaultType;
+       }
+
+       /**
+        * The default {@link Reader.ReaderType} used when calling
+        * {@link BasicReader#getReader()}.
+        * 
+        * @param defaultType
+        *            the new default type
+        */
+       public static void setDefaultReaderType(ReaderType defaultType) {
+               BasicReader.defaultType = defaultType;
+       }
+
+       /**
+        * Change the default {@link LocalLibrary} to open with the
+        * {@link BasicReader}s.
+        * 
+        * @param lib
+        *            the new {@link LocalLibrary}
+        */
+       public static void setDefaultLibrary(BasicLibrary lib) {
+               BasicReader.defaultLibrary = lib;
+       }
+
+       /**
+        * Return an {@link URL} from this {@link String}, be it a file path or an
+        * actual {@link URL}.
+        * 
+        * @param sourceString
+        *            the source
+        * 
+        * @return the corresponding {@link URL}
+        * 
+        * @throws MalformedURLException
+        *             if this is neither a file nor a conventional {@link URL}
+        */
+       public static URL getUrl(String sourceString) throws MalformedURLException {
+               if (sourceString == null || sourceString.isEmpty()) {
+                       throw new MalformedURLException("Empty url");
+               }
+
+               URL source = null;
+               try {
+                       source = new URL(sourceString);
+               } catch (MalformedURLException e) {
+                       File sourceFile = new File(sourceString);
+                       source = sourceFile.toURI().toURL();
+               }
+
+               return source;
+       }
+
+       /**
+        * Describe a {@link Story} from its {@link MetaData} and return a list of
+        * title/value that represent this {@link Story}.
+        * 
+        * @param meta
+        *            the {@link MetaData} to represent
+        * 
+        * @return the information
+        */
+       public static Map<String, String> getMetaDesc(MetaData meta) {
+               Map<String, String> metaDesc = new TreeMap<String, String>();
+
+               // TODO: i18n
+
+               StringBuilder tags = new StringBuilder();
+               for (String tag : meta.getTags()) {
+                       if (tags.length() > 0) {
+                               tags.append(", ");
+                       }
+                       tags.append(tag);
+               }
+
+               // TODO: i18n
+               metaDesc.put("Author", meta.getAuthor());
+               metaDesc.put("Publication date", formatDate(meta.getDate()));
+               metaDesc.put("Published on", meta.getPublisher());
+               metaDesc.put("URL", meta.getUrl());
+               String count = "";
+               if (meta.getWords() > 0) {
+                       count = StringUtils.formatNumber(meta.getWords());
+               }
+               if (meta.isImageDocument()) {
+                       metaDesc.put("Number of images", count);
+               } else {
+                       metaDesc.put("Number of words", count);
+               }
+               metaDesc.put("Source", meta.getSource());
+               metaDesc.put("Subject", meta.getSubject());
+               metaDesc.put("Language", meta.getLang());
+               metaDesc.put("Tags", tags.toString());
+
+               return metaDesc;
+       }
+
+       /**
+        * Open the {@link Story} with an external reader (the program will be
+        * passed the main file associated with this {@link Story}).
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to select the {@link Story} from
+        * @param luid
+        *            the {@link Story} LUID
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @Override
+       public void openExternal(BasicLibrary lib, String luid, boolean sync)
+                       throws IOException {
+               MetaData meta = lib.getInfo(luid);
+               File target = lib.getFile(luid, null);
+
+               openExternal(meta, target, sync);
+       }
+
+       /**
+        * Open the {@link Story} with an external reader (the program will be
+        * passed the given target file).
+        * 
+        * @param meta
+        *            the {@link Story} to load
+        * @param target
+        *            the target {@link File}
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void openExternal(MetaData meta, File target, boolean sync)
+                       throws IOException {
+               String program = null;
+               if (meta.isImageDocument()) {
+                       program = Instance.getUiConfig().getString(
+                                       UiConfig.IMAGES_DOCUMENT_READER);
+               } else {
+                       program = Instance.getUiConfig().getString(
+                                       UiConfig.NON_IMAGES_DOCUMENT_READER);
+               }
+
+               if (program != null && program.trim().isEmpty()) {
+                       program = null;
+               }
+
+               start(target, program, sync);
+       }
+
+       /**
+        * Start a file and open it with the given program if given or the first
+        * default system starter we can find.
+        * 
+        * @param target
+        *            the target to open
+        * @param program
+        *            the program to use or NULL for the default system starter
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void start(File target, String program, boolean sync)
+                       throws IOException {
+
+               Process proc = null;
+               if (program == null) {
+                       boolean ok = false;
+                       for (String starter : new String[] { "xdg-open", "open", "see",
+                                       "start", "run" }) {
+                               try {
+                                       Instance.getTraceHandler().trace(
+                                                       "starting external program");
+                                       proc = Runtime.getRuntime().exec(
+                                                       new String[] { starter, target.getAbsolutePath() });
+                                       ok = true;
+                                       break;
+                               } catch (IOException e) {
+                               }
+                       }
+                       if (!ok) {
+                               throw new IOException("Cannot find a program to start the file");
+                       }
+               } else {
+                       Instance.getTraceHandler().trace("starting external program");
+                       proc = Runtime.getRuntime().exec(
+                                       new String[] { program, target.getAbsolutePath() });
+               }
+
+               if (proc != null && sync) {
+                       try {
+                               proc.waitFor();
+                       } catch (InterruptedException e) {
+                       }
+               }
+       }
+
+       static private String formatDate(String date) {
+               long ms = 0;
+
+               if (date != null && !date.isEmpty()) {
+                       try {
+                               ms = StringUtils.toTime(date);
+                       } catch (ParseException e) {
+                       }
+
+                       if (ms <= 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat(
+                                               "yyyy-MM-dd'T'HH:mm:ssSSS");
+                               try {
+                                       ms = sdf.parse(date).getTime();
+                               } catch (ParseException e) {
+                               }
+                       }
+
+                       if (ms > 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                               return sdf.format(new Date(ms));
+                       }
+               }
+
+               if (date == null) {
+                       date = "";
+               }
+
+               // :(
+               return date;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/Reader.java b/src/be/nikiroo/fanfix/reader/Reader.java
new file mode 100644 (file)
index 0000000..3ecf247
--- /dev/null
@@ -0,0 +1,267 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.IOException;
+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;
+
+/**
+ * A {@link Reader} is a class that will handle {@link Story} reading and
+ * browsing.
+ * 
+ * @author niki
+ */
+public interface Reader {
+       /**
+        * A type of {@link BasicReader}.
+        * 
+        * @author niki
+        */
+       public enum ReaderType {
+               /** Simple reader that outputs everything on the console */
+               CLI,
+               /** Reader that starts local programs to handle the stories */
+               GUI,
+               /** A text (UTF-8) reader with menu and text windows */
+               TUI,
+               /** A GUI reader implemented with the Android framework */
+               ANDROID,
+
+               ;
+
+               /**
+                * Return the full class name of a type that implements said
+                * {@link ReaderType}.
+                * 
+                * @return the class name
+                */
+               public String getTypeName() {
+                       String pkg = "be.nikiroo.fanfix.reader.";
+                       switch (this) {
+                       case CLI:
+                               return pkg + "cli.CliReader";
+                       case TUI:
+                               return pkg + "tui.TuiReader";
+                       case GUI:
+                               return pkg + "ui.GuiReader";
+                       case ANDROID:
+                               return pkg + "android.AndroidReader";
+                       }
+
+                       return null;
+               }
+       }
+
+       /**
+        * Return the current target {@link MetaData}.
+        * 
+        * @return the meta
+        */
+       public MetaData getMeta();
+
+       /**
+        * Return the current {@link Story} as described by the current
+        * {@link MetaData}.
+        * 
+        * @param pg
+        *            the optional progress
+        * 
+        * @return the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        * 
+        */
+       public Story getStory(Progress pg) throws IOException;
+
+       /**
+        * The {@link BasicLibrary} to load the stories from (by default, takes the
+        * default {@link BasicLibrary}).
+        * 
+        * @return the {@link BasicLibrary}
+        */
+       public BasicLibrary getLibrary();
+
+       /**
+        * Change the {@link BasicLibrary} that will be managed by this
+        * {@link BasicReader}.
+        * 
+        * @param lib
+        *            the new {@link BasicLibrary}
+        */
+       public void setLibrary(BasicLibrary lib);
+
+       /**
+        * Set a {@link Story} from the current {@link BasicLibrary} into the
+        * {@link Reader}.
+        * 
+        * @param luid
+        *            the {@link Story} ID
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void setMeta(String luid) throws IOException;
+
+       /**
+        * Set a {@link Story} from the current {@link BasicLibrary} into the
+        * {@link Reader}.
+        * 
+        * @param meta
+        *            the meta
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void setMeta(MetaData meta) throws IOException;
+
+       /**
+        * Set an external {@link Story} into this {@link Reader}.
+        * 
+        * @param source
+        *            the {@link Story} {@link URL}
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void setMeta(URL source, Progress pg) throws IOException;
+
+       /**
+        * Start the {@link Story} Reading.
+        * 
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not
+        *             previously set
+        */
+       public void read(boolean sync) throws IOException;
+
+       /**
+        * The selected chapter to start reading at (starting at 1, 0 = description,
+        * -1 = none).
+        * 
+        * @return the chapter, or -1 for "no chapter"
+        */
+       public int getChapter();
+
+       /**
+        * The selected chapter to start reading at (starting at 1, 0 = description,
+        * -1 = none).
+        * 
+        * @param chapter
+        *            the chapter, or -1 for "no chapter"
+        */
+       public void setChapter(int chapter);
+
+       /**
+        * Start the reader in browse mode for the given source (or pass NULL for
+        * all sources).
+        * <p>
+        * Note that this must be a <b>synchronous</b> action.
+        * 
+        * @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.
+        * <p>
+        * We use the tags <tt>DisplayName</tt>.
+        * <p>
+        * If no tag is given, the main tags will be shown.
+        * <p>
+        * If a non-leaf tag is given, the subtags will be shown.
+        * <p>
+        * If a leaf tag is given (or a full hierarchy ending with a leaf tag),
+        * stories will be shown.
+        * <p>
+        * You can select the story you want with the <tt>item</tt> number.
+        * 
+        * @param searchOn
+        *            the website to search on
+        * @param page
+        *            the page of results to show (0 = request the maximum number of
+        *            pages, pages <b>start at 1</b>)
+        * @param item
+        *            the item to select (0 = do not select a specific item but show
+        *            all the page, items <b>start at 1</b>)
+        * @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
+        *            <b>hierarchy</b>, <b>NOT</b> a multiple tags choice)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       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
+        * passed the main file associated with this {@link Story}).
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to select the {@link Story} from
+        * @param luid
+        *            the {@link Story} LUID
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void openExternal(BasicLibrary lib, String luid, boolean sync)
+                       throws IOException;
+}
diff --git a/src/be/nikiroo/fanfix/reader/cli/CliReader.java b/src/be/nikiroo/fanfix/reader/cli/CliReader.java
new file mode 100644 (file)
index 0000000..2a085a7
--- /dev/null
@@ -0,0 +1,250 @@
+package be.nikiroo.fanfix.reader.cli;
+
+import java.io.IOException;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.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.
+ * <p>
+ * Will output stories to the console.
+ * 
+ * @author niki
+ */
+class CliReader extends BasicReader {
+       @Override
+       public void read(boolean sync) throws IOException {
+               MetaData meta = getMeta();
+
+               if (meta == null) {
+                       throw new IOException("No story to read");
+               }
+
+               String title = "";
+               String author = "";
+
+               if (meta.getTitle() != null) {
+                       title = meta.getTitle();
+               }
+
+               if (meta.getAuthor() != null) {
+                       author = "©" + meta.getAuthor();
+                       if (meta.getDate() != null && !meta.getDate().isEmpty()) {
+                               author = author + " (" + meta.getDate() + ")";
+                       }
+               }
+
+               System.out.println(title);
+               System.out.println(author);
+               System.out.println("");
+
+               // TODO: progress?
+               for (Chapter chap : getStory(null)) {
+                       if (chap.getName() != null && !chap.getName().isEmpty()) {
+                               System.out.println(Instance.getTrans().getString(
+                                               StringId.CHAPTER_NAMED, chap.getNumber(),
+                                               chap.getName()));
+                       } else {
+                               System.out.println(Instance.getTrans().getString(
+                                               StringId.CHAPTER_UNNAMED, chap.getNumber()));
+                       }
+               }
+       }
+
+       public void read(int chapter) throws IOException {
+               MetaData meta = getMeta();
+
+               if (meta == null) {
+                       throw new IOException("No story to read");
+               }
+
+               // TODO: progress?
+               if (chapter > getStory(null).getChapters().size()) {
+                       System.err.println("Chapter " + chapter + ": no such chapter");
+               } else {
+                       Chapter chap = getStory(null).getChapters().get(chapter - 1);
+                       System.out.println("Chapter " + chap.getNumber() + ": "
+                                       + chap.getName());
+
+                       for (Paragraph para : chap) {
+                               System.out.println(para.getContent());
+                               System.out.println("");
+                       }
+               }
+       }
+
+       @Override
+       public void browse(String source) throws IOException {
+               List<MetaData> stories = 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);
+               }
+       }
+
+       @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<MetaData> 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<MetaData> metas = null;
+                               List<SearchableTag> 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<SearchableTag> 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<MetaData> 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
new file mode 100644 (file)
index 0000000..4da86c5
--- /dev/null
@@ -0,0 +1,107 @@
+package be.nikiroo.fanfix.reader.tui;
+
+import java.io.IOException;
+
+import jexer.TApplication;
+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'
+ * (https://github.com/klamonte/jexer/) and offer, as its name suggest, a Text
+ * User Interface.
+ * <p>
+ * It is expected to be on par with the GUI version.
+ * 
+ * @author niki
+ */
+class TuiReader extends BasicReader {
+       /**
+        * Will detect the backend to use.
+        * <p>
+        * Swing is the default backend on Windows and MacOS while evreything else
+        * will use XTERM unless explicitly overridden by <tt>jexer.Swing</tt> =
+        * <tt>true</tt> or <tt>false</tt>.
+        * 
+        * @return the backend to use
+        */
+       private static BackendType guessBackendType() {
+               // TODO: allow a config option to force one or the other?
+               TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+               if (System.getProperty("os.name").startsWith("Windows")) {
+                       backendType = TApplication.BackendType.SWING;
+               }
+
+               if (System.getProperty("os.name").startsWith("Mac")) {
+                       backendType = TApplication.BackendType.SWING;
+               }
+
+               if (System.getProperty("jexer.Swing") != null) {
+                       if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                               backendType = TApplication.BackendType.SWING;
+                       } else {
+                               backendType = TApplication.BackendType.XTERM;
+                       }
+               }
+
+               return backendType;
+       }
+
+       @Override
+       public void read(boolean sync) throws IOException {
+               // TODO
+               if (!sync) {
+                       // How could you do a not-sync in TUI mode?
+                       throw new java.lang.IllegalStateException(
+                                       "Async mode not implemented yet.");
+               }
+
+               try {
+                       TuiReaderApplication app = new TuiReaderApplication(this,
+                                       guessBackendType());
+                       app.run();
+               } catch (Exception e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public void browse(String source) {
+               try {
+                       TuiReaderApplication app = new TuiReaderApplication(this, source,
+                                       guessBackendType());
+                       app.run();
+               } catch (Exception e) {
+                       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
new file mode 100644 (file)
index 0000000..2b02dd0
--- /dev/null
@@ -0,0 +1,449 @@
+package be.nikiroo.fanfix.reader.tui;
+
+import java.awt.Toolkit;
+import java.awt.datatransfer.DataFlavor;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+
+import jexer.TApplication;
+import jexer.TCommand;
+import jexer.TKeypress;
+import jexer.TMessageBox;
+import jexer.TMessageBox.Result;
+import jexer.TMessageBox.Type;
+import jexer.TStatusBar;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TMenuEvent;
+import jexer.menu.TMenu;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+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;
+
+/**
+ * Manages the TUI general mode and links and manages the {@link TWindow}s.
+ * <p>
+ * It will also enclose a {@link Reader} and simply handle the reading part
+ * differently (it will create the required sub-windows and display them).
+ * 
+ * @author niki
+ */
+class TuiReaderApplication extends TApplication implements Reader {
+       public static final int MENU_OPEN = 1025;
+       public static final int MENU_IMPORT_URL = 1026;
+       public static final int MENU_IMPORT_FILE = 1027;
+       public static final int MENU_EXPORT = 1028;
+       public static final int MENU_DELETE = 1029;
+       public static final int MENU_LIBRARY = 1030;
+       public static final int MENU_EXIT = 1031;
+
+       public static final TCommand CMD_EXIT = new TCommand(MENU_EXIT) {
+       };
+
+       private Reader reader;
+       private TuiReaderMainWindow main;
+
+       // start reading if meta present
+       public TuiReaderApplication(Reader reader, BackendType backend)
+                       throws Exception {
+               super(backend);
+               init(reader);
+
+               if (getMeta() != null) {
+                       read(false);
+               }
+       }
+
+       public TuiReaderApplication(Reader reader, String source,
+                       TApplication.BackendType backend) throws Exception {
+               super(backend);
+               init(reader);
+               
+               showMain();
+               main.setMode(Mode.SOURCE, source);
+       }
+
+       @Override
+       public void read(boolean sync) throws IOException {
+               read(getStory(null), sync);
+       }
+
+       @Override
+       public MetaData getMeta() {
+               return reader.getMeta();
+       }
+
+       @Override
+       public Story getStory(Progress pg) throws IOException {
+               return reader.getStory(pg);
+       }
+
+       @Override
+       public BasicLibrary getLibrary() {
+               return reader.getLibrary();
+       }
+
+       @Override
+       public void setLibrary(BasicLibrary lib) {
+               reader.setLibrary(lib);
+       }
+
+       @Override
+       public void setMeta(MetaData meta) throws IOException {
+               reader.setMeta(meta);
+       }
+
+       @Override
+       public void setMeta(String luid) throws IOException {
+               reader.setMeta(luid);
+       }
+
+       @Override
+       public void setMeta(URL source, Progress pg) throws IOException {
+               reader.setMeta(source, pg);
+       }
+
+       @Override
+       public void browse(String source) {
+               try {
+                       reader.browse(source);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public int getChapter() {
+               return reader.getChapter();
+       }
+
+       @Override
+       public void setChapter(int chapter) {
+               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}.
+        * 
+        * @param story
+        *            the {@link Story} to read
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public void read(Story story, boolean sync) throws IOException {
+               if (story == null) {
+                       throw new IOException("No story to read");
+               }
+
+               // TODO: open in editor + external option
+               if (!story.getMeta().isImageDocument()) {
+                       TWindow window = new TuiReaderStoryWindow(this, story, getChapter());
+                       window.maximize();
+               } else {
+                       try {
+                               openExternal(getLibrary(), story.getMeta().getLuid(), sync);
+                       } catch (IOException e) {
+                               messageBox("Error when trying to open the story",
+                                               e.getMessage(), TMessageBox.Type.OK);
+                       }
+               }
+       }
+
+       /**
+        * Set the default status bar when this window appear.
+        * <p>
+        * Some shortcuts are always visible, and will be put here.
+        * <p>
+        * Note that shortcuts placed this way on menu won't work unless the menu
+        * also implement them.
+        * 
+        * @param window
+        *            the new window or menu on screen
+        * @param description
+        *            the description to show on the status ba
+        */
+       public TStatusBar setStatusBar(TWindow window, String description) {
+               TStatusBar statusBar = window.newStatusBar(description);
+               statusBar.addShortcutKeypress(TKeypress.kbF10, CMD_EXIT, "Exit");
+               return statusBar;
+
+       }
+
+       private void showMain() {
+               if (main != null && main.isVisible()) {
+                       main.activate();
+               } else {
+                       if (main != null) {
+                               main.close();
+                       }
+                       main = new TuiReaderMainWindow(this);
+                       main.maximize();
+               }
+       }
+
+       private void init(Reader reader) {
+               this.reader = reader;
+
+               // TODO: traces/errors?
+               Instance.setTraceHandler(null);
+
+               // Add the menus TODO: i18n
+               TMenu fileMenu = addMenu("&File");
+               fileMenu.addItem(MENU_OPEN, "&Open...");
+               fileMenu.addItem(MENU_EXPORT, "&Save as...");
+               fileMenu.addItem(MENU_DELETE, "&Delete...");
+               // TODO: Move to...
+               fileMenu.addSeparator();
+               fileMenu.addItem(MENU_IMPORT_URL, "Import &URL...");
+               fileMenu.addItem(MENU_IMPORT_FILE, "Import &file...");
+               fileMenu.addSeparator();
+               fileMenu.addItem(MENU_LIBRARY, "Lib&rary");
+               fileMenu.addSeparator();
+               fileMenu.addItem(MENU_EXIT, "E&xit");
+
+               setStatusBar(fileMenu, "File-management "
+                               + "commands (Open, Save, Print, etc.)");
+
+               // TODO: Edit: re-download, delete
+
+               //
+
+               addWindowMenu();
+
+               getBackend().setTitle("Fanfix");
+       }
+
+       @Override
+       protected boolean onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderMainWindow.CMD_SEARCH)) {
+                       messageBox("title", "caption");
+                       return true;
+               }
+               return super.onCommand(command);
+       }
+
+       @Override
+       protected boolean onMenu(TMenuEvent menu) {
+               // TODO: i18n
+               switch (menu.getId()) {
+               case MENU_EXIT:
+                       close(this);
+                       return true;
+               case MENU_OPEN:
+                       String openfile = null;
+                       try {
+                               openfile = fileOpenBox(".");
+                               reader.setMeta(BasicReader.getUrl(openfile), null);
+                               read(false);
+                       } catch (IOException e) {
+                               // TODO: i18n
+                               error("Fail to open file"
+                                               + (openfile == null ? "" : ": " + openfile),
+                                               "Import error", e);
+                       }
+
+                       return true;
+               case MENU_DELETE:
+                       String luid = null;
+                       String story = null;
+                       MetaData meta = null;
+                       if (main != null) {
+                               meta = main.getSelectedMeta();
+                       }
+                       if (meta != null) {
+                               luid = meta.getLuid();
+                               story = luid + ": " + meta.getTitle();
+                       }
+
+                       // TODO: i18n
+                       TMessageBox mbox = messageBox("Delete story", "Delete story \""
+                                       + story + "\"", Type.OKCANCEL);
+                       if (mbox.getResult() == Result.OK) {
+                               try {
+                                       reader.getLibrary().delete(luid);
+                                       if (main != null) {
+                                               main.refreshStories();
+                                       }
+                               } catch (IOException e) {
+                                       // TODO: i18n
+                                       error("Fail to delete the story: \"" + story + "\"",
+                                                       "Error", e);
+                               }
+                       }
+
+                       return true;
+               case MENU_IMPORT_URL:
+                       String clipboard = "";
+                       try {
+                               clipboard = ("" + Toolkit.getDefaultToolkit()
+                                               .getSystemClipboard().getData(DataFlavor.stringFlavor))
+                                               .trim();
+                       } catch (Exception e) {
+                               // No data will be handled
+                       }
+
+                       if (clipboard == null || !clipboard.startsWith("http")) {
+                               clipboard = "";
+                       }
+
+                       String url = inputBox("Import story", "URL to import", clipboard)
+                                       .getText();
+
+                       try {
+                               if (!imprt(url)) {
+                                       // TODO: i18n
+                                       error("URK not supported: " + url, "Import error");
+                               }
+                       } catch (IOException e) {
+                               // TODO: i18n
+                               error("Fail to import URL: " + url, "Import error", e);
+                       }
+
+                       return true;
+               case MENU_IMPORT_FILE:
+                       String filename = null;
+                       try {
+                               filename = fileOpenBox(".");
+                               if (!imprt(filename)) {
+                                       // TODO: i18n
+                                       error("File not supported: " + filename, "Import error");
+                               }
+                       } catch (IOException e) {
+                               // TODO: i18n
+                               error("Fail to import file"
+                                               + (filename == null ? "" : ": " + filename),
+                                               "Import error", e);
+                       }
+                       return true;
+               case MENU_LIBRARY:
+                       showMain();
+                       return true;
+               }
+
+               return super.onMenu(menu);
+       }
+
+       /**
+        * Import the given url.
+        * <p>
+        * Can fail if the host is not supported.
+        * 
+        * @param url
+        * 
+        * @return TRUE in case of success, FALSE if the host is not supported
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private boolean imprt(String url) throws IOException {
+               try {
+                       reader.getLibrary().imprt(BasicReader.getUrl(url), null);
+                       main.refreshStories();
+                       return true;
+               } catch (UnknownHostException e) {
+                       return false;
+               }
+       }
+
+       @Override
+       public void openExternal(BasicLibrary lib, String luid, boolean sync)
+                       throws IOException {
+               reader.openExternal(lib, luid, sync);
+       }
+
+       /**
+        * Display an error message and log it.
+        * 
+        * @param message
+        *            the message
+        * @param title
+        *            the title of the error message
+        */
+       private void error(String message, String title) {
+               error(message, title, null);
+       }
+
+       /**
+        * Display an error message and log it, including the linked
+        * {@link Exception}.
+        * 
+        * @param message
+        *            the message
+        * @param title
+        *            the title of the error message
+        * @param e
+        *            the exception to log if any (can be NULL)
+        */
+       private void error(String message, String title, Exception e) {
+               Instance.getTraceHandler().error(title + ": " + message);
+               if (e != null) {
+                       Instance.getTraceHandler().error(e);
+               }
+
+               if (e != null) {
+                       messageBox(title, message //
+                                       + "\n" + e.getMessage());
+               } else {
+                       messageBox(title, message);
+               }
+       }
+
+       /**
+        * Ask the user and, if confirmed, close the {@link TApplication} this
+        * {@link TWidget} is running on.
+        * <p>
+        * This should result in the program terminating.
+        * 
+        * @param widget
+        *            the {@link TWidget}
+        */
+       static public void close(TWidget widget) {
+               close(widget.getApplication());
+       }
+
+       /**
+        * Ask the user and, if confirmed, close the {@link TApplication}.
+        * <p>
+        * This should result in the program terminating.
+        * 
+        * @param app
+        *            the {@link TApplication}
+        */
+       static void close(TApplication app) {
+               // TODO: i18n
+               if (app.messageBox("Confirmation", "(TODO: i18n) Exit application?",
+                               TMessageBox.Type.YESNO).getResult() == TMessageBox.Result.YES) {
+                       app.exit();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java b/src/be/nikiroo/fanfix/reader/tui/TuiReaderMainWindow.java
new file mode 100644 (file)
index 0000000..426355f
--- /dev/null
@@ -0,0 +1,374 @@
+package be.nikiroo.fanfix.reader.tui;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.TAction;
+import jexer.TComboBox;
+import jexer.TCommand;
+import jexer.TField;
+import jexer.TFileOpenBox.Type;
+import jexer.TKeypress;
+import jexer.TLabel;
+import jexer.TList;
+import jexer.TStatusBar;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TResizeEvent;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.jexer.TSizeConstraint;
+
+/**
+ * The library window, that will list all the (filtered) stories available in
+ * this {@link BasicLibrary}.
+ * 
+ * @author niki
+ */
+class TuiReaderMainWindow extends TWindow {
+       public static final int MENU_SEARCH = 1100;
+       public static final TCommand CMD_SEARCH = new TCommand(MENU_SEARCH) {
+       };
+
+       public enum Mode {
+               SOURCE, AUTHOR,
+       }
+
+       private TList list;
+       private List<MetaData> listKeys;
+       private List<String> listItems;
+       private TuiReaderApplication reader;
+
+       private Mode mode = Mode.SOURCE;
+       private String target = null;
+       private String filter = "";
+
+       private List<TSizeConstraint> sizeConstraints = new ArrayList<TSizeConstraint>();
+
+       // The 2 comboboxes used to select by source/author
+       private TComboBox selectTargetBox;
+       private TComboBox selectBox;
+
+       /**
+        * Create a new {@link TuiReaderMainWindow} without any stories in the list.
+        * 
+        * @param reader
+        *            the reader and main application
+        */
+       public TuiReaderMainWindow(TuiReaderApplication reader) {
+               // Construct a demo window. X and Y don't matter because it will be
+               // centred on screen.
+               super(reader, "Library", 0, 0, 60, 18, CENTERED | RESIZABLE);
+
+               this.reader = reader;
+
+               listKeys = new ArrayList<MetaData>();
+               listItems = new ArrayList<String>();
+
+               addList();
+               addSearch();
+               addSelect();
+
+               TStatusBar statusBar = reader.setStatusBar(this, "Library");
+               statusBar.addShortcutKeypress(TKeypress.kbCtrlF, CMD_SEARCH, "Search");
+
+               TSizeConstraint.resize(sizeConstraints);
+
+               // TODO: remove when not used anymore
+
+               // addLabel("Label (1,1)", 1, 1);
+               // addButton("&Button (35,1)", 35, 1, new TAction() {
+               // public void DO() {
+               // }
+               // });
+               // addCheckbox(1, 2, "Checky (1,2)", false);
+               // addProgressBar(1, 3, 30, 42);
+               // TRadioGroup groupy = addRadioGroup(1, 4, "Radio groupy");
+               // groupy.addRadioButton("Fanfan");
+               // groupy.addRadioButton("Tulipe");
+               // addField(1, 10, 20, false, "text not fixed.");
+               // addField(1, 11, 20, true, "text fixed.");
+               // addText("20x4 Text in (12,20)", 1, 12, 20, 4);
+               //
+               // TTreeView tree = addTreeView(30, 5, 20, 5);
+               // TTreeItem root = new TTreeItem(tree, "expended root", true);
+               // tree.setSelected(root); // needed to allow arrow navigation without
+               // // mouse-clicking before
+               //
+               // root.addChild("child");
+               // root.addChild("child 2").addChild("sub child");
+       }
+
+       private void addSearch() {
+               TLabel lblSearch = addLabel("Search: ", 0, 0);
+
+               TField search = new TField(this, 0, 0, 1, true) {
+                       @Override
+                       public void onKeypress(TKeypressEvent keypress) {
+                               super.onKeypress(keypress);
+                               TKeypress key = keypress.getKey();
+                               if (key.isFnKey() && key.getKeyCode() == TKeypress.ENTER) {
+                                       TuiReaderMainWindow.this.filter = getText();
+                                       TuiReaderMainWindow.this.refreshStories();
+                               }
+                       }
+               };
+
+               TSizeConstraint.setSize(sizeConstraints, lblSearch, 5, 1, null, null);
+               TSizeConstraint.setSize(sizeConstraints, search, 15, 1, -5, null);
+       }
+
+       private void addList() {
+               list = addList(listItems, 0, 0, 10, 10, new TAction() {
+                       @Override
+                       public void DO() {
+                               MetaData meta = getSelectedMeta();
+                               if (meta != null) {
+                                       readStory(meta);
+                               }
+                       }
+               });
+
+               TSizeConstraint.setSize(sizeConstraints, list, 0, 7, 0, 0);
+       }
+
+       private void addSelect() {
+               // TODO: i18n
+               final List<String> selects = new ArrayList<String>();
+               selects.add("(show all)");
+               selects.add("Sources");
+               selects.add("Author");
+
+               final List<String> selectTargets = new ArrayList<String>();
+               selectTargets.add("");
+
+               TLabel lblSelect = addLabel("Select: ", 0, 0);
+
+               TAction onSelect = new TAction() {
+                       @Override
+                       public void DO() {
+                               String smode = selectBox.getText();
+                               boolean showTarget;
+                               if (smode == null || smode.equals("(show all)")) {
+                                       showTarget = false;
+                               } else if (smode.equals("Sources")) {
+                                       selectTargets.clear();
+                                       selectTargets.add("(show all)");
+                                       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)");
+                                       try {
+                                               for (String author : reader.getLibrary().getAuthors()) {
+                                                       selectTargets.add(author);
+                                               }
+                                       } catch (IOException e) {
+                                               Instance.getTraceHandler().error(e);
+                                       }
+
+                                       showTarget = true;
+                               }
+
+                               selectTargetBox.setVisible(showTarget);
+                               selectTargetBox.setEnabled(showTarget);
+                               if (showTarget) {
+                                       selectTargetBox.reflowData();
+                               }
+
+                               selectTargetBox.setText(selectTargets.get(0));
+                               if (showTarget) {
+                                       TuiReaderMainWindow.this.activate(selectTargetBox);
+                               } else {
+                                       TuiReaderMainWindow.this.activate(list);
+                               }
+                       }
+               };
+
+               selectBox = addComboBox(0, 0, 10, selects, 0, -1, onSelect);
+
+               selectTargetBox = addComboBox(0, 0, 0, selectTargets, 0, -1,
+                               new TAction() {
+                                       @Override
+                                       public void DO() {
+                                               if (selectTargetBox.getText().equals(
+                                                               selectTargets.get(0))) {
+                                                       setMode(mode, null);
+                                               } else {
+                                                       setMode(mode, selectTargetBox.getText());
+                                               }
+                                       }
+                               });
+
+               // Set defaults
+               onSelect.DO();
+
+               TSizeConstraint.setSize(sizeConstraints, lblSelect, 5, 3, null, null);
+               TSizeConstraint.setSize(sizeConstraints, selectBox, 15, 3, -5, null);
+               TSizeConstraint.setSize(sizeConstraints, selectTargetBox, 15, 4, -5,
+                               null);
+       }
+
+       @Override
+       public void onResize(TResizeEvent resize) {
+               super.onResize(resize);
+               TSizeConstraint.resize(sizeConstraints);
+       }
+
+       @Override
+       public void onClose() {
+               setVisible(false);
+               super.onClose();
+       }
+
+       /**
+        * Refresh the list of stories displayed in this library.
+        * <p>
+        * Will take the current settings into account (filter, source...).
+        */
+       public void refreshStories() {
+               List<MetaData> metas;
+
+               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<MetaData>();
+               }
+
+               setMetas(metas);
+       }
+
+       /**
+        * Change the author/source filter and display all stories matching this
+        * target.
+        * 
+        * @param mode
+        *            the new mode or NULL for no sorting
+        * @param target
+        *            the actual target for the given mode, or NULL for all of them
+        */
+       public void setMode(Mode mode, String target) {
+               this.mode = mode;
+               this.target = target;
+               refreshStories();
+       }
+
+       /**
+        * Update the list of stories displayed in this {@link TWindow}.
+        * <p>
+        * If a filter is set, only the stories which pass the filter will be
+        * displayed.
+        * 
+        * @param metas
+        *            the new list of stories to display
+        */
+       private void setMetas(List<MetaData> metas) {
+               listKeys.clear();
+               listItems.clear();
+
+               if (metas != null) {
+                       for (MetaData meta : metas) {
+                               String desc = desc(meta);
+                               if (filter.isEmpty()
+                                               || desc.toLowerCase().contains(filter.toLowerCase())) {
+                                       listKeys.add(meta);
+                                       listItems.add(desc);
+                               }
+                       }
+               }
+
+               list.setList(listItems);
+               if (listItems.size() > 0) {
+                       list.setSelectedIndex(0);
+               }
+       }
+
+       public MetaData getSelectedMeta() {
+               if (list.getSelectedIndex() >= 0) {
+                       return listKeys.get(list.getSelectedIndex());
+               }
+
+               return null;
+       }
+
+       public void readStory(MetaData meta) {
+               try {
+                       reader.setChapter(-1);
+                       reader.setMeta(meta);
+                       reader.read(false);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       private String desc(MetaData meta) {
+               return String.format("%5s: %s", meta.getLuid(), meta.getTitle());
+       }
+
+       @Override
+       public void onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderApplication.CMD_EXIT)) {
+                       TuiReaderApplication.close(this);
+               } else {
+                       // Handle our own event if needed here
+                       super.onCommand(command);
+               }
+       }
+
+       @Override
+       public void onMenu(TMenuEvent menu) {
+               MetaData meta = getSelectedMeta();
+               if (meta != null) {
+                       switch (menu.getId()) {
+                       case TuiReaderApplication.MENU_OPEN:
+                               readStory(meta);
+
+                               return;
+                       case TuiReaderApplication.MENU_EXPORT:
+
+                               try {
+                                       // TODO: choose type, pg, error
+                                       OutputType outputType = OutputType.EPUB;
+                                       String path = fileOpenBox(".", Type.SAVE);
+                                       reader.getLibrary().export(meta.getLuid(), outputType,
+                                                       path, null);
+                               } catch (IOException e) {
+                                       // TODO
+                                       e.printStackTrace();
+                               }
+
+                               return;
+
+                       case -1:
+                               try {
+                                       reader.getLibrary().delete(meta.getLuid());
+                               } catch (IOException e) {
+                                       // TODO
+                               }
+
+                               return;
+                       }
+               }
+
+               super.onMenu(menu);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/reader/tui/TuiReaderStoryWindow.java b/src/be/nikiroo/fanfix/reader/tui/TuiReaderStoryWindow.java
new file mode 100644 (file)
index 0000000..4848ef8
--- /dev/null
@@ -0,0 +1,305 @@
+package be.nikiroo.fanfix.reader.tui;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import jexer.TAction;
+import jexer.TButton;
+import jexer.TLabel;
+import jexer.TText;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TResizeEvent;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.reader.BasicReader;
+import be.nikiroo.jexer.TSizeConstraint;
+import be.nikiroo.jexer.TTable;
+
+/**
+ * This window will contain the {@link Story} in a readable format, with a
+ * chapter browser.
+ * 
+ * @author niki
+ */
+class TuiReaderStoryWindow extends TWindow {
+       private Story story;
+       private TLabel titleField;
+       private TText textField;
+       private TTable table;
+       private int chapter = -99; // invalid value
+       private List<TButton> navigationButtons;
+       private TLabel currentChapter;
+       private List<TSizeConstraint> sizeConstraints = new ArrayList<TSizeConstraint>();
+
+       // chapter: -1 for "none" (0 is desc)
+       public TuiReaderStoryWindow(TuiReaderApplication app, Story story,
+                       int chapter) {
+               super(app, desc(story.getMeta()), 0, 0, 60, 18, CENTERED | RESIZABLE);
+
+               this.story = story;
+
+               app.setStatusBar(this, desc(story.getMeta()));
+
+               // last = use window background
+               titleField = new TLabel(this, "    Title", 0, 1, "tlabel", false);
+               textField = new TText(this, "", 0, 0, 1, 1);
+               table = new TTable(this, 0, 0, 1, 1, null, null, Arrays.asList("Key",
+                               "Value"), true);
+
+               titleField.setEnabled(false);
+
+               navigationButtons = new ArrayList<TButton>(5);
+
+               navigationButtons.add(addButton("<<", 0, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(-1);
+                       }
+               }));
+               navigationButtons.add(addButton("< ", 4, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(TuiReaderStoryWindow.this.chapter - 1);
+                       }
+               }));
+               navigationButtons.add(addButton("> ", 7, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(TuiReaderStoryWindow.this.chapter + 1);
+                       }
+               }));
+               navigationButtons.add(addButton(">>", 10, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(getStory().getChapters().size());
+                       }
+               }));
+
+               navigationButtons.get(0).setEnabled(false);
+               navigationButtons.get(1).setEnabled(false);
+
+               currentChapter = addLabel("", 0, 0);
+
+               TSizeConstraint.setSize(sizeConstraints, textField, 1, 3, -1, -1);
+               TSizeConstraint.setSize(sizeConstraints, table, 0, 3, 0, -1);
+               TSizeConstraint.setSize(sizeConstraints, currentChapter, 14, -3, -1,
+                               null);
+
+               for (TButton navigationButton : navigationButtons) {
+                       navigationButton.setShadowColor(null);
+                       // navigationButton.setEmptyBorders(false);
+                       TSizeConstraint.setSize(sizeConstraints, navigationButton, null,
+                                       -3, null, null);
+               }
+
+               onResize(null);
+
+               setChapter(chapter);
+       }
+
+       @Override
+       public void onResize(TResizeEvent resize) {
+               if (resize != null) {
+                       super.onResize(resize);
+               }
+
+               // TODO: find out why TText and TTable does not behave the same way
+               // (offset of 2 for height and width)
+
+               TSizeConstraint.resize(sizeConstraints);
+
+               // Improve the disposition of the scrollbars
+               textField.getVerticalScroller().setX(textField.getWidth());
+               textField.getVerticalScroller().setHeight(textField.getHeight());
+               textField.getHorizontalScroller().setX(-1);
+               textField.getHorizontalScroller().setWidth(textField.getWidth() + 1);
+
+               setCurrentChapterText();
+       }
+
+       /**
+        * Display the current chapter in the window, or the {@link Story} info
+        * page.
+        * 
+        * @param chapter
+        *            the chapter (including "0" which is the description) or "-1"
+        *            to display the info page instead
+        */
+       private void setChapter(int chapter) {
+               if (chapter > getStory().getChapters().size()) {
+                       chapter = getStory().getChapters().size();
+               }
+
+               if (chapter != this.chapter) {
+                       this.chapter = chapter;
+
+                       int max = getStory().getChapters().size();
+                       navigationButtons.get(0).setEnabled(chapter > -1);
+                       navigationButtons.get(1).setEnabled(chapter > -1);
+                       navigationButtons.get(2).setEnabled(chapter < max);
+                       navigationButtons.get(3).setEnabled(chapter < max);
+
+                       if (chapter < 0) {
+                               displayInfoPage();
+                       } else {
+                               displayChapterPage();
+                       }
+               }
+
+               setCurrentChapterText();
+       }
+
+       /**
+        * Append the info page about the current {@link Story}.
+        * 
+        * @param builder
+        *            the builder to append to
+        */
+       private void displayInfoPage() {
+               textField.setVisible(false);
+               table.setVisible(true);
+               textField.setEnabled(false);
+               table.setEnabled(true);
+
+               MetaData meta = getStory().getMeta();
+
+               setCurrentTitle(meta.getTitle());
+
+               Map<String, String> metaDesc = BasicReader.getMetaDesc(meta);
+               String[][] metaDescObj = new String[metaDesc.size()][2];
+               int i = 0;
+               for (String key : metaDesc.keySet()) {
+                       metaDescObj[i][0] = " " + key;
+                       metaDescObj[i][1] = metaDesc.get(key);
+                       i++;
+               }
+
+               table.setRowData(metaDescObj);
+               table.setHeaders(Arrays.asList("key", "value"), false);
+               table.toTop();
+       }
+
+       /**
+        * Append the current chapter.
+        * 
+        * @param builder
+        *            the builder to append to
+        */
+       private void displayChapterPage() {
+               table.setVisible(false);
+               textField.setVisible(true);
+               table.setEnabled(false);
+               textField.setEnabled(true);
+
+               StringBuilder builder = new StringBuilder();
+
+               Chapter chap = null;
+               if (chapter == 0) {
+                       chap = getStory().getMeta().getResume();
+               } else if (chapter > 0) {
+                       chap = getStory().getChapters().get(chapter - 1);
+               }
+
+               // TODO: i18n
+               String chapName = chap == null ? "[No RESUME]" : chap.getName();
+               setCurrentTitle(String.format("Chapter %d: %s", chapter, chapName));
+
+               if (chap != null) {
+                       for (Paragraph para : chap) {
+                               if (para.getType() == ParagraphType.BREAK) {
+                                       builder.append("\n");
+                               }
+                               builder.append(para.getContent()).append("\n");
+                               if (para.getType() == ParagraphType.BREAK) {
+                                       builder.append("\n");
+                               }
+                       }
+               }
+
+               setText(builder.toString());
+       }
+
+       private Story getStory() {
+               return story;
+       }
+
+       /**
+        * Display the given text on the window.
+        * 
+        * @param text
+        *            the text to display
+        */
+       private void setText(String text) {
+               textField.setText(text);
+               textField.reflowData();
+               textField.toTop();
+       }
+
+       /**
+        * Set the current chapter area to the correct value.
+        */
+       private void setCurrentChapterText() {
+               String name;
+               if (chapter < 0) {
+                       name = " " + getStory().getMeta().getTitle();
+               } else if (chapter == 0) {
+                       Chapter resume = getStory().getMeta().getResume();
+                       if (resume != null) {
+                               name = String.format(" %s", resume.getName());
+                       } else {
+                               // TODO: i18n
+                               name = "[No RESUME]";
+                       }
+               } else {
+                       int max = getStory().getChapters().size();
+                       Chapter chap = getStory().getChapters().get(chapter - 1);
+                       name = String.format(" %d/%d: %s", chapter, max, chap.getName());
+               }
+
+               int width = getWidth() - currentChapter.getX();
+               name = String.format("%-" + width + "s", name);
+               if (name.length() > width) {
+                       name = name.substring(0, width);
+               }
+
+               currentChapter.setLabel(name);
+       }
+
+       /**
+        * Set the current title in-window.
+        * 
+        * @param title
+        *            the new title
+        */
+       private void setCurrentTitle(String title) {
+               String pad = "";
+               if (title.length() < getWidth()) {
+                       int padSize = (getWidth() - title.length()) / 2;
+                       pad = String.format("%" + padSize + "s", "");
+               }
+
+               title = pad + title + pad;
+               titleField.setWidth(title.length());
+               titleField.setLabel(title);
+       }
+
+       private static String desc(MetaData meta) {
+               return String.format("%s: %s", meta.getLuid(), meta.getTitle());
+       }
+
+       @Override
+       public void onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderApplication.CMD_EXIT)) {
+                       TuiReaderApplication.close(this);
+               } else {
+                       // Handle our own event if needed here
+                       super.onCommand(command);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReader.java b/src/be/nikiroo/fanfix/reader/ui/GuiReader.java
new file mode 100644 (file)
index 0000000..0205e11
--- /dev/null
@@ -0,0 +1,499 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.Desktop;
+import java.awt.EventQueue;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+
+import javax.swing.JEditorPane;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkListener;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.VersionCheck;
+import be.nikiroo.fanfix.bundles.StringIdGui;
+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.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;
+
+/**
+ * This class implements a graphical {@link Reader} using the Swing library from
+ * Java.
+ * <p>
+ * It can thus be themed to look native-like, or metal-like, or use any other
+ * theme you may want to try.
+ * <p>
+ * We actually try to enable native look-alike mode on start.
+ * 
+ * @author niki
+ */
+class GuiReader extends BasicReader {
+       static private boolean nativeLookLoaded;
+
+       private CacheLibrary cacheLib;
+
+       private File cacheDir;
+
+       /**
+        * Create a new graphical {@link Reader}.
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public GuiReader() throws IOException {
+               // TODO: allow different themes?
+               if (!nativeLookLoaded) {
+                       UIUtils.setLookAndFeel();
+                       nativeLookLoaded = true;
+               }
+
+               cacheDir = Instance.getReaderDir();
+               cacheDir.mkdirs();
+               if (!cacheDir.exists()) {
+                       throw new IOException(
+                                       "Cannote create cache directory for local reader: "
+                                                       + cacheDir);
+               }
+       }
+
+       @Override
+       public synchronized BasicLibrary getLibrary() {
+               if (cacheLib == null) {
+                       BasicLibrary lib = super.getLibrary();
+                       if (lib instanceof CacheLibrary) {
+                               cacheLib = (CacheLibrary) lib;
+                       } else {
+                               cacheLib = new CacheLibrary(cacheDir, lib);
+                       }
+               }
+
+               return cacheLib;
+       }
+
+       @Override
+       public void read(boolean sync) throws IOException {
+               MetaData meta = getMeta();
+
+               if (meta == null) {
+                       throw new IOException("No story to read");
+               }
+
+               read(meta.getLuid(), sync, null);
+       }
+
+       /**
+        * Check if the {@link Story} denoted by this Library UID is present in the
+        * {@link GuiReader} cache.
+        * 
+        * @param luid
+        *            the Library UID
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isCached(String luid) {
+               return cacheLib.isCached(luid);
+       }
+
+       @Override
+       public void browse(String type) {
+               final Boolean[] done = new Boolean[] { false };
+
+               // TODO: improve presentation of update message
+               final VersionCheck updates = VersionCheck.check();
+               StringBuilder builder = new StringBuilder();
+
+               final JEditorPane updateMessage = new JEditorPane("text/html", "");
+               if (updates.isNewVersionAvailable()) {
+                       builder.append(trans(StringIdGui.NEW_VERSION_AVAILABLE,
+                                       "<span style='color: blue;'>https://github.com/nikiroo/fanfix/releases</span>"));
+                       builder.append("<br>");
+                       builder.append("<br>");
+                       for (Version v : updates.getNewer()) {
+                               builder.append("\t<b>"
+                                               + trans(StringIdGui.NEW_VERSION_VERSION, v.toString())
+                                               + "</b>");
+                               builder.append("<br>");
+                               builder.append("<ul>");
+                               for (String item : updates.getChanges().get(v)) {
+                                       builder.append("<li>" + item + "</li>");
+                               }
+                               builder.append("</ul>");
+                       }
+
+                       // html content
+                       updateMessage.setText("<html><body>" //
+                                       + builder//
+                                       + "</body></html>");
+
+                       // handle link events
+                       updateMessage.addHyperlinkListener(new HyperlinkListener() {
+                               @Override
+                               public void hyperlinkUpdate(HyperlinkEvent e) {
+                                       if (e.getEventType().equals(
+                                                       HyperlinkEvent.EventType.ACTIVATED))
+                                               try {
+                                                       Desktop.getDesktop().browse(e.getURL().toURI());
+                                               } catch (IOException ee) {
+                                                       Instance.getTraceHandler().error(ee);
+                                               } catch (URISyntaxException ee) {
+                                                       Instance.getTraceHandler().error(ee);
+                                               }
+                               }
+                       });
+                       updateMessage.setEditable(false);
+                       updateMessage.setBackground(new JLabel().getBackground());
+               }
+
+               final String typeFinal = type;
+               EventQueue.invokeLater(new Runnable() {
+                       @Override
+                       public void run() {
+                               if (updates.isNewVersionAvailable()) {
+                                       int rep = JOptionPane.showConfirmDialog(null,
+                                                       updateMessage,
+                                                       trans(StringIdGui.NEW_VERSION_TITLE),
+                                                       JOptionPane.OK_CANCEL_OPTION);
+                                       if (rep == JOptionPane.OK_OPTION) {
+                                               updates.ok();
+                                       } else {
+                                               updates.ignore();
+                                       }
+                               }
+
+                               new Thread(new Runnable() {
+                                       @Override
+                                       public void run() {
+                                               try {
+                                                       GuiReaderFrame gui = new GuiReaderFrame(
+                                                                       GuiReader.this, typeFinal);
+                                                       sync(gui);
+                                               } catch (Exception e) {
+                                                       Instance.getTraceHandler().error(e);
+                                               } finally {
+                                                       done[0] = true;
+                                               }
+
+                                       }
+                               }).start();
+                       }
+               });
+
+               // This action must be synchronous, so wait until the frame is closed
+               while (!done[0]) {
+                       try {
+                               Thread.sleep(100);
+                       } catch (InterruptedException e) {
+                       }
+               }
+       }
+
+       @Override
+       public void start(File target, String program, boolean sync)
+                       throws IOException {
+
+               boolean handled = false;
+               if (program == null && !sync) {
+                       try {
+                               Desktop.getDesktop().browse(target.toURI());
+                               handled = true;
+                       } catch (UnsupportedOperationException e) {
+                       }
+               }
+
+               if (!handled) {
+                       super.start(target, program, sync);
+               }
+       }
+
+       @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 <b>NOT</b>
+        * from the main library.
+        * <p>
+        * The next time we try to retrieve the {@link Story}, it may be required to
+        * cache it again.
+        * 
+        * @param luid
+        *            the luid of the {@link Story}
+        */
+       void clearLocalReaderCache(String luid) {
+               try {
+                       cacheLib.clearFromCache(luid);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Forward the delete operation to the main library.
+        * <p>
+        * The {@link Story} will be deleted from the main library as well as the
+        * cache if present.
+        * 
+        * @param luid
+        *            the {@link Story} to delete
+        */
+       void delete(String luid) {
+               try {
+                       cacheLib.delete(luid);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * "Open" the given {@link Story}. It usually involves starting an external
+        * program adapted to the given file type.
+        * 
+        * @param luid
+        *            the luid of the {@link Story} to open
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * @param pg
+        *            the optional progress (we may need to prepare the
+        *            {@link Story} for reading
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       void read(String luid, boolean sync, Progress pg) throws IOException {
+               MetaData meta = cacheLib.getInfo(luid);
+
+               boolean textInternal = Instance.getUiConfig().getBoolean(
+                               UiConfig.NON_IMAGES_DOCUMENT_USE_INTERNAL_READER, true);
+               boolean imageInternal = Instance.getUiConfig().getBoolean(
+                               UiConfig.IMAGES_DOCUMENT_USE_INTERNAL_READER, true);
+
+               boolean useInternalViewer = true;
+               if (meta.isImageDocument() && !imageInternal) {
+                       useInternalViewer = false;
+               }
+               if (!meta.isImageDocument() && !textInternal) {
+                       useInternalViewer = false;
+               }
+
+               if (useInternalViewer) {
+                       GuiReaderViewer viewer = new GuiReaderViewer(cacheLib,
+                                       cacheLib.getStory(luid, null));
+                       if (sync) {
+                               sync(viewer);
+                       } else {
+                               viewer.setVisible(true);
+                       }
+               } else {
+                       File file = cacheLib.getFile(luid, pg);
+                       openExternal(meta, file, sync);
+               }
+       }
+
+
+       /**
+        * "Prefetch" the given {@link Story}.
+        * <p>
+        * Synchronous method.
+        * 
+        * @param luid
+        *            the luid of the {@link Story} to prefetch
+        * @param pg
+        *            the optional progress (we may need to prepare the
+        *            {@link Story} for reading
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       void prefetch(String luid, Progress pg) throws IOException {
+               cacheLib.getFile(luid, pg);
+       }
+       /**
+        * Change the source of the given {@link Story} (the source is the main
+        * information used to group the stories together).
+        * <p>
+        * In other words, <b>move</b> the {@link Story} into other source.
+        * <p>
+        * The source can be a new one, it needs not exist before hand.
+        * 
+        * @param luid
+        *            the luid of the {@link Story} to move
+        * @param newSource
+        *            the new source
+        */
+       void changeSource(String luid, String newSource) {
+               try {
+                       cacheLib.changeSource(luid, newSource, null);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Change the title of the given {@link Story}.
+        * 
+        * @param luid
+        *            the luid of the {@link Story} to change
+        * @param newTitle
+        *            the new title
+        */
+       void changeTitle(String luid, String newTitle) {
+               try {
+                       cacheLib.changeTitle(luid, newTitle, null);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Change the author of the given {@link Story}.
+        * <p>
+        * The author can be a new one, it needs not exist before hand.
+        * 
+        * @param luid
+        *            the luid of the {@link Story} to change
+        * @param newAuthor
+        *            the new author
+        */
+       void changeAuthor(String luid, String newAuthor) {
+               try {
+                       cacheLib.changeAuthor(luid, newAuthor, null);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Simple shortcut method to call {link Instance#getTransGui()#getString()}.
+        * 
+        * @param id
+        *            the ID to translate
+        * 
+        * @return the translated result
+        */
+       static String trans(StringIdGui id, Object... params) {
+               return Instance.getTransGui().getString(id, params);
+       }
+
+       /**
+        * Start a frame and wait until it is closed before returning.
+        * 
+        * @param frame
+        *            the frame to start
+        */
+       static private void sync(final JFrame frame) {
+               if (EventQueue.isDispatchThread()) {
+                       throw new IllegalStateException(
+                                       "Cannot call a sync method in the dispatch thread");
+               }
+
+               final Boolean[] done = new Boolean[] { false };
+               try {
+                       Runnable run = new Runnable() {
+                               @Override
+                               public void run() {
+                                       try {
+                                               frame.addWindowListener(new WindowAdapter() {
+                                                       @Override
+                                                       public void windowClosing(WindowEvent e) {
+                                                               super.windowClosing(e);
+                                                               done[0] = true;
+                                                       }
+                                               });
+
+                                               frame.setVisible(true);
+                                       } catch (Exception e) {
+                                               done[0] = true;
+                                       }
+                               }
+                       };
+
+                       if (EventQueue.isDispatchThread()) {
+                               run.run();
+                       } else {
+                               EventQueue.invokeLater(run);
+                       }
+               } catch (Exception e) {
+                       Instance.getTraceHandler().error(e);
+                       done[0] = true;
+               }
+
+               // This action must be synchronous, so wait until the frame is closed
+               while (!done[0]) {
+                       try {
+                               Thread.sleep(100);
+                       } catch (InterruptedException e) {
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderBook.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderBook.java
new file mode 100644 (file)
index 0000000..73ccdaa
--- /dev/null
@@ -0,0 +1,339 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.EventListener;
+import java.util.List;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.reader.Reader;
+
+/**
+ * A book item presented in a {@link GuiReaderFrame}.
+ * <p>
+ * Can be a story, or a comic or... a group.
+ * 
+ * @author niki
+ */
+class GuiReaderBook extends JPanel {
+       /**
+        * Action on a book item.
+        * 
+        * @author niki
+        */
+       interface BookActionListener extends EventListener {
+               /**
+                * The book was selected (single click).
+                * 
+                * @param book
+                *            the {@link GuiReaderBook} itself
+                */
+               public void select(GuiReaderBook book);
+
+               /**
+                * The book was double-clicked.
+                * 
+                * @param book
+                *            the {@link GuiReaderBook} itself
+                */
+               public void action(GuiReaderBook book);
+
+               /**
+                * A popup menu was requested for this {@link GuiReaderBook}.
+                * 
+                * @param book
+                *            the {@link GuiReaderBook} itself
+                * @param target
+                *            the target component for the popup
+                * @param x
+                *            the X position of the click/request (in case of popup
+                *            request from the keyboard, the center of the target is
+                *            selected as point of reference)
+                * @param y
+                *            the Y position of the click/request (in case of popup
+                *            request from the keyboard, the center of the target is
+                *            selected as point of reference)
+                */
+               public void popupRequested(GuiReaderBook book, Component target, int x,
+                               int y);
+       }
+
+       private static final long serialVersionUID = 1L;
+
+       private static final String AUTHOR_COLOR = "#888888";
+       private static final long doubleClickDelay = 200; // in ms
+
+       private JLabel icon;
+       private JLabel title;
+       private boolean selected;
+       private boolean hovered;
+       private Date lastClick;
+
+       private List<BookActionListener> listeners;
+       private GuiReaderBookInfo info;
+       private boolean cached;
+       private boolean seeWordCount;
+
+       /**
+        * Create a new {@link GuiReaderBook} item for the given {@link Story}.
+        * 
+        * @param reader
+        *            the associated reader
+        * @param info
+        *            the information about the story to represent
+        * @param cached
+        *            TRUE if it is locally cached
+        * @param seeWordCount
+        *            TRUE to see word counts, FALSE to see authors
+        */
+       public GuiReaderBook(Reader reader, GuiReaderBookInfo info, boolean cached,
+                       boolean seeWordCount) {
+               this.info = info;
+               this.cached = cached;
+               this.seeWordCount = seeWordCount;
+
+               icon = new JLabel(GuiReaderCoverImager.generateCoverIcon(
+                               reader.getLibrary(), info));
+
+               title = new JLabel();
+               updateTitle();
+
+               setLayout(new BorderLayout(10, 10));
+               add(icon, BorderLayout.CENTER);
+               add(title, BorderLayout.SOUTH);
+
+               setupListeners();
+       }
+
+       /**
+        * The book current selection state.
+        * 
+        * @return the selection state
+        */
+       public boolean isSelected() {
+               return selected;
+       }
+
+       /**
+        * The book current selection state.
+        * <p>
+        * Setting this value to true can cause a "select" action to occur if the
+        * previous state was "unselected".
+        * 
+        * @param selected
+        *            TRUE if it is selected
+        */
+       public void setSelected(boolean selected) {
+               if (this.selected != selected) {
+                       this.selected = selected;
+                       repaint();
+
+                       if (selected) {
+                               select();
+                       }
+               }
+       }
+
+       /**
+        * The item mouse-hover state.
+        * 
+        * @return TRUE if it is mouse-hovered
+        */
+       public boolean isHovered() {
+               return this.hovered;
+       }
+
+       /**
+        * The item mouse-hover state.
+        * 
+        * @param hovered
+        *            TRUE if it is mouse-hovered
+        */
+       public void setHovered(boolean hovered) {
+               if (this.hovered != hovered) {
+                       this.hovered = hovered;
+                       repaint();
+               }
+       }
+
+       /**
+        * Setup the mouse listener that will activate {@link BookActionListener}
+        * events.
+        */
+       private void setupListeners() {
+               listeners = new ArrayList<GuiReaderBook.BookActionListener>();
+               addMouseListener(new MouseListener() {
+                       @Override
+                       public void mouseReleased(MouseEvent e) {
+                               if (isEnabled() && e.isPopupTrigger()) {
+                                       popup(e);
+                               }
+                       }
+
+                       @Override
+                       public void mousePressed(MouseEvent e) {
+                               if (isEnabled() && e.isPopupTrigger()) {
+                                       popup(e);
+                               }
+                       }
+
+                       @Override
+                       public void mouseExited(MouseEvent e) {
+                               setHovered(false);
+                       }
+
+                       @Override
+                       public void mouseEntered(MouseEvent e) {
+                               setHovered(true);
+                       }
+
+                       @Override
+                       public void mouseClicked(MouseEvent e) {
+                               if (isEnabled()) {
+                                       Date now = new Date();
+                                       if (lastClick != null
+                                                       && now.getTime() - lastClick.getTime() < doubleClickDelay) {
+                                               click(true);
+                                       } else {
+                                               click(false);
+                                       }
+
+                                       lastClick = now;
+                                       e.consume();
+                               }
+                       }
+
+                       private void click(boolean doubleClick) {
+                               if (doubleClick) {
+                                       action();
+                               } else {
+                                       select();
+                               }
+                       }
+
+                       private void popup(MouseEvent e) {
+                               GuiReaderBook.this
+                                               .popup(GuiReaderBook.this, e.getX(), e.getY());
+                               e.consume();
+                       }
+               });
+       }
+
+       /**
+        * Add a new {@link BookActionListener} on this item.
+        * 
+        * @param listener
+        *            the listener
+        */
+       public void addActionListener(BookActionListener listener) {
+               listeners.add(listener);
+       }
+
+       /**
+        * Cause an action to occur on this {@link GuiReaderBook}.
+        */
+       public void action() {
+               for (BookActionListener listener : listeners) {
+                       listener.action(GuiReaderBook.this);
+               }
+       }
+
+       /**
+        * Cause a select event on this {@link GuiReaderBook}.
+        * <p>
+        * Have a look at {@link GuiReaderBook#setSelected(boolean)}.
+        */
+       private void select() {
+               for (BookActionListener listener : listeners) {
+                       listener.select(GuiReaderBook.this);
+               }
+       }
+
+       /**
+        * Request a popup.
+        * 
+        * @param target
+        *            the target component for the popup
+        * @param x
+        *            the X position of the click/request (in case of popup request
+        *            from the keyboard, the center of the target should be selected
+        *            as point of reference)
+        * @param y
+        *            the Y position of the click/request (in case of popup request
+        *            from the keyboard, the center of the target should be selected
+        *            as point of reference)
+        */
+       public void popup(Component target, int x, int y) {
+               for (BookActionListener listener : listeners) {
+                       listener.select((GuiReaderBook.this));
+                       listener.popupRequested(GuiReaderBook.this, target, x, y);
+               }
+       }
+
+       /**
+        * The information about the book represented by this item.
+        * 
+        * @return the meta
+        */
+       public GuiReaderBookInfo getInfo() {
+               return info;
+       }
+
+       /**
+        * This item {@link GuiReader} library cache state.
+        * 
+        * @return TRUE if it is present in the {@link GuiReader} cache
+        */
+       public boolean isCached() {
+               return cached;
+       }
+
+       /**
+        * This item {@link GuiReader} library cache state.
+        * 
+        * @param cached
+        *            TRUE if it is present in the {@link GuiReader} cache
+        */
+       public void setCached(boolean cached) {
+               if (this.cached != cached) {
+                       this.cached = cached;
+                       repaint();
+               }
+       }
+
+       /**
+        * Update the title, paint the item, then call
+        * {@link GuiReaderCoverImager#paintOverlay(Graphics, boolean, boolean, boolean, boolean)}
+        * .
+        */
+       @Override
+       public void paint(Graphics g) {
+               updateTitle();
+               super.paint(g);
+               GuiReaderCoverImager.paintOverlay(g, isEnabled(), isSelected(),
+                               isHovered(), isCached());
+       }
+
+       /**
+        * Update the title with the currently registered information.
+        */
+       private void updateTitle() {
+               String optSecondary = info.getSecondaryInfo(seeWordCount);
+               title.setText(String
+                               .format("<html>"
+                                               + "<body style='width: %d px; height: %d px; text-align: center'>"
+                                               + "%s" + "<br>" + "<span style='color: %s;'>" + "%s"
+                                               + "</span>" + "</body>" + "</html>",
+                                               GuiReaderCoverImager.TEXT_WIDTH,
+                                               GuiReaderCoverImager.TEXT_HEIGHT, info.getMainInfo(),
+                                               AUTHOR_COLOR, optSecondary));
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderBookInfo.java
new file mode 100644 (file)
index 0000000..c163834
--- /dev/null
@@ -0,0 +1,252 @@
+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
+ * {@link Story}, a fake-story grouping some authors or a fake-story grouping
+ * some sources/types).
+ * 
+ * @author niki
+ */
+public class GuiReaderBookInfo {
+       public enum Type {
+               /** A normal story, which can be "read". */
+               STORY,
+               /**
+                * A special, empty story that represents a source/type common to one or
+                * more normal stories.
+                */
+               SOURCE,
+               /** A special, empty story that represents an author. */
+               AUTHOR
+       }
+
+       private Type type;
+       private String id;
+       private String value;
+       private String count;
+
+       private MetaData meta;
+
+       /**
+        * For private use, see the "fromXXX" constructors instead for public use.
+        * 
+        * @param type
+        *            the type of book
+        * @param id
+        *            the main id, which must uniquely identify this book and will
+        *            be used as a unique ID later on
+        * @param value
+        *            the main value to show (see
+        *            {@link GuiReaderBookInfo#getMainInfo()})
+        */
+       private GuiReaderBookInfo(Type type, String id, String value) {
+               this.type = type;
+               this.id = id;
+               this.value = value;
+       }
+       
+       /**
+        * The type of {@link GuiReaderBookInfo}.
+        * 
+        * @return the type
+        */
+       public Type getType() {
+               return type;
+       }
+
+       /**
+        * Get the main info to display for this book (a title, an author, a
+        * source/type name...).
+        * <p>
+        * Note that when {@link MetaData} about the book are present, the title
+        * inside is returned instead of the actual value (that way, we can update
+        * the {@link MetaData} and see the changes here).
+        * 
+        * @return the main info, usually the title
+        */
+       public String getMainInfo() {
+               if (meta != null) {
+                       return meta.getTitle();
+               }
+
+               return value;
+       }
+
+       /**
+        * Get the secondary info, of the given type.
+        * 
+        * @param seeCount
+        *            TRUE for word/image/story count, FALSE for author name
+        * 
+        * @return the secondary info
+        */
+       public String getSecondaryInfo(boolean seeCount) {
+               String author = meta == null ? null : meta.getAuthor();
+               String secondaryInfo = seeCount ? count : author;
+
+               if (secondaryInfo != null && !secondaryInfo.trim().isEmpty()) {
+                       secondaryInfo = "(" + secondaryInfo + ")";
+               } else {
+                       secondaryInfo = "";
+               }
+
+               return secondaryInfo;
+       }
+
+       /**
+        * A unique ID for this {@link GuiReaderBookInfo}.
+        * 
+        * @return the unique ID
+        */
+       public String getId() {
+               return id;
+       }
+
+       /**
+        * The {@link MetaData} associated with this book, if this book is a
+        * {@link Story}.
+        * <p>
+        * Can be NULL for non-story books (authors or sources/types).
+        * 
+        * @return the {@link MetaData} or NULL
+        */
+       public MetaData getMeta() {
+               return meta;
+       }
+
+       /**
+        * Get the base image to use to represent this book.
+        * <p>
+        * The image is <b>NOT</b> resized in any way, this is the original version.
+        * <p>
+        * It can be NULL if no image can be found for this book.
+        * 
+        * @param lib
+        *            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) throws IOException {
+               switch (type) {
+               case STORY:
+                       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:
+                       return lib.getAuthorCover(value);
+               }
+
+               return null;
+       }
+
+       /**
+        * Create a new book describing the given {@link Story}.
+        * 
+        * @param meta
+        *            the {@link MetaData} representing the {@link Story}
+        * 
+        * @return the book
+        */
+       static public GuiReaderBookInfo fromMeta(MetaData meta) {
+               String uid = meta.getUuid();
+               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 = StringUtils.formatNumber(meta.getWords());
+               if (!info.count.isEmpty()) {
+                       info.count = GuiReader.trans(
+                                       meta.isImageDocument() ? StringIdGui.BOOK_COUNT_IMAGES
+                                                       : StringIdGui.BOOK_COUNT_WORDS, info.count);
+               }
+
+               return info;
+       }
+
+       /**
+        * Create a new book describing the given source/type.
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to use to retrieve some more
+        *            information about the source
+        * @param source
+        *            the source name
+        * 
+        * @return the book
+        */
+       static public GuiReaderBookInfo fromSource(BasicLibrary lib, String source) {
+               GuiReaderBookInfo info = new GuiReaderBookInfo(Type.SOURCE, "source_"
+                               + source, source);
+
+               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);
+               }
+
+               return info;
+       }
+
+       /**
+        * Create a new book describing the given author.
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to use to retrieve some more
+        *            information about the author
+        * @param author
+        *            the author name
+        * 
+        * @return the book
+        */
+       static public GuiReaderBookInfo fromAuthor(BasicLibrary lib, String author) {
+               GuiReaderBookInfo info = new GuiReaderBookInfo(Type.AUTHOR, "author_"
+                               + author, author);
+
+               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);
+               }
+
+               return info;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderCoverImager.java
new file mode 100644 (file)
index 0000000..f46ec1b
--- /dev/null
@@ -0,0 +1,244 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Polygon;
+import java.awt.Rectangle;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+
+import javax.imageio.ImageIO;
+import javax.swing.ImageIcon;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ui.ImageUtilsAwt;
+import be.nikiroo.utils.ui.UIUtils;
+
+/**
+ * This class can create a cover icon ready to use for the graphical
+ * application.
+ * 
+ * @author niki
+ */
+class GuiReaderCoverImager {
+
+       // TODO: export some of the configuration options?
+       private static final int COVER_WIDTH = 100;
+       private static final int COVER_HEIGHT = 150;
+       private static final int SPINE_WIDTH = 5;
+       private static final int SPINE_HEIGHT = 5;
+       private static final int HOFFSET = 20;
+       private static final Color SPINE_COLOR_BOTTOM = new Color(180, 180, 180);
+       private static final Color SPINE_COLOR_RIGHT = new Color(100, 100, 100);
+       private static final Color BORDER = Color.black;
+
+       public static final int TEXT_HEIGHT = 50;
+       public static final int TEXT_WIDTH = COVER_WIDTH + 40;
+
+       //
+
+       /**
+        * Draw a partially transparent overlay if needed depending upon the
+        * selection and mouse-hover states on top of the normal component, as well
+        * as a possible "cached" icon if the item is cached.
+        * 
+        * @param g
+        *            the {@link Graphics} to paint onto
+        * @param enabled
+        *            draw an enabled overlay
+        * @param selected
+        *            draw a selected overlay
+        * @param hovered
+        *            draw a hovered overlay
+        * @param cached
+        *            draw a cached overlay
+        */
+       static public void paintOverlay(Graphics g, boolean enabled,
+                       boolean selected, boolean hovered, boolean cached) {
+               Rectangle clip = g.getClipBounds();
+               if (clip.getWidth() <= 0 || clip.getHeight() <= 0) {
+                       return;
+               }
+
+               int h = COVER_HEIGHT;
+               int w = COVER_WIDTH;
+               int xOffset = (TEXT_WIDTH - COVER_WIDTH) - 1;
+               int yOffset = HOFFSET;
+
+               if (BORDER != null) {
+                       if (BORDER != null) {
+                               g.setColor(BORDER);
+                               g.drawRect(xOffset, yOffset, COVER_WIDTH, COVER_HEIGHT);
+                       }
+
+                       xOffset++;
+                       yOffset++;
+               }
+
+               int[] xs = new int[] { xOffset, xOffset + SPINE_WIDTH,
+                               xOffset + w + SPINE_WIDTH, xOffset + w };
+               int[] ys = new int[] { yOffset + h, yOffset + h + SPINE_HEIGHT,
+                               yOffset + h + SPINE_HEIGHT, yOffset + h };
+               g.setColor(SPINE_COLOR_BOTTOM);
+               g.fillPolygon(new Polygon(xs, ys, xs.length));
+               xs = new int[] { xOffset + w, xOffset + w + SPINE_WIDTH,
+                               xOffset + w + SPINE_WIDTH, xOffset + w };
+               ys = new int[] { yOffset, yOffset + SPINE_HEIGHT,
+                               yOffset + h + SPINE_HEIGHT, yOffset + h };
+               g.setColor(SPINE_COLOR_RIGHT);
+               g.fillPolygon(new Polygon(xs, ys, xs.length));
+
+               Color color = new Color(255, 255, 255, 0);
+               if (!enabled) {
+               } else if (selected && !hovered) {
+                       color = new Color(80, 80, 100, 40);
+               } else if (!selected && hovered) {
+                       color = new Color(230, 230, 255, 100);
+               } else if (selected && hovered) {
+                       color = new Color(200, 200, 255, 100);
+               }
+
+               g.setColor(color);
+               g.fillRect(clip.x, clip.y, clip.width, clip.height);
+
+               if (cached) {
+                       UIUtils.drawEllipse3D(g, Color.green.darker(), COVER_WIDTH
+                                       + HOFFSET + 30, 10, 20, 20);
+               }
+       }
+
+       /**
+        * Generate a cover icon based upon the given {@link MetaData}.
+        * 
+        * @param lib
+        *            the library the meta comes from
+        * @param meta
+        *            the {@link MetaData}
+        * 
+        * @return the icon
+        */
+       static public ImageIcon generateCoverIcon(BasicLibrary lib, MetaData meta) {
+               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}.
+        * 
+        * @param lib
+        *            the library the meta comes from
+        * @param info
+        *            the {@link GuiReaderBookInfo}
+        * 
+        * @return the icon
+        */
+       static public ImageIcon generateCoverIcon(BasicLibrary lib,
+                       GuiReaderBookInfo info) {
+               BufferedImage resizedImage = null;
+               String id = getIconId(info);
+
+               InputStream in = Instance.getCache().getFromCache(id);
+               if (in != null) {
+                       try {
+                               resizedImage = ImageUtilsAwt.fromImage(new Image(in));
+                               in.close();
+                               in = null;
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(e);
+                       }
+               }
+
+               if (resizedImage == null) {
+                       try {
+                               Image cover = info.getBaseImage(lib);
+                               resizedImage = new BufferedImage(getCoverWidth(),
+                                               getCoverHeight(), BufferedImage.TYPE_4BYTE_ABGR);
+
+                               Graphics2D g = resizedImage.createGraphics();
+                               try {
+                                       g.setColor(Color.white);
+                                       g.fillRect(0, HOFFSET, COVER_WIDTH, COVER_HEIGHT);
+
+                                       if (cover != null) {
+                                               BufferedImage coverb = ImageUtilsAwt.fromImage(cover);
+                                               g.drawImage(coverb, 0, HOFFSET, COVER_WIDTH,
+                                                               COVER_HEIGHT, null);
+                                       } else {
+                                               g.setColor(Color.black);
+                                               g.drawLine(0, HOFFSET, COVER_WIDTH, HOFFSET
+                                                               + COVER_HEIGHT);
+                                               g.drawLine(COVER_WIDTH, HOFFSET, 0, HOFFSET
+                                                               + COVER_HEIGHT);
+                                       }
+                               } finally {
+                                       g.dispose();
+                               }
+
+                               if (id != null) {
+                                       ByteArrayOutputStream out = new ByteArrayOutputStream();
+                                       ImageIO.write(resizedImage, "png", out);
+                                       byte[] imageBytes = out.toByteArray();
+                                       in = new ByteArrayInputStream(imageBytes);
+                                       Instance.getCache().addToCache(in, id);
+                                       in.close();
+                                       in = null;
+                               }
+                       } catch (MalformedURLException e) {
+                               Instance.getTraceHandler().error(e);
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(e);
+                       }
+               }
+
+               return new ImageIcon(resizedImage);
+       }
+
+       /**
+        * Manually clear the icon set for this item.
+        * 
+        * @param info
+        *            the info about the story or source/type or author
+        */
+       static public void clearIcon(GuiReaderBookInfo info) {
+               String id = getIconId(info);
+               Instance.getCache().removeFromCache(id);
+       }
+
+       /**
+        * Get a unique ID from this {@link GuiReaderBookInfo} (note that it can be
+        * a story, a fake item for a source/type or a fake item for an author).
+        * 
+        * @param info
+        *            the info
+        * @return the unique ID
+        */
+       static private String getIconId(GuiReaderBookInfo info) {
+               return info.getId() + ".thumb_" + SPINE_WIDTH + "x" + COVER_WIDTH + "+"
+                               + SPINE_HEIGHT + "+" + COVER_HEIGHT + "@" + HOFFSET;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderFrame.java
new file mode 100644 (file)
index 0000000..f374821
--- /dev/null
@@ -0,0 +1,1016 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Frame;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyEvent;
+import java.awt.event.WindowEvent;
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+import javax.swing.JPopupMenu;
+import javax.swing.SwingUtilities;
+import javax.swing.filechooser.FileFilter;
+import javax.swing.filechooser.FileNameExtensionFilter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringIdGui;
+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.MetaDataRunnable;
+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;
+
+/**
+ * A {@link Frame} that will show a {@link GuiReaderBook} item for each
+ * {@link Story} in the main cache ({@link Instance#getCache()}), and offer a
+ * way to copy them to the {@link GuiReader} cache (
+ * {@link BasicReader#getLibrary()}), read them, delete them...
+ * 
+ * @author niki
+ */
+class GuiReaderFrame extends JFrame implements FrameHelper {
+       private static final long serialVersionUID = 1L;
+       private GuiReader reader;
+       private GuiReaderMainPanel mainPanel;
+
+       /**
+        * The different modification actions you can use on {@link Story} items.
+        * 
+        * @author niki
+        */
+       private enum ChangeAction {
+               /** Change the source/type, that is, move it to another source. */
+               SOURCE,
+               /** Change its name. */
+               TITLE,
+               /** Change its author. */
+               AUTHOR
+       }
+
+       /**
+        * Create a new {@link GuiReaderFrame}.
+        * 
+        * @param reader
+        *            the associated {@link GuiReader} to forward some commands and
+        *            access its {@link LocalLibrary}
+        * @param type
+        *            the type of {@link Story} to load, or NULL for all types
+        */
+       public GuiReaderFrame(GuiReader reader, String type) {
+               super(getAppTitle(reader.getLibrary().getLibraryName()));
+
+               this.reader = reader;
+
+               mainPanel = new GuiReaderMainPanel(this, type);
+
+               setSize(800, 600);
+               setLayout(new BorderLayout());
+               add(mainPanel, BorderLayout.CENTER);
+       }
+
+       @Override
+       public JPopupMenu createBookPopup() {
+               Status status = reader.getLibrary().getStatus();
+               JPopupMenu popup = new JPopupMenu();
+               popup.add(createMenuItemOpenBook());
+               popup.addSeparator();
+               popup.add(createMenuItemExport());
+               if (status.isWritable()) {
+                       popup.add(createMenuItemMoveTo());
+                       popup.add(createMenuItemSetCoverForSource());
+                       popup.add(createMenuItemSetCoverForAuthor());
+               }
+               popup.add(createMenuItemDownloadToCache());
+               popup.add(createMenuItemClearCache());
+               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;
+       }
+
+       @Override
+       public JPopupMenu createSourceAuthorPopup() {
+               JPopupMenu popup = new JPopupMenu();
+               popup.add(createMenuItemOpenBook());
+               return popup;
+       }
+
+       @Override
+       public void createMenu(Status status) {
+               invalidate();
+
+               JMenuBar bar = new JMenuBar();
+
+               JMenu file = new JMenu(GuiReader.trans(StringIdGui.MENU_FILE));
+               file.setMnemonic(KeyEvent.VK_F);
+
+               JMenuItem imprt = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_IMPORT_URL),
+                               KeyEvent.VK_U);
+               imprt.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               mainPanel.imprt(true);
+                       }
+               });
+               JMenuItem imprtF = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_IMPORT_FILE),
+                               KeyEvent.VK_F);
+               imprtF.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               mainPanel.imprt(false);
+                       }
+               });
+               JMenuItem exit = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_EXIT), KeyEvent.VK_X);
+               exit.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               GuiReaderFrame.this.dispatchEvent(new WindowEvent(
+                                               GuiReaderFrame.this, WindowEvent.WINDOW_CLOSING));
+                       }
+               });
+
+               file.add(createMenuItemOpenBook());
+               file.add(createMenuItemExport());
+               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();
+               file.add(exit);
+
+               bar.add(file);
+
+               JMenu edit = new JMenu(GuiReader.trans(StringIdGui.MENU_EDIT));
+               edit.setMnemonic(KeyEvent.VK_E);
+
+               edit.add(createMenuItemSetCoverForSource());
+               edit.add(createMenuItemSetCoverForAuthor());
+               edit.add(createMenuItemDownloadToCache());
+               edit.add(createMenuItemClearCache());
+               edit.add(createMenuItemRedownload());
+               edit.addSeparator();
+               edit.add(createMenuItemDelete());
+
+               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(
+                               GuiReader.trans(StringIdGui.MENU_VIEW_AUTHOR));
+               vauthors.setMnemonic(KeyEvent.VK_A);
+               vauthors.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               mainPanel.setWords(false);
+                               mainPanel.refreshBooks();
+                       }
+               });
+               view.add(vauthors);
+               JMenuItem vwords = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_VIEW_WCOUNT));
+               vwords.setMnemonic(KeyEvent.VK_W);
+               vwords.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               mainPanel.setWords(true);
+                               mainPanel.refreshBooks();
+                       }
+               });
+               view.add(vwords);
+               bar.add(view);
+
+               Map<String, List<String>> groupedSources = new HashMap<String, List<String>>();
+               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);
+               populateMenuSA(sources, groupedSources, true);
+               bar.add(sources);
+
+               Map<String, List<String>> goupedAuthors = new HashMap<String, List<String>>();
+               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);
+               populateMenuSA(authors, goupedAuthors, false);
+               bar.add(authors);
+
+               JMenu options = new JMenu(GuiReader.trans(StringIdGui.MENU_OPTIONS));
+               options.setMnemonic(KeyEvent.VK_O);
+               options.add(createMenuItemConfig());
+               options.add(createMenuItemUiConfig());
+               bar.add(options);
+
+               setJMenuBar(bar);
+       }
+
+       // "" = [unknown]
+       private void populateMenuSA(JMenu menu,
+                       Map<String, List<String>> groupedValues, boolean type) {
+
+               // "All" and "Listing" special items first
+               JMenuItem item = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_XXX_ALL_GROUPED));
+               item.addActionListener(getActionOpenList(type, false));
+               menu.add(item);
+               item = new JMenuItem(GuiReader.trans(StringIdGui.MENU_XXX_ALL_LISTING));
+               item.addActionListener(getActionOpenList(type, true));
+               menu.add(item);
+
+               menu.addSeparator();
+
+               for (final String value : groupedValues.keySet()) {
+                       List<String> list = groupedValues.get(value);
+                       if (type && list.size() == 1 && list.get(0).isEmpty()) {
+                               // leaf item source/type
+                               item = new JMenuItem(
+                                               value.isEmpty() ? GuiReader
+                                                               .trans(StringIdGui.MENU_AUTHORS_UNKNOWN)
+                                                               : value);
+                               item.addActionListener(getActionOpen(value, type));
+                               menu.add(item);
+                       } else {
+                               JMenu dir;
+                               if (!type && groupedValues.size() == 1) {
+                                       // only one group of authors
+                                       dir = menu;
+                               } else {
+                                       dir = new JMenu(
+                                                       value.isEmpty() ? GuiReader
+                                                                       .trans(StringIdGui.MENU_AUTHORS_UNKNOWN)
+                                                                       : value);
+                               }
+
+                               for (String sub : list) {
+                                       // " " instead of "" for the visual height
+                                       String itemName = sub;
+                                       if (itemName.isEmpty()) {
+                                               itemName = type ? " " : GuiReader
+                                                               .trans(StringIdGui.MENU_AUTHORS_UNKNOWN);
+                                       }
+
+                                       String actualValue = value;
+                                       if (type) {
+                                               if (!sub.isEmpty()) {
+                                                       actualValue += "/" + sub;
+                                               }
+                                       } else {
+                                               actualValue = sub;
+                                       }
+
+                                       item = new JMenuItem(itemName);
+                                       item.addActionListener(getActionOpen(actualValue, type));
+                                       dir.add(item);
+                               }
+
+                               if (menu != dir) {
+                                       menu.add(dir);
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Return an {@link ActionListener} that will set the given source (type) as
+        * the selected/displayed one.
+        * 
+        * @param type
+        *            the type (source) to select, cannot be NULL
+        * 
+        * @return the {@link ActionListener}
+        */
+       private ActionListener getActionOpen(final String source, final boolean type) {
+               return new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               mainPanel.removeBookPanes();
+                               mainPanel.addBookPane(source, type);
+                               mainPanel.refreshBooks();
+                       }
+               };
+       }
+
+       private ActionListener getActionOpenList(final boolean type,
+                       final boolean listMode) {
+               return new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent ae) {
+                               mainPanel.removeBookPanes();
+                               try {
+                                       mainPanel.addBookPane(type, listMode);
+                               } catch (IOException e) {
+                                       error(e.getLocalizedMessage(), "IOException", e);
+                               }
+                               mainPanel.refreshBooks();
+                       }
+               };
+       }
+
+       /**
+        * Create the Fanfix Configuration menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemConfig() {
+               final String title = GuiReader.trans(StringIdGui.TITLE_CONFIG);
+               JMenuItem item = new JMenuItem(title);
+               item.setMnemonic(KeyEvent.VK_F);
+
+               item.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               ConfigEditor<Config> ed = new ConfigEditor<Config>(
+                                               Config.class, Instance.getConfig(), GuiReader
+                                                               .trans(StringIdGui.SUBTITLE_CONFIG));
+                               JFrame frame = new JFrame(title);
+                               frame.add(ed);
+                               frame.setSize(850, 600);
+                               frame.setVisible(true);
+                       }
+               });
+
+               return item;
+       }
+
+       /**
+        * Create the UI Configuration menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemUiConfig() {
+               final String title = GuiReader.trans(StringIdGui.TITLE_CONFIG_UI);
+               JMenuItem item = new JMenuItem(title);
+               item.setMnemonic(KeyEvent.VK_U);
+
+               item.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               ConfigEditor<UiConfig> ed = new ConfigEditor<UiConfig>(
+                                               UiConfig.class, Instance.getUiConfig(), GuiReader
+                                                               .trans(StringIdGui.SUBTITLE_CONFIG_UI));
+                               JFrame frame = new JFrame(title);
+                               frame.add(ed);
+                               frame.setSize(800, 600);
+                               frame.setVisible(true);
+                       }
+               });
+
+               return item;
+       }
+
+       /**
+        * Create the export menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemExport() {
+               final JFileChooser fc = new JFileChooser();
+               fc.setAcceptAllFileFilterUsed(false);
+
+               // Add the "ALL" filters first, then the others
+               final Map<FileFilter, OutputType> otherFilters = new HashMap<FileFilter, OutputType>();
+               for (OutputType type : OutputType.values()) {
+                       String ext = type.getDefaultExtension(false);
+                       String desc = type.getDesc(false);
+
+                       if (ext == null || ext.isEmpty()) {
+                               fc.addChoosableFileFilter(createAllFilter(desc));
+                       } else {
+                               otherFilters.put(new FileNameExtensionFilter(desc, ext), type);
+                       }
+               }
+
+               for (Entry<FileFilter, OutputType> entry : otherFilters.entrySet()) {
+                       fc.addChoosableFileFilter(entry.getKey());
+               }
+               //
+
+               JMenuItem export = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_EXPORT), KeyEvent.VK_S);
+               export.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       fc.showDialog(GuiReaderFrame.this,
+                                                       GuiReader.trans(StringIdGui.TITLE_SAVE));
+                                       if (fc.getSelectedFile() != null) {
+                                               final OutputType type = otherFilters.get(fc
+                                                               .getFileFilter());
+                                               final String path = fc.getSelectedFile()
+                                                               .getAbsolutePath()
+                                                               + type.getDefaultExtension(false);
+                                               final Progress pg = new Progress();
+                                               mainPanel.outOfUi(pg, false, new Runnable() {
+                                                       @Override
+                                                       public void run() {
+                                                               try {
+                                                                       reader.getLibrary().export(
+                                                                                       selectedBook.getInfo().getMeta()
+                                                                                                       .getLuid(), type, path, pg);
+                                                               } catch (IOException e) {
+                                                                       Instance.getTraceHandler().error(e);
+                                                               }
+                                                       }
+                                               });
+                                       }
+                               }
+                       }
+               });
+
+               return export;
+       }
+
+       /**
+        * Create a {@link FileFilter} that accepts all files and return the given
+        * description.
+        * 
+        * @param desc
+        *            the description
+        * 
+        * @return the filter
+        */
+       private FileFilter createAllFilter(final String desc) {
+               return new FileFilter() {
+                       @Override
+                       public String getDescription() {
+                               return desc;
+                       }
+
+                       @Override
+                       public boolean accept(File f) {
+                               return true;
+                       }
+               };
+       }
+
+       /**
+        * Create the refresh (delete cache) menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemClearCache() {
+               JMenuItem refresh = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_EDIT_CLEAR_CACHE),
+                               KeyEvent.VK_C);
+               refresh.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       mainPanel.outOfUi(null, false, new Runnable() {
+                                               @Override
+                                               public void run() {
+                                                       reader.clearLocalReaderCache(selectedBook.getInfo()
+                                                                       .getMeta().getLuid());
+                                                       selectedBook.setCached(false);
+                                                       GuiReaderCoverImager.clearIcon(selectedBook
+                                                                       .getInfo());
+                                                       SwingUtilities.invokeLater(new Runnable() {
+                                                               @Override
+                                                               public void run() {
+                                                                       selectedBook.repaint();
+                                                               }
+                                                       });
+                                               }
+                                       });
+                               }
+                       }
+               });
+
+               return refresh;
+       }
+
+       /**
+        * Create the "move to" menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemMoveTo() {
+               JMenu changeTo = new JMenu(
+                               GuiReader.trans(StringIdGui.MENU_FILE_MOVE_TO));
+               changeTo.setMnemonic(KeyEvent.VK_M);
+
+               Map<String, List<String>> groupedSources = new HashMap<String, List<String>>();
+               try {
+                       groupedSources = reader.getLibrary().getSourcesGrouped();
+               } catch (IOException e) {
+                       error(e.getLocalizedMessage(), "IOException", e);
+               }
+
+               JMenuItem item = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_MOVE_TO_NEW_TYPE));
+               item.addActionListener(createMoveAction(ChangeAction.SOURCE, null));
+               changeTo.add(item);
+               changeTo.addSeparator();
+
+               for (final String type : groupedSources.keySet()) {
+                       List<String> list = groupedSources.get(type);
+                       if (list.size() == 1 && list.get(0).isEmpty()) {
+                               item = new JMenuItem(type);
+                               item.addActionListener(createMoveAction(ChangeAction.SOURCE,
+                                               type));
+                               changeTo.add(item);
+                       } else {
+                               JMenu dir = new JMenu(type);
+                               for (String sub : list) {
+                                       // " " instead of "" for the visual height
+                                       String itemName = sub.isEmpty() ? " " : sub;
+                                       String actualType = type;
+                                       if (!sub.isEmpty()) {
+                                               actualType += "/" + sub;
+                                       }
+
+                                       item = new JMenuItem(itemName);
+                                       item.addActionListener(createMoveAction(
+                                                       ChangeAction.SOURCE, actualType));
+                                       dir.add(item);
+                               }
+                               changeTo.add(dir);
+                       }
+               }
+
+               return changeTo;
+       }
+
+       /**
+        * Create the "set author" menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemSetAuthor() {
+               JMenu changeTo = new JMenu(
+                               GuiReader.trans(StringIdGui.MENU_FILE_SET_AUTHOR));
+               changeTo.setMnemonic(KeyEvent.VK_A);
+
+               // New author
+               JMenuItem newItem = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_MOVE_TO_NEW_AUTHOR));
+               changeTo.add(newItem);
+               changeTo.addSeparator();
+               newItem.addActionListener(createMoveAction(ChangeAction.AUTHOR, null));
+
+               // Existing authors
+               Map<String, List<String>> groupedAuthors;
+
+               try {
+                       groupedAuthors = reader.getLibrary().getAuthorsGrouped();
+               } catch (IOException e) {
+                       error(e.getLocalizedMessage(), "IOException", e);
+                       groupedAuthors = new HashMap<String, List<String>>();
+
+               }
+
+               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()) {
+                               JMenuItem item = new JMenuItem(
+                                               value.isEmpty() ? GuiReader
+                                                               .trans(StringIdGui.MENU_AUTHORS_UNKNOWN)
+                                                               : value);
+                               item.addActionListener(createMoveAction(ChangeAction.AUTHOR,
+                                               value));
+                               changeTo.add(item);
+                       }
+               }
+
+               return changeTo;
+       }
+
+       /**
+        * Create the "rename" menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemRename() {
+               JMenuItem changeTo = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_RENAME));
+               changeTo.setMnemonic(KeyEvent.VK_R);
+               changeTo.addActionListener(createMoveAction(ChangeAction.TITLE, null));
+               return changeTo;
+       }
+
+       private ActionListener createMoveAction(final ChangeAction what,
+                       final String type) {
+               return new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       boolean refreshRequired = false;
+
+                                       if (what == ChangeAction.SOURCE) {
+                                               refreshRequired = mainPanel.getCurrentType();
+                                       } else if (what == ChangeAction.TITLE) {
+                                               refreshRequired = false;
+                                       } else if (what == ChangeAction.AUTHOR) {
+                                               refreshRequired = !mainPanel.getCurrentType();
+                                       }
+
+                                       String changeTo = type;
+                                       if (type == null) {
+                                               MetaData meta = selectedBook.getInfo().getMeta();
+                                               String init = "";
+                                               if (what == ChangeAction.SOURCE) {
+                                                       init = meta.getSource();
+                                               } else if (what == ChangeAction.TITLE) {
+                                                       init = meta.getTitle();
+                                               } else if (what == ChangeAction.AUTHOR) {
+                                                       init = meta.getAuthor();
+                                               }
+
+                                               Object rep = JOptionPane.showInputDialog(
+                                                               GuiReaderFrame.this,
+                                                               GuiReader.trans(StringIdGui.SUBTITLE_MOVE_TO),
+                                                               GuiReader.trans(StringIdGui.TITLE_MOVE_TO),
+                                                               JOptionPane.QUESTION_MESSAGE, null, null, init);
+
+                                               if (rep == null) {
+                                                       return;
+                                               }
+
+                                               changeTo = rep.toString();
+                                       }
+
+                                       final String fChangeTo = changeTo;
+                                       mainPanel.outOfUi(null, refreshRequired, new Runnable() {
+                                               @Override
+                                               public void run() {
+                                                       String luid = selectedBook.getInfo().getMeta()
+                                                                       .getLuid();
+                                                       if (what == ChangeAction.SOURCE) {
+                                                               reader.changeSource(luid, fChangeTo);
+                                                       } else if (what == ChangeAction.TITLE) {
+                                                               reader.changeTitle(luid, fChangeTo);
+                                                       } else if (what == ChangeAction.AUTHOR) {
+                                                               reader.changeAuthor(luid, fChangeTo);
+                                                       }
+
+                                                       mainPanel.getSelectedBook().repaint();
+                                                       mainPanel.unsetSelectedBook();
+
+                                                       SwingUtilities.invokeLater(new Runnable() {
+                                                               @Override
+                                                               public void run() {
+                                                                       createMenu(reader.getLibrary().getStatus());
+                                                               }
+                                                       });
+                                               }
+                                       });
+                               }
+                       }
+               };
+       }
+
+       /**
+        * Create the re-download (then delete original) menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemRedownload() {
+               JMenuItem refresh = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_EDIT_REDOWNLOAD),
+                               KeyEvent.VK_R);
+               refresh.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       final MetaData meta = selectedBook.getInfo().getMeta();
+                                       mainPanel.imprt(meta.getUrl(), new MetaDataRunnable() {
+                                               @Override
+                                               public void run(MetaData newMeta) {
+                                                       if (!newMeta.getSource().equals(meta.getSource())) {
+                                                               reader.changeSource(newMeta.getLuid(),
+                                                                               meta.getSource());
+                                                       }
+                                               }
+                                       }, GuiReader.trans(StringIdGui.PROGRESS_CHANGE_SOURCE));
+                               }
+                       }
+               });
+
+               return refresh;
+       }
+       
+       /**
+        * Create the download to cache menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemDownloadToCache() {
+               JMenuItem refresh = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_EDIT_DOWNLOAD_TO_CACHE),
+                               KeyEvent.VK_T);
+               refresh.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       mainPanel.prefetchBook(selectedBook);
+                               }
+                       }
+               });
+
+               return refresh;
+       }
+
+
+       /**
+        * Create the delete menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemDelete() {
+               JMenuItem delete = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_EDIT_DELETE), KeyEvent.VK_D);
+               delete.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null
+                                               && selectedBook.getInfo().getMeta() != null) {
+
+                                       final MetaData meta = selectedBook.getInfo().getMeta();
+                                       int rep = JOptionPane.showConfirmDialog(
+                                                       GuiReaderFrame.this,
+                                                       GuiReader.trans(StringIdGui.SUBTITLE_DELETE,
+                                                                       meta.getLuid(), meta.getTitle()),
+                                                       GuiReader.trans(StringIdGui.TITLE_DELETE),
+                                                       JOptionPane.OK_CANCEL_OPTION);
+
+                                       if (rep == JOptionPane.OK_OPTION) {
+                                               mainPanel.outOfUi(null, true, new Runnable() {
+                                                       @Override
+                                                       public void run() {
+                                                               reader.delete(meta.getLuid());
+                                                               mainPanel.unsetSelectedBook();
+                                                       }
+                                               });
+                                       }
+                               }
+                       }
+               });
+
+               return delete;
+       }
+
+       /**
+        * Create the properties menu item.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemProperties() {
+               JMenuItem delete = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_PROPERTIES),
+                               KeyEvent.VK_P);
+               delete.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       mainPanel.outOfUi(null, false, new Runnable() {
+                                               @Override
+                                               public void run() {
+                                                       new GuiReaderPropertiesFrame(reader.getLibrary(),
+                                                                       selectedBook.getInfo().getMeta())
+                                                                       .setVisible(true);
+                                               }
+                                       });
+                               }
+                       }
+               });
+
+               return delete;
+       }
+
+       /**
+        * Create the open menu item for a book, a source/type or an author.
+        * 
+        * @return the item
+        */
+       public JMenuItem createMenuItemOpenBook() {
+               JMenuItem open = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_FILE_OPEN), KeyEvent.VK_O);
+               open.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       if (selectedBook.getInfo().getMeta() == null) {
+                                               mainPanel.removeBookPanes();
+                                               mainPanel.addBookPane(selectedBook.getInfo()
+                                                               .getMainInfo(), mainPanel.getCurrentType());
+                                               mainPanel.refreshBooks();
+                                       } else {
+                                               mainPanel.openBook(selectedBook);
+                                       }
+                               }
+                       }
+               });
+
+               return open;
+       }
+
+       /**
+        * Create the SetCover menu item for a book to change the linked source
+        * cover.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemSetCoverForSource() {
+               JMenuItem open = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_EDIT_SET_COVER_FOR_SOURCE),
+                               KeyEvent.VK_C);
+               open.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent ae) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       BasicLibrary lib = reader.getLibrary();
+                                       String luid = selectedBook.getInfo().getMeta().getLuid();
+                                       String source = selectedBook.getInfo().getMeta()
+                                                       .getSource();
+
+                                       try {
+                                               lib.setSourceCover(source, luid);
+                                       } catch (IOException e) {
+                                               error(e.getLocalizedMessage(), "IOException", e);
+                                       }
+
+                                       GuiReaderBookInfo sourceInfo = GuiReaderBookInfo
+                                                       .fromSource(lib, source);
+                                       GuiReaderCoverImager.clearIcon(sourceInfo);
+                               }
+                       }
+               });
+
+               return open;
+       }
+
+       /**
+        * Create the SetCover menu item for a book to change the linked source
+        * cover.
+        * 
+        * @return the item
+        */
+       private JMenuItem createMenuItemSetCoverForAuthor() {
+               JMenuItem open = new JMenuItem(
+                               GuiReader.trans(StringIdGui.MENU_EDIT_SET_COVER_FOR_AUTHOR),
+                               KeyEvent.VK_A);
+               open.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent ae) {
+                               final GuiReaderBook selectedBook = mainPanel.getSelectedBook();
+                               if (selectedBook != null) {
+                                       BasicLibrary lib = reader.getLibrary();
+                                       String luid = selectedBook.getInfo().getMeta().getLuid();
+                                       String author = selectedBook.getInfo().getMeta()
+                                                       .getAuthor();
+
+                                       try {
+                                               lib.setAuthorCover(author, luid);
+                                       } catch (IOException e) {
+                                               error(e.getLocalizedMessage(), "IOException", e);
+                                       }
+
+                                       GuiReaderBookInfo authorInfo = GuiReaderBookInfo
+                                                       .fromAuthor(lib, author);
+                                       GuiReaderCoverImager.clearIcon(authorInfo);
+                               }
+                       }
+               });
+
+               return open;
+       }
+
+       /**
+        * Display an error message and log the linked {@link Exception}.
+        * 
+        * @param message
+        *            the message
+        * @param title
+        *            the title of the error message
+        * @param e
+        *            the exception to log if any
+        */
+       public void error(final String message, final String title, Exception e) {
+               Instance.getTraceHandler().error(title + ": " + message);
+               if (e != null) {
+                       Instance.getTraceHandler().error(e);
+               }
+
+               SwingUtilities.invokeLater(new Runnable() {
+                       @Override
+                       public void run() {
+                               JOptionPane.showMessageDialog(GuiReaderFrame.this, message,
+                                               title, JOptionPane.ERROR_MESSAGE);
+                       }
+               });
+       }
+
+       @Override
+       public GuiReader getReader() {
+               return reader;
+       }
+
+       /**
+        * Return the title of the application.
+        * 
+        * @param libraryName
+        *            the name of the associated {@link BasicLibrary}, which can be
+        *            EMPTY
+        * 
+        * @return the title
+        */
+       static private String getAppTitle(String libraryName) {
+               if (!libraryName.isEmpty()) {
+                       return GuiReader.trans(StringIdGui.TITLE_LIBRARY_WITH_NAME, Version
+                                       .getCurrentVersion().toString(), libraryName);
+               }
+
+               return GuiReader.trans(StringIdGui.TITLE_LIBRARY, Version
+                               .getCurrentVersion().toString());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderGroup.java
new file mode 100644 (file)
index 0000000..cc3f1e1
--- /dev/null
@@ -0,0 +1,476 @@
+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;
+import java.awt.event.FocusAdapter;
+import java.awt.event.FocusEvent;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.bundles.StringIdGui;
+import be.nikiroo.fanfix.reader.ui.GuiReaderBook.BookActionListener;
+import be.nikiroo.utils.ui.WrapLayout;
+
+/**
+ * A group of {@link GuiReaderBook}s for display.
+ * 
+ * @author niki
+ */
+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<GuiReaderBookInfo> infos;
+       private List<GuiReaderBook> books;
+       private JPanel pane;
+       private JLabel titleLabel;
+       private boolean words; // words or authors (secondary info on books)
+       private int itemsPerLine;
+
+       /**
+        * Create a new {@link GuiReaderGroup}.
+        * 
+        * @param reader
+        *            the {@link GuiReaderBook} used to probe some information about
+        *            the stories
+        * @param title
+        *            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.pane = new JPanel();
+               pane.setLayout(new WrapLayout(WrapLayout.LEADING, 5, 5));
+
+               this.backgroundColorDef = getBackground();
+               this.backgroundColorDefPane = pane.getBackground();
+               setBackground(backgroundColor);
+
+               setLayout(new BorderLayout(0, 10));
+
+               // Make it focusable:
+               setFocusable(true);
+               setEnabled(true);
+               setVisible(true);
+
+               add(pane, BorderLayout.CENTER);
+
+               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() {
+                       @Override
+                       public void componentResized(ComponentEvent e) {
+                               super.componentResized(e);
+                               computeItemsPerLine();
+                       }
+               });
+               computeItemsPerLine();
+
+               addKeyListener(new KeyAdapter() {
+                       @Override
+                       public void keyPressed(KeyEvent e) {
+                               onKeyPressed(e);
+                       }
+
+                       @Override
+                       public void keyTyped(KeyEvent e) {
+                               onKeyTyped(e);
+                       }
+               });
+
+               addFocusListener(new FocusAdapter() {
+                       @Override
+                       public void focusGained(FocusEvent e) {
+                               if (getSelectedBookIndex() < 0) {
+                                       setSelectedBook(0, true);
+                               }
+                       }
+
+                       @Override
+                       public void focusLost(FocusEvent e) {
+                               setBackground(null);
+                               setSelectedBook(-1, false);
+                       }
+               });
+       }
+
+       /**
+        * Note: this class supports NULL as a background colour, which will revert
+        * it to its default state.
+        * <p>
+        * Note: this class' implementation will also set the main pane background
+        * colour at the same time.
+        * <p>
+        * Sets the background colour of this component. The background colour is
+        * used only if the component is opaque, and only by subclasses of
+        * <code>JComponent</code> or <code>ComponentUI</code> implementations.
+        * Direct subclasses of <code>JComponent</code> must override
+        * <code>paintComponent</code> to honour this property.
+        * <p>
+        * It is up to the look and feel to honour this property, some may choose to
+        * ignore it.
+        * 
+        * @param backgroundColor
+        *            the desired background <code>Colour</code>
+        * @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("<html>"
+                                       + "<body style='text-align: center; color: gray;'><br><b>"
+                                       + "%s" + "</b></body>" + "</html>", 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() {
+               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;
+               }
+       }
+
+       /**
+        * Set the {@link ActionListener} that will be fired on each
+        * {@link GuiReaderBook} action.
+        * 
+        * @param action
+        *            the action
+        */
+       public void setActionListener(BookActionListener action) {
+               this.action = action;
+               refreshBooks();
+       }
+
+       /**
+        * Clear all the books in this {@link GuiReaderGroup}.
+        */
+       public void clear() {
+               refreshBooks(new ArrayList<GuiReaderBookInfo>());
+       }
+
+       /**
+        * 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<GuiReaderBookInfo> infos) {
+               refreshBooks(infos, words);
+       }
+
+       /**
+        * Refresh the list of {@link GuiReaderBook}s displayed in the control.
+        * 
+        * @param infos
+        *            the new list of infos
+        * @param seeWordcount
+        *            TRUE to see word counts, FALSE to see authors
+        */
+       public void refreshBooks(List<GuiReaderBookInfo> infos, boolean seeWordcount) {
+               this.infos = infos;
+               refreshBooks(seeWordcount);
+       }
+
+       /**
+        * Refresh the list of {@link GuiReaderBook}s displayed in the control.
+        * <p>
+        * Will not change the current stories.
+        * 
+        * @param seeWordcount
+        *            TRUE to see word counts, FALSE to see authors
+        */
+       public void refreshBooks(boolean seeWordcount) {
+               this.words = seeWordcount;
+
+               books = new ArrayList<GuiReaderBook>();
+               invalidate();
+               pane.invalidate();
+               pane.removeAll();
+
+               if (infos != null) {
+                       for (GuiReaderBookInfo info : infos) {
+                               boolean isCached = false;
+                               if (info.getMeta() != null && info.getMeta().getLuid() != null) {
+                                       isCached = reader.isCached(info.getMeta().getLuid());
+                               }
+
+                               GuiReaderBook book = new GuiReaderBook(reader, info, isCached,
+                                               words);
+                               if (backgroundColor != null) {
+                                       book.setBackground(backgroundColor);
+                               }
+
+                               books.add(book);
+
+                               book.addActionListener(new BookActionListener() {
+                                       @Override
+                                       public void select(GuiReaderBook book) {
+                                               GuiReaderGroup.this.requestFocusInWindow();
+                                               for (GuiReaderBook abook : books) {
+                                                       abook.setSelected(abook == book);
+                                               }
+                                       }
+
+                                       @Override
+                                       public void popupRequested(GuiReaderBook book,
+                                                       Component target, int x, int y) {
+                                       }
+
+                                       @Override
+                                       public void action(GuiReaderBook book) {
+                                       }
+                               });
+
+                               if (action != null) {
+                                       book.addActionListener(action);
+                               }
+
+                               pane.add(book);
+                       }
+               }
+
+               pane.validate();
+               pane.repaint();
+               validate();
+               repaint();
+
+               computeItemsPerLine();
+       }
+
+       /**
+        * Enables or disables this component, depending on the value of the
+        * parameter <code>b</code>. An enabled component can respond to user input
+        * and generate events. Components are enabled initially by default.
+        * <p>
+        * Disabling this component will also affect its children.
+        * 
+        * @param b
+        *            If <code>true</code>, this component is enabled; otherwise
+        *            this component is disabled
+        */
+       @Override
+       public void setEnabled(boolean b) {
+               if (books != null) {
+                       for (GuiReaderBook book : books) {
+                               book.setEnabled(b);
+                               book.repaint();
+                       }
+               }
+
+               pane.setEnabled(b);
+               super.setEnabled(b);
+               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
+        */
+       public int getSelectedBookIndex() {
+               int index = -1;
+               for (int i = 0; i < books.size(); i++) {
+                       if (books.get(i).isSelected()) {
+                               index = i;
+                               break;
+                       }
+               }
+               return index;
+       }
+
+       /**
+        * Select the given book, or unselect all items.
+        * 
+        * @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 <tt>forceRange></tt>)
+        * @param forceRange
+        *            TRUE to constraint the index to the first/last element, FALSE
+        *            to unselect when outside the range
+        */
+       public void setSelectedBook(int index, boolean forceRange) {
+               int previousIndex = getSelectedBookIndex();
+
+               if (index >= books.size()) {
+                       if (forceRange) {
+                               index = books.size() - 1;
+                       } else {
+                               index = -1;
+                       }
+               }
+
+               if (index < 0 && forceRange) {
+                       index = 0;
+               }
+
+               if (previousIndex >= 0) {
+                       books.get(previousIndex).setSelected(false);
+               }
+
+               if (index >= 0 && !books.isEmpty()) {
+                       books.get(index).setSelected(true);
+               }
+       }
+
+       /**
+        * The action to execute when a key is typed.
+        * 
+        * @param e
+        *            the key event
+        */
+       private void onKeyTyped(KeyEvent e) {
+               boolean consumed = false;
+               boolean action = e.getKeyChar() == '\n';
+               boolean popup = e.getKeyChar() == ' ';
+               if (action || popup) {
+                       consumed = true;
+
+                       int index = getSelectedBookIndex();
+                       if (index >= 0) {
+                               GuiReaderBook book = books.get(index);
+                               if (action) {
+                                       book.action();
+                               } else if (popup) {
+                                       book.popup(book, book.getWidth() / 2, book.getHeight() / 2);
+                               }
+                       }
+               }
+
+               if (consumed) {
+                       e.consume();
+               }
+       }
+
+       /**
+        * The action to execute when a key is pressed.
+        * 
+        * @param e
+        *            the key event
+        */
+       private void onKeyPressed(KeyEvent e) {
+               boolean consumed = false;
+               if (e.isActionKey()) {
+                       int offset = 0;
+                       switch (e.getKeyCode()) {
+                       case KeyEvent.VK_LEFT:
+                               offset = -1;
+                               break;
+                       case KeyEvent.VK_RIGHT:
+                               offset = 1;
+                               break;
+                       case KeyEvent.VK_UP:
+                               offset = -itemsPerLine;
+                               break;
+                       case KeyEvent.VK_DOWN:
+                               offset = itemsPerLine;
+                               break;
+                       }
+
+                       if (offset != 0) {
+                               consumed = true;
+
+                               int previousIndex = getSelectedBookIndex();
+                               if (previousIndex >= 0) {
+                                       setSelectedBook(previousIndex + offset, true);
+                               }
+                       }
+               }
+
+               if (consumed) {
+                       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
new file mode 100644 (file)
index 0000000..476e130
--- /dev/null
@@ -0,0 +1,792 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.EventQueue;
+import java.awt.Frame;
+import java.awt.Toolkit;
+import java.awt.datatransfer.DataFlavor;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.FocusAdapter;
+import java.awt.event.FocusEvent;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import javax.swing.BoxLayout;
+import javax.swing.JFileChooser;
+import javax.swing.JLabel;
+import javax.swing.JMenuBar;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollPane;
+import javax.swing.SwingConstants;
+import javax.swing.SwingUtilities;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringIdGui;
+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.reader.BasicReader;
+import be.nikiroo.fanfix.reader.ui.GuiReaderBook.BookActionListener;
+import be.nikiroo.fanfix.reader.ui.GuiReaderBookInfo.Type;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.ui.ProgressBar;
+
+/**
+ * A {@link Frame} that will show a {@link GuiReaderBook} item for each
+ * {@link Story} in the main cache ({@link Instance#getCache()}), and offer a
+ * way to copy them to the {@link GuiReader} cache (
+ * {@link BasicReader#getLibrary()}), read them, delete them...
+ * 
+ * @author niki
+ */
+class GuiReaderMainPanel extends JPanel {
+       private static final long serialVersionUID = 1L;
+       private FrameHelper helper;
+       private Map<String, GuiReaderGroup> books;
+       private GuiReaderGroup bookPane; // for more "All"
+       private JPanel pane;
+       private Color color;
+       private ProgressBar pgBar;
+       private JMenuBar bar;
+       private GuiReaderBook selectedBook;
+       private boolean words; // words or authors (secondary info on books)
+       private boolean currentType; // type/source or author mode (All and Listing)
+
+       /**
+        * An object that offers some helper methods to access the frame that host
+        * it and the Fanfix-related functions.
+        * 
+        * @author niki
+        */
+       public interface FrameHelper {
+               /**
+                * Return the reader associated to this {@link FrameHelper}.
+                * 
+                * @return the reader
+                */
+               public GuiReader getReader();
+
+               /**
+                * Create the main menu bar.
+                * <p>
+                * Will invalidate the layout.
+                * 
+                * @param status
+                *            the library status, <b>must not</b> be NULL
+                */
+               public void createMenu(Status status);
+
+               /**
+                * Create a popup menu for a {@link GuiReaderBook} that represents a
+                * story.
+                * 
+                * @return the popup menu to display
+                */
+               public JPopupMenu createBookPopup();
+
+               /**
+                * Create a popup menu for a {@link GuiReaderBook} that represents a
+                * source/type or an author.
+                * 
+                * @return the popup menu to display
+                */
+               public JPopupMenu createSourceAuthorPopup();
+       }
+
+       /**
+        * A {@link Runnable} with a {@link MetaData} parameter.
+        * 
+        * @author niki
+        */
+       public interface MetaDataRunnable {
+               /**
+                * Run the action.
+                * 
+                * @param meta
+                *            the meta of the story
+                */
+               public void run(MetaData meta);
+       }
+
+       /**
+        * Create a new {@link GuiReaderMainPanel}.
+        * 
+        * @param parent
+        *            the associated {@link FrameHelper} to forward some commands
+        *            and access its {@link LocalLibrary}
+        * @param type
+        *            the type of {@link Story} to load, or NULL for all types
+        */
+       public GuiReaderMainPanel(FrameHelper parent, String type) {
+               super(new BorderLayout(), true);
+
+               this.helper = parent;
+
+               pane = new JPanel();
+               pane.setLayout(new BoxLayout(pane, BoxLayout.PAGE_AXIS));
+               JScrollPane scroll = new JScrollPane(pane);
+
+               Integer icolor = Instance.getUiConfig().getColor(
+                               UiConfig.BACKGROUND_COLOR);
+               if (icolor != null) {
+                       color = new Color(icolor);
+                       setBackground(color);
+                       pane.setBackground(color);
+                       scroll.setBackground(color);
+               }
+
+               scroll.getVerticalScrollBar().setUnitIncrement(16);
+               add(scroll, BorderLayout.CENTER);
+
+               String message = parent.getReader().getLibrary().getLibraryName();
+               if (!message.isEmpty()) {
+                       JLabel name = new JLabel(message, SwingConstants.CENTER);
+                       add(name, BorderLayout.NORTH);
+               }
+
+               pgBar = new ProgressBar();
+               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();
+                       }
+               });
+
+               books = new TreeMap<String, GuiReaderGroup>();
+
+               addFocusListener(new FocusAdapter() {
+                       @Override
+                       public void focusGained(FocusEvent e) {
+                               focus();
+                       }
+               });
+
+               pane.setVisible(false);
+               final Progress pg = new Progress();
+               final String typeF = type;
+               outOfUi(pg, true, new Runnable() {
+                       @Override
+                       public void run() {
+                               final BasicLibrary lib = helper.getReader().getLibrary();
+                               final Status status = lib.getStatus();
+
+                               if (status == Status.READ_WRITE) {
+                                       lib.refresh(pg);
+                               }
+
+                               inUi(new Runnable() {
+                                       @Override
+                                       public void run() {
+                                               if (status.isReady()) {
+                                                       helper.createMenu(status);
+                                                       pane.setVisible(true);
+                                                       if (typeF == null) {
+                                                               try {
+                                                                       addBookPane(true, false);
+                                                               } catch (IOException e) {
+                                                                       error(e.getLocalizedMessage(),
+                                                                                       "IOException", e);
+                                                               }
+                                                       } else {
+                                                               addBookPane(typeF, true);
+                                                       }
+                                               } else {
+                                                       helper.createMenu(status);
+                                                       validate();
+
+                                                       String desc = Instance.getTransGui().getStringX(
+                                                                       StringIdGui.ERROR_LIB_STATUS,
+                                                                       status.toString());
+                                                       if (desc == null) {
+                                                               desc = GuiReader
+                                                                               .trans(StringIdGui.ERROR_LIB_STATUS);
+                                                       }
+
+                                                       String err = lib.getLibraryName() + "\n" + desc;
+                                                       error(err, GuiReader
+                                                                       .trans(StringIdGui.TITLE_ERROR_LIBRARY),
+                                                                       null);
+                                               }
+                                       }
+                               });
+                       }
+               });
+       }
+
+       public boolean getCurrentType() {
+               return currentType;
+       }
+
+       /**
+        * Add a new {@link GuiReaderGroup} on the frame to display all the
+        * sources/types or all the authors, or a listing of all the books sorted
+        * either by source or author.
+        * <p>
+        * A display of all the sources/types or all the authors will show one icon
+        * per source/type or author.
+        * <p>
+        * A listing of all the books sorted by source/type or author will display
+        * all the books.
+        * 
+        * @param type
+        *            TRUE for type/source, FALSE for author
+        * @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) throws IOException {
+               this.currentType = type;
+               BasicLibrary lib = helper.getReader().getLibrary();
+               if (type) {
+                       if (!listMode) {
+                               addListPane(GuiReader.trans(StringIdGui.MENU_SOURCES),
+                                               lib.getSources(), type);
+                       } else {
+                               for (String tt : lib.getSources()) {
+                                       if (tt != null) {
+                                               addBookPane(tt, type);
+                                       }
+                               }
+                       }
+               } else {
+                       if (!listMode) {
+                               addListPane(GuiReader.trans(StringIdGui.MENU_AUTHORS),
+                                               lib.getAuthors(), type);
+                       } else {
+                               for (String tt : lib.getAuthors()) {
+                                       if (tt != null) {
+                                               addBookPane(tt, type);
+                                       }
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Add a new {@link GuiReaderGroup} on the frame to display the books of the
+        * selected type or author.
+        * <p>
+        * Will invalidate the layout.
+        * 
+        * @param value
+        *            the author or the type, or NULL to get all the
+        *            authors-or-types
+        * @param type
+        *            TRUE for type/source, FALSE for author
+        */
+       public void addBookPane(String value, boolean type) {
+               this.currentType = type;
+
+               GuiReaderGroup bookPane = new GuiReaderGroup(helper.getReader(), value,
+                               color);
+
+               books.put(value, bookPane);
+
+               pane.invalidate();
+               pane.add(bookPane);
+
+               bookPane.setActionListener(new BookActionListener() {
+                       @Override
+                       public void select(GuiReaderBook book) {
+                               selectedBook = book;
+                       }
+
+                       @Override
+                       public void popupRequested(GuiReaderBook book, Component target,
+                                       int x, int y) {
+                               JPopupMenu popup = helper.createBookPopup();
+                               popup.show(target, x, y);
+                       }
+
+                       @Override
+                       public void action(final GuiReaderBook book) {
+                               openBook(book);
+                       }
+               });
+
+               focus();
+       }
+
+       /**
+        * Clear the pane from any book that may be present, usually prior to adding
+        * new ones.
+        * <p>
+        * Will invalidate the layout.
+        */
+       public void removeBookPanes() {
+               books.clear();
+               pane.invalidate();
+               pane.removeAll();
+       }
+
+       /**
+        * Refresh the list of {@link GuiReaderBook}s from disk.
+        * <p>
+        * Will validate the layout, as it is a "refresh" operation.
+        */
+       public void refreshBooks() {
+               BasicLibrary lib = helper.getReader().getLibrary();
+               for (String value : books.keySet()) {
+                       List<GuiReaderBookInfo> infos = new ArrayList<GuiReaderBookInfo>();
+
+                       List<MetaData> metas;
+                       try {
+                               if (currentType) {
+                                       metas = lib.getListBySource(value);
+                               } else {
+                                       metas = lib.getListByAuthor(value);
+                               }
+                       } catch (IOException e) {
+                               error(e.getLocalizedMessage(), "IOException", e);
+                               metas = new ArrayList<MetaData>();
+                       }
+
+                       for (MetaData meta : metas) {
+                               infos.add(GuiReaderBookInfo.fromMeta(meta));
+                       }
+
+                       books.get(value).refreshBooks(infos, words);
+               }
+
+               if (bookPane != null) {
+                       bookPane.refreshBooks(words);
+               }
+
+               this.validate();
+       }
+
+       /**
+        * Open a {@link GuiReaderBook} item.
+        * 
+        * @param book
+        *            the {@link GuiReaderBook} to open
+        */
+       public void openBook(final GuiReaderBook book) {
+               final Progress pg = new Progress();
+               outOfUi(pg, false, new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       helper.getReader().read(book.getInfo().getMeta().getLuid(),
+                                                       false, pg);
+                                       SwingUtilities.invokeLater(new Runnable() {
+                                               @Override
+                                               public void run() {
+                                                       book.setCached(true);
+                                               }
+                                       });
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(e);
+                                       error(GuiReader.trans(StringIdGui.ERROR_CANNOT_OPEN),
+                                                       GuiReader.trans(StringIdGui.TITLE_ERROR), e);
+                               }
+                       }
+               });
+       }
+       
+       /**
+        * Prefetch a {@link GuiReaderBook} item (which can be a group, in which
+        * case we prefetch all its members).
+        * 
+        * @param book
+        *            the {@link GuiReaderBook} to open
+        */
+       public void prefetchBook(final GuiReaderBook book) {
+               final List<String> luids = new LinkedList<String>();
+               try {
+                       switch (book.getInfo().getType()) {
+                       case STORY:
+                               luids.add(book.getInfo().getMeta().getLuid());
+                               break;
+                       case SOURCE:
+                               for (MetaData meta : helper.getReader().getLibrary()
+                                               .getListBySource(book.getInfo().getMainInfo())) {
+                                       luids.add(meta.getLuid());
+                               }
+                               break;
+                       case AUTHOR:
+                               for (MetaData meta : helper.getReader().getLibrary()
+                                               .getListByAuthor(book.getInfo().getMainInfo())) {
+                                       luids.add(meta.getLuid());
+                               }
+                               break;
+                       }
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+
+               final Progress pg = new Progress();
+               pg.setMax(luids.size());
+
+               outOfUi(pg, false, new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       for (String luid : luids) {
+                                               Progress pgStep = new Progress();
+                                               pg.addProgress(pgStep, 1);
+
+                                               helper.getReader().prefetch(luid, pgStep);
+                                       }
+
+                                       // TODO: also set the green button on sources/authors?
+                                       // requires to do the same when all stories inside are green
+                                       if (book.getInfo().getType() == Type.STORY) {
+                                               SwingUtilities.invokeLater(new Runnable() {
+                                                       @Override
+                                                       public void run() {
+                                                               book.setCached(true);
+                                                       }
+                                               });
+                                       }
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(e);
+                                       error(GuiReader.trans(StringIdGui.ERROR_CANNOT_OPEN),
+                                                       GuiReader.trans(StringIdGui.TITLE_ERROR), e);
+                               }
+                       }
+               });
+       }
+
+       /**
+        * Process the given action out of the Swing UI thread and link the given
+        * {@link ProgressBar} to the action.
+        * <p>
+        * The code will make sure that the {@link ProgressBar} (if not NULL) is set
+        * to done when the action is done.
+        * 
+        * @param progress
+        *            the {@link ProgressBar} or NULL
+        * @param refreshBooks
+        *            TRUE to refresh the books after
+        * @param run
+        *            the action to run
+        */
+       public void outOfUi(Progress progress, final boolean refreshBooks,
+                       final Runnable run) {
+               final Progress pg = new Progress();
+               final Progress reload = new Progress(
+                               GuiReader.trans(StringIdGui.PROGRESS_OUT_OF_UI_RELOAD_BOOKS));
+
+               if (progress == null) {
+                       progress = new Progress();
+               }
+
+               if (refreshBooks) {
+                       pg.addProgress(progress, 100);
+               } else {
+                       pg.addProgress(progress, 90);
+                       pg.addProgress(reload, 10);
+               }
+
+               invalidate();
+               pgBar.setProgress(pg);
+               validate();
+               setEnabled(false);
+
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       run.run();
+                                       if (refreshBooks) {
+                                               refreshBooks();
+                                       }
+                               } finally {
+                                       reload.done();
+                                       if (!pg.isDone()) {
+                                               // will trigger pgBar ActionListener:
+                                               pg.done();
+                                       }
+                               }
+                       }
+               }, "outOfUi thread").start();
+       }
+
+       /**
+        * Process the given action in the main Swing UI thread.
+        * <p>
+        * The code will make sure the current thread is the main UI thread and, if
+        * not, will switch to it before executing the runnable.
+        * <p>
+        * Synchronous operation.
+        * 
+        * @param run
+        *            the action to run
+        */
+       public void inUi(final Runnable run) {
+               if (EventQueue.isDispatchThread()) {
+                       run.run();
+               } else {
+                       try {
+                               EventQueue.invokeAndWait(run);
+                       } catch (InterruptedException e) {
+                               Instance.getTraceHandler().error(e);
+                       } catch (InvocationTargetException e) {
+                               Instance.getTraceHandler().error(e);
+                       }
+               }
+       }
+
+       /**
+        * Import a {@link Story} into the main {@link LocalLibrary}.
+        * <p>
+        * Should be called inside the UI thread.
+        * 
+        * @param askUrl
+        *            TRUE for an {@link URL}, false for a {@link File}
+        */
+       public void imprt(boolean askUrl) {
+               JFileChooser fc = new JFileChooser();
+
+               Object url;
+               if (askUrl) {
+                       String clipboard = "";
+                       try {
+                               clipboard = ("" + Toolkit.getDefaultToolkit()
+                                               .getSystemClipboard().getData(DataFlavor.stringFlavor))
+                                               .trim();
+                       } catch (Exception e) {
+                               // No data will be handled
+                       }
+
+                       if (clipboard == null || !(clipboard.startsWith("http://") || //
+                                       clipboard.startsWith("https://"))) {
+                               clipboard = "";
+                       }
+
+                       url = JOptionPane.showInputDialog(GuiReaderMainPanel.this,
+                                       GuiReader.trans(StringIdGui.SUBTITLE_IMPORT_URL),
+                                       GuiReader.trans(StringIdGui.TITLE_IMPORT_URL),
+                                       JOptionPane.QUESTION_MESSAGE, null, null, clipboard);
+               } else if (fc.showOpenDialog(this) != JFileChooser.CANCEL_OPTION) {
+                       url = fc.getSelectedFile().getAbsolutePath();
+               } else {
+                       url = null;
+               }
+
+               if (url != null && !url.toString().isEmpty()) {
+                       imprt(url.toString(), null, null);
+               }
+       }
+
+       /**
+        * Actually import the {@link Story} into the main {@link LocalLibrary}.
+        * <p>
+        * Should be called inside the UI thread.
+        * 
+        * @param url
+        *            the {@link Story} to import by {@link URL}
+        * @param onSuccess
+        *            Action to execute on success
+        * @param onSuccessPgName
+        *            the name to use for the onSuccess progress bar
+        */
+       public void imprt(final String url, final MetaDataRunnable onSuccess,
+                       String onSuccessPgName) {
+               final Progress pg = new Progress();
+               final Progress pgImprt = new Progress();
+               final Progress pgOnSuccess = new Progress(onSuccessPgName);
+               pg.addProgress(pgImprt, 95);
+               pg.addProgress(pgOnSuccess, 5);
+
+               outOfUi(pg, true, new Runnable() {
+                       @Override
+                       public void run() {
+                               Exception ex = null;
+                               MetaData meta = null;
+                               try {
+                                       meta = helper.getReader().getLibrary()
+                                                       .imprt(BasicReader.getUrl(url), pgImprt);
+                               } catch (IOException e) {
+                                       ex = e;
+                               }
+
+                               final Exception e = ex;
+
+                               final boolean ok = (e == null);
+
+                               pgOnSuccess.setProgress(0);
+                               if (!ok) {
+                                       if (e instanceof UnknownHostException) {
+                                               error(GuiReader.trans(
+                                                               StringIdGui.ERROR_URL_NOT_SUPPORTED, url),
+                                                               GuiReader.trans(StringIdGui.TITLE_ERROR), null);
+                                       } else {
+                                               error(GuiReader.trans(
+                                                               StringIdGui.ERROR_URL_IMPORT_FAILED, url,
+                                                               e.getMessage()), GuiReader
+                                                               .trans(StringIdGui.TITLE_ERROR), e);
+                                       }
+                               } else {
+                                       if (onSuccess != null) {
+                                               onSuccess.run(meta);
+                                       }
+                               }
+                               pgOnSuccess.done();
+                       }
+               });
+       }
+
+       /**
+        * Enables or disables this component, depending on the value of the
+        * parameter <code>b</code>. An enabled component can respond to user input
+        * and generate events. Components are enabled initially by default.
+        * <p>
+        * Enabling or disabling <b>this</b> component will also affect its
+        * children.
+        * 
+        * @param b
+        *            If <code>true</code>, this component is enabled; otherwise
+        *            this component is disabled
+        */
+       @Override
+       public void setEnabled(boolean b) {
+               if (bar != null) {
+                       bar.setEnabled(b);
+               }
+
+               for (GuiReaderGroup group : books.values()) {
+                       group.setEnabled(b);
+               }
+               super.setEnabled(b);
+               repaint();
+       }
+
+       public void setWords(boolean words) {
+               this.words = words;
+       }
+
+       public GuiReaderBook getSelectedBook() {
+               return selectedBook;
+       }
+
+       public void unsetSelectedBook() {
+               selectedBook = null;
+       }
+
+       private void addListPane(String name, List<String> values,
+                       final boolean type) {
+               GuiReader reader = helper.getReader();
+               BasicLibrary lib = reader.getLibrary();
+
+               bookPane = new GuiReaderGroup(reader, name, color);
+
+               List<GuiReaderBookInfo> infos = new ArrayList<GuiReaderBookInfo>();
+               for (String value : values) {
+                       if (type) {
+                               infos.add(GuiReaderBookInfo.fromSource(lib, value));
+                       } else {
+                               infos.add(GuiReaderBookInfo.fromAuthor(lib, value));
+                       }
+               }
+
+               bookPane.refreshBooks(infos, words);
+
+               this.invalidate();
+               pane.invalidate();
+               pane.add(bookPane);
+               pane.validate();
+               this.validate();
+
+               bookPane.setActionListener(new BookActionListener() {
+                       @Override
+                       public void select(GuiReaderBook book) {
+                               selectedBook = book;
+                       }
+
+                       @Override
+                       public void popupRequested(GuiReaderBook book, Component target,
+                                       int x, int y) {
+                               JPopupMenu popup = helper.createSourceAuthorPopup();
+                               popup.show(target, x, y);
+                       }
+
+                       @Override
+                       public void action(final GuiReaderBook book) {
+                               removeBookPanes();
+                               addBookPane(book.getInfo().getMainInfo(), type);
+                               refreshBooks();
+                       }
+               });
+
+               focus();
+       }
+
+       /**
+        * Focus the first {@link GuiReaderGroup} we find.
+        */
+       private void focus() {
+               GuiReaderGroup group = null;
+               Map<String, GuiReaderGroup> books = this.books;
+               if (books.size() > 0) {
+                       group = books.values().iterator().next();
+               }
+
+               if (group == null) {
+                       group = bookPane;
+               }
+
+               if (group != null) {
+                       group.requestFocusInWindow();
+               }
+       }
+
+       /**
+        * Display an error message and log the linked {@link Exception}.
+        * 
+        * @param message
+        *            the message
+        * @param title
+        *            the title of the error message
+        * @param e
+        *            the exception to log if any
+        */
+       private void error(final String message, final String title, Exception e) {
+               Instance.getTraceHandler().error(title + ": " + message);
+               if (e != null) {
+                       Instance.getTraceHandler().error(e);
+               }
+
+               SwingUtilities.invokeLater(new Runnable() {
+                       @Override
+                       public void run() {
+                               JOptionPane.showMessageDialog(GuiReaderMainPanel.this, message,
+                                               title, JOptionPane.ERROR_MESSAGE);
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderNavBar.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderNavBar.java
new file mode 100644 (file)
index 0000000..fb726bb
--- /dev/null
@@ -0,0 +1,343 @@
+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<ActionListener> listeners = new ArrayList<ActionListener>();
+
+       /**
+        * Create a new navigation bar.
+        * <p>
+        * The minimum must be lower or equal to the maximum.
+        * <p>
+        * 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 &gt; 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);
+
+               // TODO:
+               // JButton up = new BasicArrowButton(BasicArrowButton.NORTH);
+               // JButton down = new BasicArrowButton(BasicArrowButton.SOUTH);
+
+               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.
+        * <p>
+        * May update the index if needed (if the index is &lt; the new min).
+        * <p>
+        * 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).
+        * <p>
+        * May update the index if needed (if the index is &gt; the new max).
+        * <p>
+        * 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.
+        * <p>
+        * Swing HTML (HTML3) is supported if surrounded by &lt;HTML&gt; and
+        * &lt;/HTML&gt;.
+        * <p>
+        * 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 = "&nbsp;&nbsp;<B>Page <SPAN COLOR='#444466'>%d</SPAN>&nbsp;";
+               if (max >= 0) {
+                       base += "/&nbsp;%d";
+               }
+               base += "</B>";
+
+               String ifLabel = ": %s";
+
+               String display = base;
+               String label = getExtraLabel();
+               if (label != null && !label.trim().isEmpty()) {
+                       display += ifLabel;
+               }
+
+               display = "<HTML>" + display + "</HTML>";
+
+               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/GuiReaderPropertiesFrame.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderPropertiesFrame.java
new file mode 100644 (file)
index 0000000..9615d75
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+
+import javax.swing.JFrame;
+
+import be.nikiroo.fanfix.bundles.StringIdGui;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+
+/**
+ * A frame displaying properties and other information of a {@link Story}.
+ * 
+ * @author niki
+ */
+public class GuiReaderPropertiesFrame extends JFrame {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Create a new {@link GuiReaderPropertiesFrame}.
+        * 
+        * @param lib
+        *            the library to use for the cover image
+        * @param meta
+        *            the meta to describe
+        */
+       public GuiReaderPropertiesFrame(BasicLibrary lib, MetaData meta) {
+               setTitle(GuiReader.trans(StringIdGui.TITLE_STORY, meta.getLuid(),
+                               meta.getTitle()));
+
+               GuiReaderPropertiesPane desc = new GuiReaderPropertiesPane(lib, meta);
+               setSize(800,
+                               (int) desc.getPreferredSize().getHeight() + 2
+                                               * desc.getBorderThickness());
+
+               setLayout(new BorderLayout());
+               add(desc, BorderLayout.NORTH);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderPropertiesPane.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderPropertiesPane.java
new file mode 100644 (file)
index 0000000..2c9c7e7
--- /dev/null
@@ -0,0 +1,99 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Font;
+import java.util.Map;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.ImageIcon;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextArea;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.reader.BasicReader;
+
+/**
+ * A panel displaying properties and other information of a {@link Story}.
+ * 
+ * @author niki
+ */
+public class GuiReaderPropertiesPane extends JPanel {
+       private static final long serialVersionUID = 1L;
+       private final int space = 10;
+
+       /**
+        * Create a new {@link GuiReaderPropertiesPane}.
+        * 
+        * @param lib
+        *            the library to use for the cover image
+        * @param meta
+        *            the meta to describe
+        */
+       public GuiReaderPropertiesPane(BasicLibrary lib, MetaData meta) {
+               // Image
+               ImageIcon img = GuiReaderCoverImager.generateCoverIcon(lib, meta);
+
+               setLayout(new BorderLayout());
+
+               // Main panel
+               JPanel mainPanel = new JPanel(new BorderLayout());
+               JPanel mainPanelKeys = new JPanel();
+               mainPanelKeys.setLayout(new BoxLayout(mainPanelKeys, BoxLayout.Y_AXIS));
+               JPanel mainPanelValues = new JPanel();
+               mainPanelValues.setLayout(new BoxLayout(mainPanelValues,
+                               BoxLayout.Y_AXIS));
+
+               mainPanel.add(mainPanelKeys, BorderLayout.WEST);
+               mainPanel.add(mainPanelValues, BorderLayout.CENTER);
+
+               Map<String, String> desc = BasicReader.getMetaDesc(meta);
+
+               Color trans = new Color(0, 0, 0, 1);
+               Color base = mainPanelValues.getBackground();
+               for (String key : desc.keySet()) {
+                       JTextArea jKey = new JTextArea(key);
+                       jKey.setFont(new Font(jKey.getFont().getFontName(), Font.BOLD, jKey
+                                       .getFont().getSize()));
+                       jKey.setEditable(false);
+                       jKey.setLineWrap(false);
+                       jKey.setBackground(trans);
+                       mainPanelKeys.add(jKey);
+
+                       final JTextArea jValue = new JTextArea(desc.get(key));
+                       jValue.setEditable(false);
+                       jValue.setLineWrap(false);
+                       jValue.setBackground(base);
+                       mainPanelValues.add(jValue);
+               }
+
+               // Image
+               JLabel imgLabel = new JLabel(img);
+               imgLabel.setVerticalAlignment(JLabel.TOP);
+
+               // Borders
+               mainPanelKeys.setBorder(BorderFactory.createEmptyBorder(space, space,
+                               space, space));
+               mainPanelValues.setBorder(BorderFactory.createEmptyBorder(space, space,
+                               space, space));
+               imgLabel.setBorder(BorderFactory.createEmptyBorder(0, space, space, 0));
+
+               // Add all
+               add(imgLabel, BorderLayout.WEST);
+               add(mainPanel, BorderLayout.CENTER);
+       }
+
+       /**
+        * The invisible border size (multiply by 2 if you need the total width or
+        * the total height).
+        * 
+        * @return the invisible border thickness
+        */
+       public int getBorderThickness() {
+               return space;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchAction.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderSearchAction.java
new file mode 100644 (file)
index 0000000..b3c8f8b
--- /dev/null
@@ -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 (file)
index 0000000..ebdb21a
--- /dev/null
@@ -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<MetaData> stories = new ArrayList<MetaData>();
+       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.
+        * <p>
+        * 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<MetaData>();
+               updateKeywords("");
+       }
+
+       /**
+        * The currently displayed page of result for the current search (see the
+        * <tt>page</tt> 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
+        * <tt>page</tt> parameter of
+        * {@link GuiReaderSearchByPanel#search(String, int, int)}).
+        * <p>
+        * 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<MetaData> getStories() {
+               return stories;
+       }
+
+       /**
+        * Return the currently selected story (the <tt>item</tt>) if it was
+        * specified in the latest, or 0 if not.
+        * <p>
+        * Note: this is thus a 1-based index, <b>not</b> 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.
+        * <p>
+        * 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<MetaData> stories = new ArrayList<MetaData>();
+               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 <code>b</code>. An enabled component can respond to user input
+        * and generate events. Components are enabled initially by default.
+        * <p>
+        * Disabling this component will also affect its children.
+        * 
+        * @param b
+        *            If <code>true</code>, 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 (file)
index 0000000..8f95d4c
--- /dev/null
@@ -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.
+        * <p>
+        * 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}.
+        * <p>
+        * This operation can be long and should be run outside the UI thread.
+        * <p>
+        * 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
+        * <tt>page</tt> 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
+        * <tt>page</tt> parameter of
+        * {@link GuiReaderSearchByPanel#search(String, int, int)} or
+        * {@link GuiReaderSearchByPanel#searchTag(SupportType, int, int, SearchableTag)}
+        * ).
+        * <p>
+        * 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.
+        * <p>
+        * 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<MetaData> getStories() {
+               if (!searchByTags) {
+                       return byName.getStories();
+               }
+
+               return byTag.getStories();
+       }
+
+       /**
+        * Return the currently selected story (the <tt>item</tt>) if it was
+        * specified in the latest, or 0 if not.
+        * <p>
+        * Note: this is thus a 1-based index, <b>not</b> 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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 <code>b</code>. An enabled component can respond to user input
+        * and generate events. Components are enabled initially by default.
+        * <p>
+        * Disabling this component will also affect its children.
+        * 
+        * @param b
+        *            If <code>true</code>, 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 (file)
index 0000000..260fc48
--- /dev/null
@@ -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<E> 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<JComboBox> combos;
+
+       private int page;
+       private int maxPage;
+       private List<MetaData> stories = new ArrayList<MetaData>();
+       private int storyItem;
+
+       public GuiReaderSearchByTagPanel(Waitable waitable) {
+               setLayout(new BorderLayout());
+
+               this.waitable = waitable;
+               combos = new ArrayList<JComboBox>();
+               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.
+        * <p>
+        * This operation can be long and should be run outside the UI thread.
+        * <p>
+        * 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<MetaData>();
+               updateTags(null);
+       }
+
+       /**
+        * The currently displayed page of result for the current search (see the
+        * <tt>page</tt> 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
+        * <tt>page</tt> parameter of
+        * {@link GuiReaderSearchByPanel#searchTag(SupportType, int, int, SearchableTag)}
+        * ).
+        * <p>
+        * 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<MetaData> getStories() {
+               return stories;
+       }
+
+       /**
+        * Return the currently selected story (the <tt>item</tt>) if it was
+        * specified in the latest, or 0 if not.
+        * <p>
+        * Note: this is thus a 1-based index, <b>not</b> a 0-based index.
+        * 
+        * @return the item
+        */
+       public int getStoryItem() {
+               return storyItem;
+       }
+
+       /**
+        * Update the tags displayed on screen and reset the tags bar.
+        * <p>
+        * 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<SearchableTag> parents = new ArrayList<SearchableTag>();
+               SearchableTag parent = (tag == null) ? null : tag;
+               while (parent != null) {
+                       parents.add(parent);
+                       parent = parent.getParent();
+               }
+
+               List<SearchableTag> rootTags = new ArrayList<SearchableTag>();
+               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<SearchableTag> 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).
+        * <p>
+        * 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<SearchableTag> 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.
+        * <p>
+        * The content of the action is:
+        * <ul>
+        * <li>Remove all tags bar below this one</li>
+        * <li>Load the subtags if any in anew tags bar</li>
+        * <li>Load the related stories if the tag was a leaf tag and notify the
+        * {@link Waitable} (via {@link Waitable#fireEvent()})</li>
+        * </ul>
+        * 
+        * @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<JComboBox> 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<SearchableTag> 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<MetaData>();
+                                                               }
+
+                                                               waitable.fireEvent();
+                                                       }
+                                               } finally {
+                                                       waitable.setWaiting(false);
+                                               }
+                                       }
+                               }).start();
+                       }
+               };
+       }
+
+       /**
+        * Get the children of the given tag (or the base tags if the given tag is
+        * NULL).
+        * <p>
+        * This action will "fill" ({@link BasicSearchable#fillTag(SearchableTag)})
+        * the given tag if needed first.
+        * <p>
+        * 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<SearchableTag> getChildrenForTag(final SearchableTag tag) {
+               List<SearchableTag> children = new ArrayList<SearchableTag>();
+               if (tag == null) {
+                       try {
+                               List<SearchableTag> 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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<MetaData> stories = new ArrayList<MetaData>();
+               int storyItem = 0;
+
+               currentTag = tag;
+               updateTags(tag);
+
+               int maxPage = -1;
+               if (tag != null) {
+                       try {
+                               searchable.fillTag(tag);
+
+                               if (!tag.isLeaf()) {
+                                       List<SearchableTag> 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 <code>b</code>. An enabled component can respond to user input
+        * and generate events. Components are enabled initially by default.
+        * <p>
+        * Disabling this component will also affect its children.
+        * 
+        * @param b
+        *            If <code>true</code>, 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 (file)
index 0000000..5b99772
--- /dev/null
@@ -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<E> not 1.6 compatible
+@SuppressWarnings({ "unchecked", "rawtypes" })
+public class GuiReaderSearchFrame extends JFrame {
+       private static final long serialVersionUID = 1L;
+
+       private List<SupportType> 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<SupportType>();
+               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<GuiReaderBookInfo> infos = new ArrayList<GuiReaderBookInfo>();
+                                               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.
+        * <p>
+        * Will also cause a search for the new base tags of the given support if
+        * not NULL.
+        * <p>
+        * 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.
+        * <p>
+        * 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<GuiReaderBookInfo> 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * The code will make sure the current thread is the main UI thread and, if
+        * not, will switch to it before executing the runnable.
+        * <p>
+        * 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 <code>b</code>. An enabled component can respond to user input
+        * and generate events. Components are enabled initially by default.
+        * <p>
+        * Disabling this component will also affect its children.
+        * 
+        * @param b
+        *            If <code>true</code>, 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
new file mode 100644 (file)
index 0000000..bfb1892
--- /dev/null
@@ -0,0 +1,158 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Font;
+import java.awt.LayoutManager;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.SwingConstants;
+
+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.fanfix.library.BasicLibrary;
+
+/**
+ * An internal, Swing-based {@link Story} viewer.
+ * <p>
+ * Works on both text and image document (see {@link MetaData#isImageDocument()}
+ * ).
+ * 
+ * @author niki
+ */
+public class GuiReaderViewer extends JFrame {
+       private static final long serialVersionUID = 1L;
+
+       private Story story;
+       private MetaData meta;
+       private JLabel title;
+       private GuiReaderPropertiesPane descPane;
+       private GuiReaderViewerPanel mainPanel;
+       private GuiReaderNavBar navbar;
+
+       /**
+        * Create a new {@link Story} viewer.
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to load the cover from
+        * @param story
+        *            the {@link Story} to display
+        */
+       public GuiReaderViewer(BasicLibrary lib, Story story) {
+               setTitle(GuiReader.trans(StringIdGui.TITLE_STORY, story.getMeta()
+                               .getLuid(), story.getMeta().getTitle()));
+
+               setSize(800, 600);
+
+               this.story = story;
+               this.meta = story.getMeta();
+
+               initGuiBase(lib);
+               initGuiNavButtons();
+
+               setChapter(-1);
+       }
+
+       /**
+        * Initialise the base panel with everything but the navigation buttons.
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to use to retrieve the cover image in
+        *            the description panel
+        */
+       private void initGuiBase(BasicLibrary lib) {
+               setLayout(new BorderLayout());
+
+               title = new JLabel();
+               title.setFont(new Font(Font.SERIF, Font.BOLD,
+                               title.getFont().getSize() * 3));
+               title.setText(meta.getTitle());
+               title.setHorizontalAlignment(SwingConstants.CENTER);
+               title.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+               add(title, BorderLayout.NORTH);
+
+               JPanel contentPane = new JPanel(new BorderLayout());
+               add(contentPane, BorderLayout.CENTER);
+
+               descPane = new GuiReaderPropertiesPane(lib, meta);
+               contentPane.add(descPane, BorderLayout.NORTH);
+
+               mainPanel = new GuiReaderViewerPanel(story);
+               contentPane.add(mainPanel, BorderLayout.CENTER);
+       }
+
+       /**
+        * Create the 4 navigation buttons in {@link GuiReaderViewer#navButtons} and
+        * initialise them.
+        */
+       private void initGuiNavButtons() {
+               navbar = new GuiReaderNavBar(-1, story.getChapters().size() - 1) {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       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 "<HTML>" + chapterDisplay + "</HTML>";
+                       }
+               };
+
+               navbar.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               setChapter(navbar.getIndex());
+                       }
+               });
+
+               JPanel navButtonsPane = new JPanel();
+               LayoutManager layout = new BoxLayout(navButtonsPane, BoxLayout.X_AXIS);
+               navButtonsPane.setLayout(layout);
+
+               add(navbar, BorderLayout.SOUTH);
+       }
+
+       /**
+        * Set the current chapter, 0-based.
+        * <p>
+        * Chapter -1 is reserved for the description page.
+        * 
+        * @param chapter
+        *            the chapter number to set
+        */
+       private void setChapter(int chapter) {
+               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
new file mode 100644 (file)
index 0000000..4cc10b4
--- /dev/null
@@ -0,0 +1,298 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.awt.BorderLayout;
+import java.awt.EventQueue;
+import java.awt.Graphics2D;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.image.BufferedImage;
+
+import javax.swing.Icon;
+import javax.swing.ImageIcon;
+import javax.swing.JButton;
+import javax.swing.JEditorPane;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+import javax.swing.JScrollPane;
+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;
+
+/**
+ * A {@link JPanel} that will show a {@link Story} chapter on screen.
+ * 
+ * @author niki
+ */
+public class GuiReaderViewerPanel extends JPanel {
+       private static final long serialVersionUID = 1L;
+
+       private boolean imageDocument;
+       private Chapter chap;
+       private JScrollPane scroll;
+       private GuiReaderViewerTextOutput htmlOutput;
+
+       // text only:
+       private JEditorPane text;
+
+       // image only:
+       private JLabel image;
+       private JProgressBar imageProgress;
+       private int currentImage;
+       private JButton left;
+       private JButton right;
+
+       /**
+        * Create a new viewer.
+        * 
+        * @param story
+        *            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 = isImageDocument;
+
+               this.text = new JEditorPane("text/html", "");
+               text.setEditable(false);
+               text.setAlignmentY(TOP_ALIGNMENT);
+               htmlOutput = new GuiReaderViewerTextOutput();
+
+               image = new JLabel();
+               image.setHorizontalAlignment(SwingConstants.CENTER);
+
+               scroll = new JScrollPane(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
+                               JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+               scroll.getVerticalScrollBar().setUnitIncrement(16);
+
+               // TODO:
+               // JButton up = new BasicArrowButton(BasicArrowButton.NORTH);
+               // JButton down = new BasicArrowButton(BasicArrowButton.SOUTH);
+
+               if (!imageDocument) {
+                       add(scroll, BorderLayout.CENTER);
+               } else {
+                       imageProgress = new JProgressBar();
+                       imageProgress.setStringPainted(true);
+                       add(imageProgress, BorderLayout.SOUTH);
+
+                       JPanel main = new JPanel(new BorderLayout());
+                       main.add(scroll, BorderLayout.CENTER);
+
+                       left = new JButton("<HTML>&nbsp; &nbsp; &lt; &nbsp; &nbsp;</HTML>");
+                       left.addActionListener(new ActionListener() {
+                               @Override
+                               public void actionPerformed(ActionEvent e) {
+                                       setImage(--currentImage);
+                               }
+                       });
+                       main.add(left, BorderLayout.WEST);
+
+                       right = new JButton("<HTML>&nbsp; &nbsp; &gt; &nbsp; &nbsp;</HTML>");
+                       right.addActionListener(new ActionListener() {
+                               @Override
+                               public void actionPerformed(ActionEvent e) {
+                                       setImage(++currentImage);
+                               }
+                       });
+                       main.add(right, BorderLayout.EAST);
+
+                       add(main, BorderLayout.CENTER);
+                       main.invalidate();
+               }
+
+               setChapter(meta.getResume());
+       }
+
+       /**
+        * Load the given chapter.
+        * <p>
+        * Will always be text for a non-image document.
+        * <p>
+        * Will be an image and left/right controls for an image-document, except
+        * for chapter 0 which will be text (chapter 0 = resume).
+        * 
+        * @param chap
+        *            the chapter to load
+        */
+       public void setChapter(Chapter chap) {
+               this.chap = chap;
+
+               if (!imageDocument) {
+                       setText(chap);
+               } else {
+                       left.setVisible(chap.getNumber() > 0);
+                       right.setVisible(chap.getNumber() > 0);
+                       imageProgress.setVisible(chap.getNumber() > 0);
+
+                       imageProgress.setMinimum(0);
+                       imageProgress.setMaximum(chap.getParagraphs().size() - 1);
+
+                       if (chap.getNumber() == 0) {
+                               setText(chap);
+                       } else {
+                               setImage(0);
+                       }
+               }
+       }
+
+       /**
+        * Will set and display the current chapter text.
+        * 
+        * @param chap
+        *            the chapter to display
+        */
+       private void setText(final Chapter chap) {
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               final String content = htmlOutput.convert(chap);
+                               // Wait until size computations are correct
+                               while (!scroll.isValid()) {
+                                       try {
+                                               Thread.sleep(1);
+                                       } catch (InterruptedException e) {
+                                       }
+                               }
+
+                               setText(content);
+                       }
+               }).start();
+       }
+
+       /**
+        * Actually set the text in the UI.
+        * <p>
+        * Do <b>NOT</b> use this method from the UI thread.
+        * 
+        * @param content
+        *            the text
+        */
+       private void setText(final String content) {
+               EventQueue.invokeLater(new Runnable() {
+                       @Override
+                       public void run() {
+                               text.setText(content);
+                               text.setCaretPosition(0);
+                               scroll.setViewportView(text);
+                       }
+               });
+       }
+
+       /**
+        * Will set and display the current image, take care about the progression
+        * and update the left and right cursors' <tt>enabled</tt> property.
+        * 
+        * @param i
+        *            the image index to load
+        */
+       private void setImage(int i) {
+               left.setEnabled(i > 0);
+               right.setEnabled(i + 1 < chap.getParagraphs().size());
+
+               if (i < 0 || i >= chap.getParagraphs().size()) {
+                       return;
+               }
+
+               imageProgress.setValue(i);
+               imageProgress.setString(GuiReader.trans(StringIdGui.IMAGE_PROGRESSION,
+                               i + 1, chap.getParagraphs().size()));
+
+               currentImage = i;
+
+               final Image img = chap.getParagraphs().get(i).getContentImage();
+
+               // prepare the viewport to get the right sizes later on
+               image.setIcon(null);
+               scroll.setViewportView(image);
+
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               // Wait until size computations are correct
+                               while (!scroll.isValid()) {
+                                       try {
+                                               Thread.sleep(1);
+                                       } catch (InterruptedException e) {
+                                       }
+                               }
+
+                               if (img == null) {
+                                       setText("Error: cannot render image.");
+                               } else {
+                                       setImage(img);
+                               }
+                       }
+               }).start();
+       }
+
+       /**
+        * Actually set the image in the UI.
+        * <p>
+        * Do <b>NOT</b> use this method from the UI thread.
+        * 
+        * @param img
+        *            the image to set
+        */
+       private void setImage(Image img) {
+               try {
+                       int scrollWidth = scroll.getWidth()
+                                       - scroll.getVerticalScrollBar().getWidth();
+
+                       BufferedImage buffImg = ImageUtilsAwt.fromImage(img);
+
+                       int iw = buffImg.getWidth();
+                       int ih = buffImg.getHeight();
+                       double ratio = ((double) ih) / iw;
+
+                       int w = scrollWidth;
+                       int h = (int) (ratio * scrollWidth);
+
+                       BufferedImage resizedImage = new BufferedImage(w, h,
+                                       BufferedImage.TYPE_4BYTE_ABGR);
+
+                       Graphics2D g = resizedImage.createGraphics();
+                       try {
+                               g.drawImage(buffImg, 0, 0, w, h, null);
+                       } finally {
+                               g.dispose();
+                       }
+
+                       final Icon icon = new ImageIcon(resizedImage);
+                       EventQueue.invokeLater(new Runnable() {
+                               @Override
+                               public void run() {
+                                       image.setIcon(icon);
+                                       scroll.setViewportView(image);
+                               }
+                       });
+               } catch (Exception e) {
+                       Instance.getTraceHandler().error(
+                                       new Exception("Failed to load image into label", e));
+                       EventQueue.invokeLater(new Runnable() {
+                               @Override
+                               public void run() {
+                                       text.setText("Error: cannot load image.");
+                               }
+                       });
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerTextOutput.java b/src/be/nikiroo/fanfix/reader/ui/GuiReaderViewerTextOutput.java
new file mode 100644 (file)
index 0000000..47d9664
--- /dev/null
@@ -0,0 +1,128 @@
+package be.nikiroo.fanfix.reader.ui;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+
+/**
+ * This class can export a chapter into HTML3 code ready for Java Swing support.
+ * 
+ * @author niki
+ */
+public class GuiReaderViewerTextOutput {
+       private StringBuilder builder;
+       private BasicOutput output;
+       private Story fakeStory;
+
+       /**
+        * Create a new {@link GuiReaderViewerTextOutput} that will convert a
+        * {@link Chapter} into HTML3 suited for Java Swing.
+        */
+       public GuiReaderViewerTextOutput() {
+               builder = new StringBuilder();
+               fakeStory = new Story();
+
+               output = new BasicOutput() {
+                       private boolean paraInQuote;
+
+                       @Override
+                       protected void writeChapterHeader(Chapter chap) throws IOException {
+                               builder.append("<HTML>");
+
+                               builder.append("<H1>");
+                               builder.append("Chapter ");
+                               builder.append(chap.getNumber());
+                               builder.append(": ");
+                               builder.append(chap.getName());
+                               builder.append("</H1>");
+
+                               builder.append("<DIV align='justify'>");
+                       }
+
+                       @Override
+                       protected void writeChapterFooter(Chapter chap) throws IOException {
+                               if (paraInQuote) {
+                                       builder.append("</DIV>");
+                               }
+                               paraInQuote = false;
+
+                               builder.append("</DIV>");
+                               builder.append("</HTML>");
+                       }
+
+                       @Override
+                       protected void writeParagraph(Paragraph para) throws IOException {
+                               if ((para.getType() == ParagraphType.QUOTE) == !paraInQuote) {
+                                       paraInQuote = !paraInQuote;
+                                       if (paraInQuote) {
+                                               builder.append("<BR>");
+                                               builder.append("<DIV>");
+                                       } else {
+                                               builder.append("</DIV>");
+                                               builder.append("<BR>");
+                                       }
+                               }
+
+                               switch (para.getType()) {
+                               case NORMAL:
+                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+                                       builder.append(decorateText(para.getContent()));
+                                       builder.append("<BR>");
+                                       break;
+                               case BLANK:
+                                       builder.append("<BR><BR>");
+                                       break;
+                               case BREAK:
+                                       builder.append("<BR><P COLOR='#7777DD' ALIGN='CENTER'><B>");
+                                       builder.append("* * *");
+                                       builder.append("</B></P><BR><BR>");
+                                       break;
+                               case QUOTE:
+                                       builder.append("<DIV>");
+                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+                                       builder.append("&mdash;&nbsp;");
+                                       builder.append(decorateText(para.getContent()));
+                                       builder.append("</DIV>");
+
+                                       break;
+                               case IMAGE:
+                               }
+                       }
+
+                       @Override
+                       protected String enbold(String word) {
+                               return "<B COLOR='#7777DD'>" + word + "</B>";
+                       }
+
+                       @Override
+                       protected String italize(String word) {
+                               return "<I COLOR='GRAY'>" + word + "</I>";
+                       }
+               };
+       }
+
+       /**
+        * Convert the chapter into HTML3 code.
+        * 
+        * @param chap
+        *            the {@link Chapter} to convert.
+        * 
+        * @return HTML3 code tested with Java Swing
+        */
+       public String convert(Chapter chap) {
+               builder.setLength(0);
+               try {
+                       fakeStory.setChapters(Arrays.asList(chap));
+                       output.process(fakeStory, null, null);
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(e);
+               }
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/BasicSearchable.java b/src/be/nikiroo/fanfix/searchable/BasicSearchable.java
new file mode 100644 (file)
index 0000000..d38505e
--- /dev/null
@@ -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.
+        * <p>
+        * 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<SearchableTag> 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}.
+        * <p>
+        * 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<SearchableTag> 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.
+        * <p>
+        * Not that the returned stories will <b>NOT</b> be complete, but will only
+        * contain enough information to present them to the user and retrieve them.
+        * <p>
+        * 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<MetaData> search(String search, int page)
+                       throws IOException;
+
+       /**
+        * Search for the given tag and return a list of stories satisfying this
+        * tag.
+        * <p>
+        * Not that the returned stories will <b>NOT</b> be complete, but will only
+        * contain enough information to present them to the user and retrieve them.
+        * <p>
+        * 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<MetaData> 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 (file)
index 0000000..c2dfd5d
--- /dev/null
@@ -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<SearchableTag> getTags() throws IOException {
+               String storiesName = null;
+               String crossoversName = null;
+               Map<String, String> stories = new HashMap<String, String>();
+               Map<String, String> crossovers = new HashMap<String, String>();
+
+               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<String, String> 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<SearchableTag> tags = new ArrayList<SearchableTag>();
+
+               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<MetaData> 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<MetaData> search(SearchableTag tag, int page)
+                       throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+
+               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
+        *            <tt>sourceUrl</tt>)
+        * @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<MetaData> getStories(String sourceUrl, Document doc,
+                       String mainSubject) throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+
+               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<String> tagList = new ArrayList<String>();
+                       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 (file)
index 0000000..3e2924f
--- /dev/null
@@ -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<SearchableTag> getTags() throws IOException {
+               List<SearchableTag> tags = new ArrayList<SearchableTag>();
+
+               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<MetaData> 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<MetaData> 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<MetaData> getResults(String sourceUrl) throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+
+               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
+                               // <IMG>
+
+                               Elements infos = result.getElementsByClass("texte");
+                               if (infos != null) {
+                                       String[] tab = infos.outerHtml().split("<br>");
+
+                                       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 (file)
index 0000000..de86798
--- /dev/null
@@ -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<SearchableTag> children;
+
+       /**
+        * The number of stories result pages this tag can get.
+        * <p>
+        * We keep more information than what the getter/setter returns/accepts.
+        * <ul>
+        * <li>-2: this tag does not support stories results (not a leaf tag)</li>
+        * <li>-1: the number is not yet known, but will be known after a
+        * {@link BasicSearchable#fillTag(SearchableTag)} operation</li>
+        * <li>X: the number of pages</li>
+        * </ul>
+        */
+       private int pages;
+
+       /**
+        * Create a new {@link SearchableTag}.
+        * <p>
+        * 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<SearchableTag>();
+       }
+
+       /**
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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.
+        * <p>
+        * 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)}.
+        * <p>
+        * 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}.
+        * <p>
+        * Never NULL.
+        * <p>
+        * 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<SearchableTag> getChildren() {
+               return children;
+       }
+
+       /**
+        * Add the given {@link SearchableTag} as a subtag child.
+        * 
+        * @param tag
+        *            the tag to add
+        */
+       public void add(SearchableTag tag) {
+               if (tag == null) {
+                       throw new NullPointerException("tag");
+               }
+
+               for (SearchableTag p = this; p != null; p = p.parent) {
+                       if (p.equals(tag)) {
+                               throw new IllegalArgumentException(
+                                               "Tags do not allow recursion");
+                       }
+               }
+               for (SearchableTag p = tag; p != null; p = p.parent) {
+                       if (p.equals(this)) {
+                               throw new IllegalArgumentException(
+                                               "Tags do not allow recursion");
+                       }
+               }
+
+               children.add(tag);
+               tag.parent = this;
+       }
+
+       /**
+        * This {@link SearchableTag} parent tag, or NULL if none.
+        * 
+        * @return the parent or NULL
+        */
+       public SearchableTag getParent() {
+               return parent;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               String rep = name + " [" + id + "]";
+               if (!complete) {
+                       rep += "*";
+               }
+
+               if (getCount() > 0) {
+                       rep += " (" + getCount() + ")";
+               }
+
+               if (!children.isEmpty()) {
+                       String tags = "";
+                       int i = 1;
+                       for (SearchableTag tag : children) {
+                               if (!tags.isEmpty()) {
+                                       tags += ", ";
+                               }
+
+                               if (i > 10) {
+                                       tags += "...";
+                                       break;
+                               }
+
+                               tags += tag;
+                               i++;
+                       }
+
+                       rep += ": " + tags;
+               }
+
+               return rep;
+       }
+
+       @Override
+       public int hashCode() {
+               return getFqName().hashCode();
+       }
+
+       @Override
+       public boolean equals(Object otherObj) {
+               if (otherObj instanceof SearchableTag) {
+                       SearchableTag other = (SearchableTag) otherObj;
+                       if ((id == null && other.id == null)
+                                       || (id != null && id.equals(other.id))) {
+                               if (getFqName().equals(other.getFqName())) {
+                                       if ((parent == null && other.parent == null)
+                                                       || (parent != null && parent.equals(other.parent))) {
+                                               return true;
+                                       }
+                               }
+                       }
+               }
+
+               return false;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport.java b/src/be/nikiroo/fanfix/supported/BasicSupport.java
new file mode 100644 (file)
index 0000000..d3c0ebb
--- /dev/null
@@ -0,0 +1,526 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class is the base class used by the other support classes. It can be
+ * used outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * <p>
+ * It will be used with 'resources' (usually web pages or files).
+ * 
+ * @author niki
+ */
+public abstract class BasicSupport {
+       private Document sourceNode;
+       private URL source;
+       private SupportType type;
+       private URL currentReferer; // with only one 'r', as in 'HTTP'...
+       
+       static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
+       static protected BasicSupportImages bsImages = new BasicSupportImages();
+       static protected BasicSupportPara bsPara = new BasicSupportPara(new BasicSupportHelper(), new BasicSupportImages());
+
+       /**
+        * Check if the given resource is supported by this {@link BasicSupport}.
+        * 
+        * @param url
+        *            the resource to check for
+        * 
+        * @return TRUE if it is
+        */
+       protected abstract boolean supports(URL url);
+
+       /**
+        * Return TRUE if the support will return HTML encoded content values for
+        * the chapters content.
+        * 
+        * @return TRUE for HTML
+        */
+       protected abstract boolean isHtml();
+
+       /**
+        * Return the {@link MetaData} of this story.
+        * 
+        * @return the associated {@link MetaData}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract MetaData getMeta() throws IOException;
+
+       /**
+        * Return the story description.
+        * 
+        * @return the description
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getDesc() throws IOException;
+
+       /**
+        * Return the list of chapters (name and resource).
+        * <p>
+        * Can be NULL if this {@link BasicSupport} do no use chapters.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the chapters or NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException;
+
+       /**
+        * Return the content of the chapter (possibly HTML encoded, if
+        * {@link BasicSupport#isHtml()} is TRUE).
+        * 
+        * @param chapUrl
+        *            the chapter {@link URL}
+        * @param number
+        *            the chapter number
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getChapterContent(URL chapUrl, int number,
+                       Progress pg) throws IOException;
+
+       /**
+        * Return the list of cookies (values included) that must be used to
+        * correctly fetch the resources.
+        * <p>
+        * You are expected to call the super method implementation if you override
+        * it.
+        * 
+        * @return the cookies
+        */
+       public Map<String, String> getCookies() {
+               return new HashMap<String, String>();
+       }
+
+       /**
+        * OAuth authorisation (aka, "bearer XXXXXXX").
+        * 
+        * @return the OAuth string
+        */
+       public String getOAuth() {
+               return null;
+       }
+
+       /**
+        * Return the canonical form of the main {@link URL}.
+        * 
+        * @param source
+        *            the source {@link URL}, which can be NULL
+        * 
+        * @return the canonical form of this {@link URL} or NULL if the source was
+        *         NULL
+        */
+       protected URL getCanonicalUrl(URL source) {
+               return source;
+       }
+
+       /**
+        * The main {@link Node} for this {@link Story}.
+        * 
+        * @return the node
+        */
+       protected Element getSourceNode() {
+               return sourceNode;
+       }
+
+       /**
+        * The main {@link URL} for this {@link Story}.
+        * 
+        * @return the URL
+        */
+       protected URL getSource() {
+               return source;
+       }
+
+       /**
+        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+        * the current {@link URL} we work on.
+        * 
+        * @return the referer
+        */
+       public URL getCurrentReferer() {
+               return currentReferer;
+       }
+
+       /**
+        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+        * the current {@link URL} we work on.
+        * 
+        * @param currentReferer
+        *            the new referer
+        */
+       protected void setCurrentReferer(URL currentReferer) {
+               this.currentReferer = currentReferer;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @return the type
+        */
+       public SupportType getType() {
+               return type;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @param type
+        *            the new type
+        */
+       protected void setType(SupportType type) {
+               this.type = type;
+       }
+
+       /**
+        * Open an input link that will be used for the support.
+        * <p>
+        * Can return NULL, in which case you are supposed to work without a source
+        * node.
+        * 
+        * @param source
+        *            the source {@link URL}
+        * 
+        * @return the {@link InputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Document loadDocument(URL source) throws IOException {
+               String url = getCanonicalUrl(source).toString();
+               return DataUtil.load(Instance.getCache().open(source, this, false),
+                               "UTF-8", url.toString());
+       }
+
+       /**
+        * Log into the support (can be a no-op depending upon the support).
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void login() throws IOException {
+       }
+
+       /**
+        * Now that we have processed the {@link Story}, close the resources if any.
+        */
+       protected void close() {
+               setCurrentReferer(null);
+       }
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata.
+        * 
+        * @param getDesc
+        *            retrieve the description of the story, or not
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story processMeta(boolean getDesc, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               pg.setProgress(30);
+
+               Story story = new Story();
+               MetaData meta = getMeta();
+               if (meta.getCreationDate() == null || meta.getCreationDate().isEmpty()) {
+                       meta.setCreationDate(StringUtils.fromTime(new Date().getTime()));
+               }
+               story.setMeta(meta);
+
+               pg.setProgress(50);
+
+               if (meta.getCover() == null) {
+                       meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
+               }
+
+               pg.setProgress(60);
+
+               if (getDesc) {
+                       String descChapterName = Instance.getTrans().getString(
+                                       StringId.DESCRIPTION);
+                       story.getMeta().setResume(
+                                       bsPara.makeChapter(this, source, 0,
+                                                       descChapterName, //
+                                                       getDesc(), isHtml(), null));
+               }
+
+               pg.done();
+               return story;
+       }
+
+       /**
+        * Process the given story resource into a fully filled {@link Story}
+        * object.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       // TODO: ADD final when BasicSupport_Deprecated is gone
+       public Story process(Progress pg) throws IOException {
+               setCurrentReferer(source);
+               login();
+               sourceNode = loadDocument(source);
+
+               try {
+                       return doProcess(pg);
+               } finally {
+                       close();
+               }
+       }
+
+       /**
+        * Actual processing step, without the calls to other methods.
+        * <p>
+        * Will convert the story resource into a fully filled {@link Story} object.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story doProcess(Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               pg.setProgress(1);
+               Progress pgMeta = new Progress();
+               pg.addProgress(pgMeta, 10);
+               Story story = processMeta(true, pgMeta);
+               pgMeta.done(); // 10%
+
+               pg.setName("Retrieving " + story.getMeta().getTitle());
+
+               Progress pgGetChapters = new Progress();
+               pg.addProgress(pgGetChapters, 10);
+               story.setChapters(new ArrayList<Chapter>());
+               List<Entry<String, URL>> chapters = getChapters(pgGetChapters);
+               pgGetChapters.done(); // 20%
+
+               if (chapters != null) {
+                       Progress pgChaps = new Progress("Extracting chapters", 0,
+                                       chapters.size() * 300);
+                       pg.addProgress(pgChaps, 80);
+
+                       long words = 0;
+                       int i = 1;
+                       for (Entry<String, URL> chap : chapters) {
+                               pgChaps.setName("Extracting chapter " + i);
+                               URL chapUrl = chap.getValue();
+                               String chapName = chap.getKey();
+                               if (chapUrl != null) {
+                                       setCurrentReferer(chapUrl);
+                               }
+
+                               pgChaps.setProgress(i * 100);
+                               Progress pgGetChapterContent = new Progress();
+                               Progress pgMakeChapter = new Progress();
+                               pgChaps.addProgress(pgGetChapterContent, 100);
+                               pgChaps.addProgress(pgMakeChapter, 100);
+
+                               String content = getChapterContent(chapUrl, i,
+                                               pgGetChapterContent);
+                               pgGetChapterContent.done();
+                               Chapter cc = bsPara.makeChapter(this, chapUrl, i,
+                                               chapName, content, isHtml(), pgMakeChapter);
+                               pgMakeChapter.done();
+
+                               words += cc.getWords();
+                               story.getChapters().add(cc);
+                               story.getMeta().setWords(words);
+
+                               i++;
+                       }
+
+                       pgChaps.setName("Extracting chapters");
+                       pgChaps.done();
+               }
+
+               pg.done();
+
+               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 bsPara.makeChapter(this, source, number, name,
+                               content, isHtml(), null);
+       }
+
+       /**
+        * Return a {@link BasicSupport} implementation supporting the given
+        * resource if possible.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       public static BasicSupport getSupport(URL url) {
+               if (url == null) {
+                       return null;
+               }
+
+               // TEXT and INFO_TEXT always support files (not URLs though)
+               for (SupportType type : SupportType.values()) {
+                       if (type != SupportType.TEXT && type != SupportType.INFO_TEXT) {
+                               BasicSupport support = getSupport(type, url);
+                               if (support != null && support.supports(url)) {
+                                       return support;
+                               }
+                       }
+               }
+
+               for (SupportType type : new SupportType[] { SupportType.INFO_TEXT,
+                               SupportType.TEXT }) {
+                       BasicSupport support = getSupport(type, url);
+                       if (support != null && support.supports(url)) {
+                               return support;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return a {@link BasicSupport} implementation supporting the given type.
+        * 
+        * @param type
+        *            the type, must not be NULL
+        * @param url
+        *            the {@link URL} to support (can be NULL to get an
+        *            "abstract support"; if not NULL, will be used as the source
+        *            URL)
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       public static BasicSupport getSupport(SupportType type, URL url) {
+               BasicSupport support = null;
+
+               switch (type) {
+               case EPUB:
+                       support = new Epub();
+                       break;
+               case INFO_TEXT:
+                       support = new InfoText();
+                       break;
+               case FIMFICTION:
+                       try {
+                               // Can fail if no client key or NO in options
+                               support = new FimfictionApi();
+                       } catch (IOException e) {
+                               support = new Fimfiction();
+                       }
+                       break;
+               case FANFICTION:
+                       support = new Fanfiction();
+                       break;
+               case TEXT:
+                       support = new Text();
+                       break;
+               case MANGAFOX:
+                       support = new MangaFox();
+                       break;
+               case E621:
+                       support = new E621();
+                       break;
+               case YIFFSTAR:
+                       support = new YiffStar();
+                       break;
+               case E_HENTAI:
+                       support = new EHentai();
+                       break;
+               case MANGA_LEL:
+                       support = new MangaLel();
+                       break;
+               case CBZ:
+                       support = new Cbz();
+                       break;
+               case HTML:
+                       support = new Html();
+                       break;
+               }
+
+               if (support != null) {
+                       support.setType(type);
+                       support.source = support.getCanonicalUrl(url);
+               }
+
+               return support;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java b/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java
new file mode 100644 (file)
index 0000000..41716df
--- /dev/null
@@ -0,0 +1,225 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.utils.Image;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to text formating for
+ * the classes that implement {@link BasicSupport}.
+ * 
+ * @author niki
+ */
+public class BasicSupportHelper {
+       /**
+        * Get the default cover related to this subject (see <tt>.info</tt> files).
+        * 
+        * @param subject
+        *            the subject
+        * 
+        * @return the cover if any, or NULL
+        */
+       public Image getDefaultCover(String subject) {
+               if (subject != null && !subject.isEmpty()
+                               && Instance.getCoverDir() != null) {
+                       try {
+                               File fileCover = new File(Instance.getCoverDir(), subject);
+                               return getImage(null, fileCover.toURI().toURL(), subject);
+                       } catch (MalformedURLException e) {
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @param emptyAllowed
+        *            TRUE to allow an empty extension on first place, which can be
+        *            used when you may already have an extension in your input but
+        *            are not sure about it
+        * 
+        * @return the extensions
+        */
+       public String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+
+               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL)
+        * @param source
+        *            the source of the story (for image lookup in the same path if
+        *            the source is a file, can be NULL)
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image if found, or NULL
+        * 
+        */
+       public Image getImage(BasicSupport support, URL source, String line) {
+               URL url = getImageUrl(support, source, line);
+               if (url != null) {
+                       if ("file".equals(url.getProtocol())) {
+                               if (new File(url.getPath()).isDirectory()) {
+                                       return null;
+                               }
+                       }
+                       InputStream in = null;
+                       try {
+                               in = Instance.getCache().open(url, support, true);
+                               return new Image(in);
+                       } catch (IOException e) {
+                       } finally {
+                               if (in != null) {
+                                       try {
+                                               in.close();
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL)
+        * @param source
+        *            the source of the story (for image lookup in the same path if
+        *            the source is a file, can be NULL)
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       public URL getImageUrl(BasicSupport support, URL source, String line) {
+               URL url = null;
+
+               if (line != null) {
+                       // try for files
+                       if (source != null) {
+                               try {
+
+                                       String relPath = null;
+                                       String absPath = null;
+                                       try {
+                                               String path = new File(source.getFile()).getParent();
+                                               relPath = new File(new File(path), line.trim())
+                                                               .getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (one possibility to take
+                                               // into account: absolute path on Windows)
+                                       }
+                                       try {
+                                               absPath = new File(line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (at all)
+                                       }
+
+                                       for (String ext : getImageExt(true)) {
+                                               File absFile = new File(absPath + ext);
+                                               File relFile = new File(relPath + ext);
+                                               if (absPath != null && absFile.exists()
+                                                               && absFile.isFile()) {
+                                                       url = absFile.toURI().toURL();
+                                               } else if (relPath != null && relFile.exists()
+                                                               && relFile.isFile()) {
+                                                       url = relFile.toURI().toURL();
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       // Should not happen since we control the correct arguments
+                               }
+                       }
+
+                       if (url == null) {
+                               // try for URLs
+                               try {
+                                       for (String ext : getImageExt(true)) {
+                                               if (Instance.getCache()
+                                                               .check(new URL(line + ext), true)) {
+                                                       url = new URL(line + ext);
+                                                       break;
+                                               }
+                                       }
+
+                                       // try out of cache
+                                       if (url == null) {
+                                               for (String ext : getImageExt(true)) {
+                                                       try {
+                                                               url = new URL(line + ext);
+                                                               Instance.getCache().refresh(url, support, true);
+                                                               break;
+                                                       } catch (IOException e) {
+                                                               // no image with this ext
+                                                               url = null;
+                                                       }
+                                               }
+                                       }
+                               } catch (MalformedURLException e) {
+                                       // Not an url
+                               }
+                       }
+
+                       // refresh the cached file
+                       if (url != null) {
+                               try {
+                                       Instance.getCache().refresh(url, support, true);
+                               } catch (IOException e) {
+                                       // woops, broken image
+                                       url = null;
+                               }
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Fix the author name if it is prefixed with some "by" {@link String}.
+        * 
+        * @param author
+        *            the author with a possible prefix
+        * 
+        * @return the author without prefixes
+        */
+       public String fixAuthor(String author) {
+               if (author != null) {
+                       for (String suffix : new String[] { " ", ":" }) {
+                               for (String byString : Instance.getConfig().getList(Config.CONF_BYS)) {
+                                       byString += suffix;
+                                       if (author.toUpperCase().startsWith(byString.toUpperCase())) {
+                                               author = author.substring(byString.length()).trim();
+                                       }
+                               }
+                       }
+
+                       // Special case (without suffix):
+                       if (author.startsWith("©")) {
+                               author = author.substring(1);
+                       }
+               }
+
+               return author;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportImages.java b/src/be/nikiroo/fanfix/supported/BasicSupportImages.java
new file mode 100644 (file)
index 0000000..69a7c86
--- /dev/null
@@ -0,0 +1,167 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.utils.Image;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to images for
+ * the classes that implement {@link BasicSupport}.
+ * 
+ * @author niki
+ */
+public class BasicSupportImages {
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param dir
+        *            the local directory to search, if any
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image if found, or NULL
+        * 
+        */
+       public Image getImage(BasicSupport support, File dir, String line) {
+               URL url = getImageUrl(support, dir, line);
+               if (url != null) {
+                       if ("file".equals(url.getProtocol())) {
+                               if (new File(url.getPath()).isDirectory()) {
+                                       return null;
+                               }
+                       }
+                       InputStream in = null;
+                       try {
+                               in = Instance.getCache().open(url, support, true);
+                               return new Image(in);
+                       } catch (IOException e) {
+                       } finally {
+                               if (in != null) {
+                                       try {
+                                               in.close();
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param dir
+        *            the local directory to search, if any
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       public URL getImageUrl(BasicSupport support, File dir, String line) {
+               URL url = null;
+
+               if (line != null) {
+                       // try for files
+                       if (dir != null && dir.exists() && !dir.isFile()) {
+                               try {
+
+                                       String relPath = null;
+                                       String absPath = null;
+                                       try {
+                                               relPath = new File(dir, line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (one possibility to take
+                                               // into account: absolute path on Windows)
+                                       }
+                                       try {
+                                               absPath = new File(line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (at all)
+                                       }
+
+                                       for (String ext : getImageExt(true)) {
+                                               File absFile = new File(absPath + ext);
+                                               File relFile = new File(relPath + ext);
+                                               if (absPath != null && absFile.exists()
+                                                               && absFile.isFile()) {
+                                                       url = absFile.toURI().toURL();
+                                               } else if (relPath != null && relFile.exists()
+                                                               && relFile.isFile()) {
+                                                       url = relFile.toURI().toURL();
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       // Should not happen since we control the correct arguments
+                               }
+                       }
+
+                       if (url == null) {
+                               // try for URLs
+                               try {
+                                       for (String ext : getImageExt(true)) {
+                                               if (Instance.getCache()
+                                                               .check(new URL(line + ext), true)) {
+                                                       url = new URL(line + ext);
+                                                       break;
+                                               }
+                                       }
+
+                                       // try out of cache
+                                       if (url == null) {
+                                               for (String ext : getImageExt(true)) {
+                                                       try {
+                                                               url = new URL(line + ext);
+                                                               Instance.getCache().refresh(url, support, true);
+                                                               break;
+                                                       } catch (IOException e) {
+                                                               // no image with this ext
+                                                               url = null;
+                                                       }
+                                               }
+                                       }
+                               } catch (MalformedURLException e) {
+                                       // Not an url
+                               }
+                       }
+
+                       // refresh the cached file
+                       if (url != null) {
+                               try {
+                                       Instance.getCache().refresh(url, support, true);
+                               } catch (IOException e) {
+                                       // woops, broken image
+                                       url = null;
+                               }
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @param emptyAllowed
+        *            TRUE to allow an empty extension on first place, which can be
+        *            used when you may already have an extension in your input but
+        *            are not sure about it
+        * 
+        * @return the extensions
+        */
+       public String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+
+               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportPara.java b/src/be/nikiroo/fanfix/supported/BasicSupportPara.java
new file mode 100644 (file)
index 0000000..ef4d7d7
--- /dev/null
@@ -0,0 +1,584 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to {@link Paragraph}
+ * and text formating for the {@link BasicSupport} class.
+ * 
+ * @author niki
+ */
+public class BasicSupportPara {
+       // quote chars
+       private static char openQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private static char closeQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private static char openDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private static char closeDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       // used by this class:
+       BasicSupportHelper bsHelper;
+       BasicSupportImages bsImages;
+       
+       public BasicSupportPara(BasicSupportHelper bsHelper, BasicSupportImages bsImages) {
+               this.bsHelper = bsHelper;
+               this.bsImages = bsImages;
+       }
+       
+       /**
+        * Create a {@link Chapter} object from the given information, formatting
+        * the content as it should be.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport}
+        * @param source
+        *            the source of the story (for image lookup in the same path if
+        *            the source is a file, can be NULL)
+        * @param number
+        *            the chapter number
+        * @param name
+        *            the chapter name
+        * @param content
+        *            the chapter content
+        * @param pg
+        *            the optional progress reporter
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the {@link Chapter}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Chapter makeChapter(BasicSupport support, URL source,
+                       int number, String name, String content, boolean html, Progress pg)
+                       throws IOException {
+               // Chapter name: process it correctly, then remove the possible
+               // redundant "Chapter x: " in front of it, or "-" (as in
+               // "Chapter 5: - Fun!" after the ": " was automatically added)
+               String chapterName = processPara(name, false)
+                               .getContent().trim();
+               for (String lang : Instance.getConfig().getList(Config.CONF_CHAPTER)) {
+                       String chapterWord = Instance.getConfig().getStringX(
+                                       Config.CONF_CHAPTER, lang);
+                       if (chapterName.startsWith(chapterWord)) {
+                               chapterName = chapterName.substring(chapterWord.length())
+                                               .trim();
+                               break;
+                       }
+               }
+
+               if (chapterName.startsWith(Integer.toString(number))) {
+                       chapterName = chapterName.substring(
+                                       Integer.toString(number).length()).trim();
+               }
+
+               while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
+                       chapterName = chapterName.substring(1).trim();
+               }
+               //
+
+               Chapter chap = new Chapter(number, chapterName);
+
+               if (content != null) {
+                       List<Paragraph> paras = makeParagraphs(support, source, content,
+                                       html, pg);
+                       long words = 0;
+                       for (Paragraph para : paras) {
+                               words += para.getWords();
+                       }
+                       chap.setParagraphs(paras);
+                       chap.setWords(words);
+               }
+
+               return chap;
+       }
+
+       /**
+        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+        * and requotify them (i.e., separate them into QUOTE paragraphs and other
+        * paragraphs (quotes or not)).
+        * 
+        * @param para
+        *            the paragraph to requotify (not necessarily a quote)
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the correctly (or so we hope) quotified paragraphs
+        */
+       protected List<Paragraph> requotify(Paragraph para, boolean html) {
+               List<Paragraph> newParas = new ArrayList<Paragraph>();
+
+               if (para.getType() == ParagraphType.QUOTE
+                               && para.getContent().length() > 2) {
+                       String line = para.getContent();
+                       boolean singleQ = line.startsWith("" + openQuote);
+                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+                       // Do not try when more than one quote at a time
+                       // (some stories are not easily readable if we do)
+                       if (singleQ
+                                       && line.indexOf(closeQuote, 1) < line
+                                                       .lastIndexOf(closeQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       if (doubleQ
+                                       && line.indexOf(closeDoubleQuote, 1) < line
+                                                       .lastIndexOf(closeDoubleQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       //
+
+                       if (!singleQ && !doubleQ) {
+                               line = openDoubleQuote + line + closeDoubleQuote;
+                               newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
+                                               .getWords()));
+                       } else {
+                               char open = singleQ ? openQuote : openDoubleQuote;
+                               char close = singleQ ? closeQuote : closeDoubleQuote;
+
+                               int posDot = -1;
+                               boolean inQuote = false;
+                               int i = 0;
+                               for (char car : line.toCharArray()) {
+                                       if (car == open) {
+                                               inQuote = true;
+                                       } else if (car == close) {
+                                               inQuote = false;
+                                       } else if (car == '.' && !inQuote) {
+                                               posDot = i;
+                                               break;
+                                       }
+                                       i++;
+                               }
+
+                               if (posDot >= 0) {
+                                       String rest = line.substring(posDot + 1).trim();
+                                       line = line.substring(0, posDot + 1).trim();
+                                       long words = 1;
+                                       for (char car : line.toCharArray()) {
+                                               if (car == ' ') {
+                                                       words++;
+                                               }
+                                       }
+                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
+                                       if (!rest.isEmpty()) {
+                                               newParas.addAll(requotify(processPara(rest, html), html));
+                                       }
+                               } else {
+                                       newParas.add(para);
+                               }
+                       }
+               } else {
+                       newParas.add(para);
+               }
+
+               return newParas;
+       }
+
+       /**
+        * Process a {@link Paragraph} from a raw line of text.
+        * <p>
+        * Will also fix quotes and HTML encoding if needed.
+        * 
+        * @param line
+        *            the raw line
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the processed {@link Paragraph}
+        */
+       protected Paragraph processPara(String line, boolean html) {
+               if (html) {
+                       line = StringUtils.unhtml(line).trim();
+               }
+               boolean space = true;
+               boolean brk = true;
+               boolean quote = false;
+               boolean tentativeCloseQuote = false;
+               char prev = '\0';
+               int dashCount = 0;
+               long words = 1;
+
+               StringBuilder builder = new StringBuilder();
+               for (char car : line.toCharArray()) {
+                       if (car != '-') {
+                               if (dashCount > 0) {
+                                       // dash, ndash and mdash: - – —
+                                       // currently: always use mdash
+                                       builder.append(dashCount == 1 ? '-' : '—');
+                               }
+                               dashCount = 0;
+                       }
+
+                       if (tentativeCloseQuote) {
+                               tentativeCloseQuote = false;
+                               if (Character.isLetterOrDigit(car)) {
+                                       builder.append("'");
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.append(closeDoubleQuote);
+                                               continue;
+                                       }
+
+                                       builder.append(closeQuote);
+                               }
+                       }
+
+                       switch (car) {
+                       case ' ': // note: unbreakable space
+                       case ' ':
+                       case '\t':
+                       case '\n': // just in case
+                       case '\r': // just in case
+                               if (builder.length() > 0
+                                               && builder.charAt(builder.length() - 1) != ' ') {
+                                       words++;
+                               }
+                               builder.append(' ');
+                               break;
+
+                       case '\'':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else if (prev == ' ' || prev == car) {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else {
+                                       // it is a quote ("I'm off") or a 'quote' ("This
+                                       // 'good' restaurant"...)
+                                       tentativeCloseQuote = true;
+                               }
+                               break;
+
+                       case '"':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else if (prev == ' ') {
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(closeDoubleQuote);
+                               }
+                               break;
+
+                       case '-':
+                               if (space) {
+                                       quote = true;
+                               } else {
+                                       dashCount++;
+                               }
+                               space = false;
+                               break;
+
+                       case '*':
+                       case '~':
+                       case '/':
+                       case '\\':
+                       case '<':
+                       case '>':
+                       case '=':
+                       case '+':
+                       case '_':
+                       case '–':
+                       case '—':
+                               space = false;
+                               builder.append(car);
+                               break;
+
+                       case '‘':
+                       case '`':
+                       case '‹':
+                       case '﹁':
+                       case '〈':
+                       case '「':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openQuote);
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '’':
+                       case '›':
+                       case '﹂':
+                       case '〉':
+                       case '」':
+                               space = false;
+                               brk = false;
+                               // handle double-single quotes as double quotes
+                               if (prev == car) {
+                                       builder.deleteCharAt(builder.length() - 1);
+                                       builder.append(closeDoubleQuote);
+                               } else {
+                                       builder.append(closeQuote);
+                               }
+                               break;
+
+                       case '«':
+                       case '“':
+                       case '﹃':
+                       case '《':
+                       case '『':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(openDoubleQuote);
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '»':
+                       case '”':
+                       case '﹄':
+                       case '》':
+                       case '』':
+                               space = false;
+                               brk = false;
+                               builder.append(closeDoubleQuote);
+                               break;
+
+                       default:
+                               space = false;
+                               brk = false;
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (tentativeCloseQuote) {
+                       tentativeCloseQuote = false;
+                       builder.append(closeQuote);
+               }
+
+               line = builder.toString().trim();
+
+               ParagraphType type = ParagraphType.NORMAL;
+               if (space) {
+                       type = ParagraphType.BLANK;
+               } else if (brk) {
+                       type = ParagraphType.BREAK;
+               } else if (quote) {
+                       type = ParagraphType.QUOTE;
+               }
+
+               return new Paragraph(type, line, words);
+       }
+
+       /**
+        * Convert the given content into {@link Paragraph}s.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL), used to
+        *            download optional image content in []
+        * @param source
+        *            the source URL of the story (for image lookup in the same path
+        *            if the source is a file, can be NULL)
+        * @param content
+        *            the textual content
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Paragraph}s
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected List<Paragraph> makeParagraphs(BasicSupport support,
+                       URL source, String content, boolean html, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               if (html) {
+                       // Special <HR> processing:
+                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
+                                       "<br/>* * *<br/>");
+               }
+
+               List<Paragraph> paras = new ArrayList<Paragraph>();
+
+               if (content != null && !content.trim().isEmpty()) {
+                       if (html) {
+                               String[] tab = content.split("(<p>|</p>|<br>|<br/>)");
+                               pg.setMinMax(0, tab.length);
+                               int i = 1;
+                               for (String line : tab) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(support, source, line.trim(), html));
+                                       pg.setProgress(i++);
+                               }
+                       } else {
+                               List<String> lines = new ArrayList<String>();
+                               BufferedReader buff = null;
+                               try {
+                                       buff = new BufferedReader(
+                                                       new InputStreamReader(new ByteArrayInputStream(
+                                                                       content.getBytes("UTF-8")), "UTF-8"));
+                                       for (String line = buff.readLine(); line != null; line = buff
+                                                       .readLine()) {
+                                               lines.add(line.trim());
+                                       }
+                               } finally {
+                                       if (buff != null) {
+                                               buff.close();
+                                       }
+                               }
+
+                               pg.setMinMax(0, lines.size());
+                               int i = 0;
+                               for (String line : lines) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(support, source, line, html));
+                                       pg.setProgress(i++);
+                               }
+                       }
+
+                       pg.done();
+                       pg.setName(null);
+
+                       // Check quotes for "bad" format
+                       List<Paragraph> newParas = new ArrayList<Paragraph>();
+                       for (Paragraph para : paras) {
+                               newParas.addAll(requotify(para, html));
+                       }
+                       paras = newParas;
+
+                       // Remove double blanks/brks
+                       fixBlanksBreaks(paras);
+               }
+
+               return paras;
+       }
+
+       /**
+        * Convert the given line into a single {@link Paragraph}.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL), used to
+        *            download optional image content in []
+        * @param source
+        *            the source URL of the story (for image lookup in the same path
+        *            if the source is a file, can be NULL)
+        * @param line
+        *            the textual content of the paragraph
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the {@link Paragraph}
+        */
+       protected Paragraph makeParagraph(BasicSupport support, URL source,
+                       String line, boolean html) {
+               Image image = null;
+               if (line.startsWith("[") && line.endsWith("]")) {
+                       image = bsHelper.getImage(support, source, line
+                                       .substring(1, line.length() - 1).trim());
+               }
+
+               if (image != null) {
+                       return new Paragraph(image);
+               }
+
+               return processPara(line, html);
+       }
+
+       /**
+        * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
+        * those {@link Paragraph}s.
+        * <p>
+        * The resulting list will not contain a starting or trailing blank/break
+        * nor 2 blanks or breaks following each other.
+        * 
+        * @param paras
+        *            the list of {@link Paragraph}s to fix
+        */
+       protected void fixBlanksBreaks(List<Paragraph> paras) {
+               boolean space = false;
+               boolean brk = true;
+               for (int i = 0; i < paras.size(); i++) {
+                       Paragraph para = paras.get(i);
+                       boolean thisSpace = para.getType() == ParagraphType.BLANK;
+                       boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+                       if (i > 0 && space && thisBrk) {
+                               paras.remove(i - 1);
+                               i--;
+                       } else if ((space || brk) && (thisSpace || thisBrk)) {
+                               paras.remove(i);
+                               i--;
+                       }
+
+                       space = thisSpace;
+                       brk = thisBrk;
+               }
+
+               // Remove blank/brk at start
+               if (paras.size() > 0
+                               && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
+                                               0).getType() == ParagraphType.BREAK)) {
+                       paras.remove(0);
+               }
+
+               // Remove blank/brk at end
+               int last = paras.size() - 1;
+               if (paras.size() > 0
+                               && (paras.get(last).getType() == ParagraphType.BLANK || paras
+                                               .get(last).getType() == ParagraphType.BREAK)) {
+                       paras.remove(last);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java
new file mode 100644 (file)
index 0000000..1faac03
--- /dev/null
@@ -0,0 +1,1322 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * DEPRECATED: use the new Jsoup 'Node' system.
+ * <p>
+ * This class is the base class used by the other support classes. It can be
+ * used outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * <p>
+ * It will be used with 'resources' (usually web pages or files).
+ * 
+ * @author niki
+ */
+@Deprecated
+public abstract class BasicSupport_Deprecated extends BasicSupport {
+       private InputStream in;
+
+       // quote chars
+       private char openQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       // New methods not used in Deprecated mode
+       @Override
+       protected String getDesc() throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       public Story process(Progress pg) throws IOException {
+               return process(getSource(), pg);
+       }
+
+       //
+
+       /**
+        * Return the {@link MetaData} of this story.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the associated {@link MetaData}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract MetaData getMeta(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the story description.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the description
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getDesc(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the list of chapters (name and resource).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the chapters
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract List<Entry<String, URL>> getChapters(URL source,
+                       InputStream in, Progress pg) throws IOException;
+
+       /**
+        * Return the content of the chapter (possibly HTML encoded, if
+        * {@link BasicSupport_Deprecated#isHtml()} is TRUE).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * @param number
+        *            the chapter number
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getChapterContent(URL source, InputStream in,
+                       int number, Progress pg) throws IOException;
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata, except for the description.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story processMeta(URL url) throws IOException {
+               return processMeta(url, true, false, null);
+       }
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata.
+        * 
+        * @param url
+        *            the story resource
+        * @param close
+        *            close "this" and "in" when done
+        * @param getDesc
+        *            retrieve the description of the story, or not
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story processMeta(URL url, boolean close, boolean getDesc,
+                       Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               login();
+               pg.setProgress(10);
+
+               url = getCanonicalUrl(url);
+
+               setCurrentReferer(url);
+
+               in = openInput(url); // NULL allowed here
+               try {
+                       preprocess(url, getInput());
+                       pg.setProgress(30);
+
+                       Story story = new Story();
+                       MetaData meta = getMeta(url, getInput());
+                       if (meta.getCreationDate() == null
+                                       || meta.getCreationDate().isEmpty()) {
+                               meta.setCreationDate(StringUtils.fromTime(new Date().getTime()));
+                       }
+                       story.setMeta(meta);
+
+                       pg.setProgress(50);
+
+                       if (meta.getCover() == null) {
+                               meta.setCover(getDefaultCover(meta.getSubject()));
+                       }
+
+                       pg.setProgress(60);
+
+                       if (getDesc) {
+                               String descChapterName = Instance.getTrans().getString(
+                                               StringId.DESCRIPTION);
+                               story.getMeta().setResume(
+                                               makeChapter(url, 0, descChapterName,
+                                                               getDesc(url, getInput()), null));
+                       }
+
+                       pg.setProgress(100);
+                       return story;
+               } finally {
+                       if (close) {
+                               close();
+
+                               if (in != null) {
+                                       in.close();
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Process the given story resource into a fully filled {@link Story}
+        * object.
+        * 
+        * @param url
+        *            the story resource
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story process(URL url, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               url = getCanonicalUrl(url);
+               pg.setProgress(1);
+               try {
+                       Progress pgMeta = new Progress();
+                       pg.addProgress(pgMeta, 10);
+                       Story story = processMeta(url, false, true, pgMeta);
+                       if (!pgMeta.isDone()) {
+                               pgMeta.setProgress(pgMeta.getMax()); // 10%
+                       }
+
+                       pg.setName("Retrieving " + story.getMeta().getTitle());
+
+                       setCurrentReferer(url);
+
+                       Progress pgGetChapters = new Progress();
+                       pg.addProgress(pgGetChapters, 10);
+                       story.setChapters(new ArrayList<Chapter>());
+                       List<Entry<String, URL>> chapters = getChapters(url, getInput(),
+                                       pgGetChapters);
+                       if (!pgGetChapters.isDone()) {
+                               pgGetChapters.setProgress(pgGetChapters.getMax()); // 20%
+                       }
+
+                       if (chapters != null) {
+                               Progress pgChaps = new Progress("Extracting chapters", 0,
+                                               chapters.size() * 300);
+                               pg.addProgress(pgChaps, 80);
+
+                               long words = 0;
+                               int i = 1;
+                               for (Entry<String, URL> chap : chapters) {
+                                       pgChaps.setName("Extracting chapter " + i);
+                                       InputStream chapIn = null;
+                                       if (chap.getValue() != null) {
+                                               setCurrentReferer(chap.getValue());
+                                               chapIn = Instance.getCache().open(chap.getValue(),
+                                                               this, false);
+                                       }
+                                       pgChaps.setProgress(i * 100);
+                                       try {
+                                               Progress pgGetChapterContent = new Progress();
+                                               Progress pgMakeChapter = new Progress();
+                                               pgChaps.addProgress(pgGetChapterContent, 100);
+                                               pgChaps.addProgress(pgMakeChapter, 100);
+
+                                               String content = getChapterContent(url, chapIn, i,
+                                                               pgGetChapterContent);
+                                               if (!pgGetChapterContent.isDone()) {
+                                                       pgGetChapterContent.setProgress(pgGetChapterContent
+                                                                       .getMax());
+                                               }
+
+                                               Chapter cc = makeChapter(url, i, chap.getKey(),
+                                                               content, pgMakeChapter);
+                                               if (!pgMakeChapter.isDone()) {
+                                                       pgMakeChapter.setProgress(pgMakeChapter.getMax());
+                                               }
+
+                                               words += cc.getWords();
+                                               story.getChapters().add(cc);
+                                               story.getMeta().setWords(words);
+                                       } finally {
+                                               if (chapIn != null) {
+                                                       chapIn.close();
+                                               }
+                                       }
+
+                                       i++;
+                               }
+
+                               pgChaps.setName("Extracting chapters");
+                       } else {
+                               pg.setProgress(80);
+                       }
+
+                       return story;
+
+               } finally {
+                       close();
+
+                       if (in != null) {
+                               in.close();
+                       }
+               }
+       }
+
+       /**
+        * Prepare the support if needed before processing.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @throws IOException
+        *             on I/O error
+        */
+       @SuppressWarnings("unused")
+       protected void preprocess(URL source, InputStream in) throws IOException {
+       }
+
+       /**
+        * Create a {@link Chapter} object from the given information, formatting
+        * the content as it should be.
+        * 
+        * @param source
+        *            the source of the story
+        * @param number
+        *            the chapter number
+        * @param name
+        *            the chapter name
+        * @param content
+        *            the chapter content
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Chapter}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Chapter makeChapter(URL source, int number, String name,
+                       String content, Progress pg) throws IOException {
+               // Chapter name: process it correctly, then remove the possible
+               // redundant "Chapter x: " in front of it, or "-" (as in
+               // "Chapter 5: - Fun!" after the ": " was automatically added)
+               String chapterName = processPara(name).getContent().trim();
+               for (String lang : Instance.getConfig().getList(Config.CONF_CHAPTER)) {
+                       String chapterWord = Instance.getConfig().getStringX(
+                                       Config.CONF_CHAPTER, lang);
+                       if (chapterName.startsWith(chapterWord)) {
+                               chapterName = chapterName.substring(chapterWord.length())
+                                               .trim();
+                               break;
+                       }
+               }
+
+               if (chapterName.startsWith(Integer.toString(number))) {
+                       chapterName = chapterName.substring(
+                                       Integer.toString(number).length()).trim();
+               }
+
+               while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
+                       chapterName = chapterName.substring(1).trim();
+               }
+               //
+
+               Chapter chap = new Chapter(number, chapterName);
+
+               if (content != null) {
+                       List<Paragraph> paras = makeParagraphs(source, content, pg);
+                       long words = 0;
+                       for (Paragraph para : paras) {
+                               words += para.getWords();
+                       }
+                       chap.setParagraphs(paras);
+                       chap.setWords(words);
+               }
+
+               return chap;
+
+       }
+
+       /**
+        * Convert the given content into {@link Paragraph}s.
+        * 
+        * @param source
+        *            the source URL of the story
+        * @param content
+        *            the textual content
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Paragraph}s
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected List<Paragraph> makeParagraphs(URL source, String content,
+                       Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               if (isHtml()) {
+                       // Special <HR> processing:
+                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
+                                       "<br/>* * *<br/>");
+               }
+
+               List<Paragraph> paras = new ArrayList<Paragraph>();
+
+               if (content != null && !content.trim().isEmpty()) {
+                       if (isHtml()) {
+                               String[] tab = content.split("(<p>|</p>|<br>|<br/>)");
+                               pg.setMinMax(0, tab.length);
+                               int i = 1;
+                               for (String line : tab) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(source, line.trim()));
+                                       pg.setProgress(i++);
+                               }
+                               pg.setName(null);
+                       } else {
+                               List<String> lines = new ArrayList<String>();
+                               BufferedReader buff = null;
+                               try {
+                                       buff = new BufferedReader(
+                                                       new InputStreamReader(new ByteArrayInputStream(
+                                                                       content.getBytes("UTF-8")), "UTF-8"));
+                                       for (String line = buff.readLine(); line != null; line = buff
+                                                       .readLine()) {
+                                               lines.add(line.trim());
+                                       }
+                               } finally {
+                                       if (buff != null) {
+                                               buff.close();
+                                       }
+                               }
+
+                               pg.setMinMax(0, lines.size());
+                               int i = 0;
+                               for (String line : lines) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(source, line));
+                                       pg.setProgress(i++);
+                               }
+                               pg.setName(null);
+                       }
+
+                       // Check quotes for "bad" format
+                       List<Paragraph> newParas = new ArrayList<Paragraph>();
+                       for (Paragraph para : paras) {
+                               newParas.addAll(requotify(para));
+                       }
+                       paras = newParas;
+
+                       // Remove double blanks/brks
+                       fixBlanksBreaks(paras);
+               }
+
+               return paras;
+       }
+
+       /**
+        * Convert the given line into a single {@link Paragraph}.
+        * 
+        * @param source
+        *            the source URL of the story
+        * @param line
+        *            the textual content of the paragraph
+        * 
+        * @return the {@link Paragraph}
+        */
+       private Paragraph makeParagraph(URL source, String line) {
+               Image image = null;
+               if (line.startsWith("[") && line.endsWith("]")) {
+                       image = getImage(this, source, line.substring(1, line.length() - 1)
+                                       .trim());
+               }
+
+               if (image != null) {
+                       return new Paragraph(image);
+               }
+
+               return processPara(line);
+       }
+
+       /**
+        * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
+        * those {@link Paragraph}s.
+        * <p>
+        * The resulting list will not contain a starting or trailing blank/break
+        * nor 2 blanks or breaks following each other.
+        * 
+        * @param paras
+        *            the list of {@link Paragraph}s to fix
+        */
+       protected void fixBlanksBreaks(List<Paragraph> paras) {
+               boolean space = false;
+               boolean brk = true;
+               for (int i = 0; i < paras.size(); i++) {
+                       Paragraph para = paras.get(i);
+                       boolean thisSpace = para.getType() == ParagraphType.BLANK;
+                       boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+                       if (i > 0 && space && thisBrk) {
+                               paras.remove(i - 1);
+                               i--;
+                       } else if ((space || brk) && (thisSpace || thisBrk)) {
+                               paras.remove(i);
+                               i--;
+                       }
+
+                       space = thisSpace;
+                       brk = thisBrk;
+               }
+
+               // Remove blank/brk at start
+               if (paras.size() > 0
+                               && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
+                                               0).getType() == ParagraphType.BREAK)) {
+                       paras.remove(0);
+               }
+
+               // Remove blank/brk at end
+               int last = paras.size() - 1;
+               if (paras.size() > 0
+                               && (paras.get(last).getType() == ParagraphType.BLANK || paras
+                                               .get(last).getType() == ParagraphType.BREAK)) {
+                       paras.remove(last);
+               }
+       }
+
+       /**
+        * Get the default cover related to this subject (see <tt>.info</tt> files).
+        * 
+        * @param subject
+        *            the subject
+        * 
+        * @return the cover if any, or NULL
+        */
+       static Image getDefaultCover(String subject) {
+               if (subject != null && !subject.isEmpty()
+                               && Instance.getCoverDir() != null) {
+                       try {
+                               File fileCover = new File(Instance.getCoverDir(), subject);
+                               return getImage(null, fileCover.toURI().toURL(), subject);
+                       } catch (MalformedURLException e) {
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @param emptyAllowed
+        *            TRUE to allow an empty extension on first place, which can be
+        *            used when you may already have an extension in your input but
+        *            are not sure about it
+        * 
+        * @return the extensions
+        */
+       static String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+
+               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param source
+        *            the story source
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image if found, or NULL
+        * 
+        */
+       static Image getImage(BasicSupport_Deprecated support, URL source,
+                       String line) {
+               URL url = getImageUrl(support, source, line);
+               if (url != null) {
+                       if ("file".equals(url.getProtocol())) {
+                               if (new File(url.getPath()).isDirectory()) {
+                                       return null;
+                               }
+                       }
+                       InputStream in = null;
+                       try {
+                               in = Instance.getCache().open(url, getSupport(url), true);
+                               return new Image(in);
+                       } catch (IOException e) {
+                       } finally {
+                               if (in != null) {
+                                       try {
+                                               in.close();
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param source
+        *            the story source
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       static URL getImageUrl(BasicSupport_Deprecated support, URL source,
+                       String line) {
+               URL url = null;
+
+               if (line != null) {
+                       // try for files
+                       if (source != null) {
+                               try {
+                                       String relPath = null;
+                                       String absPath = null;
+                                       try {
+                                               String path = new File(source.getFile()).getParent();
+                                               relPath = new File(new File(path), line.trim())
+                                                               .getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (one possibility to take
+                                               // into account: absolute path on Windows)
+                                       }
+                                       try {
+                                               absPath = new File(line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (at all)
+                                       }
+
+                                       for (String ext : getImageExt(true)) {
+                                               File absFile = new File(absPath + ext);
+                                               File relFile = new File(relPath + ext);
+                                               if (absPath != null && absFile.exists()
+                                                               && absFile.isFile()) {
+                                                       url = absFile.toURI().toURL();
+                                               } else if (relPath != null && relFile.exists()
+                                                               && relFile.isFile()) {
+                                                       url = relFile.toURI().toURL();
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       // Should not happen since we control the correct arguments
+                               }
+                       }
+
+                       if (url == null) {
+                               // try for URLs
+                               try {
+                                       for (String ext : getImageExt(true)) {
+                                               if (Instance.getCache()
+                                                               .check(new URL(line + ext), true)) {
+                                                       url = new URL(line + ext);
+                                                       break;
+                                               }
+                                       }
+
+                                       // try out of cache
+                                       if (url == null) {
+                                               for (String ext : getImageExt(true)) {
+                                                       try {
+                                                               url = new URL(line + ext);
+                                                               Instance.getCache().refresh(url, support, true);
+                                                               break;
+                                                       } catch (IOException e) {
+                                                               // no image with this ext
+                                                               url = null;
+                                                       }
+                                               }
+                                       }
+                               } catch (MalformedURLException e) {
+                                       // Not an url
+                               }
+                       }
+
+                       // refresh the cached file
+                       if (url != null) {
+                               try {
+                                       Instance.getCache().refresh(url, support, true);
+                               } catch (IOException e) {
+                                       // woops, broken image
+                                       url = null;
+                               }
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Open the input file that will be used through the support.
+        * <p>
+        * Can return NULL, in which case you are supposed to work without an
+        * {@link InputStream}.
+        * 
+        * @param source
+        *            the source {@link URL}
+        * 
+        * @return the {@link InputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected InputStream openInput(URL source) throws IOException {
+               return Instance.getCache().open(source, this, false);
+       }
+
+       /**
+        * Reset then return {@link BasicSupport_Deprecated#in}.
+        * 
+        * @return {@link BasicSupport_Deprecated#in}
+        */
+       protected InputStream getInput() {
+               return reset(in);
+       }
+
+       /**
+        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+        * and requotify them (i.e., separate them into QUOTE paragraphs and other
+        * paragraphs (quotes or not)).
+        * 
+        * @param para
+        *            the paragraph to requotify (not necessarily a quote)
+        * 
+        * @return the correctly (or so we hope) quotified paragraphs
+        */
+       protected List<Paragraph> requotify(Paragraph para) {
+               List<Paragraph> newParas = new ArrayList<Paragraph>();
+
+               if (para.getType() == ParagraphType.QUOTE
+                               && para.getContent().length() > 2) {
+                       String line = para.getContent();
+                       boolean singleQ = line.startsWith("" + openQuote);
+                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+                       // Do not try when more than one quote at a time
+                       // (some stories are not easily readable if we do)
+                       if (singleQ
+                                       && line.indexOf(closeQuote, 1) < line
+                                                       .lastIndexOf(closeQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       if (doubleQ
+                                       && line.indexOf(closeDoubleQuote, 1) < line
+                                                       .lastIndexOf(closeDoubleQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       //
+
+                       if (!singleQ && !doubleQ) {
+                               line = openDoubleQuote + line + closeDoubleQuote;
+                               newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
+                                               .getWords()));
+                       } else {
+                               char open = singleQ ? openQuote : openDoubleQuote;
+                               char close = singleQ ? closeQuote : closeDoubleQuote;
+
+                               int posDot = -1;
+                               boolean inQuote = false;
+                               int i = 0;
+                               for (char car : line.toCharArray()) {
+                                       if (car == open) {
+                                               inQuote = true;
+                                       } else if (car == close) {
+                                               inQuote = false;
+                                       } else if (car == '.' && !inQuote) {
+                                               posDot = i;
+                                               break;
+                                       }
+                                       i++;
+                               }
+
+                               if (posDot >= 0) {
+                                       String rest = line.substring(posDot + 1).trim();
+                                       line = line.substring(0, posDot + 1).trim();
+                                       long words = 1;
+                                       for (char car : line.toCharArray()) {
+                                               if (car == ' ') {
+                                                       words++;
+                                               }
+                                       }
+                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
+                                       if (!rest.isEmpty()) {
+                                               newParas.addAll(requotify(processPara(rest)));
+                                       }
+                               } else {
+                                       newParas.add(para);
+                               }
+                       }
+               } else {
+                       newParas.add(para);
+               }
+
+               return newParas;
+       }
+
+       /**
+        * Process a {@link Paragraph} from a raw line of text.
+        * <p>
+        * Will also fix quotes and HTML encoding if needed.
+        * 
+        * @param line
+        *            the raw line
+        * 
+        * @return the processed {@link Paragraph}
+        */
+       protected Paragraph processPara(String line) {
+               line = ifUnhtml(line).trim();
+
+               boolean space = true;
+               boolean brk = true;
+               boolean quote = false;
+               boolean tentativeCloseQuote = false;
+               char prev = '\0';
+               int dashCount = 0;
+               long words = 1;
+
+               StringBuilder builder = new StringBuilder();
+               for (char car : line.toCharArray()) {
+                       if (car != '-') {
+                               if (dashCount > 0) {
+                                       // dash, ndash and mdash: - – —
+                                       // currently: always use mdash
+                                       builder.append(dashCount == 1 ? '-' : '—');
+                               }
+                               dashCount = 0;
+                       }
+
+                       if (tentativeCloseQuote) {
+                               tentativeCloseQuote = false;
+                               if (Character.isLetterOrDigit(car)) {
+                                       builder.append("'");
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.append(closeDoubleQuote);
+                                               continue;
+                                       }
+
+                                       builder.append(closeQuote);
+                               }
+                       }
+
+                       switch (car) {
+                       case ' ': // note: unbreakable space
+                       case ' ':
+                       case '\t':
+                       case '\n': // just in case
+                       case '\r': // just in case
+                               if (builder.length() > 0
+                                               && builder.charAt(builder.length() - 1) != ' ') {
+                                       words++;
+                               }
+                               builder.append(' ');
+                               break;
+
+                       case '\'':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else if (prev == ' ' || prev == car) {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else {
+                                       // it is a quote ("I'm off") or a 'quote' ("This
+                                       // 'good' restaurant"...)
+                                       tentativeCloseQuote = true;
+                               }
+                               break;
+
+                       case '"':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else if (prev == ' ') {
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(closeDoubleQuote);
+                               }
+                               break;
+
+                       case '-':
+                               if (space) {
+                                       quote = true;
+                               } else {
+                                       dashCount++;
+                               }
+                               space = false;
+                               break;
+
+                       case '*':
+                       case '~':
+                       case '/':
+                       case '\\':
+                       case '<':
+                       case '>':
+                       case '=':
+                       case '+':
+                       case '_':
+                       case '–':
+                       case '—':
+                               space = false;
+                               builder.append(car);
+                               break;
+
+                       case '‘':
+                       case '`':
+                       case '‹':
+                       case '﹁':
+                       case '〈':
+                       case '「':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openQuote);
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '’':
+                       case '›':
+                       case '﹂':
+                       case '〉':
+                       case '」':
+                               space = false;
+                               brk = false;
+                               // handle double-single quotes as double quotes
+                               if (prev == car) {
+                                       builder.deleteCharAt(builder.length() - 1);
+                                       builder.append(closeDoubleQuote);
+                               } else {
+                                       builder.append(closeQuote);
+                               }
+                               break;
+
+                       case '«':
+                       case '“':
+                       case '﹃':
+                       case '《':
+                       case '『':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(openDoubleQuote);
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '»':
+                       case '”':
+                       case '﹄':
+                       case '》':
+                       case '』':
+                               space = false;
+                               brk = false;
+                               builder.append(closeDoubleQuote);
+                               break;
+
+                       default:
+                               space = false;
+                               brk = false;
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (tentativeCloseQuote) {
+                       tentativeCloseQuote = false;
+                       builder.append(closeQuote);
+               }
+
+               line = builder.toString().trim();
+
+               ParagraphType type = ParagraphType.NORMAL;
+               if (space) {
+                       type = ParagraphType.BLANK;
+               } else if (brk) {
+                       type = ParagraphType.BREAK;
+               } else if (quote) {
+                       type = ParagraphType.QUOTE;
+               }
+
+               return new Paragraph(type, line, words);
+       }
+
+       /**
+        * Remove the HTML from the input <b>if</b>
+        * {@link BasicSupport_Deprecated#isHtml()} is true.
+        * 
+        * @param input
+        *            the input
+        * 
+        * @return the no html version if needed
+        */
+       private String ifUnhtml(String input) {
+               if (isHtml() && input != null) {
+                       return StringUtils.unhtml(input);
+               }
+
+               return input;
+       }
+
+       /**
+        * Reset the given {@link InputStream} and return it.
+        * 
+        * @param in
+        *            the {@link InputStream} to reset
+        * 
+        * @return the same {@link InputStream} after reset
+        */
+       static protected InputStream reset(InputStream in) {
+               try {
+                       if (in != null) {
+                               in.reset();
+                       }
+               } catch (IOException e) {
+               }
+
+               return in;
+       }
+
+       /**
+        * Return the first line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * 
+        * @return the line, or NULL if not found
+        */
+       static protected String getLine(InputStream in, String needle,
+                       int relativeLine) {
+               return getLine(in, needle, relativeLine, true);
+       }
+
+       /**
+        * Return a line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * @param first
+        *            takes the first result (as opposed to the last one, which will
+        *            also always spend the input)
+        * 
+        * @return the line, or NULL if not found
+        */
+       static protected String getLine(InputStream in, String needle,
+                       int relativeLine, boolean first) {
+               String rep = null;
+
+               reset(in);
+
+               List<String> lines = new ArrayList<String>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               int index = -1;
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       lines.add(scan.next());
+
+                       if (index == -1) {
+                               if (needle.startsWith("^")) {
+                                       if (lines.get(lines.size() - 1).startsWith(
+                                                       needle.substring(1))) {
+                                               index = lines.size() - 1;
+                                       }
+
+                               } else {
+                                       if (lines.get(lines.size() - 1).contains(needle)) {
+                                               index = lines.size() - 1;
+                                       }
+                               }
+                       }
+
+                       if (index >= 0 && index + relativeLine < lines.size()) {
+                               rep = lines.get(index + relativeLine);
+                               if (first) {
+                                       break;
+                               }
+                       }
+               }
+
+               return rep;
+       }
+
+       /**
+        * Return the text between the key and the endKey (and optional subKey can
+        * be passed, in this case we will look for the key first, then take the
+        * text between the subKey and the endKey).
+        * <p>
+        * Will only match the first line with the given key if more than one are
+        * possible. Which also means that if the subKey or endKey is not found on
+        * that line, NULL will be returned.
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match (also supports "^" at start to say
+        *            "only if it starts with" the key)
+        * @param subKey
+        *            the sub key or NULL if none
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @return the text or NULL if not found
+        */
+       static protected String getKeyLine(InputStream in, String key,
+                       String subKey, String endKey) {
+               return getKeyText(getLine(in, key, 0), key, subKey, endKey);
+       }
+
+       /**
+        * Return the text between the key and the endKey (and optional subKey can
+        * be passed, in this case we will look for the key first, then take the
+        * text between the subKey and the endKey).
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match (also supports "^" at start to say
+        *            "only if it starts with" the key)
+        * @param subKey
+        *            the sub key or NULL if none
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @return the text or NULL if not found
+        */
+       static protected String getKeyText(String in, String key, String subKey,
+                       String endKey) {
+               String result = null;
+
+               String line = in;
+               if (line != null && line.contains(key)) {
+                       line = line.substring(line.indexOf(key) + key.length());
+                       if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
+                               if (subKey != null) {
+                                       line = line.substring(line.indexOf(subKey)
+                                                       + subKey.length());
+                               }
+                               if (endKey == null || line.contains(endKey)) {
+                                       if (endKey != null) {
+                                               line = line.substring(0, line.indexOf(endKey));
+                                               result = line;
+                                       }
+                               }
+                       }
+               }
+
+               return result;
+       }
+
+       /**
+        * Return the text between the key and the endKey (optional subKeys can be
+        * passed, in this case we will look for the subKeys first, then take the
+        * text between the key and the endKey).
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @param afters
+        *            the sub-keys to find before checking for key/endKey
+        * 
+        * @return the text or NULL if not found
+        */
+       static protected String getKeyTextAfter(String in, String key,
+                       String endKey, String... afters) {
+
+               if (in != null && !in.isEmpty()) {
+                       int pos = indexOfAfter(in, 0, afters);
+                       if (pos < 0) {
+                               return null;
+                       }
+
+                       in = in.substring(pos);
+               }
+
+               return getKeyText(in, key, null, endKey);
+       }
+
+       /**
+        * Return the first index after all the given "afters" have been found in
+        * the {@link String}, or -1 if it was not possible.
+        * 
+        * @param in
+        *            the input
+        * @param startAt
+        *            start at this position in the string
+        * @param afters
+        *            the sub-keys to find before checking for key/endKey
+        * 
+        * @return the text or NULL if not found
+        */
+       static protected int indexOfAfter(String in, int startAt, String... afters) {
+               int pos = -1;
+               if (in != null && !in.isEmpty()) {
+                       pos = startAt;
+                       if (afters != null) {
+                               for (int i = 0; pos >= 0 && i < afters.length; i++) {
+                                       String subKey = afters[i];
+                                       if (!subKey.isEmpty()) {
+                                               pos = in.indexOf(subKey, pos);
+                                               if (pos >= 0) {
+                                                       pos += subKey.length();
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return pos;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Cbz.java b/src/be/nikiroo/fanfix/supported/Cbz.java
new file mode 100644 (file)
index 0000000..22e436a
--- /dev/null
@@ -0,0 +1,222 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for CBZ files (works better with CBZ created with this program,
+ * as they have some metadata available).
+ * 
+ * @author niki
+ */
+class Cbz extends Epub {
+       @Override
+       protected boolean supports(URL url) {
+               return url.toString().toLowerCase().endsWith(".cbz");
+       }
+
+       @Override
+       protected String getDataPrefix() {
+               return "";
+       }
+
+       @Override
+       protected boolean requireInfo() {
+               return false;
+       }
+
+       @Override
+       protected boolean isImagesDocumentByDefault() {
+               return true;
+       }
+
+       @Override
+       protected boolean getCover() {
+               return false;
+       }
+
+       @Override
+       public Story doProcess(Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               Progress pgMeta = new Progress();
+               pg.addProgress(pgMeta, 10);
+               Story story = processMeta(true, pgMeta);
+               MetaData meta = story.getMeta();
+
+               pgMeta.done(); // 10%
+
+               File tmpDir = Instance.getTempFiles().createTempDir("info-text");
+               String basename = null;
+
+               Map<String, Image> images = new HashMap<String, Image>();
+               InputStream cbzIn = null;
+               ZipInputStream zipIn = null;
+               try {
+                       cbzIn = new MarkableFileInputStream(getSourceFileOriginal());
+                       zipIn = new ZipInputStream(cbzIn);
+                       for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
+                                       .getNextEntry()) {
+                               if (!entry.isDirectory()
+                                               && entry.getName().startsWith(getDataPrefix())) {
+                                       String entryLName = entry.getName().toLowerCase();
+                                       boolean imageEntry = false;
+                                       for (String ext : bsImages.getImageExt(false)) {
+                                               if (entryLName.endsWith(ext)) {
+                                                       imageEntry = true;
+                                               }
+                                       }
+                                       
+                                       if (imageEntry) {
+                                               String uuid = meta.getUuid() + "_" + entry.getName();
+                                               try {
+                                                       images.put(uuid, new Image(zipIn));
+                                               } catch (Exception e) {
+                                                       Instance.getTraceHandler().error(e);
+                                               }
+
+                                               if (pg.getProgress() < 85) {
+                                                       pg.add(1);
+                                               }
+                                       } else if (entryLName.endsWith(".info")) {
+                                               basename = entryLName.substring(0, entryLName.length()
+                                                               - ".info".length());
+                                               IOUtils.write(zipIn, new File(tmpDir, entryLName));
+                                       } else if (entryLName.endsWith(".txt")) {
+                                               IOUtils.write(zipIn, new File(tmpDir, entryLName));
+                                       }
+                               }
+                       }
+                       
+                       String ext = "."
+                                       + Instance.getConfig()
+                                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
+                                                       .toLowerCase();
+                       String coverName = meta.getUuid() + "_" + basename + ext;
+                       Image cover = images.get(coverName);
+                       images.remove(coverName);
+
+                       pg.setProgress(85);
+
+                       // ZIP order is not correct for us
+                       List<String> imagesList = new ArrayList<String>(images.keySet());
+                       Collections.sort(imagesList);
+
+                       pg.setProgress(90);
+
+                       // only the description/cover is kept
+                       Story origStory = getStoryFromTxt(tmpDir, basename);
+                       if (origStory != null) {
+                               if (origStory.getMeta().getCover() == null) {
+                                       origStory.getMeta().setCover(story.getMeta().getCover());
+                               }
+                               story.setMeta(origStory.getMeta());
+                       }
+                       if (story.getMeta().getCover() == null) {
+                               story.getMeta().setCover(cover);
+                       }
+                       story.setChapters(new ArrayList<Chapter>());
+
+                       // Check if we can find non-images chapters, for hybrid-cbz support
+                       if (origStory != null) {
+                               for (Chapter chap : origStory) {
+                                       Boolean isImages = null;
+                                       for (Paragraph para : chap) {
+                                               ParagraphType t = para.getType();
+                                               if (isImages == null && !t.isText(true)) {
+                                                       isImages = true;
+                                               }
+                                               if (t.isText(false)) {
+                                                       String line = para.getContent();
+                                                       // Images are saved in text mode as "[image-link]"
+                                                       if (!(line.startsWith("[") && line.endsWith("]"))) {
+                                                               isImages = false;
+                                                       }
+                                               }
+                                       }
+
+                                       if (isImages != null && !isImages) {
+                                               story.getChapters().add(chap);
+                                               chap.setNumber(story.getChapters().size());
+                                       }
+                               }
+                       }
+
+                       if (!imagesList.isEmpty()) {
+                               Chapter chap = new Chapter(story.getChapters().size() + 1, null);
+                               story.getChapters().add(chap);
+
+                               for (String uuid : imagesList) {
+                                       try {
+                                               chap.getParagraphs().add(
+                                                               new Paragraph(images.get(uuid)));
+                                       } catch (Exception e) {
+                                               Instance.getTraceHandler().error(e);
+                                       }
+                               }
+                       }
+
+                       if (meta.getCover() == null && !images.isEmpty()) {
+                               meta.setCover(images.get(imagesList.get(0)));
+                               meta.setFakeCover(true);
+                       }
+               } finally {
+                       IOUtils.deltree(tmpDir);
+                       if (zipIn != null) {
+                               zipIn.close();
+                       }
+                       if (cbzIn != null) {
+                               cbzIn.close();
+                       }
+               }
+
+               pg.setProgress(100);
+               return story;
+       }
+
+       private Story getStoryFromTxt(File tmpDir, String basename) {
+               Story origStory = null;
+
+               File txt = new File(tmpDir, basename + ".txt");
+               if (!txt.exists()) {
+                       basename = null;
+               }
+               if (basename != null) {
+                       try {
+                               BasicSupport support = BasicSupport.getSupport(txt.toURI()
+                                               .toURL());
+                               origStory = support.process(null);
+                       } catch (Exception e) {
+                               basename = null;
+                       }
+               }
+
+               return origStory;
+
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/E621.java b/src/be/nikiroo/fanfix/supported/E621.java
new file mode 100644 (file)
index 0000000..dfa9e5e
--- /dev/null
@@ -0,0 +1,409 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://e621.net/">e621.net</a> and <a
+ * href="http://e926.net/">e926.net</a>, a Furry website supporting comics,
+ * including some of MLP.
+ * <p>
+ * <a href="http://e926.net/">e926.net</a> only shows the "clean" images and
+ * comics, but it can be difficult to browse.
+ * 
+ * @author niki
+ */
+class E621 extends BasicSupport_Deprecated {
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(source, reset(in)));
+               meta.setDate("");
+               meta.setTags(getTags(source, reset(in), false));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("Furry");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover(source, reset(in)));
+               meta.setFakeCover(true);
+
+               return meta;
+       }
+
+       private List<String> getTags(URL source, InputStream in, boolean authors) {
+               List<String> tags = new ArrayList<String>();
+
+               if (isSearch(source)) {
+                       String tagLine = getLine(in, "id=\"tag-sidebar\"", 1);
+                       if (tagLine != null) {
+                               String key = "href=\"";
+                               for (int pos = tagLine.indexOf(key); pos >= 0; pos = tagLine
+                                               .indexOf(key, pos + 1)) {
+                                       int end = tagLine.indexOf("\"", pos + key.length());
+                                       if (end >= 0) {
+                                               String href = tagLine.substring(pos, end);
+                                               String subkey;
+                                               if (authors)
+                                                       subkey = "?name=";
+                                               else
+                                                       subkey = "?title=";
+                                               if (href.contains(subkey)) {
+                                                       String tag = href.substring(href.indexOf(subkey)
+                                                                       + subkey.length());
+                                                       try {
+                                                               tags.add(URLDecoder.decode(tag, "UTF-8"));
+                                                       } catch (UnsupportedEncodingException e) {
+                                                               // supported JVMs must have UTF-8 support
+                                                               e.printStackTrace();
+                                                       }
+                                               }
+                                       }
+                               }
+
+                       }
+               }
+
+               return tags;
+       }
+
+       @Override
+       public Story process(URL url, Progress pg) throws IOException {
+               // There is no chapters on e621, just pagination...
+               Story story = super.process(url, pg);
+
+               Chapter only = new Chapter(1, null);
+               for (Chapter chap : story) {
+                       only.getParagraphs().addAll(chap.getParagraphs());
+               }
+
+               story.getChapters().clear();
+               story.getChapters().add(only);
+
+               return story;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               String host = url.getHost();
+               if (host.startsWith("www.")) {
+                       host = host.substring("www.".length());
+               }
+
+               return ("e621.net".equals(host) || "e926.net".equals(host))
+                               && (isPool(url) || isSearch(url));
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       private Image getCover(URL source, InputStream in) throws IOException {
+               URL urlForCover = source;
+               if (isPool(source)) {
+                       urlForCover = new URL(source.toString() + "?page=1");
+               }
+
+               String images = getChapterContent(urlForCover, in, 1, null);
+               if (!images.isEmpty()) {
+                       int pos = images.indexOf("<br/>");
+                       if (pos >= 0) {
+                               images = images.substring(1, pos - 1);
+                               return getImage(this, null, images);
+                       }
+               }
+
+               return null;
+       }
+
+       private String getAuthor(URL source, InputStream in) {
+               if (isSearch(source)) {
+                       StringBuilder builder = new StringBuilder();
+                       for (String author : getTags(source, in, true)) {
+                               if (builder.length() > 0)
+                                       builder.append(", ");
+                               builder.append(author);
+                       }
+
+                       return builder.toString();
+               }
+
+               String author = getLine(in, "href=\"/post/show/", 0);
+               if (author != null) {
+                       String key = "href=\"";
+                       int pos = author.indexOf(key);
+                       if (pos >= 0) {
+                               author = author.substring(pos + key.length());
+                               pos = author.indexOf("\"");
+                               if (pos >= 0) {
+                                       author = author.substring(0, pos - 1);
+                                       String page = source.getProtocol() + "://"
+                                                       + source.getHost() + author;
+                                       try {
+                                               InputStream pageIn = Instance.getCache().open(
+                                                               new URL(page), this, false);
+                                               try {
+                                                       key = "class=\"tag-type-artist\"";
+                                                       author = getLine(pageIn, key, 0);
+                                                       if (author != null) {
+                                                               pos = author.indexOf("<a href=\"");
+                                                               if (pos >= 0) {
+                                                                       author = author.substring(pos);
+                                                                       pos = author.indexOf("</a>");
+                                                                       if (pos >= 0) {
+                                                                               author = author.substring(0, pos);
+                                                                               return StringUtils.unhtml(author);
+                                                                       }
+                                                               }
+                                                       }
+                                               } finally {
+                                                       pageIn.close();
+                                               }
+                                       } catch (Exception e) {
+                                               // No author found
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getTitle(InputStream in) {
+               String title = getLine(in, "<title>", 0);
+               if (title != null) {
+                       int pos = title.indexOf('>');
+                       if (pos >= 0) {
+                               title = title.substring(pos + 1);
+                               pos = title.indexOf('<');
+                               if (pos >= 0) {
+                                       title = title.substring(0, pos);
+                               }
+                       }
+
+                       if (title.startsWith("Pool:")) {
+                               title = title.substring("Pool:".length());
+                       }
+
+                       title = StringUtils.unhtml(title).trim();
+               }
+
+               return title;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               String desc = getLine(in, "margin-bottom: 2em;", 0);
+
+               if (desc != null) {
+                       StringBuilder builder = new StringBuilder();
+
+                       boolean inTags = false;
+                       for (char car : desc.toCharArray()) {
+                               if ((inTags && car == '>') || (!inTags && car == '<')) {
+                                       inTags = !inTags;
+                               }
+
+                               if (inTags) {
+                                       builder.append(car);
+                               }
+                       }
+
+                       return builder.toString().trim();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) throws IOException {
+               if (isPool(source)) {
+                       return getChaptersPool(source, in, pg);
+               } else if (isSearch(source)) {
+                       return getChaptersSearch(source, in, pg);
+               }
+
+               return new LinkedList<Entry<String, URL>>();
+       }
+
+       private List<Entry<String, URL>> getChaptersSearch(URL source,
+                       InputStream in, Progress pg) throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               String search = source.getPath();
+               if (search.endsWith("/")) {
+                       search = search.substring(0, search.length() - 1);
+               }
+
+               int pos = search.lastIndexOf('/');
+               if (pos >= 0) {
+                       search = search.substring(pos + 1);
+               }
+
+               String baseUrl = "https://e621.net/post/index/";
+               if (source.getHost().contains("e926")) {
+                       baseUrl = baseUrl.replace("e621", "e926");
+               }
+
+               for (int i = 1; true; i++) {
+                       URL url = new URL(baseUrl + i + "/" + search + "/");
+                       try {
+                               InputStream pageI = Instance.getCache().open(url, this, false);
+                               try {
+                                       if (getLine(pageI, "No posts matched your search.", 0) != null)
+                                               break;
+                                       urls.add(new AbstractMap.SimpleEntry<String, URL>("Page "
+                                                       + Integer.toString(i), url));
+                               } finally {
+                                       pageI.close();
+                               }
+                       } catch (Exception e) {
+                               break;
+                       }
+               }
+
+               // They are sorted in reverse order on the website
+               Collections.reverse(urls);
+               return urls;
+       }
+
+       private List<Entry<String, URL>> getChaptersPool(URL source,
+                       InputStream in, Progress pg) throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               int last = 1; // no pool/show when only one page
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       for (int pos = line.indexOf(source.getPath()); pos >= 0; pos = line
+                                       .indexOf(source.getPath(), pos + source.getPath().length())) {
+                               int equalPos = line.indexOf("=", pos);
+                               int quotePos = line.indexOf("\"", pos);
+                               if (equalPos >= 0 && quotePos > equalPos) {
+                                       String snum = line.substring(equalPos + 1, quotePos);
+                                       try {
+                                               int num = Integer.parseInt(snum);
+                                               if (num > last) {
+                                                       last = num;
+                                               }
+                                       } catch (NumberFormatException e) {
+                                       }
+                               }
+                       }
+               }
+
+               for (int i = 1; i <= last; i++) {
+                       urls.add(new AbstractMap.SimpleEntry<String, URL>(Integer
+                                       .toString(i), new URL(source.toString() + "?page=" + i)));
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) throws IOException {
+               StringBuilder builder = new StringBuilder();
+               String staticSite = "https://static1.e621.net";
+               if (source.getHost().contains("e926")) {
+                       staticSite = staticSite.replace("e621", "e926");
+               }
+
+               String key = staticSite + "/data/preview/";
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("class=\"preview")) {
+                               for (int pos = line.indexOf(key); pos >= 0; pos = line.indexOf(
+                                               key, pos + key.length())) {
+                                       int endPos = line.indexOf("\"", pos);
+                                       if (endPos >= 0) {
+                                               String id = line.substring(pos + key.length(), endPos);
+                                               id = staticSite + "/data/" + id;
+
+                                               int dotPos = id.lastIndexOf(".");
+                                               if (dotPos >= 0) {
+                                                       id = id.substring(0, dotPos);
+                                                       builder.append("[");
+                                                       builder.append(id);
+                                                       builder.append("]<br/>");
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected URL getCanonicalUrl(URL source) {
+               if (isSearch(source)) {
+                       // /post?tags=tag1+tag2 -> ../post/index/1/tag1%32tag2
+                       String key = "?tags=";
+                       if (source.toString().contains(key)) {
+                               int pos = source.toString().indexOf(key);
+                               String tags = source.toString().substring(pos + key.length());
+                               tags = tags.replace("+", "%20");
+
+                               String base = source.toString().substring(0, pos);
+                               if (!base.endsWith("/")) {
+                                       base += "/";
+                               }
+                               if (base.endsWith("/search/")) {
+                                       base = base.substring(0, base.indexOf("/search/") + 1);
+                               }
+
+                               try {
+                                       return new URL(base + "index/1/" + tags);
+                               } catch (MalformedURLException e) {
+                                       Instance.getTraceHandler().error(e);
+                               }
+                       }
+               }
+
+               return super.getCanonicalUrl(source);
+       }
+
+       private boolean isPool(URL url) {
+               return url.getPath().startsWith("/pool/");
+       }
+
+       private boolean isSearch(URL url) {
+               return url.getPath().startsWith("/post/index/")
+                               || (url.getPath().equals("/post/search") && url.getQuery()
+                                               .startsWith("tags="));
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/EHentai.java b/src/be/nikiroo/fanfix/supported/EHentai.java
new file mode 100644 (file)
index 0000000..67585cd
--- /dev/null
@@ -0,0 +1,294 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="https://e-hentai.org/">e-hentai.org</a>, a website
+ * supporting mostly but not always NSFW comics, including some of MLP.
+ * 
+ * @author niki
+ */
+class EHentai extends BasicSupport_Deprecated {
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate(getDate(reset(in)));
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang(getLang(reset(in)));
+               meta.setSubject("Hentai");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover(source, reset(in)));
+               meta.setFakeCover(true);
+
+               return meta;
+       }
+
+       @Override
+       public Story process(URL url, Progress pg) throws IOException {
+               // There is no chapters on e621, just pagination...
+               Story story = super.process(url, pg);
+
+               Chapter only = new Chapter(1, null);
+               for (Chapter chap : story) {
+                       only.getParagraphs().addAll(chap.getParagraphs());
+               }
+
+               story.getChapters().clear();
+               story.getChapters().add(only);
+
+               return story;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "e-hentai.org".equals(url.getHost());
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       public Map<String, String> getCookies() {
+               Map<String, String> cookies = super.getCookies();
+               cookies.put("nw", "1");
+               return cookies;
+       }
+
+       private Image getCover(URL source, InputStream in) {
+               Image author = null;
+               String coverLine = getKeyLine(in, "<div id=\"gd1\"", " url(", ")");
+               if (coverLine != null) {
+                       coverLine = StringUtils.unhtml(coverLine).trim();
+                       author = getImage(this, source, coverLine);
+               }
+
+               return author;
+       }
+
+       private String getAuthor(InputStream in) {
+               String author = null;
+
+               List<String> tagsAuthor = getTagsAuthor(in);
+               if (!tagsAuthor.isEmpty()) {
+                       author = tagsAuthor.get(0);
+               }
+
+               return author;
+       }
+
+       private String getLang(InputStream in) {
+               String lang = null;
+
+               String langLine = getKeyLine(in, "class=\"gdt1\">Language",
+                               "class=\"gdt2\"", "</td>");
+               if (langLine != null) {
+                       langLine = StringUtils.unhtml(langLine).trim();
+                       if (langLine.equalsIgnoreCase("English")) {
+                               lang = "en";
+                       } else if (langLine.equalsIgnoreCase("Japanese")) {
+                               lang = "jp";
+                       } else if (langLine.equalsIgnoreCase("French")) {
+                               lang = "fr";
+                       } else {
+                               // TODO find the code?
+                               lang = langLine;
+                       }
+               }
+
+               return lang;
+       }
+
+       private String getDate(InputStream in) {
+               String date = null;
+
+               String dateLine = getKeyLine(in, "class=\"gdt1\">Posted",
+                               "class=\"gdt2\"", "</td>");
+               if (dateLine != null) {
+                       dateLine = StringUtils.unhtml(dateLine).trim();
+                       if (dateLine.length() > 10) {
+                               dateLine = dateLine.substring(0, 10).trim();
+                       }
+
+                       date = dateLine;
+               }
+
+               return date;
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+               List<String> tagsAuthor = getTagsAuthor(in);
+
+               for (int i = 1; i < tagsAuthor.size(); i++) {
+                       tags.add(tagsAuthor.get(i));
+               }
+
+               return tags;
+       }
+
+       private List<String> getTagsAuthor(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+               String tagLine = getKeyLine(in, "<meta name=\"description\"", "Tags: ",
+                               null);
+               if (tagLine != null) {
+                       for (String tag : tagLine.split(",")) {
+                               String candi = tag.trim();
+                               if (!candi.isEmpty() && !tags.contains(candi)) {
+                                       tags.add(candi);
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       private String getTitle(InputStream in) {
+               String siteName = " - E-Hentai Galleries";
+
+               String title = getLine(in, "<title>", 0);
+               if (title != null) {
+                       title = StringUtils.unhtml(title).trim();
+                       if (title.endsWith(siteName)) {
+                               title = title.substring(0, title.length() - siteName.length())
+                                               .trim();
+                       }
+               }
+
+               return title;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               String desc = null;
+
+               String descLine = getKeyLine(in, "Uploader Comment", null,
+                               "<div class=\"c7\"");
+               if (descLine != null) {
+                       desc = StringUtils.unhtml(descLine);
+               }
+
+               return desc;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               int last = 0; // no pool/show when only one page, first page == page 0
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter(">");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains(source.toString())) {
+                               String page = line.substring(line.indexOf(source.toString()));
+                               String pkey = "?p=";
+                               if (page.contains(pkey)) {
+                                       page = page.substring(page.indexOf(pkey) + pkey.length());
+                                       String number = "";
+                                       while (!page.isEmpty() && page.charAt(0) >= '0'
+                                                       && page.charAt(0) <= '9') {
+                                               number += page.charAt(0);
+                                               page = page.substring(1);
+                                       }
+                                       if (number.isEmpty()) {
+                                               number = "0";
+                                       }
+
+                                       int current = Integer.parseInt(number);
+                                       if (last < current) {
+                                               last = current;
+                                       }
+                               }
+                       }
+               }
+
+               for (int i = 0; i <= last; i++) {
+                       urls.add(new AbstractMap.SimpleEntry<String, URL>(Integer
+                                       .toString(i + 1), new URL(source.toString() + "?p=" + i)));
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) throws IOException {
+               String staticSite = "https://e-hentai.org/s/";
+               List<URL> pages = new ArrayList<URL>();
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\"");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.startsWith(staticSite)) {
+                               try {
+                                       pages.add(new URL(line));
+                               } catch (MalformedURLException e) {
+                                       Instance.getTraceHandler().error(
+                                                       new IOException(
+                                                                       "Parsing error, a link is not correctly parsed: "
+                                                                                       + line, e));
+                               }
+                       }
+               }
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+               pg.setMinMax(0, pages.size());
+               pg.setProgress(0);
+
+               StringBuilder builder = new StringBuilder();
+
+               for (URL page : pages) {
+                       InputStream pageIn = Instance.getCache().open(page, this, false);
+                       try {
+                               String link = getKeyLine(pageIn, "id=\"img\"", "src=\"", "\"");
+                               if (link != null && !link.isEmpty()) {
+                                       builder.append("[");
+                                       builder.append(link);
+                                       builder.append("]<br/>");
+                               }
+                               pg.add(1);
+                       } finally {
+                               if (pageIn != null) {
+                                       pageIn.close();
+                               }
+                       }
+               }
+
+               pg.done();
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Epub.java b/src/be/nikiroo/fanfix/supported/Epub.java
new file mode 100644 (file)
index 0000000..82af118
--- /dev/null
@@ -0,0 +1,249 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import org.jsoup.nodes.Document;
+
+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.StringUtils;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for EPUB files created with this program (as we need some
+ * metadata available in those we create).
+ * 
+ * @author niki
+ */
+class Epub extends InfoText {
+       private MetaData meta;
+       private File tmpDir;
+       private String desc;
+
+       private URL fakeSource;
+       private InputStream fakeIn;
+
+       public File getSourceFileOriginal() {
+               return super.getSourceFile();
+       }
+
+       @Override
+       protected File getSourceFile() {
+               try {
+                       return new File(fakeSource.toURI());
+               } catch (URISyntaxException e) {
+                       Instance.getTraceHandler()
+                                       .error(new IOException(
+                                                       "Cannot get the source file from the info-text URL",
+                                                       e));
+               }
+
+               return null;
+       }
+
+       @Override
+       protected InputStream getInput() {
+               if (fakeIn != null) {
+                       try {
+                               fakeIn.reset();
+                       } catch (IOException e) {
+                               Instance.getTraceHandler()
+                                               .error(new IOException(
+                                                               "Cannot reset the Epub Text stream", e));
+                       }
+
+                       return fakeIn;
+               }
+
+               return null;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return url.getPath().toLowerCase().endsWith(".epub");
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               return meta;
+       }
+
+       @Override
+       protected Document loadDocument(URL source) throws IOException {
+               super.loadDocument(source); // prepares super.getSourceFile() and
+                                                                       // super.getInput()
+
+               InputStream in = super.getInput();
+               ZipInputStream zipIn = null;
+               try {
+                       zipIn = new ZipInputStream(in);
+                       tmpDir = Instance.getTempFiles().createTempDir(
+                                       "fanfic-reader-parser");
+                       File tmp = new File(tmpDir, "file.txt");
+                       File tmpInfo = new File(tmpDir, "file.info");
+
+                       fakeSource = tmp.toURI().toURL();
+                       Image cover = null;
+
+                       String url;
+                       try {
+                               url = getSource().toURI().toURL().toString();
+                       } catch (URISyntaxException e1) {
+                               url = getSource().toString();
+                       }
+                       String title = null;
+                       String author = null;
+
+                       for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
+                                       .getNextEntry()) {
+                               if (!entry.isDirectory()
+                                               && entry.getName().startsWith(getDataPrefix())) {
+                                       String entryLName = entry.getName().toLowerCase();
+
+                                       boolean imageEntry = false;
+                                       for (String ext : bsImages.getImageExt(false)) {
+                                               if (entryLName.endsWith(ext)) {
+                                                       imageEntry = true;
+                                               }
+                                       }
+
+                                       if (entry.getName().equals(getDataPrefix() + "version")) {
+                                               // Nothing to do for now ("first"
+                                               // version is 3.0)
+                                       } else if (entryLName.endsWith(".info")) {
+                                               // Info file
+                                               IOUtils.write(zipIn, tmpInfo);
+                                       } else if (imageEntry) {
+                                               // Cover
+                                               if (getCover()) {
+                                                       try {
+                                                               cover = new Image(zipIn);
+                                                       } catch (Exception e) {
+                                                               Instance.getTraceHandler().error(e);
+                                                       }
+                                               }
+                                       } else if (entry.getName().equals(getDataPrefix() + "URL")) {
+                                               String[] descArray = StringUtils
+                                                               .unhtml(IOUtils.readSmallStream(zipIn)).trim()
+                                                               .split("\n");
+                                               if (descArray.length > 0) {
+                                                       url = descArray[0].trim();
+                                               }
+                                       } else if (entry.getName().equals(
+                                                       getDataPrefix() + "SUMMARY")) {
+                                               String[] descArray = StringUtils
+                                                               .unhtml(IOUtils.readSmallStream(zipIn)).trim()
+                                                               .split("\n");
+                                               int skip = 0;
+                                               if (descArray.length > 1) {
+                                                       title = descArray[0].trim();
+                                                       skip = 1;
+                                                       if (descArray.length > 2
+                                                                       && descArray[1].startsWith("©")) {
+                                                               author = descArray[1].substring(1).trim();
+                                                               skip = 2;
+                                                       }
+                                               }
+                                               this.desc = "";
+                                               for (int i = skip; i < descArray.length; i++) {
+                                                       this.desc += descArray[i].trim() + "\n";
+                                               }
+
+                                               this.desc = this.desc.trim();
+                                       } else {
+                                               // Hopefully the data file
+                                               IOUtils.write(zipIn, tmp);
+                                       }
+                               }
+                       }
+
+                       if (requireInfo() && (!tmp.exists() || !tmpInfo.exists())) {
+                               throw new IOException(
+                                               "file not supported (maybe not created with this program or corrupt)");
+                       }
+
+                       if (tmp.exists()) {
+                               this.fakeIn = new MarkableFileInputStream(tmp);
+                       }
+
+                       if (tmpInfo.exists()) {
+                               meta = InfoReader.readMeta(tmpInfo, true);
+                               tmpInfo.delete();
+                       } else {
+                               if (title == null || title.isEmpty()) {
+                                       title = getSourceFileOriginal().getName();
+                                       if (title.toLowerCase().endsWith(".cbz")) {
+                                               title = title.substring(0, title.length() - 4);
+                                       }
+                                       title = URLDecoder.decode(title, "UTF-8").trim();
+                               }
+
+                               meta = new MetaData();
+                               meta.setLang("en");
+                               meta.setTags(new ArrayList<String>());
+                               meta.setSource(getType().getSourceName());
+                               meta.setUuid(url);
+                               meta.setUrl(url);
+                               meta.setTitle(title);
+                               meta.setAuthor(author);
+                               meta.setImageDocument(isImagesDocumentByDefault());
+                       }
+
+                       if (meta.getCover() == null) {
+                               if (cover != null) {
+                                       meta.setCover(cover);
+                               } else {
+                                       meta.setCover(InfoReader
+                                                       .getCoverByName(getSourceFileOriginal().toURI()
+                                                                       .toURL()));
+                               }
+                       }
+               } finally {
+                       if (zipIn != null) {
+                               zipIn.close();
+                       }
+                       if (in != null) {
+                               in.close();
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected void close() {
+               if (tmpDir != null) {
+                       IOUtils.deltree(tmpDir);
+               }
+
+               tmpDir = null;
+
+               super.close();
+       }
+
+       protected String getDataPrefix() {
+               return "DATA/";
+       }
+
+       protected boolean requireInfo() {
+               return true;
+       }
+
+       protected boolean getCover() {
+               return true;
+       }
+
+       protected boolean isImagesDocumentByDefault() {
+               return false;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Fanfiction.java b/src/be/nikiroo/fanfix/supported/Fanfiction.java
new file mode 100644 (file)
index 0000000..0dcd790
--- /dev/null
@@ -0,0 +1,337 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.SimpleDateFormat;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://www.fanfiction.net/">Faniction.net</a>
+ * stories, a website dedicated to fanfictions of many, many different
+ * universes, from TV shows to novels to games.
+ * 
+ * @author niki
+ */
+class Fanfiction extends BasicSupport_Deprecated {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate(getDate(reset(in)));
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en"); // TODO!
+               meta.setSubject(getSubject(reset(in)));
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(source, reset(in)));
+
+               return meta;
+       }
+
+       private String getSubject(InputStream in) {
+               String line = getLine(in, "id=pre_story_links", 0);
+               if (line != null) {
+                       int pos = line.lastIndexOf('"');
+                       if (pos >= 1) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('<');
+                               if (pos >= 0) {
+                                       return StringUtils.unhtml(line.substring(0, pos)).trim();
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+
+               String key = "title=\"Send Private Message\"";
+               String line = getLine(in, key, 2);
+               if (line != null) {
+                       key = "Rated:";
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               key = "Chapters:";
+                               pos = line.indexOf(key);
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos);
+                                       line = StringUtils.unhtml(line).trim();
+                                       if (line.endsWith("-")) {
+                                               line = line.substring(0, line.length() - 1);
+                                       }
+
+                                       for (String tag : line.split("-")) {
+                                               tags.add(StringUtils.unhtml(tag).trim());
+                                       }
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       private String getTitle(InputStream in) {
+               int i = 0;
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("xcontrast_txt")) {
+                               if ((++i) == 2) {
+                                       line = StringUtils.unhtml(line).trim();
+                                       if (line.startsWith("Follow/Fav")) {
+                                               line = line.substring("Follow/Fav".length()).trim();
+                                       }
+
+                                       return StringUtils.unhtml(line).trim();
+                               }
+                       }
+               }
+
+               return "";
+       }
+
+       private String getAuthor(InputStream in) {
+               String author = null;
+
+               int i = 0;
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("xcontrast_txt")) {
+                               if ((++i) == 3) {
+                                       author = StringUtils.unhtml(line).trim();
+                                       break;
+                               }
+                       }
+               }
+
+               return bsHelper.fixAuthor(author);
+       }
+
+       private String getDate(InputStream in) {
+               String key = "Published: <span data-xutime='";
+               String line = getLine(in, key, 0);
+               if (line != null) {
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               pos = line.indexOf('\'');
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos).trim();
+                                       try {
+                                               SimpleDateFormat sdf = new SimpleDateFormat(
+                                                               "YYYY-MM-dd");
+                                               return sdf
+                                                               .format(new Date(1000 * Long.parseLong(line)));
+                                       } catch (NumberFormatException e) {
+                                               Instance.getTraceHandler().error(
+                                                               new IOException(
+                                                                               "Cannot convert publication date: "
+                                                                                               + line, e));
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) {
+               return getLine(in, "title=\"Send Private Message\"", 1);
+       }
+
+       private Image getCover(URL url, InputStream in) {
+               String key = "class='cimage";
+               String line = getLine(in, key, 0);
+               if (line != null) {
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               key = "src='";
+                               pos = line.indexOf(key);
+                               if (pos >= 0) {
+                                       line = line.substring(pos + key.length());
+                                       pos = line.indexOf('\'');
+                                       if (pos >= 0) {
+                                               line = line.substring(0, pos);
+                                               if (line.startsWith("//")) {
+                                                       line = url.getProtocol() + "://"
+                                                                       + line.substring(2);
+                                               } else if (line.startsWith("//")) {
+                                                       line = url.getProtocol() + "://" + url.getHost()
+                                                                       + "/" + line.substring(1);
+                                               } else {
+                                                       line = url.getProtocol() + "://" + url.getHost()
+                                                                       + "/" + url.getPath() + "/" + line;
+                                               }
+
+                                               return getImage(this, null, line);
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               String base = source.toString();
+               int pos = base.lastIndexOf('/');
+               String suffix = base.substring(pos); // including '/' at start
+               base = base.substring(0, pos);
+               if (base.endsWith("/1")) {
+                       base = base.substring(0, base.length() - 1); // including '/' at end
+               }
+
+               String line = getLine(in, "id=chap_select", 0);
+               String key = "<option  value=";
+               int i = 1;
+
+               if (line != null) {
+                       for (pos = line.indexOf(key); pos >= 0; pos = line
+                                       .indexOf(key, pos), i++) {
+                               pos = line.indexOf('>', pos);
+                               if (pos >= 0) {
+                                       int endOfName = line.indexOf('<', pos);
+                                       if (endOfName >= 0) {
+                                               String name = line.substring(pos + 1, endOfName);
+                                               String chapNum = i + ".";
+                                               if (name.startsWith(chapNum)) {
+                                                       name = name.substring(chapNum.length(),
+                                                                       name.length());
+                                               }
+
+                                               try {
+                                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(
+                                                                       name.trim(), new URL(base + i + suffix)));
+                                               } catch (MalformedURLException e) {
+                                                       Instance.getTraceHandler()
+                                                                       .error(new IOException(
+                                                                                       "Cannot parse chapter " + i
+                                                                                                       + " url: "
+                                                                                                       + (base + i + suffix), e));
+                                               }
+                                       }
+                               }
+                       }
+               } else {
+                       // only one chapter:
+                       final String chapName = getTitle(reset(in));
+                       final URL chapURL = source;
+                       urls.add(new Entry<String, URL>() {
+                               @Override
+                               public URL setValue(URL value) {
+                                       return null;
+                               }
+
+                               @Override
+                               public URL getValue() {
+                                       return chapURL;
+                               }
+
+                               @Override
+                               public String getKey() {
+                                       return chapName;
+                               }
+                       });
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) {
+               StringBuilder builder = new StringBuilder();
+               String startAt = "class='storytext ";
+               String endAt1 = "function review_init";
+               String endAt2 = "id=chap_select";
+               boolean ok = false;
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (!ok && line.contains(startAt)) {
+                               ok = true;
+                       } else if (ok && (line.contains(endAt1) || line.contains(endAt2))) {
+                               ok = false;
+                               break;
+                       }
+
+                       if (ok) {
+                               // First line may contain the title and chap name again
+                               if (builder.length() == 0) {
+                                       int pos = line.indexOf("<hr");
+                                       if (pos >= 0) {
+                                               boolean chaptered = false;
+                                               for (String lang : Instance.getConfig().getList(
+                                                               Config.CONF_CHAPTER)) {
+                                                       String chapterWord = Instance.getConfig()
+                                                                       .getStringX(Config.CONF_CHAPTER, lang);
+                                                       int posChap = line.indexOf(chapterWord + " ");
+                                                       if (posChap < pos) {
+                                                               chaptered = true;
+                                                               break;
+                                                       }
+                                               }
+
+                                               if (chaptered) {
+                                                       line = line.substring(pos);
+                                               }
+                                       }
+                               }
+
+                               builder.append(line);
+                               builder.append(' ');
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "fanfiction.net".equals(url.getHost())
+                               || "www.fanfiction.net".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Fimfiction.java b/src/be/nikiroo/fanfix/supported/Fimfiction.java
new file mode 100644 (file)
index 0000000..e96ac4f
--- /dev/null
@@ -0,0 +1,253 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a>
+ * stories, a website dedicated to My Little Pony.
+ * 
+ * @author niki
+ */
+class Fimfiction extends BasicSupport_Deprecated {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate(getDate(reset(in)));
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("MLP");
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(reset(in)));
+
+               return meta;
+       }
+
+       @Override
+       public Map<String, String> getCookies() {
+               Map<String, String> cookies = new HashMap<String, String>();
+               cookies.put("view_mature", "true");
+               return cookies;
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+               tags.add("MLP");
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               boolean started = false;
+               while (scan.hasNext()) {
+                       String line = scan.next();
+
+                       if (!started) {
+                               started = line.contains("\"story_container\"");
+                       }
+
+                       if (started && line.contains("class=\"tag-")) {
+                               if (line.contains("index.php")) {
+                                       break; // end of *this story* tags
+                               }
+
+                               String keyword = "title=\"";
+                               Scanner tagScanner = new Scanner(line);
+                               tagScanner.useDelimiter(keyword);
+                               if (tagScanner.hasNext()) {
+                                       tagScanner.next();// Ignore first one
+                               }
+                               while (tagScanner.hasNext()) {
+                                       String tag = tagScanner.next();
+                                       if (tag.contains("\"")) {
+                                               tag = tag.split("\"")[0];
+                                               tag = StringUtils.unhtml(tag).trim();
+                                               if (!tag.isEmpty() && !tags.contains(tag)) {
+                                                       tags.add(tag);
+                                               }
+                                       }
+                               }
+                               tagScanner.close();
+                       }
+               }
+
+               return tags;
+       }
+
+       private String getTitle(InputStream in) {
+               String line = getLine(in, " property=\"og:title\"", 0);
+               if (line != null) {
+                       int pos = -1;
+                       for (int i = 0; i < 3; i++) {
+                               pos = line.indexOf('"', pos + 1);
+                       }
+
+                       if (pos >= 0) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('"');
+                               if (pos >= 0) {
+                                       return StringUtils.unhtml(line.substring(0, pos)).trim();
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getAuthor(InputStream in) {
+               String line = getLine(in, " href=\"/user/", 0);
+               if (line != null) {
+                       int pos = line.indexOf('"');
+                       if (pos >= 0) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('"');
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos);
+                                       pos = line.lastIndexOf('/');
+                                       if (pos >= 0) {
+                                               line = line.substring(pos + 1);
+                                               return line.replace('+', ' ');
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getDate(InputStream in) {
+               String line = getLine(in, "<span class=\"date\">", 0);
+               if (line != null) {
+                       int pos = -1;
+                       for (int i = 0; i < 3; i++) {
+                               pos = line.indexOf('>', pos + 1);
+                       }
+
+                       if (pos >= 0) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('<');
+                               if (pos >= 0) {
+                                       return line.substring(0, pos).trim();
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) {
+               // the og: meta version is the SHORT resume, this is the LONG resume
+               return getLine(in, "class=\"description-text bbcode\"", 1);
+       }
+
+       private Image getCover(InputStream in) {
+               // Note: the 'og:image' is the SMALL cover, not the full version
+               String cover = getLine(in, "class=\"story_container__story_image\"", 1);
+               if (cover != null) {
+                       int pos = cover.indexOf('"');
+                       if (pos >= 0) {
+                               cover = cover.substring(pos + 1);
+                               pos = cover.indexOf('"');
+                               if (pos >= 0) {
+                                       cover = cover.substring(0, pos);
+                               }
+                       }
+               }
+
+               return getImage(this, null, cover);
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               boolean started = false;
+               while (scan.hasNext()) {
+                       String line = scan.next().trim();
+
+                       if (!started) {
+                               started = line.equals("<!--Chapters-->");
+                       } else {
+                               if (line.equals("</form>")) {
+                                       break;
+                               }
+
+                               if (line.startsWith("<a href=")
+                                               || line.contains("class=\"chapter-title\"")) {
+                                       // Chapter name
+                                       String name = line;
+                                       int pos = name.indexOf('>');
+                                       if (pos >= 0) {
+                                               name = name.substring(pos + 1);
+                                               pos = name.indexOf('<');
+                                               if (pos >= 0) {
+                                                       name = name.substring(0, pos);
+                                               }
+                                       }
+                                       // Chapter content
+                                       pos = line.indexOf('/');
+                                       if (pos >= 0) {
+                                               line = line.substring(pos); // we take the /, not +1
+                                               pos = line.indexOf('"');
+                                               if (pos >= 0) {
+                                                       line = line.substring(0, pos);
+                                               }
+                                       }
+
+                                       try {
+                                               urls.add(new AbstractMap.SimpleEntry<String, URL>(name,
+                                                               new URL("http://www.fimfiction.net" + line)));
+                                       } catch (MalformedURLException e) {
+                                               Instance.getTraceHandler().error(e);
+                                       }
+                               }
+                       }
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) {
+               return getLine(in, "<div class=\"bbcode\">", 1);
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "fimfiction.net".equals(url.getHost())
+                               || "www.fimfiction.net".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/FimfictionApi.java b/src/be/nikiroo/fanfix/supported/FimfictionApi.java
new file mode 100644 (file)
index 0000000..c97ecf7
--- /dev/null
@@ -0,0 +1,425 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a>
+ * stories, a website dedicated to My Little Pony.
+ * <p>
+ * This version uses the new, official API of FimFiction.
+ * 
+ * @author niki
+ */
+class FimfictionApi extends BasicSupport {
+       private String oauth;
+       private String json;
+
+       private Map<Integer, String> chapterNames;
+       private Map<Integer, String> chapterContents;
+
+       public FimfictionApi() throws IOException {
+               if (Instance.getConfig().getBoolean(
+                               Config.LOGIN_FIMFICTION_APIKEY_FORCE_HTML, false)) {
+                       throw new IOException(
+                                       "Configuration is set to force HTML scrapping");
+               }
+
+               String oauth = Instance.getConfig().getString(
+                               Config.LOGIN_FIMFICTION_APIKEY_TOKEN);
+
+               if (oauth == null || oauth.isEmpty()) {
+                       String clientId = Instance.getConfig().getString(
+                                       Config.LOGIN_FIMFICTION_APIKEY_CLIENT_ID)
+                                       + "";
+                       String clientSecret = Instance.getConfig().getString(
+                                       Config.LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET)
+                                       + "";
+
+                       if (clientId.trim().isEmpty() || clientSecret.trim().isEmpty()) {
+                               throw new IOException("API key required for the beta API v2");
+                       }
+
+                       oauth = generateOAuth(clientId, clientSecret);
+
+                       Instance.getConfig().setString(
+                                       Config.LOGIN_FIMFICTION_APIKEY_TOKEN, oauth);
+                       Instance.getConfig().updateFile();
+               }
+
+               this.oauth = oauth;
+       }
+
+       @Override
+       protected Document loadDocument(URL source) throws IOException {
+               json = getJsonData();
+               return null;
+       }
+
+       @Override
+       public String getOAuth() {
+               return oauth;
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       /**
+        * Extract the full JSON data we will later use to build the {@link Story}.
+        * 
+        * @return the data in a JSON format
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private String getJsonData() throws IOException {
+               // extract the ID from:
+               // https://www.fimfiction.net/story/123456/name-of-story
+               String storyId = getKeyText(getSource().toString(), "/story/", null,
+                               "/");
+
+               // Selectors, so to download all I need and only what I need
+               String storyContent = "fields[story]=title,description,date_published,cover_image";
+               String authorContent = "fields[author]=name";
+               String chapterContent = "fields[chapter]=chapter_number,title,content_html,authors_note_html";
+               String includes = "author,chapters,tags";
+
+               String urlString = String.format(
+                               "https://www.fimfiction.net/api/v2/stories/%s?" //
+                                               + "%s&%s&%s&" //
+                                               + "include=%s", //
+                               storyId, //
+                               storyContent, authorContent, chapterContent,//
+                               includes);
+
+               // URL params must be URL-encoded: "[ ]" <-> "%5B %5D"
+               urlString = urlString.replace("[", "%5B").replace("]", "%5D");
+
+               URL url = new URL(urlString);
+               InputStream jsonIn = Instance.getCache().open(url, this, false);
+               try {
+                       return IOUtils.readSmallStream(jsonIn);
+               } finally {
+                       jsonIn.close();
+               }
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getKeyJson(json, 0, "type", "story", "title"));
+               meta.setAuthor(getKeyJson(json, 0, "type", "user", "name"));
+               meta.setDate(getKeyJson(json, 0, "type", "story", "date_published"));
+               meta.setTags(getTags());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("MLP");
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+
+               String coverImageLink = getKeyJson(json, 0, "type", "story",
+                               "cover_image", "full");
+               if (!coverImageLink.trim().isEmpty()) {
+                       URL coverImageUrl = new URL(coverImageLink.trim());
+
+                       // No need to use the oauth, cookies... for the cover
+                       // Plus: it crashes on Android because of the referer
+                       try {
+                               InputStream in = Instance.getCache().open(coverImageUrl, null,
+                                               true);
+                               try {
+                                       meta.setCover(new Image(in));
+                               } finally {
+                                       in.close();
+                               }
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(
+                                               new IOException(
+                                                               "Cannot get the story cover, ignoring...", e));
+                       }
+               }
+
+               return meta;
+       }
+
+       private List<String> getTags() {
+               List<String> tags = new ArrayList<String>();
+               tags.add("MLP");
+
+               int pos = 0;
+               while (pos >= 0) {
+                       pos = indexOfJsonAfter(json, pos, "type", "story_tag");
+                       if (pos >= 0) {
+                               tags.add(getKeyJson(json, pos, "name").trim());
+                       }
+               }
+
+               return tags;
+       }
+
+       @Override
+       protected String getDesc() {
+               String desc = getKeyJson(json, 0, "type", "story", "description");
+               return unbbcode(desc);
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg) {
+               chapterNames = new TreeMap<Integer, String>();
+               chapterContents = new TreeMap<Integer, String>();
+
+               int pos = 0;
+               while (pos >= 0) {
+                       pos = indexOfJsonAfter(json, pos, "type", "chapter");
+                       if (pos >= 0) {
+                               int posNumber = indexOfJsonAfter(json, pos, "chapter_number");
+                               int posComa = json.indexOf(",", posNumber);
+                               final int number = Integer.parseInt(json.substring(posNumber,
+                                               posComa).trim());
+                               final String title = getKeyJson(json, pos, "title");
+                               String notes = getKeyJson(json, pos, "authors_note_html");
+                               String content = getKeyJson(json, pos, "content_html");
+
+                               if (!notes.trim().isEmpty()) {
+                                       notes = "<br/>* * *<br/>" + notes;
+                               }
+
+                               chapterNames.put(number, title);
+                               chapterContents.put(number, content + notes);
+                       }
+               }
+
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               for (String title : chapterNames.values()) {
+                       urls.add(new AbstractMap.SimpleEntry<String, URL>(title, null));
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, int number, Progress pg) {
+               return chapterContents.get(number);
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "fimfiction.net".equals(url.getHost())
+                               || "www.fimfiction.net".equals(url.getHost());
+       }
+
+       /**
+        * Generate a new token from the client ID and secret.
+        * <p>
+        * Note that those tokens are long-lived, and it would be badly seen to
+        * create a lot of them without due cause.
+        * <p>
+        * So, please cache and re-use them.
+        * 
+        * @param clientId
+        *            the client ID offered on FimFiction
+        * @param clientSecret
+        *            the client secret that goes with it
+        * 
+        * @return a new generated token linked to that client ID
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       static private String generateOAuth(String clientId, String clientSecret)
+                       throws IOException {
+               URL url = new URL("https://www.fimfiction.net/api/v2/token");
+               Map<String, String> params = new HashMap<String, String>();
+               params.put("client_id", clientId);
+               params.put("client_secret", clientSecret);
+               params.put("grant_type", "client_credentials");
+               InputStream in = Instance.getCache().openNoCache(url, null, params,
+                               null, null);
+
+               String jsonToken = IOUtils.readSmallStream(in);
+               in.close();
+
+               // Extract token type and token from: {
+               // token_type = "Bearer",
+               // access_token = "xxxxxxxxxxxxxx"
+               // }
+
+               String tokenType = getKeyText(jsonToken, "\"token_type\"", "\"", "\"");
+               String token = getKeyText(jsonToken, "\"access_token\"", "\"", "\"");
+
+               return tokenType + " " + token;
+       }
+
+       // afters: [name, value] pairs (or "" for any of them), can end without
+       // value
+       static private int indexOfJsonAfter(String json, int startAt,
+                       String... afterKeys) {
+               ArrayList<String> afters = new ArrayList<String>();
+               boolean name = true;
+               for (String key : afterKeys) {
+                       if (key != null && !key.isEmpty()) {
+                               afters.add("\"" + key + "\"");
+                       } else {
+                               afters.add("\"");
+                               afters.add("\"");
+                       }
+
+                       if (name) {
+                               afters.add(":");
+                       }
+
+                       name = !name;
+               }
+
+               return indexOfAfter(json, startAt, afters.toArray(new String[] {}));
+       }
+
+       // afters: [name, value] pairs (or "" for any of them), can end without
+       // value but will then be empty, not NULL
+       static private String getKeyJson(String json, int startAt,
+                       String... afterKeys) {
+               int pos = indexOfJsonAfter(json, startAt, afterKeys);
+               if (pos < 0) {
+                       return "";
+               }
+
+               String result = "";
+               String wip = json.substring(pos);
+
+               pos = nextUnescapedQuote(wip, 0);
+               if (pos >= 0) {
+                       wip = wip.substring(pos + 1);
+                       pos = nextUnescapedQuote(wip, 0);
+                       if (pos >= 0) {
+                               result = wip.substring(0, pos);
+                       }
+               }
+
+               result = result.replace("\\t", "\t").replace("\\\"", "\"");
+
+               return result;
+       }
+
+       // next " but don't take \" into account
+       static private int nextUnescapedQuote(String result, int pos) {
+               while (pos >= 0) {
+                       pos = result.indexOf("\"", pos);
+                       if (pos == 0 || (pos > 0 && result.charAt(pos - 1) != '\\')) {
+                               break;
+                       }
+
+                       if (pos < result.length()) {
+                               pos++;
+                       }
+               }
+
+               return pos;
+       }
+
+       // quick & dirty filter
+       static private String unbbcode(String bbcode) {
+               String text = bbcode.replace("\\r\\n", "<br/>") //
+                               .replace("[i]", "_").replace("[/i]", "_") //
+                               .replace("[b]", "*").replace("[/b]", "*") //
+                               .replaceAll("\\[[^\\]]*\\]", "");
+               return text;
+       }
+
+       /**
+        * Return the text between the key and the endKey (and optional subKey can
+        * be passed, in this case we will look for the key first, then take the
+        * text between the subKey and the endKey).
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match (also supports "^" at start to say
+        *            "only if it starts with" the key)
+        * @param subKey
+        *            the sub key or NULL if none
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @return the text or NULL if not found
+        */
+       static private String getKeyText(String in, String key, String subKey,
+                       String endKey) {
+               String result = null;
+
+               String line = in;
+               if (line != null && line.contains(key)) {
+                       line = line.substring(line.indexOf(key) + key.length());
+                       if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
+                               if (subKey != null) {
+                                       line = line.substring(line.indexOf(subKey)
+                                                       + subKey.length());
+                               }
+                               if (endKey == null || line.contains(endKey)) {
+                                       if (endKey != null) {
+                                               line = line.substring(0, line.indexOf(endKey));
+                                               result = line;
+                                       }
+                               }
+                       }
+               }
+
+               return result;
+       }
+
+       /**
+        * Return the first index after all the given "afters" have been found in
+        * the {@link String}, or -1 if it was not possible.
+        * 
+        * @param in
+        *            the input
+        * @param startAt
+        *            start at this position in the string
+        * @param afters
+        *            the sub-keys to find before checking for key/endKey
+        * 
+        * @return the text or NULL if not found
+        */
+       static private int indexOfAfter(String in, int startAt, String... afters) {
+               int pos = -1;
+               if (in != null && !in.isEmpty()) {
+                       pos = startAt;
+                       if (afters != null) {
+                               for (int i = 0; pos >= 0 && i < afters.length; i++) {
+                                       String subKey = afters[i];
+                                       if (!subKey.isEmpty()) {
+                                               pos = in.indexOf(subKey, pos);
+                                               if (pos >= 0) {
+                                                       pos += subKey.length();
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return pos;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Html.java b/src/be/nikiroo/fanfix/supported/Html.java
new file mode 100644 (file)
index 0000000..c27dd32
--- /dev/null
@@ -0,0 +1,90 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+
+/**
+ * Support class for HTML files created with this program (as we need some
+ * metadata available in those we create).
+ * 
+ * @author niki
+ */
+class Html extends InfoText {
+       @Override
+       protected boolean supports(URL url) {
+               try {
+                       File txt = getTxt(url);
+                       if (txt != null) {
+                               return super.supports(txt.toURI().toURL());
+                       }
+               } catch (MalformedURLException e) {
+               }
+
+               return false;
+       }
+
+       @Override
+       protected File getInfoFile() {
+               File source = getSourceFile();
+               if ("index.html".equals(source.getName())) {
+                       source = source.getParentFile();
+               }
+
+               String src = source.getPath();
+               File infoFile = new File(src + ".info");
+               if (!infoFile.exists() && src.endsWith(".txt")) {
+                       infoFile = new File(
+                                       src.substring(0, src.length() - ".txt".length()) + ".info");
+               }
+
+               return infoFile;
+       }
+
+       @Override
+       public URL getCanonicalUrl(URL source) {
+               File txt = getTxt(source);
+               if (txt != null) {
+                       try {
+                               source = txt.toURI().toURL();
+                       } catch (MalformedURLException e) {
+                               Instance.getTraceHandler().error(
+                                               new IOException("Cannot convert the right URL for "
+                                                               + source, e));
+                       }
+               }
+
+               return source;
+       }
+
+       /**
+        * Return the associated TXT source file if it can be found.
+        * 
+        * @param source
+        *            the source URL
+        * 
+        * @return the supported source text file or NULL
+        */
+       private static File getTxt(URL source) {
+               try {
+                       File fakeFile = new File(source.toURI());
+                       if (fakeFile.getName().equals("index.html")) { // "story/index.html"
+                               fakeFile = new File(fakeFile.getParent()); // -> "story/"
+                       }
+
+                       if (fakeFile.isDirectory()) { // "story/"
+                               fakeFile = new File(fakeFile, fakeFile.getName() + ".txt"); // "story/story.txt"
+                       }
+
+                       if (fakeFile.getName().endsWith(".txt")) {
+                               return fakeFile;
+                       }
+               } catch (Exception e) {
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/InfoReader.java b/src/be/nikiroo/fanfix/supported/InfoReader.java
new file mode 100644 (file)
index 0000000..c22dbd7
--- /dev/null
@@ -0,0 +1,261 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+
+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.streams.MarkableFileInputStream;
+
+// not complete: no "description" tag
+public class InfoReader {
+       static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
+       // static protected BasicSupportImages bsImages = new BasicSupportImages();
+       // static protected BasicSupportPara bsPara = new BasicSupportPara(new BasicSupportHelper(), new BasicSupportImages());
+
+       public static MetaData readMeta(File infoFile, boolean withCover)
+                       throws IOException {
+               if (infoFile == null) {
+                       throw new IOException("File is null");
+               }
+
+               if (infoFile.exists()) {
+                       InputStream in = new MarkableFileInputStream(infoFile);
+                       try {
+                               return createMeta(infoFile.toURI().toURL(), in, withCover);
+                       } finally {
+                               in.close();
+                       }
+               }
+
+               throw new FileNotFoundException(
+                               "File given as argument does not exists: "
+                                               + infoFile.getAbsolutePath());
+       }
+
+       private static MetaData createMeta(URL sourceInfoFile, InputStream in,
+                       boolean withCover) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getInfoTag(in, "TITLE"));
+               meta.setAuthor(getInfoTag(in, "AUTHOR"));
+               meta.setDate(getInfoTag(in, "DATE"));
+               meta.setTags(getInfoTagList(in, "TAGS", ","));
+               meta.setSource(getInfoTag(in, "SOURCE"));
+               meta.setUrl(getInfoTag(in, "URL"));
+               meta.setPublisher(getInfoTag(in, "PUBLISHER"));
+               meta.setUuid(getInfoTag(in, "UUID"));
+               meta.setLuid(getInfoTag(in, "LUID"));
+               meta.setLang(getInfoTag(in, "LANG"));
+               meta.setSubject(getInfoTag(in, "SUBJECT"));
+               meta.setType(getInfoTag(in, "TYPE"));
+               meta.setImageDocument(getInfoTagBoolean(in, "IMAGES_DOCUMENT", false));
+               if (withCover) {
+                       String infoTag = getInfoTag(in, "COVER");
+                       if (infoTag != null && !infoTag.trim().isEmpty()) {
+                               meta.setCover(bsHelper.getImage(null, sourceInfoFile,
+                                               infoTag));
+                       }
+                       if (meta.getCover() == null) {
+                               // Second chance: try to check for a cover next to the info file
+                               meta.setCover(getCoverByName(sourceInfoFile));
+                       }
+               }
+               try {
+                       meta.setWords(Long.parseLong(getInfoTag(in, "WORDCOUNT")));
+               } catch (NumberFormatException e) {
+                       meta.setWords(0);
+               }
+               meta.setCreationDate(getInfoTag(in, "CREATION_DATE"));
+               meta.setFakeCover(Boolean.parseBoolean(getInfoTag(in, "FAKE_COVER")));
+
+               if (withCover && meta.getCover() == null) {
+                       meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
+               }
+
+               return meta;
+       }
+
+       /**
+        * Return the cover image if it is next to the source file.
+        * 
+        * @param sourceInfoFile
+        *            the source file
+        * 
+        * @return the cover if present, NULL if not
+        */
+       public static Image getCoverByName(URL sourceInfoFile) {
+               Image cover = null;
+
+               File basefile = new File(sourceInfoFile.getFile());
+
+               String ext = "."
+                               + Instance.getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
+                                               .toLowerCase();
+
+               // Without removing ext
+               cover = bsHelper.getImage(null, sourceInfoFile,
+                               basefile.getAbsolutePath() + ext);
+
+               // Try without ext
+               String name = basefile.getName();
+               int pos = name.lastIndexOf(".");
+               if (cover == null && pos > 0) {
+                       name = name.substring(0, pos);
+                       basefile = new File(basefile.getParent(), name);
+
+                       cover = bsHelper.getImage(null, sourceInfoFile,
+                                       basefile.getAbsolutePath() + ext);
+               }
+
+               return cover;
+       }
+
+       private static boolean getInfoTagBoolean(InputStream in, String key,
+                       boolean def) throws IOException {
+               Boolean value = getInfoTagBoolean(in, key);
+               return value == null ? def : value;
+       }
+
+       private static Boolean getInfoTagBoolean(InputStream in, String key)
+                       throws IOException {
+               String value = getInfoTag(in, key);
+               if (value != null && !value.trim().isEmpty()) {
+                       value = value.toLowerCase().trim();
+                       return value.equals("1") || value.equals("on")
+                                       || value.equals("true") || value.equals("yes");
+               }
+
+               return null;
+       }
+
+       private static List<String> getInfoTagList(InputStream in, String key,
+                       String separator) throws IOException {
+               List<String> list = new ArrayList<String>();
+               String tt = getInfoTag(in, key);
+               if (tt != null) {
+                       for (String tag : tt.split(separator)) {
+                               list.add(tag.trim());
+                       }
+               }
+
+               return list;
+       }
+
+       /**
+        * Return the value of the given tag in the <tt>.info</tt> file if present.
+        * 
+        * @param key
+        *            the tag key
+        * 
+        * @return the value or NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private static String getInfoTag(InputStream in, String key)
+                       throws IOException {
+               key = "^" + key + "=";
+
+               if (in != null) {
+                       in.reset();
+                       String value = getLine(in, key, 0);
+                       if (value != null && !value.isEmpty()) {
+                               value = value.trim().substring(key.length() - 1).trim();
+                               if (value.startsWith("'") && value.endsWith("'")
+                                               || value.startsWith("\"") && value.endsWith("\"")) {
+                                       value = value.substring(1, value.length() - 1).trim();
+                               }
+
+                               return value;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the first line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * 
+        * @return the line
+        */
+       static private String getLine(InputStream in, String needle,
+                       int relativeLine) {
+               return getLine(in, needle, relativeLine, true);
+       }
+
+       /**
+        * Return a line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * @param first
+        *            takes the first result (as opposed to the last one, which will
+        *            also always spend the input)
+        * 
+        * @return the line
+        */
+       static private String getLine(InputStream in, String needle,
+                       int relativeLine, boolean first) {
+               String rep = null;
+
+               List<String> lines = new ArrayList<String>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               int index = -1;
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       lines.add(scan.next());
+
+                       if (index == -1) {
+                               if (needle.startsWith("^")) {
+                                       if (lines.get(lines.size() - 1).startsWith(
+                                                       needle.substring(1))) {
+                                               index = lines.size() - 1;
+                                       }
+
+                               } else {
+                                       if (lines.get(lines.size() - 1).contains(needle)) {
+                                               index = lines.size() - 1;
+                                       }
+                               }
+                       }
+
+                       if (index >= 0 && index + relativeLine < lines.size()) {
+                               rep = lines.get(index + relativeLine);
+                               if (first) {
+                                       break;
+                               }
+                       }
+               }
+
+               return rep;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/InfoText.java b/src/be/nikiroo/fanfix/supported/InfoText.java
new file mode 100644 (file)
index 0000000..42e2c13
--- /dev/null
@@ -0,0 +1,55 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.data.MetaData;
+
+/**
+ * Support class for <tt>.info</tt> text files ({@link Text} files with a
+ * <tt>.info</tt> metadata file next to them).
+ * <p>
+ * The <tt>.info</tt> file is supposed to be written by this program, or
+ * compatible.
+ * 
+ * @author niki
+ */
+class InfoText extends Text {
+       protected File getInfoFile() {
+               return new File(assureNoTxt(getSourceFile()).getPath() + ".info");
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = InfoReader.readMeta(getInfoFile(), true);
+
+               // Some old .info files don't have those now required fields...
+               String test = meta.getTitle() == null ? "" : meta.getTitle();
+               test += meta.getAuthor() == null ? "" : meta.getAuthor();
+               test += meta.getDate() == null ? "" : meta.getDate();
+               test += meta.getUrl() == null ? "" : meta.getUrl();
+               if (test.isEmpty()) {
+                       MetaData superMeta = super.getMeta();
+                       if (meta.getTitle() == null || meta.getTitle().isEmpty()) {
+                               meta.setTitle(superMeta.getTitle());
+                       }
+                       if (meta.getAuthor() == null || meta.getAuthor().isEmpty()) {
+                               meta.setAuthor(superMeta.getAuthor());
+                       }
+                       if (meta.getDate() == null || meta.getDate().isEmpty()) {
+                               meta.setDate(superMeta.getDate());
+                       }
+                       if (meta.getUrl() == null || meta.getUrl().isEmpty()) {
+                               meta.setUrl(superMeta.getUrl());
+                       }
+               }
+
+               return meta;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return supports(url, true);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/MangaFox.java b/src/be/nikiroo/fanfix/supported/MangaFox.java
new file mode 100644 (file)
index 0000000..dae2d31
--- /dev/null
@@ -0,0 +1,405 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+class MangaFox extends BasicSupport {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+               Element doc = getSourceNode();
+
+               Element title = doc.getElementById("title");
+               Elements table = null;
+               if (title != null) {
+                       table = title.getElementsByTag("table");
+               }
+               if (table != null) {
+                       // Rows: header, data
+                       Elements rows = table.first().getElementsByTag("tr");
+                       if (rows.size() > 1) {
+                               table = rows.get(1).getElementsByTag("td");
+                               // Columns: Realeased, Authors, Artists, Genres
+                               if (table.size() < 4) {
+                                       table = null;
+                               }
+                       }
+               }
+
+               meta.setTitle(getTitle());
+               if (table != null) {
+                       meta.setAuthor(getAuthors(table.get(1).text() + ","
+                                       + table.get(2).text()));
+
+                       meta.setDate(StringUtils.unhtml(table.get(0).text()).trim());
+                       meta.setTags(explode(table.get(3).text()));
+               }
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("manga");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover());
+
+               return meta;
+       }
+
+       private String getTitle() {
+               Element doc = getSourceNode();
+
+               Element title = doc.getElementById("title");
+               Element h1 = title.getElementsByTag("h1").first();
+               if (h1 != null) {
+                       return StringUtils.unhtml(h1.text()).trim();
+               }
+
+               return null;
+       }
+
+       private String getAuthors(String authorList) {
+               String author = "";
+               for (String auth : explode(authorList)) {
+                       if (!author.isEmpty()) {
+                               author = author + ", ";
+                       }
+                       author += auth;
+               }
+
+               return author;
+       }
+
+       @Override
+       protected String getDesc() {
+               Element doc = getSourceNode();
+               Element title = doc.getElementsByClass("summary").first();
+               if (title != null) {
+                       return StringUtils.unhtml(title.text()).trim();
+               }
+
+               return null;
+       }
+
+       private Image getCover() {
+               Element doc = getSourceNode();
+               Element cover = doc.getElementsByClass("cover").first();
+               if (cover != null) {
+                       cover = cover.getElementsByTag("img").first();
+               }
+
+               if (cover != null) {
+                       String coverUrl = cover.absUrl("src");
+
+                       InputStream coverIn;
+                       try {
+                               coverIn = openEx(coverUrl);
+                               try {
+                                       return new Image(coverIn);
+                               } finally {
+                                       coverIn.close();
+                               }
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(e);
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg) {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               String prefix = null; // each chapter starts with this prefix, then a
+                                                               // chapter number (including "x.5"), then name
+
+               Element doc = getSourceNode();
+               for (Element li : doc.getElementsByTag("li")) {
+                       Element el = li.getElementsByTag("h4").first();
+                       if (el == null) {
+                               el = li.getElementsByTag("h3").first();
+                       }
+                       if (el != null) {
+                               Element a = el.getElementsByTag("a").first();
+                               if (a != null) {
+                                       String title = StringUtils.unhtml(el.text()).trim();
+                                       try {
+                                               String url = a.absUrl("href");
+                                               if (url.endsWith("1.html")) {
+                                                       url = url.substring(0,
+                                                                       url.length() - "1.html".length());
+                                               }
+                                               if (!url.endsWith("/")) {
+                                                       url += "/";
+                                               }
+
+                                               if (prefix == null || !prefix.isEmpty()) {
+                                                       StringBuilder possiblePrefix = new StringBuilder(
+                                                                       StringUtils.unhtml(a.text()).trim());
+                                                       while (possiblePrefix.length() > 0) {
+                                                               char car = possiblePrefix.charAt(possiblePrefix
+                                                                               .length() - 1);
+                                                               boolean punctuation = (car == '.' || car == ' ');
+                                                               boolean digit = (car >= '0' && car <= '9');
+                                                               if (!punctuation && !digit) {
+                                                                       break;
+                                                               }
+
+                                                               possiblePrefix.setLength(possiblePrefix
+                                                                               .length() - 1);
+                                                       }
+
+                                                       if (prefix == null) {
+                                                               prefix = possiblePrefix.toString();
+                                                       }
+
+                                                       if (!prefix.equalsIgnoreCase(possiblePrefix
+                                                                       .toString())) {
+                                                               prefix = ""; // prefix not ok
+                                                       }
+                                               }
+
+                                               urls.add(new AbstractMap.SimpleEntry<String, URL>(
+                                                               title, new URL(url)));
+                                       } catch (Exception e) {
+                                               Instance.getTraceHandler().error(e);
+                                       }
+                               }
+                       }
+               }
+
+               if (prefix != null && !prefix.isEmpty()) {
+                       try {
+                               // We found a prefix, so everything should be sortable
+                               SortedMap<Double, Entry<String, URL>> map = new TreeMap<Double, Entry<String, URL>>();
+                               for (Entry<String, URL> entry : urls) {
+                                       String num = entry.getKey().substring(prefix.length() + 1)
+                                                       .trim();
+                                       String name = "";
+                                       int pos = num.indexOf(' ');
+                                       if (pos >= 0) {
+                                               name = num.substring(pos).trim();
+                                               num = num.substring(0, pos).trim();
+                                       }
+
+                                       if (!name.isEmpty()) {
+                                               name = "Tome " + num + ": " + name;
+                                       } else {
+                                               name = "Tome " + num;
+                                       }
+
+                                       double key = Double.parseDouble(num);
+
+                                       map.put(key, new AbstractMap.SimpleEntry<String, URL>(name,
+                                                       entry.getValue()));
+                               }
+                               urls = new ArrayList<Entry<String, URL>>(map.values());
+                       } catch (NumberFormatException e) {
+                               Instance.getTraceHandler()
+                                               .error(new IOException(
+                                                               "Cannot find a tome number, revert to default sorting",
+                                                               e));
+                               // by default, the chapters are in reversed order
+                               Collections.reverse(urls);
+                       }
+               } else {
+                       // by default, the chapters are in reversed order
+                       Collections.reverse(urls);
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               StringBuilder builder = new StringBuilder();
+
+               String url = chapUrl.toString();
+               InputStream imageIn = null;
+               Element imageDoc = null;
+
+               // 1. find out how many images there are
+               int size;
+               try {
+                       // note: when used, the base URL can be an ad-page
+                       imageIn = openEx(url + "1.html");
+                       imageDoc = DataUtil.load(imageIn, "UTF-8", url + "1.html");
+               } catch (IOException e) {
+                       Instance.getTraceHandler().error(
+                                       new IOException("Cannot get image " + 1 + " of manga", e));
+               } finally {
+                       if (imageIn != null) {
+                               imageIn.close();
+                       }
+               }
+               Element select = imageDoc.getElementsByClass("m").first();
+               Elements options = select.getElementsByTag("option");
+               size = options.size() - 1; // last is "Comments"
+
+               pg.setMinMax(0, size);
+
+               // 2. list them
+               for (int i = 1; i <= size; i++) {
+                       if (i > 1) { // because first one was opened for size
+                               try {
+                                       imageIn = openEx(url + i + ".html");
+                                       imageDoc = DataUtil.load(imageIn, "UTF-8", url + i
+                                                       + ".html");
+
+                                       String linkImage = imageDoc.getElementById("image").absUrl(
+                                                       "src");
+                                       if (linkImage != null) {
+                                               builder.append("[");
+                                               // to help with the retry and the originalUrl, part 1
+                                               builder.append(withoutQuery(linkImage));
+                                               builder.append("]<br/>");
+                                       }
+
+                                       // to help with the retry and the originalUrl, part 2
+                                       refresh(linkImage);
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(
+                                                       new IOException("Cannot get image " + i
+                                                                       + " of manga", e));
+                               } finally {
+                                       if (imageIn != null) {
+                                               imageIn.close();
+                                       }
+                               }
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Refresh the {@link URL} by calling {@link MangaFox#openEx(String)}.
+        * 
+        * @param url
+        *            the URL to refresh
+        * 
+        * @return TRUE if it was refreshed
+        */
+       private boolean refresh(String url) {
+               try {
+                       openEx(url).close();
+                       return true;
+               } catch (Exception e) {
+                       return false;
+               }
+       }
+
+       /**
+        * Open the URL through the cache, but: retry a second time after 100ms if
+        * it fails, remove the query part of the {@link URL} before saving it to
+        * the cache (so it can be recalled later).
+        * 
+        * @param url
+        *            the {@link URL}
+        * 
+        * @return the resource
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private InputStream openEx(String url) throws IOException {
+               try {
+                       return Instance.getCache().open(new URL(url), withoutQuery(url),
+                                       this, true);
+               } catch (Exception e) {
+                       // second chance
+                       try {
+                               Thread.sleep(100);
+                       } catch (InterruptedException ee) {
+                       }
+
+                       return Instance.getCache().open(new URL(url), withoutQuery(url),
+                                       this, true);
+               }
+       }
+
+       /**
+        * Return the same input {@link URL} but without the query part.
+        * 
+        * @param url
+        *            the inpiut {@link URL} as a {@link String}
+        * 
+        * @return the input {@link URL} without query
+        */
+       private URL withoutQuery(String url) {
+               URL o = null;
+               try {
+                       // Remove the query from o (originalUrl), so it can be cached
+                       // correctly
+                       o = new URL(url);
+                       o = new URL(o.getProtocol() + "://" + o.getHost() + o.getPath());
+
+                       return o;
+               } catch (MalformedURLException e) {
+                       return null;
+               }
+       }
+
+       /**
+        * 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<String> explode(String values) {
+               List<String> list = new ArrayList<String>();
+               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 "mangafox.me".equals(url.getHost())
+                               || "www.mangafox.me".equals(url.getHost())
+                               || "fanfox.net".equals(url.getHost())
+                               || "www.fanfox.net".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/MangaLel.java b/src/be/nikiroo/fanfix/supported/MangaLel.java
new file mode 100644 (file)
index 0000000..1ba51bc
--- /dev/null
@@ -0,0 +1,241 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+class MangaLel extends BasicSupport {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle());
+               meta.setAuthor(getAuthor());
+               meta.setDate(getDate());
+               meta.setTags(getTags());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("fr");
+               meta.setSubject("manga");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover());
+
+               return meta;
+       }
+
+       private String getTitle() {
+               Element doc = getSourceNode();
+               Element h4 = doc.getElementsByTag("h4").first();
+               if (h4 != null) {
+                       return StringUtils.unhtml(h4.text()).trim();
+               }
+
+               return null;
+       }
+
+       private String getAuthor() {
+               Element doc = getSourceNode();
+               Element tabEls = doc.getElementsByClass("presentation-projet").first();
+               if (tabEls != null) {
+                       String[] tab = tabEls.outerHtml().split("<br>");
+                       return getVal(tab, 1);
+               }
+
+               return "";
+       }
+
+       private List<String> getTags() {
+               Element doc = getSourceNode();
+               Element tabEls = doc.getElementsByClass("presentation-projet").first();
+               if (tabEls != null) {
+                       String[] tab = tabEls.outerHtml().split("<br>");
+                       List<String> tags = new ArrayList<String>();
+                       for (String tag : getVal(tab, 3).split(" ")) {
+                               tags.add(tag);
+                       }
+                       return tags;
+               }
+
+               return new ArrayList<String>();
+
+       }
+
+       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();
+                               }
+                       }
+               }
+
+               if (!value.isEmpty()) {
+                       try {
+                               long time = StringUtils.toTime(value);
+                               value = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+                                               .format(time);
+                       } catch (ParseException e) {
+                       }
+               }
+
+               return value;
+       }
+
+       @Override
+       protected String getDesc() {
+               Element doc = getSourceNode();
+               Element tabEls = doc.getElementsByClass("presentation-projet").first();
+               if (tabEls != null) {
+                       String[] tab = tabEls.outerHtml().split("<br>");
+                       return getVal(tab, 4);
+               }
+
+               return "";
+       }
+
+       private Image getCover() {
+               Element doc = getSourceNode();
+               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 (img != null) {
+                               String coverUrl = img.absUrl("src");
+
+                               InputStream coverIn;
+                               try {
+                                       coverIn = Instance.getCache().open(new URL(coverUrl), this,
+                                                       true);
+                                       try {
+                                               return new Image(coverIn);
+                                       } finally {
+                                               coverIn.close();
+                                       }
+                               } catch (IOException e) {
+                                       Instance.getTraceHandler().error(e);
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       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;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               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<String, URL>(name, url));
+                               }
+                       }
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               StringBuilder builder = new StringBuilder();
+
+               InputStream in = Instance.getCache().open(chapUrl, this, false);
+               try {
+                       Element pageDoc = DataUtil.load(in, "UTF-8", chapUrl.toString());
+                       Element content = pageDoc.getElementById("content");
+                       Elements linkEls = content.getElementsByTag("img");
+                       for (Element linkEl : linkEls) {
+                               if (linkEl.absUrl("src").isEmpty()) {
+                                       continue;
+                               }
+
+                               builder.append("[");
+                               builder.append(linkEl.absUrl("src"));
+                               builder.append("]<br/>");
+                       }
+
+               } finally {
+                       in.close();
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               // 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
new file mode 100644 (file)
index 0000000..ba18949
--- /dev/null
@@ -0,0 +1,141 @@
+package be.nikiroo.fanfix.supported;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+
+/**
+ * The supported input types for which we can get a {@link BasicSupport} object.
+ * 
+ * @author niki
+ */
+public enum SupportType {
+       /** EPUB files created with this program */
+       EPUB,
+       /** Pure text file with some rules */
+       TEXT,
+       /** TEXT but with associated .info file */
+       INFO_TEXT,
+       /** My Little Pony fanfictions */
+       FIMFICTION,
+       /** Fanfictions from a lot of different universes */
+       FANFICTION,
+       /** Website with lots of Mangas */
+       MANGAFOX,
+       /** Furry website with comics support */
+       E621,
+       /** Furry website with stories */
+       YIFFSTAR,
+       /** Comics and images groups, mostly but not only NSFW */
+       E_HENTAI,
+       /** Website with lots of Mangas, in French */
+       MANGA_LEL,
+       /** CBZ files */
+       CBZ,
+       /** HTML files */
+       HTML;
+
+       /**
+        * 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
+        */
+       public String getDesc() {
+               String desc = Instance.getTrans().getStringX(StringId.INPUT_DESC,
+                               this.name());
+
+               if (desc == null) {
+                       desc = Instance.getTrans().getString(StringId.INPUT_DESC, this);
+               }
+
+               return desc;
+       }
+
+       @Override
+       public String toString() {
+               return super.toString().toLowerCase();
+       }
+
+       /**
+        * Call {@link SupportType#valueOf(String)} after conversion to upper case.
+        * 
+        * @param typeName
+        *            the possible type name
+        * 
+        * @return NULL or the type
+        */
+       public static SupportType valueOfUC(String typeName) {
+               return SupportType.valueOf(typeName == null ? null : typeName
+                               .toUpperCase());
+       }
+
+       /**
+        * Call {@link SupportType#valueOf(String)} after conversion to upper case
+        * but return NULL for NULL instead of raising exception.
+        * 
+        * @param typeName
+        *            the possible type name
+        * 
+        * @return NULL or the type
+        */
+       public static SupportType valueOfNullOkUC(String typeName) {
+               if (typeName == null) {
+                       return null;
+               }
+
+               return SupportType.valueOfUC(typeName);
+       }
+
+       /**
+        * Call {@link SupportType#valueOf(String)} after conversion to upper case
+        * but return NULL in case of error instead of raising an exception.
+        * 
+        * @param typeName
+        *            the possible type name
+        * 
+        * @return NULL or the type
+        */
+       public static SupportType valueOfAllOkUC(String typeName) {
+               try {
+                       return SupportType.valueOfUC(typeName);
+               } catch (Exception e) {
+                       return null;
+               }
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/supported/Text.java b/src/be/nikiroo/fanfix/supported/Text.java
new file mode 100644 (file)
index 0000000..daa108f
--- /dev/null
@@ -0,0 +1,379 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for local stories encoded in textual format, with a few rules:
+ * <ul>
+ * <li>The title must be on the first line</li>
+ * <li>The author (preceded by nothing, "by " or "©") must be on the second
+ * line, possibly with the publication date in parenthesis (i.e., "
+ * <tt>By Unknown (3rd October 1998)</tt>")</li>
+ * <li>Chapters must be declared with "<tt>Chapter x</tt>" or "
+ * <tt>Chapter x: NAME OF THE CHAPTER</tt>", where "<tt>x</tt>" is the chapter
+ * number</li>
+ * <li>A description of the story must be given as chapter number 0</li>
+ * <li>A cover may be present, with the same filename but a PNG, JPEG or JPG
+ * extension</li>
+ * </ul>
+ * 
+ * @author niki
+ */
+class Text extends BasicSupport {
+       private File sourceFile;
+       private InputStream in;
+
+       protected File getSourceFile() {
+               return sourceFile;
+       }
+
+       protected InputStream getInput() {
+               if (in != null) {
+                       try {
+                               in.reset();
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(
+                                               new IOException("Cannot reset the Text stream", e));
+                       }
+
+                       return in;
+               }
+
+               return null;
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return false;
+       }
+
+       @Override
+       protected Document loadDocument(URL source) throws IOException {
+               try {
+                       sourceFile = new File(source.toURI());
+                       in = new MarkableFileInputStream(sourceFile);
+               } catch (URISyntaxException e) {
+                       throw new IOException("Cannot load the text document: " + source);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle());
+               meta.setAuthor(getAuthor());
+               meta.setDate(getDate());
+               meta.setTags(new ArrayList<String>());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSourceFile().toURI().toURL().toString());
+               meta.setPublisher("");
+               meta.setUuid(getSourceFile().toString());
+               meta.setLuid("");
+               meta.setLang(getLang()); // default is EN
+               meta.setSubject(getSourceFile().getParentFile().getName());
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(getSourceFile()));
+
+               return meta;
+       }
+
+       private String getLang() {
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next(); // Title
+               scan.next(); // Author (Date)
+               String chapter0 = scan.next(); // empty or Chapter 0
+               while (chapter0.isEmpty()) {
+                       chapter0 = scan.next();
+               }
+
+               String lang = detectChapter(chapter0, 0);
+               if (lang == null) {
+                       // No description??
+                       lang = detectChapter(chapter0, 1);
+               }
+
+               if (lang == null) {
+                       lang = "en";
+               } else {
+                       lang = lang.toLowerCase();
+               }
+
+               return lang;
+       }
+
+       private String getTitle() {
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               return scan.next();
+       }
+
+       private String getAuthor() {
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next();
+               String authorDate = scan.next();
+
+               String author = authorDate;
+               int pos = authorDate.indexOf('(');
+               if (pos >= 0) {
+                       author = authorDate.substring(0, pos);
+               }
+
+               return bsHelper.fixAuthor(author);
+       }
+
+       private String getDate() {
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next();
+               String authorDate = scan.next();
+
+               String date = "";
+               int pos = authorDate.indexOf('(');
+               if (pos >= 0) {
+                       date = authorDate.substring(pos + 1).trim();
+                       pos = date.lastIndexOf(')');
+                       if (pos >= 0) {
+                               date = date.substring(0, pos).trim();
+                       }
+               }
+
+               return date;
+       }
+
+       @Override
+       protected String getDesc() throws IOException {
+               String content = getChapterContent(null, 0, null).trim();
+               if (!content.isEmpty()) {
+                       Chapter desc = bsPara.makeChapter(this, null, 0, "Description",
+                                       content, isHtml(), null);
+                       StringBuilder builder = new StringBuilder();
+                       for (Paragraph para : desc) {
+                               if (builder.length() > 0) {
+                                       builder.append("\n");
+                               }
+                               builder.append(para.getContent());
+                       }
+               }
+
+               return content;
+       }
+
+       private Image getCover(File sourceFile) {
+               String path = sourceFile.getName();
+
+               for (String ext : new String[] { ".txt", ".text", ".story" }) {
+                       if (path.endsWith(ext)) {
+                               path = path.substring(0, path.length() - ext.length());
+                       }
+               }
+
+               Image cover = bsImages.getImage(this, sourceFile.getParentFile(), path);
+               if (cover != null) {
+                       try {
+                               File tmp = Instance.getTempFiles().createTempFile(
+                                               "test_cover_image");
+                               ImageUtils.getInstance().saveAsImage(cover, tmp, "png");
+                               tmp.delete();
+                       } catch (IOException e) {
+                               cover = null;
+                       }
+               }
+
+               return cover;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               List<Entry<String, URL>> chaps = new ArrayList<Entry<String, URL>>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               boolean prevLineEmpty = false;
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (prevLineEmpty && detectChapter(line, chaps.size() + 1) != null) {
+                               String chapName = Integer.toString(chaps.size() + 1);
+                               int pos = line.indexOf(':');
+                               if (pos >= 0 && pos + 1 < line.length()) {
+                                       chapName = line.substring(pos + 1).trim();
+                               }
+
+                               chaps.add(new AbstractMap.SimpleEntry<String, URL>(//
+                                               chapName, //
+                                               getSourceFile().toURI().toURL()));
+                       }
+
+                       prevLineEmpty = line.trim().isEmpty();
+               }
+
+               return chaps;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, int number, Progress pg)
+                       throws IOException {
+               StringBuilder builder = new StringBuilder();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               boolean inChap = false;
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (!inChap && detectChapter(line, number) != null) {
+                               inChap = true;
+                       } else if (detectChapter(line, number + 1) != null) {
+                               break;
+                       } else if (inChap) {
+                               builder.append(line);
+                               builder.append("\n");
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected void close() {
+               InputStream in = getInput();
+               if (in != null) {
+                       try {
+                               in.close();
+                       } catch (IOException e) {
+                               Instance.getTraceHandler().error(
+                                               new IOException(
+                                                               "Cannot close the text source file input", e));
+                       }
+               }
+
+               super.close();
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return supports(url, false);
+       }
+
+       /**
+        * Check if we supports this {@link URL}, that is, if the info file can be
+        * found OR not found.
+        * 
+        * @param url
+        *            the {@link URL} to check
+        * @param info
+        *            TRUE to require the info file, FALSE to forbid the info file
+        * 
+        * @return TRUE if it is supported
+        */
+       protected boolean supports(URL url, boolean info) {
+               boolean infoPresent = false;
+               if ("file".equals(url.getProtocol())) {
+                       File file;
+                       try {
+                               file = new File(url.toURI());
+                               file = assureNoTxt(file);
+                               file = new File(file.getPath() + ".info");
+                       } catch (URISyntaxException e) {
+                               Instance.getTraceHandler().error(e);
+                               file = null;
+                       }
+
+                       infoPresent = (file != null && file.exists());
+               }
+
+               return infoPresent == info;
+       }
+
+       /**
+        * Remove the ".txt" extension if it is present.
+        * 
+        * @param file
+        *            the file to process
+        * 
+        * @return the same file or a copy of it without the ".txt" extension if it
+        *         was present
+        */
+       protected File assureNoTxt(File file) {
+               if (file.getName().endsWith(".txt")) {
+                       file = new File(file.getPath().substring(0,
+                                       file.getPath().length() - 4));
+               }
+
+               return file;
+       }
+
+       /**
+        * Check if the given line looks like the given starting chapter in a
+        * supported language, and return the language if it does (or NULL if not).
+        * 
+        * @param line
+        *            the line to check
+        * @param number
+        *            the specific chapter number to check for
+        * 
+        * @return the language or NULL
+        */
+       static private String detectChapter(String line, int number) {
+               line = line.toUpperCase();
+               for (String lang : Instance.getConfig().getList(Config.CONF_CHAPTER)) {
+                       String chapter = Instance.getConfig().getStringX(
+                                       Config.CONF_CHAPTER, lang);
+                       if (chapter != null && !chapter.isEmpty()) {
+                               chapter = chapter.toUpperCase() + " ";
+                               if (line.startsWith(chapter)) {
+                                       // We want "[CHAPTER] [number]: [name]", with ": [name]"
+                                       // optional
+                                       String test = line.substring(chapter.length()).trim();
+
+                                       String possibleNum = test.trim();
+                                       if (possibleNum.indexOf(':') > 0) {
+                                               possibleNum = possibleNum.substring(0,
+                                                               possibleNum.indexOf(':')).trim();
+                                       }
+
+                                       if (test.startsWith(Integer.toString(number))) {
+                                               test = test
+                                                               .substring(Integer.toString(number).length())
+                                                               .trim();
+                                               if (test.isEmpty() || test.startsWith(":")) {
+                                                       return lang;
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/YiffStar.java b/src/be/nikiroo/fanfix/supported/YiffStar.java
new file mode 100644 (file)
index 0000000..aad01a6
--- /dev/null
@@ -0,0 +1,271 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="https://sofurry.com/">SoFurry.com</a>, a Furry
+ * website supporting images and stories (we only retrieve the stories).
+ * 
+ * @author niki
+ */
+class YiffStar extends BasicSupport_Deprecated {
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate("");
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("Furry");
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(source, reset(in)));
+
+               return meta;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               String host = url.getHost();
+               if (host.startsWith("www.")) {
+                       host = host.substring("www.".length());
+               }
+
+               return "sofurry.com".equals(host);
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       public void login() throws IOException {
+               // Note: this should not be necessary anymore
+               // (the "/guest" trick is enough)
+               String login = Instance.getConfig().getString(
+                               Config.LOGIN_YIFFSTAR_USER);
+               String password = Instance.getConfig().getString(
+                               Config.LOGIN_YIFFSTAR_PASS);
+
+               if (login != null && !login.isEmpty() && password != null
+                               && !password.isEmpty()) {
+
+                       Map<String, String> post = new HashMap<String, String>();
+                       post.put("LoginForm[sfLoginUsername]", login);
+                       post.put("LoginForm[sfLoginPassword]", password);
+                       post.put("YII_CSRF_TOKEN", "");
+                       post.put("yt1", "Login");
+                       post.put("returnUrl", "/");
+
+                       // Cookies will actually be retained by the cache manager once
+                       // logged in
+                       Instance.getCache()
+                                       .openNoCache(new URL("https://www.sofurry.com/user/login"),
+                                                       this, post, null, null).close();
+               }
+       }
+
+       @Override
+       public URL getCanonicalUrl(URL source) {
+               try {
+                       if (source.getPath().startsWith("/view")) {
+                               source = guest(source.toString());
+                               // NO CACHE because we don't want the NotLoggedIn message later
+                               InputStream in = Instance.getCache().openNoCache(source, this,
+                                               null, null, null);
+                               String line = getLine(in, "/browse/folder/", 0);
+                               if (line != null) {
+                                       String[] tab = line.split("\"");
+                                       if (tab.length > 1) {
+                                               String groupUrl = source.getProtocol() + "://"
+                                                               + source.getHost() + tab[1];
+                                               return guest(groupUrl);
+                                       }
+                               }
+                       }
+               } catch (Exception e) {
+                       Instance.getTraceHandler().error(e);
+               }
+
+               return super.getCanonicalUrl(source);
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+
+               String line = getLine(in, "class=\"sf-story-big-tags", 0);
+               if (line != null) {
+                       String[] tab = StringUtils.unhtml(line).split(",");
+                       for (String possibleTag : tab) {
+                               String tag = possibleTag.trim();
+                               if (!tag.isEmpty() && !tag.equals("...") && !tags.contains(tag)) {
+                                       tags.add(tag);
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       private Image getCover(URL source, InputStream in) throws IOException {
+
+               List<Entry<String, URL>> chaps = getChapters(source, in, null);
+               if (!chaps.isEmpty()) {
+                       in = Instance.getCache().open(chaps.get(0).getValue(), this, true);
+                       String line = getLine(in, " name=\"og:image\"", 0);
+                       if (line != null) {
+                               int pos = -1;
+                               for (int i = 0; i < 3; i++) {
+                                       pos = line.indexOf('"', pos + 1);
+                               }
+
+                               if (pos >= 0) {
+                                       line = line.substring(pos + 1);
+                                       pos = line.indexOf('"');
+                                       if (pos >= 0) {
+                                               line = line.substring(0, pos);
+                                               if (line.contains("/thumb?")) {
+                                                       line = line.replace("/thumb?",
+                                                                       "/auxiliaryContent?type=25&");
+                                                       return getImage(this, null, line);
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getAuthor(InputStream in) {
+               String author = getLine(in, "class=\"onlinestatus", 0);
+               if (author != null) {
+                       return StringUtils.unhtml(author).trim();
+               }
+
+               return null;
+       }
+
+       private String getTitle(InputStream in) {
+               String title = getLine(in, "class=\"sflabel pagetitle", 0);
+               if (title != null) {
+                       if (title.contains("(series)")) {
+                               title = title.replace("(series)", "");
+                       }
+                       return StringUtils.unhtml(title).trim();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               return null; // TODO: no description at all? Cannot find one...
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("\"/view/") && line.contains("title=")) {
+                               String[] tab = line.split("\"");
+                               if (tab.length > 5) {
+                                       String link = tab[5];
+                                       if (link.startsWith("/")) {
+                                               link = source.getProtocol() + "://" + source.getHost()
+                                                               + link;
+                                       }
+                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(
+                                                       StringUtils.unhtml(line).trim(), guest(link)));
+                               }
+                       }
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) throws IOException {
+               StringBuilder builder = new StringBuilder();
+
+               String startAt = "id=\"sfContentBody";
+               String endAt = "id=\"recommendationArea";
+               boolean ok = false;
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (!ok && line.contains(startAt)) {
+                               ok = true;
+                       } else if (ok && line.contains(endAt)) {
+                               ok = false;
+                               break;
+                       }
+
+                       if (ok) {
+                               builder.append(line);
+                               builder.append(' ');
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Return a {@link URL} from the given link, but add the "/guest" part to it
+        * to make sure we don't need to be logged-in to see it.
+        * 
+        * @param link
+        *            the link
+        * 
+        * @return the {@link URL}
+        * 
+        * @throws MalformedURLException
+        *             in case of data error
+        */
+       private URL guest(String link) throws MalformedURLException {
+               if (link.contains("?")) {
+                       if (link.contains("/?")) {
+                               return new URL(link.replace("?", "guest?"));
+                       }
+
+                       return new URL(link.replace("?", "/guest?"));
+               }
+
+               return new URL(link + "/guest");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/package-info.java b/src/be/nikiroo/fanfix/supported/package-info.java
new file mode 100644 (file)
index 0000000..1762e32
--- /dev/null
@@ -0,0 +1,11 @@
+/**
+ * This package contains different implementation of 
+ * {@link be.nikiroo.fanfix.supported.BasicSupport} to cater to different 
+ * sources.
+ * <p>
+ * You are expected to use the static methods from 
+ * {@link be.nikiroo.fanfix.supported.BasicSupport} to get those you need.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.supported;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/test/BasicSupportDeprecatedTest.java b/src/be/nikiroo/fanfix/test/BasicSupportDeprecatedTest.java
new file mode 100644 (file)
index 0000000..9f40a80
--- /dev/null
@@ -0,0 +1,455 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupport_Deprecated;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BasicSupportDeprecatedTest extends TestLauncher {
+       // quote chars
+       private char openQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+
+       public BasicSupportDeprecatedTest(String[] args) {
+               super("BasicSupportDeprecated", args);
+
+               addSeries(new TestLauncher("General", args) {
+                       {
+                               addTest(new TestCase("BasicSupport.makeParagraphs()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+
+                                                       @Override
+                                                       public void fixBlanksBreaks(List<Paragraph> paras) {
+                                                       }
+
+                                                       @Override
+                                                       public List<Paragraph> requotify(Paragraph para) {
+                                                               List<Paragraph> paras = new ArrayList<Paragraph>(
+                                                                               1);
+                                                               paras.add(para);
+                                                               return paras;
+                                                       }
+                                               };
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = support.makeParagraphs(null, "", null);
+                                               assertEquals(
+                                                               "An empty content should not generate paragraphs",
+                                                               0, paras.size());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "Line 1</p><p>Line 2</p><p>Line 3</p>", null);
+                                               assertEquals(5, paras.size());
+                                               assertEquals("Line 1", paras.get(0).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(1)
+                                                               .getType());
+                                               assertEquals("Line 2", paras.get(2).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(3)
+                                                               .getType());
+                                               assertEquals("Line 3", paras.get(4).getContent());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "<p>Line1</p><p>Line2</p><p>Line3</p>", null);
+                                               assertEquals(6, paras.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.removeDoubleBlanks()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>",
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>* * *",
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support.makeParagraphs(null, "1<p>* * *<p>2",
+                                                               null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "1<p><br/><p>* * *<p>2", null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "1<p>* * *<br/><p><br><p>2", null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "1<p><br/><br>* * *<br/><p><br><p>2", null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() quotes") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               // sanity check
+                                               para = support.processPara("");
+                                               assertEquals(ParagraphType.BLANK, para.getType());
+                                               //
+
+                                               para = support.processPara("\"Yes, my Lord!\"");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("«Yes, my Lord!»");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("'Yes, my Lord!'");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+
+                                               para = support.processPara("‹Yes, my Lord!›");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase(
+                                               "BasicSupport.processPara() double-simple quotes") {
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               super.setUp();
+
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+
+                                               super.tearDown();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               para = support.processPara("''Yes, my Lord!''");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("‹‹Yes, my Lord!››");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() apostrophe") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               String text = "Nous étions en été, mais cela aurait être l'hiver quand nous n'étions encore qu'à Aubeuge";
+                                               para = support.processPara(text);
+                                               assertEquals(ParagraphType.NORMAL, para.getType());
+                                               assertEquals(text, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               para = support.processPara("«Yes, my Lord!»");
+                                               assertEquals(3, para.getWords());
+
+                                               para = support.processPara("One, twee, trois.");
+                                               assertEquals(3, para.getWords());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.requotify() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty();
+
+                                               char openDoubleQuote = Instance.getTrans()
+                                                               .getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+                                               char closeDoubleQuote = Instance.getTrans()
+                                                               .getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+                                               String content = null;
+                                               Paragraph para = null;
+                                               List<Paragraph> paras = null;
+                                               long words = 0;
+
+                                               content = "One, twee, trois.";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = "Such WoW! So Web2.0! With Colours!";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = openDoubleQuote + "Such a good idea!"
+                                                               + closeDoubleQuote
+                                                               + ", she said. This ought to be a new para.";
+                                               para = new Paragraph(ParagraphType.QUOTE, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals(
+                                                               "Bad words count in a requotified paragraph",
+                                                               para.getWords(), words);
+                                       }
+                               });
+                       }
+               });
+
+               addSeries(new TestLauncher("Text", args) {
+                       {
+                               addTest(new TestCase("Chapter detection simple") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("Chapter detection with String 'Chapter'") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Chapter fout-la-merde" + "\n"
+                                                                               + "\n"//
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+                       }
+               });
+       }
+
+       private class BasicSupportEmpty extends BasicSupport_Deprecated {
+               @Override
+               protected boolean supports(URL url) {
+                       return false;
+               }
+
+               @Override
+               protected boolean isHtml() {
+                       return false;
+               }
+
+               @Override
+               protected MetaData getMeta(URL source, InputStream in)
+                               throws IOException {
+                       return null;
+               }
+
+               @Override
+               protected String getDesc(URL source, InputStream in) throws IOException {
+                       return null;
+               }
+
+               @Override
+               protected List<Entry<String, URL>> getChapters(URL source,
+                               InputStream in, Progress pg) throws IOException {
+                       return null;
+               }
+
+               @Override
+               protected String getChapterContent(URL source, InputStream in,
+                               int number, Progress pg) throws IOException {
+                       return null;
+               }
+
+               @Override
+               // and make it public!
+               public List<Paragraph> makeParagraphs(URL source, String content,
+                               Progress pg) throws IOException {
+                       return super.makeParagraphs(source, content, pg);
+               }
+
+               @Override
+               // and make it public!
+               public void fixBlanksBreaks(List<Paragraph> paras) {
+                       super.fixBlanksBreaks(paras);
+               }
+
+               @Override
+               // and make it public!
+               public Paragraph processPara(String line) {
+                       return super.processPara(line);
+               }
+
+               @Override
+               // and make it public!
+               public List<Paragraph> requotify(Paragraph para) {
+                       return super.requotify(para);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java b/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java
new file mode 100644 (file)
index 0000000..4e34891
--- /dev/null
@@ -0,0 +1,401 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupportHelper;
+import be.nikiroo.fanfix.supported.BasicSupportImages;
+import be.nikiroo.fanfix.supported.BasicSupportPara;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BasicSupportUtilitiesTest extends TestLauncher {
+       // quote chars
+       private char openQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getTrans().getCharacter(
+                       StringId.CLOSE_DOUBLE_QUOTE);
+       
+       public BasicSupportUtilitiesTest(String[] args) {
+               super("BasicSupportUtilities", args);
+               
+               addSeries(new TestLauncher("General", args) {
+                       {
+                               addTest(new TestCase("BasicSupport.makeParagraphs()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic bsPara = new BasicSupportParaPublic() {
+                                                       @Override
+                                                       public void fixBlanksBreaks(List<Paragraph> paras) {
+                                                       }
+
+                                                       @Override
+                                                       public List<Paragraph> requotify(Paragraph para, boolean html) {
+                                                               List<Paragraph> paras = new ArrayList<Paragraph>(
+                                                                               1);
+                                                               paras.add(para);
+                                                               return paras;
+                                                       }
+                                               };
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = bsPara.makeParagraphs(null, null, "", true, null);
+                                               assertEquals(
+                                                               "An empty content should not generate paragraphs",
+                                                               0, paras.size());
+
+                                               paras = bsPara.makeParagraphs(null, null,
+                                                               "Line 1</p><p>Line 2</p><p>Line 3</p>", true, null);
+                                               assertEquals(5, paras.size());
+                                               assertEquals("Line 1", paras.get(0).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(1)
+                                                               .getType());
+                                               assertEquals("Line 2", paras.get(2).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(3)
+                                                               .getType());
+                                               assertEquals("Line 3", paras.get(4).getContent());
+
+                                               paras = bsPara.makeParagraphs(null, null,
+                                                               "<p>Line1</p><p>Line2</p><p>Line3</p>", true, null);
+                                               assertEquals(6, paras.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.removeDoubleBlanks()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>",
+                                                                               true,
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>* * *",
+                                                                               true,
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support.makeParagraphs(null, null, "1<p>* * *<p>2",
+                                                               true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null, null,
+                                                               "1<p><br/><p>* * *<p>2", true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null, null,
+                                                               "1<p>* * *<br/><p><br><p>2", true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null, null,
+                                                               "1<p><br/><br>* * *<br/><p><br><p>2", true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() quotes") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               // sanity check
+                                               para = support.processPara("", true);
+                                               assertEquals(ParagraphType.BLANK, para.getType());
+                                               //
+
+                                               para = support.processPara("\"Yes, my Lord!\"", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("«Yes, my Lord!»", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("'Yes, my Lord!'", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+
+                                               para = support.processPara("‹Yes, my Lord!›", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase(
+                                               "BasicSupport.processPara() double-simple quotes") {
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               super.setUp();
+
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+
+                                               super.tearDown();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               para = support.processPara("''Yes, my Lord!''", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("‹‹Yes, my Lord!››", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() apostrophe") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               String text = "Nous étions en été, mais cela aurait être l'hiver quand nous n'étions encore qu'à Aubeuge";
+                                               para = support.processPara(text, true);
+                                               assertEquals(ParagraphType.NORMAL, para.getType());
+                                               assertEquals(text, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               para = support.processPara("«Yes, my Lord!»", true);
+                                               assertEquals(3, para.getWords());
+
+                                               para = support.processPara("One, twee, trois.", true);
+                                               assertEquals(3, para.getWords());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.requotify() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               char openDoubleQuote = Instance.getTrans()
+                                                               .getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+                                               char closeDoubleQuote = Instance.getTrans()
+                                                               .getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+                                               String content = null;
+                                               Paragraph para = null;
+                                               List<Paragraph> paras = null;
+                                               long words = 0;
+
+                                               content = "One, twee, trois.";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para, false);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = "Such WoW! So Web2.0! With Colours!";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para, false);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = openDoubleQuote + "Such a good idea!"
+                                                               + closeDoubleQuote
+                                                               + ", she said. This ought to be a new para.";
+                                               para = new Paragraph(ParagraphType.QUOTE, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para, false);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals(
+                                                               "Bad words count in a requotified paragraph",
+                                                               para.getWords(), words);
+                                       }
+                               });
+                       }
+               });
+
+               addSeries(new TestLauncher("Text", args) {
+                       {
+                               addTest(new TestCase("Chapter detection simple") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("Chapter detection with String 'Chapter'") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Chapter fout-la-merde" + "\n"
+                                                                               + "\n"//
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+                       }
+               });
+       }
+       
+       class BasicSupportParaPublic extends BasicSupportPara {
+               public BasicSupportParaPublic() {
+                       super(new BasicSupportHelper(), new BasicSupportImages());
+               }
+               
+               @Override
+               // and make it public!
+               public Paragraph makeParagraph(BasicSupport support, URL source,
+                               String line, boolean html) {
+                       return super.makeParagraph(support, source, line, html);
+               }
+               
+               @Override
+               // and make it public!
+               public List<Paragraph> makeParagraphs(BasicSupport support,
+                               URL source, String content, boolean html, Progress pg)
+                               throws IOException {
+                       return super.makeParagraphs(support, source, content, html, pg);
+               }
+               
+               @Override
+               // and make it public!
+               public Paragraph processPara(String line, boolean html) {
+                       return super.processPara(line, html);
+               }
+               
+               @Override
+               // and make it public!
+               public List<Paragraph> requotify(Paragraph para, boolean html) {
+                       return super.requotify(para, html);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/ConversionTest.java b/src/be/nikiroo/fanfix/test/ConversionTest.java
new file mode 100644 (file)
index 0000000..607f49b
--- /dev/null
@@ -0,0 +1,284 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.Main;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class ConversionTest extends TestLauncher {
+       private String testUri;
+       private String expectedDir;
+       private String resultDir;
+       private List<BasicOutput.OutputType> realTypes;
+       private Map<String, List<String>> skipCompare;
+       private Map<String, List<String>> skipCompareCross;
+
+       public ConversionTest(String testName, final String testUri,
+                       final String expectedDir, final String resultDir, String[] args) {
+               super("Conversion - " + testName, args);
+
+               this.testUri = testUri;
+               this.expectedDir = expectedDir;
+               this.resultDir = resultDir;
+
+               // Special mode SYSOUT is not a file type (System.out)
+               realTypes = new ArrayList<BasicOutput.OutputType>();
+               for (BasicOutput.OutputType type : BasicOutput.OutputType.values()) {
+                       if (!BasicOutput.OutputType.SYSOUT.equals(type)) {
+                               realTypes.add(type);
+                       }
+               }
+
+               if (!testUri.startsWith("http://") && !testUri.startsWith("https://")) {
+                       addTest(new TestCase("Read the test file") {
+                               @Override
+                               public void test() throws Exception {
+                                       assertEquals("The test file \"" + testUri
+                                                       + "\" cannot be found", true,
+                                                       new File(testUri).exists());
+                               }
+                       });
+               }
+
+               addTest(new TestCase("Assure directories exist") {
+                       @Override
+                       public void test() throws Exception {
+                               new File(expectedDir).mkdirs();
+                               new File(resultDir).mkdirs();
+                               assertEquals("The Expected directory \"" + expectedDir
+                                               + "\" cannot be created", true,
+                                               new File(expectedDir).exists());
+                               assertEquals("The Result directory \"" + resultDir
+                                               + "\" cannot be created", true,
+                                               new File(resultDir).exists());
+                       }
+               });
+
+               for (BasicOutput.OutputType type : realTypes) {
+                       addTest(getTestFor(type));
+               }
+       }
+
+       @Override
+       protected void start() throws Exception {
+               skipCompare = new HashMap<String, List<String>>();
+               skipCompareCross = new HashMap<String, List<String>>();
+
+               skipCompare.put("epb.ncx", Arrays.asList(
+                               "               <meta name=\"dtb:uid\" content=",
+                               "               <meta name=\"epub-creator\" content=\""));
+               skipCompare.put("epb.opf", Arrays.asList("      <dc:subject>",
+                               "      <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"));
+               skipCompare.put(".info", Arrays.asList("CREATION_DATE=",
+                               "URL=\"file:/", "UUID=EPUBCREATOR=\"", ""));
+               skipCompare.put("URL", Arrays.asList("file:/"));
+
+               for (String key : skipCompare.keySet()) {
+                       skipCompareCross.put(key, skipCompare.get(key));
+               }
+
+               skipCompareCross.put(".info", Arrays.asList(""));
+               skipCompareCross.put("epb.opf", Arrays.asList("      <dc:"));
+               skipCompareCross.put("title.xhtml",
+                               Arrays.asList("                 <div class=\"type\">"));
+               skipCompareCross.put("index.html",
+                               Arrays.asList("                 <div class=\"type\">"));
+               skipCompareCross.put("URL", Arrays.asList(""));
+       }
+
+       @Override
+       protected void stop() throws Exception {
+       }
+
+       private TestCase getTestFor(final BasicOutput.OutputType type) {
+               return new TestCase(type + " output mode") {
+                       @Override
+                       public void test() throws Exception {
+                               File target = generate(this, testUri, new File(resultDir), type);
+                               target = new File(target.getAbsolutePath()
+                                               + type.getDefaultExtension(false));
+
+                               // Check conversion:
+                               compareFiles(this, new File(expectedDir), new File(resultDir),
+                                               type, "Generate " + type);
+
+                               // LATEX not supported as input
+                               if (BasicOutput.OutputType.LATEX.equals(type)) {
+                                       return;
+                               }
+
+                               // Cross-checks:
+                               for (BasicOutput.OutputType crossType : realTypes) {
+                                       File crossDir = Test.tempFiles
+                                                       .createTempDir("cross-result");
+
+                                       generate(this, target.getAbsolutePath(), crossDir,
+                                                       crossType);
+                                       compareFiles(this, new File(resultDir), crossDir,
+                                                       crossType, "Cross compare " + crossType
+                                                                       + " generated from " + type);
+                               }
+                       }
+               };
+       }
+
+       private File generate(TestCase testCase, String testUri, File resultDir,
+                       BasicOutput.OutputType type) throws Exception {
+               final List<String> errors = new ArrayList<String>();
+
+               TraceHandler previousTraceHandler = Instance.getTraceHandler();
+               Instance.setTraceHandler(new TraceHandler(true, true, 0) {
+                       @Override
+                       public void error(String message) {
+                               errors.add(message);
+                       }
+
+                       @Override
+                       public void error(Exception e) {
+                               error(" ");
+                               for (Throwable t = e; t != null; t = t.getCause()) {
+                                       error(((t == e) ? "(" : "..caused by: (")
+                                                       + t.getClass().getSimpleName() + ") "
+                                                       + t.getMessage());
+                                       for (StackTraceElement s : t.getStackTrace()) {
+                                               error("\t" + s.toString());
+                                       }
+                               }
+                       }
+               });
+
+               try {
+                       File target = new File(resultDir, type.toString());
+                       int code = Main.convert(testUri, type.toString(),
+                                       target.getAbsolutePath(), false, null);
+
+                       String error = "";
+                       for (String err : errors) {
+                               if (!error.isEmpty())
+                                       error += "\n";
+                               error += err;
+                       }
+                       testCase.assertEquals("The conversion returned an error message: "
+                                       + error, 0, errors.size());
+                       if (code != 0) {
+                               testCase.fail("The conversion failed with return code: " + code);
+                       }
+
+                       return target;
+               } finally {
+                       Instance.setTraceHandler(previousTraceHandler);
+               }
+       }
+
+       private void compareFiles(TestCase testCase, File expectedDir,
+                       File resultDir, final BasicOutput.OutputType limitTiFiles,
+                       final String errMess) throws Exception {
+
+               Map<String, List<String>> skipCompare = errMess.startsWith("Cross") ? this.skipCompareCross
+                               : this.skipCompare;
+
+               FilenameFilter filter = null;
+               if (limitTiFiles != null) {
+                       filter = new FilenameFilter() {
+                               @Override
+                               public boolean accept(File dir, String name) {
+                                       return name.toLowerCase().startsWith(
+                                                       limitTiFiles.toString().toLowerCase());
+                               }
+                       };
+               }
+
+               List<String> resultFiles;
+               List<String> expectedFiles;
+               {
+                       String[] resultArr = resultDir.list(filter);
+                       Arrays.sort(resultArr);
+                       resultFiles = Arrays.asList(resultArr);
+                       String[] expectedArr = expectedDir.list(filter);
+                       Arrays.sort(expectedArr);
+                       expectedFiles = Arrays.asList(expectedArr);
+               }
+
+               testCase.assertEquals(errMess, expectedFiles, resultFiles);
+
+               for (int i = 0; i < resultFiles.size(); i++) {
+                       File expected = new File(expectedDir, expectedFiles.get(i));
+                       File result = new File(resultDir, resultFiles.get(i));
+
+                       testCase.assertEquals(errMess + ": type mismatch: expected a "
+                                       + (expected.isDirectory() ? "directory" : "file")
+                                       + ", received a "
+                                       + (result.isDirectory() ? "directory" : "file"),
+                                       expected.isDirectory(), result.isDirectory());
+
+                       if (expected.isDirectory()) {
+                               compareFiles(testCase, expected, result, null, errMess);
+                               continue;
+                       }
+
+                       if (expected.getName().endsWith(".cbz")
+                                       || expected.getName().endsWith(".epub")) {
+                               File tmpExpected = Test.tempFiles.createTempDir(expected
+                                               .getName() + "[zip-content]");
+                               File tmpResult = Test.tempFiles.createTempDir(result.getName()
+                                               + "[zip-content]");
+                               IOUtils.unzip(expected, tmpExpected);
+                               IOUtils.unzip(result, tmpResult);
+                               compareFiles(testCase, tmpExpected, tmpResult, null, errMess);
+                       } else {
+                               List<String> expectedLines = Arrays.asList(IOUtils
+                                               .readSmallFile(expected).split("\n"));
+                               List<String> resultLines = Arrays.asList(IOUtils.readSmallFile(
+                                               result).split("\n"));
+
+                               String name = expected.getAbsolutePath();
+                               if (name.startsWith(expectedDir.getAbsolutePath())) {
+                                       name = expectedDir.getName()
+                                                       + name.substring(expectedDir.getAbsolutePath()
+                                                                       .length());
+                               }
+
+                               testCase.assertEquals(errMess + ": " + name
+                                               + ": the number of lines is not the same",
+                                               expectedLines.size(), resultLines.size());
+
+                               for (int j = 0; j < expectedLines.size(); j++) {
+                                       String expectedLine = expectedLines.get(j);
+                                       String resultLine = resultLines.get(j);
+
+                                       boolean skip = false;
+                                       for (Entry<String, List<String>> skipThose : skipCompare
+                                                       .entrySet()) {
+                                               for (String skipStart : skipThose.getValue()) {
+                                                       if (name.endsWith(skipThose.getKey())
+                                                                       && expectedLine.startsWith(skipStart)
+                                                                       && resultLine.startsWith(skipStart)) {
+                                                               skip = true;
+                                                       }
+                                               }
+                                       }
+
+                                       if (skip) {
+                                               continue;
+                                       }
+
+                                       testCase.assertEquals(errMess + ": line " + (j + 1)
+                                                       + " is not the same in file " + name, expectedLine,
+                                                       resultLine);
+                               }
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/LibraryTest.java b/src/be/nikiroo/fanfix/test/LibraryTest.java
new file mode 100644 (file)
index 0000000..cf85b71
--- /dev/null
@@ -0,0 +1,258 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class LibraryTest extends TestLauncher {
+       private BasicLibrary lib;
+       private File tmp;
+
+       public LibraryTest(String[] args) {
+               super("Library", args);
+
+               final String luid1 = "001"; // A
+               final String luid2 = "002"; // B
+               final String luid3 = "003"; // B then A, then B
+               final String name1 = "My story 1";
+               final String name2 = "My story 2";
+               final String name3 = "My story 3";
+               final String name3ex = "My story 3 [edited]";
+               final String source1 = "Source A";
+               final String source2 = "Source B";
+               final String author1 = "Unknown author";
+               final String author2 = "Another other otter author";
+
+               final String errMess = "The resulting stories in the list are not what we expected";
+
+               addSeries(new TestLauncher("Local", args) {
+                       {
+                               addTest(new TestCase("getList") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = lib.getList();
+                                               assertEquals(errMess, Arrays.asList(),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("save") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               lib.save(story(luid1, name1, source1, author1), luid1,
+                                                               null);
+
+                                               List<MetaData> metas = lib.getList();
+                                               assertEquals(errMess, Arrays.asList(name1),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("save more") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               lib.save(story(luid2, name2, source2, author1), luid2,
+                                                               null);
+
+                                               metas = lib.getList();
+                                               assertEquals(errMess, Arrays.asList(name1, name2),
+                                                               titlesAsList(metas));
+
+                                               lib.save(story(luid3, name3, source2, author1), luid3,
+                                                               null);
+
+                                               metas = lib.getList();
+                                               assertEquals(errMess,
+                                                               Arrays.asList(name1, name2, name3),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("save override luid (change author)") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               // same luid as a previous one
+                                               lib.save(story(luid3, name3ex, source2, author2),
+                                                               luid3, null);
+
+                                               List<MetaData> metas = lib.getList();
+                                               assertEquals(errMess,
+                                                               Arrays.asList(name1, name2, name3ex),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("getList with results") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = lib.getList();
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("getList by source") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               metas = lib.getListBySource(source1);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getListBySource(source2);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getListBySource(null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("getList by author") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               metas = lib.getListByAuthor(author1);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getListByAuthor(author2);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getListByAuthor(null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("changeType") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               lib.changeSource(luid3, source1, null);
+
+                                               metas = lib.getListBySource(source1);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getListBySource(source2);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getListBySource(null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("save override luid (change source)") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               // same luid as a previous one
+                                               lib.save(story(luid3, "My story 3", source2, author2),
+                                                               luid3, null);
+
+                                               metas = lib.getListBySource(source1);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getListBySource(source2);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getListBySource(null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+                       }
+               });
+       }
+
+       @Override
+       protected void start() throws Exception {
+               tmp = File.createTempFile(".test-fanfix", ".library");
+               tmp.delete();
+               tmp.mkdir();
+
+               lib = new LocalLibrary(tmp, OutputType.INFO_TEXT, OutputType.CBZ);
+       }
+
+       @Override
+       protected void stop() throws Exception {
+               IOUtils.deltree(tmp);
+       }
+
+       /**
+        * Return the (sorted) list of titles present in this list of
+        * {@link MetaData}s.
+        * 
+        * @param metas
+        *            the meta
+        * 
+        * @return the sorted list
+        */
+       private List<String> titlesAsList(List<MetaData> metas) {
+               List<String> list = new ArrayList<String>();
+               for (MetaData meta : metas) {
+                       list.add(meta.getTitle());
+               }
+
+               Collections.sort(list);
+               return list;
+       }
+
+       private Story story(String luid, String title, String source, String author) {
+               Story story = new Story();
+
+               MetaData meta = new MetaData();
+               meta.setLuid(luid);
+               meta.setTitle(title);
+               meta.setSource(source);
+               meta.setAuthor(author);
+               story.setMeta(meta);
+
+               Chapter resume = chapter(0, "Resume");
+               meta.setResume(resume);
+
+               List<Chapter> chapters = new ArrayList<Chapter>();
+               chapters.add(chapter(1, "Chap 1"));
+               chapters.add(chapter(2, "Chap 2"));
+               story.setChapters(chapters);
+
+               long words = 0;
+               for (Chapter chap : story.getChapters()) {
+                       words += chap.getWords();
+               }
+               meta.setWords(words);
+
+               return story;
+       }
+
+       private Chapter chapter(int number, String name) {
+               Chapter chapter = new Chapter(number, name);
+
+               List<Paragraph> paragraphs = new ArrayList<Paragraph>();
+               paragraphs.add(new Paragraph(Paragraph.ParagraphType.NORMAL,
+                               "some words in this paragraph please thank you", 8));
+
+               chapter.setParagraphs(paragraphs);
+
+               long words = 0;
+               for (Paragraph para : chapter.getParagraphs()) {
+                       words += para.getWords();
+               }
+               chapter.setWords(words);
+
+               return chapter;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/Test.java b/src/be/nikiroo/fanfix/test/Test.java
new file mode 100644 (file)
index 0000000..d772561
--- /dev/null
@@ -0,0 +1,177 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.TempFiles;
+import be.nikiroo.utils.resources.Bundles;
+import be.nikiroo.utils.test.TestLauncher;
+
+/**
+ * Tests for Fanfix.
+ * 
+ * @author niki
+ */
+public class Test extends TestLauncher {
+       //
+       // 4 files can control the test:
+       // - test/VERBOSE: enable verbose mode
+       // - test/OFFLINE: to forbid any downloading
+       // - test/URLS: to allow testing URLs
+       // - test/FORCE_REFRESH: to force a clear of the cache
+       //
+       // Note that test/CACHE can be kept, as it will contain all internet related
+       // files you need (if you allow URLs, run the test once which will populate
+       // the CACHE then go OFFLINE, it will still work).
+       //
+       // The test files will be:
+       // - test/*.url: URL to download in text format, content = URL
+       // - test/*.story: text mode story, content = story
+       //
+
+       /**
+        * The temporary files handler.
+        */
+       static TempFiles tempFiles;
+
+       /**
+        * Create the Fanfix {@link TestLauncher}.
+        * 
+        * @param args
+        *            the arguments to configure the number of columns and the ok/ko
+        *            {@link String}s
+        * @param urlsAllowed
+        *            allow testing URLs (<tt>.url</tt> files)
+        * 
+        * @throws IOException
+        */
+       public Test(String[] args, boolean urlsAllowed) throws IOException {
+               super("Fanfix", args);
+               Instance.setTraceHandler(null);
+               addSeries(new BasicSupportUtilitiesTest(args));
+               addSeries(new BasicSupportDeprecatedTest(args));
+               addSeries(new LibraryTest(args));
+
+               File sources = new File("test/");
+               if (sources.isDirectory()) {
+                       for (File file : sources.listFiles()) {
+                               if (file.isDirectory()) {
+                                       continue;
+                               }
+
+                               String expectedDir = new File(file.getParentFile(), "expected_"
+                                               + file.getName()).getAbsolutePath();
+                               String resultDir = new File(file.getParentFile(), "result_"
+                                               + file.getName()).getAbsolutePath();
+
+                               String uri;
+                               if (urlsAllowed && file.getName().endsWith(".url")) {
+                                       uri = IOUtils.readSmallFile(file).trim();
+                               } else if (file.getName().endsWith(".story")) {
+                                       uri = file.getAbsolutePath();
+                               } else {
+                                       continue;
+                               }
+
+                               addSeries(new ConversionTest(file.getName(), uri, expectedDir,
+                                               resultDir, args));
+                       }
+               }
+       }
+
+       /**
+        * Main entry point of the program.
+        * 
+        * @param args
+        *            the arguments passed to the {@link TestLauncher}s.
+        * @throws IOException
+        *             in case of I/O error
+        */
+       static public void main(String[] args) throws IOException {
+               Instance.init();
+
+               // Verbose mode:
+               boolean verbose = new File("test/VERBOSE").exists();
+
+               // Can force refresh
+               boolean forceRefresh = new File("test/FORCE_REFRESH").exists();
+
+               // Allow URLs:
+               boolean urlsAllowed = new File("test/URLS").exists();
+
+               
+               // Only download files if allowed:
+               boolean offline = new File("test/OFFLINE").exists();
+               Instance.getCache().setOffline(offline);
+
+
+               
+               int result = 0;
+               tempFiles = new TempFiles("fanfix-test");
+               try {
+                       File tmpConfig = tempFiles.createTempDir("fanfix-config");
+                       File localCache = new File("test/CACHE");
+                       prepareCache(localCache, forceRefresh);
+
+                       ConfigBundle config = new ConfigBundle();
+                       Bundles.setDirectory(tmpConfig.getAbsolutePath());
+                       config.setString(Config.CACHE_DIR, localCache.getAbsolutePath());
+                       config.setInteger(Config.CACHE_MAX_TIME_STABLE, -1);
+                       config.setInteger(Config.CACHE_MAX_TIME_CHANGING, -1);
+                       config.updateFile(tmpConfig.getPath());
+                       System.setProperty("CONFIG_DIR", tmpConfig.getAbsolutePath());
+
+                       Instance.init(true);
+                       Instance.getCache().setOffline(offline);
+
+                       TestLauncher tests = new Test(args, urlsAllowed);
+                       tests.setDetails(verbose);
+
+                       result = tests.launch();
+
+                       IOUtils.deltree(tmpConfig);
+                       prepareCache(localCache, forceRefresh);
+               } finally {
+                       // Test temp files
+                       tempFiles.close();
+
+                       // This is usually done in Fanfix.Main:
+                       Instance.getTempFiles().close();
+               }
+
+               System.exit(result);
+       }
+
+       /**
+        * Prepare the cache (or clean it up).
+        * <p>
+        * The cache directory will always exist if this method succeed
+        * 
+        * @param localCache
+        *            the cache directory
+        * @param forceRefresh
+        *            TRUE to force acache refresh (delete all files)
+        * 
+        * @throw IOException if the cache cannot be created
+        */
+       private static void prepareCache(File localCache, boolean forceRefresh)
+                       throws IOException {
+               // if needed
+               localCache.mkdirs();
+
+               if (!localCache.isDirectory()) {
+                       throw new IOException("Cannot get a cache");
+               }
+
+               // delete local cached files (_*) or all files if forceRefresh
+               for (File f : localCache.listFiles()) {
+                       if (forceRefresh || f.getName().startsWith("_")) {
+                               IOUtils.deltree(f);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TBrowsableWidget.java b/src/be/nikiroo/jexer/TBrowsableWidget.java
new file mode 100644 (file)
index 0000000..44fa710
--- /dev/null
@@ -0,0 +1,418 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ *
+ * @author David ROULET [niki@nikiroo.be]
+ * @version 2
+ */
+package be.nikiroo.jexer;
+
+import static jexer.TKeypress.kbBackTab;
+import static jexer.TKeypress.kbDown;
+import static jexer.TKeypress.kbEnd;
+import static jexer.TKeypress.kbEnter;
+import static jexer.TKeypress.kbHome;
+import static jexer.TKeypress.kbLeft;
+import static jexer.TKeypress.kbPgDn;
+import static jexer.TKeypress.kbPgUp;
+import static jexer.TKeypress.kbRight;
+import static jexer.TKeypress.kbShiftTab;
+import static jexer.TKeypress.kbTab;
+import static jexer.TKeypress.kbUp;
+import jexer.THScroller;
+import jexer.TScrollableWidget;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+/**
+ * This class represents a browsable {@link TWidget}, that is, a {@link TWidget}
+ * where you can use the keyboard or mouse to browse to one line to the next, or
+ * from left t right.
+ * 
+ * @author niki
+ */
+abstract public class TBrowsableWidget extends TScrollableWidget {
+       private int selectedRow;
+       private int selectedColumn;
+       private int yOffset;
+
+       /**
+        * The number of rows in this {@link TWidget}.
+        * 
+        * @return the number of rows
+        */
+       abstract protected int getRowCount();
+
+       /**
+        * The number of columns in this {@link TWidget}.
+        * 
+        * @return the number of columns
+        */
+       abstract protected int getColumnCount();
+
+       /**
+        * The virtual width of this {@link TWidget}, that is, the total width it
+        * can take to display all the data.
+        * 
+        * @return the width
+        */
+       abstract int getVirtualWidth();
+
+       /**
+        * The virtual height of this {@link TWidget}, that is, the total width it
+        * can take to display all the data.
+        * 
+        * @return the height
+        */
+       abstract int getVirtualHeight();
+
+       /**
+        * Basic setup of this class (called by all constructors)
+        */
+       private void setup() {
+               vScroller = new TVScroller(this, 0, 0, 1);
+               hScroller = new THScroller(this, 0, 0, 1);
+               fixScrollers();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        */
+       protected TBrowsableWidget(final TWidget parent) {
+               super(parent);
+               setup();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        * @param x
+        *            column relative to parent
+        * @param y
+        *            row relative to parent
+        * @param width
+        *            width of widget
+        * @param height
+        *            height of widget
+        */
+       protected TBrowsableWidget(final TWidget parent, final int x, final int y,
+                       final int width, final int height) {
+               super(parent, x, y, width, height);
+               setup();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        * @param enabled
+        *            if true assume enabled
+        */
+       protected TBrowsableWidget(final TWidget parent, final boolean enabled) {
+               super(parent, enabled);
+               setup();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        * @param enabled
+        *            if true assume enabled
+        * @param x
+        *            column relative to parent
+        * @param y
+        *            row relative to parent
+        * @param width
+        *            width of widget
+        * @param height
+        *            height of widget
+        */
+       protected TBrowsableWidget(final TWidget parent, final boolean enabled,
+                       final int x, final int y, final int width, final int height) {
+               super(parent, enabled, x, y, width, height);
+               setup();
+       }
+
+       /**
+        * The currently selected row (or -1 if no row is selected).
+        * 
+        * @return the selected row
+        */
+       public int getSelectedRow() {
+               return selectedRow;
+       }
+
+       /**
+        * The currently selected row (or -1 if no row is selected).
+        * <p>
+        * You may want to call {@link TBrowsableWidget#reflowData()} when done to
+        * see the changes.
+        * 
+        * @param selectedRow
+        *            the new selected row
+        * 
+        * @throws IndexOutOfBoundsException
+        *             when the index is out of bounds
+        */
+       public void setSelectedRow(int selectedRow) {
+               if (selectedRow < -1 || selectedRow >= getRowCount()) {
+                       throw new IndexOutOfBoundsException(String.format(
+                                       "Cannot set row %d on a table with %d rows", selectedRow,
+                                       getRowCount()));
+               }
+
+               this.selectedRow = selectedRow;
+       }
+
+       /**
+        * The currently selected column (or -1 if no column is selected).
+        * 
+        * @return the new selected column
+        */
+       public int getSelectedColumn() {
+               return selectedColumn;
+       }
+
+       /**
+        * The currently selected column (or -1 if no column is selected).
+        * <p>
+        * You may want to call {@link TBrowsableWidget#reflowData()} when done to
+        * see the changes.
+        * 
+        * @param selectedColumn
+        *            the new selected column
+        * 
+        * @throws IndexOutOfBoundsException
+        *             when the index is out of bounds
+        */
+       public void setSelectedColumn(int selectedColumn) {
+               if (selectedColumn < -1 || selectedColumn >= getColumnCount()) {
+                       throw new IndexOutOfBoundsException(String.format(
+                                       "Cannot set column %d on a table with %d columns",
+                                       selectedColumn, getColumnCount()));
+               }
+
+               this.selectedColumn = selectedColumn;
+       }
+
+       /**
+        * An offset on the Y position of the table, i.e., the number of rows to
+        * skip so the control can draw that many rows always on top.
+        * 
+        * @return the offset
+        */
+       public int getYOffset() {
+               return yOffset;
+       }
+
+       /**
+        * An offset on the Y position of the table, i.e., the number of rows that
+        * should always stay on top.
+        * 
+        * @param yOffset
+        *            the new offset
+        */
+       public void setYOffset(int yOffset) {
+               this.yOffset = yOffset;
+       }
+
+       @SuppressWarnings("unused")
+       public void dispatchMove(int fromRow, int toRow) {
+               reflowData();
+       }
+
+       @SuppressWarnings("unused")
+       public void dispatchEnter(int selectedRow) {
+               reflowData();
+       }
+
+       @Override
+       public void onMouseDown(final TMouseEvent mouse) {
+               if (mouse.isMouseWheelUp()) {
+                       vScroller.decrement();
+                       return;
+               }
+               if (mouse.isMouseWheelDown()) {
+                       vScroller.increment();
+                       return;
+               }
+
+               if ((mouse.getX() < getWidth() - 1) && (mouse.getY() < getHeight() - 1)) {
+                       if (vScroller.getValue() + mouse.getY() < getRowCount()) {
+                               selectedRow = vScroller.getValue() + mouse.getY()
+                                               - getYOffset();
+                       }
+                       dispatchEnter(selectedRow);
+                       return;
+               }
+
+               // Pass to children
+               super.onMouseDown(mouse);
+       }
+
+       @Override
+       public void onKeypress(final TKeypressEvent keypress) {
+               int maxX = getRowCount();
+               int prevSelectedRow = selectedRow;
+
+               int firstLineIndex = vScroller.getValue() - getYOffset() + 2;
+               int lastLineIndex = firstLineIndex - hScroller.getHeight()
+                               + getHeight() - 2 - 2;
+
+               if (keypress.equals(kbLeft)) {
+                       hScroller.decrement();
+               } else if (keypress.equals(kbRight)) {
+                       hScroller.increment();
+               } else if (keypress.equals(kbUp)) {
+                       if (maxX > 0 && selectedRow < maxX) {
+                               if (selectedRow > 0) {
+                                       if (selectedRow <= firstLineIndex) {
+                                               vScroller.decrement();
+                                       }
+                                       selectedRow--;
+                               } else {
+                                       selectedRow = 0;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbDown)) {
+                       if (maxX > 0) {
+                               if (selectedRow >= 0) {
+                                       if (selectedRow < maxX - 1) {
+                                               selectedRow++;
+                                               if (selectedRow >= lastLineIndex) {
+                                                       vScroller.increment();
+                                               }
+                                       }
+                               } else {
+                                       selectedRow = 0;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbPgUp)) {
+                       if (selectedRow >= 0) {
+                               vScroller.bigDecrement();
+                               selectedRow -= getHeight() - 1;
+                               if (selectedRow < 0) {
+                                       selectedRow = 0;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbPgDn)) {
+                       if (selectedRow >= 0) {
+                               vScroller.bigIncrement();
+                               selectedRow += getHeight() - 1;
+                               if (selectedRow > getRowCount() - 1) {
+                                       selectedRow = getRowCount() - 1;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbHome)) {
+                       if (getRowCount() > 0) {
+                               vScroller.toTop();
+                               selectedRow = 0;
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbEnd)) {
+                       if (getRowCount() > 0) {
+                               vScroller.toBottom();
+                               selectedRow = getRowCount() - 1;
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbTab)) {
+                       getParent().switchWidget(true);
+               } else if (keypress.equals(kbShiftTab) || keypress.equals(kbBackTab)) {
+                       getParent().switchWidget(false);
+               } else if (keypress.equals(kbEnter)) {
+                       if (selectedRow >= 0) {
+                               dispatchEnter(selectedRow);
+                       }
+               } else {
+                       // Pass other keys (tab etc.) on
+                       super.onKeypress(keypress);
+               }
+       }
+
+       @Override
+       public void onResize(TResizeEvent event) {
+               super.onResize(event);
+               reflowData();
+       }
+
+       @Override
+       public void reflowData() {
+               super.reflowData();
+               fixScrollers();
+       }
+
+       private void fixScrollers() {
+               int width = getWidth() - 1; // vertical prio
+               int height = getHeight();
+
+               // TODO: why did we do that before?
+               if (false) {
+                       width -= 2;
+                       height = -1;
+               }
+
+               int x = Math.max(0, width);
+               int y = Math.max(0, height - 1);
+
+               vScroller.setX(x);
+               vScroller.setHeight(height);
+               hScroller.setY(y);
+               hScroller.setWidth(width);
+
+               // TODO why did we use to add 2?
+               // + 2 (for the border of the window)
+
+               // virtual_size
+               // + the other scroll bar size
+               vScroller.setTopValue(0);
+               vScroller.setBottomValue(Math.max(0, getVirtualHeight() - getHeight()
+                               + hScroller.getHeight()));
+               hScroller.setLeftValue(0);
+               hScroller.setRightValue(Math.max(0, getVirtualWidth() - getWidth()
+                               + vScroller.getWidth()));
+       }
+}
diff --git a/src/be/nikiroo/jexer/TSizeConstraint.java b/src/be/nikiroo/jexer/TSizeConstraint.java
new file mode 100644 (file)
index 0000000..39daa20
--- /dev/null
@@ -0,0 +1,90 @@
+package be.nikiroo.jexer;
+
+import java.util.List;
+
+import jexer.TScrollableWidget;
+import jexer.TWidget;
+import jexer.event.TResizeEvent;
+import jexer.event.TResizeEvent.Type;
+
+public class TSizeConstraint {
+       private TWidget widget;
+       private Integer x1;
+       private Integer y1;
+       private Integer x2;
+       private Integer y2;
+
+       // TODO: include in the window classes I use?
+
+       public TSizeConstraint(TWidget widget, Integer x1, Integer y1, Integer x2,
+                       Integer y2) {
+               this.widget = widget;
+               this.x1 = x1;
+               this.y1 = y1;
+               this.x2 = x2;
+               this.y2 = y2;
+       }
+
+       public TWidget getWidget() {
+               return widget;
+       }
+
+       public Integer getX1() {
+               if (x1 != null && x1 < 0)
+                       return widget.getParent().getWidth() + x1;
+               return x1;
+       }
+
+       public Integer getY1() {
+               if (y1 != null && y1 < 0)
+                       return widget.getParent().getHeight() + y1;
+               return y1;
+       }
+
+       public Integer getX2() {
+               if (x2 != null && x2 <= 0)
+                       return widget.getParent().getWidth() - 2 + x2;
+               return x2;
+       }
+
+       public Integer getY2() {
+               if (y2 != null && y2 <= 0)
+                       return widget.getParent().getHeight() - 2 + y2;
+               return y2;
+       }
+
+       // coordinates < 0 = from the other side, x2 or y2 = 0 = max size
+       static public void setSize(List<TSizeConstraint> sizeConstraints, TWidget child,
+                       Integer x1, Integer y1, Integer x2, Integer y2) {
+               sizeConstraints.add(new TSizeConstraint(child, x1, y1, x2, y2));
+       }
+
+       static public void resize(List<TSizeConstraint> sizeConstraints) {
+               for (TSizeConstraint sizeConstraint : sizeConstraints) {
+                       TWidget widget = sizeConstraint.getWidget();
+                       Integer x1 = sizeConstraint.getX1();
+                       Integer y1 = sizeConstraint.getY1();
+                       Integer x2 = sizeConstraint.getX2();
+                       Integer y2 = sizeConstraint.getY2();
+
+                       if (x1 != null)
+                               widget.setX(x1);
+                       if (y1 != null)
+                               widget.setY(y1);
+
+                       if (x2 != null)
+                               widget.setWidth(x2 - widget.getX());
+                       if (y2 != null)
+                               widget.setHeight(y2 - widget.getY());
+
+                       // Resize the text field
+                       // TODO: why setW/setH/reflow not enough for the scrollbars?
+                       widget.onResize(new TResizeEvent(Type.WIDGET, widget.getWidth(),
+                                       widget.getHeight()));
+
+                       if (widget instanceof TScrollableWidget) {
+                               ((TScrollableWidget) widget).reflowData();
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTable.java b/src/be/nikiroo/jexer/TTable.java
new file mode 100644 (file)
index 0000000..45e5df2
--- /dev/null
@@ -0,0 +1,516 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ *
+ * @author David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.swing.table.TableModel;
+
+import be.nikiroo.jexer.TTableCellRenderer.CellRendererMode;
+import jexer.TAction;
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+
+/**
+ * A table widget to display and browse through tabular data.
+ * <p>
+ * Currently, you can only select a line (a row) at a time, but the data you
+ * present is still tabular. You also access the data in a tabular way (by
+ * <tt>(raw,column)</tt>).
+ * 
+ * @author niki
+ */
+public class TTable extends TBrowsableWidget {
+       // Default renderers use text mode
+       static private TTableCellRenderer defaultSeparatorRenderer = new TTableCellRendererText(
+                       CellRendererMode.SEPARATOR);
+       static private TTableCellRenderer defaultHeaderRenderer = new TTableCellRendererText(
+                       CellRendererMode.HEADER);
+       static private TTableCellRenderer defaultHeaderSeparatorRenderer = new TTableCellRendererText(
+                       CellRendererMode.HEADER_SEPARATOR);
+
+       private boolean showHeader;
+
+       private List<TTableColumn> columns = new ArrayList<TTableColumn>();
+       private TableModel model;
+
+       private int selectedColumn;
+
+       private TTableCellRenderer separatorRenderer;
+       private TTableCellRenderer headerRenderer;
+       private TTableCellRenderer headerSeparatorRenderer;
+
+       /**
+        * The action to perform when the user selects an item (clicks or enter).
+        */
+       private TAction enterAction = null;
+
+       /**
+        * The action to perform when the user navigates with keyboard.
+        */
+       private TAction moveAction = null;
+
+       /**
+        * Create a new {@link TTable}.
+        * 
+        * @param parent
+        *            the parent widget
+        * @param x
+        *            the X position
+        * @param y
+        *            the Y position
+        * @param width
+        *            the width of the {@link TTable}
+        * @param height
+        *            the height of the {@link TTable}
+        * @param enterAction
+        *            an action to call when a cell is selected
+        * @param moveAction
+        *            an action to call when the currently active cell is changed
+        */
+       public TTable(TWidget parent, int x, int y, int width, int height,
+                       final TAction enterAction, final TAction moveAction) {
+               this(parent, x, y, width, height, enterAction, moveAction, null, false);
+       }
+
+       /**
+        * Create a new {@link TTable}.
+        * 
+        * @param parent
+        *            the parent widget
+        * @param x
+        *            the X position
+        * @param y
+        *            the Y position
+        * @param width
+        *            the width of the {@link TTable}
+        * @param height
+        *            the height of the {@link TTable}
+        * @param enterAction
+        *            an action to call when a cell is selected
+        * @param moveAction
+        *            an action to call when the currently active cell is changed
+        * @param headers
+        *            the headers of the {@link TTable}
+        * @param showHeaders
+        *            TRUE to show the headers on screen
+        */
+       public TTable(TWidget parent, int x, int y, int width, int height,
+                       final TAction enterAction, final TAction moveAction,
+                       List<? extends Object> headers, boolean showHeaders) {
+               super(parent, x, y, width, height);
+
+               this.model = new TTableModel(new Object[][] {});
+               setSelectedRow(-1);
+               this.selectedColumn = -1;
+
+               setHeaders(headers, showHeaders);
+
+               this.enterAction = enterAction;
+               this.moveAction = moveAction;
+
+               reflowData();
+       }
+
+       /**
+        * The data model (containing the actual data) used by this {@link TTable},
+        * as with the usual Swing tables.
+        * 
+        * @return the model
+        */
+       public TableModel getModel() {
+               return model;
+       }
+
+       /**
+        * The data model (containing the actual data) used by this {@link TTable},
+        * as with the usual Swing tables.
+        * <p>
+        * Will reset all the rendering cells.
+        * 
+        * @param model
+        *            the new model
+        */
+       public void setModel(TableModel model) {
+               this.model = model;
+               reflowData();
+       }
+
+       /**
+        * The columns used by this {@link TTable} (you need to access them if you
+        * want to change the way they are rendered, for instance, or their size).
+        * 
+        * @return the columns
+        */
+       public List<TTableColumn> getColumns() {
+               return columns;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the separators (one separator
+        * between two data columns).
+        * 
+        * @return the renderer, or the default one if none is set (never NULL)
+        */
+       public TTableCellRenderer getSeparatorRenderer() {
+               return separatorRenderer != null ? separatorRenderer
+                               : defaultSeparatorRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the separators (one separator
+        * between two data columns).
+        * 
+        * @param separatorRenderer
+        *            the new renderer, or NULL to use the default renderer
+        */
+       public void setSeparatorRenderer(TTableCellRenderer separatorRenderer) {
+               this.separatorRenderer = separatorRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the headers (if
+        * {@link TTable#isShowHeader()} is enabled, the first line represents the
+        * headers with the column names).
+        * 
+        * @return the renderer, or the default one if none is set (never NULL)
+        */
+       public TTableCellRenderer getHeaderRenderer() {
+               return headerRenderer != null ? headerRenderer : defaultHeaderRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the headers (if
+        * {@link TTable#isShowHeader()} is enabled, the first line represents the
+        * headers with the column names).
+        * 
+        * @param headerRenderer
+        *            the new renderer, or NULL to use the default renderer
+        */
+       public void setHeaderRenderer(TTableCellRenderer headerRenderer) {
+               this.headerRenderer = headerRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} to use on separators in header lines (see
+        * the related methods to understand what each of them is).
+        * 
+        * @return the renderer, or the default one if none is set (never NULL)
+        */
+       public TTableCellRenderer getHeaderSeparatorRenderer() {
+               return headerSeparatorRenderer != null ? headerSeparatorRenderer
+                               : defaultHeaderSeparatorRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} to use on separators in header lines (see
+        * the related methods to understand what each of them is).
+        * 
+        * @param headerSeparatorRenderer
+        *            the new renderer, or NULL to use the default renderer
+        */
+       public void setHeaderSeparatorRenderer(
+                       TTableCellRenderer headerSeparatorRenderer) {
+               this.headerSeparatorRenderer = headerSeparatorRenderer;
+       }
+
+       /**
+        * Show the header row on this {@link TTable}.
+        * 
+        * @return TRUE if we show them
+        */
+       public boolean isShowHeader() {
+               return showHeader;
+       }
+
+       /**
+        * Show the header row on this {@link TTable}.
+        * 
+        * @param showHeader
+        *            TRUE to show them
+        */
+       public void setShowHeader(boolean showHeader) {
+               this.showHeader = showHeader;
+               setYOffset(showHeader ? 2 : 0);
+               reflowData();
+       }
+
+       /**
+        * Change the headers of the table.
+        * <p>
+        * Note that this method is a convenience method that will create columns of
+        * the corresponding names and set them. As such, the previous columns if
+        * any will be replaced.
+        * 
+        * @param headers
+        *            the new headers
+        */
+       public void setHeaders(List<? extends Object> headers) {
+               setHeaders(headers, showHeader);
+       }
+
+       /**
+        * Change the headers of the table.
+        * <p>
+        * Note that this method is a convenience method that will create columns of
+        * the corresponding names and set them in the same order. As such, the
+        * previous columns if any will be replaced.
+        * 
+        * @param headers
+        *            the new headers
+        * @param showHeader
+        *            TRUE to show them on screen
+        */
+       public void setHeaders(List<? extends Object> headers, boolean showHeader) {
+               if (headers == null) {
+                       headers = new ArrayList<Object>();
+               }
+
+               int i = 0;
+               this.columns = new ArrayList<TTableColumn>();
+               for (Object header : headers) {
+                       this.columns.add(new TTableColumn(i++, header, getModel()));
+               }
+
+               setShowHeader(showHeader);
+       }
+
+       /**
+        * Set the data and create a new {@link TTableModel} for them.
+        * 
+        * @param data
+        *            the data to set into this table, as an array of rows, that is,
+        *            an array of arrays of values
+        */
+
+       public void setRowData(Object[][] data) {
+               setRowData(TTableModel.convert(data));
+       }
+
+       /**
+        * Set the data and create a new {@link TTableModel} for them.
+        * 
+        * @param data
+        *            the data to set into this table, as a collection of rows, that
+        *            is, a collection of collections of values
+        */
+       public void setRowData(
+                       final Collection<? extends Collection<? extends Object>> data) {
+               setModel(new TTableModel(data));
+       }
+
+       /**
+        * The currently selected cell.
+        * 
+        * @return the cell
+        */
+       public Object getSelectedCell() {
+               int selectedRow = getSelectedRow();
+               if (selectedRow >= 0 && selectedColumn >= 0) {
+                       return model.getValueAt(selectedRow, selectedColumn);
+               }
+
+               return null;
+       }
+
+       @Override
+       public int getRowCount() {
+               if (model == null) {
+                       return 0;
+               }
+               return model.getRowCount();
+       }
+
+       @Override
+       public int getColumnCount() {
+               if (model == null) {
+                       return 0;
+               }
+               return model.getColumnCount();
+       }
+
+       @Override
+       public void dispatchEnter(int selectedRow) {
+               super.dispatchEnter(selectedRow);
+               if (enterAction != null) {
+                       enterAction.DO();
+               }
+       }
+
+       @Override
+       public void dispatchMove(int fromRow, int toRow) {
+               super.dispatchMove(fromRow, toRow);
+               if (moveAction != null) {
+                       moveAction.DO();
+               }
+       }
+
+       /**
+        * Clear the content of the {@link TTable}.
+        * <p>
+        * It will not affect the headers.
+        * <p>
+        * You may want to call {@link TTable#reflowData()} when done to see the
+        * changes.
+        */
+       public void clear() {
+               setSelectedRow(-1);
+               selectedColumn = -1;
+               setModel(new TTableModel(new Object[][] {}));
+       }
+
+       @Override
+       public void reflowData() {
+               super.reflowData();
+
+               int lastAutoColumn = -1;
+               int rowWidth = 0;
+
+               int i = 0;
+               for (TTableColumn tcol : columns) {
+                       tcol.reflowData();
+
+                       if (!tcol.isForcedWidth()) {
+                               lastAutoColumn = i;
+                       }
+
+                       rowWidth += tcol.getWidth();
+
+                       i++;
+               }
+
+               if (!columns.isEmpty()) {
+                       rowWidth += (i - 1) * getSeparatorRenderer().getWidthOf(null);
+
+                       int extraWidth = getWidth() - rowWidth;
+                       if (extraWidth > 0) {
+                               if (lastAutoColumn < 0) {
+                                       lastAutoColumn = columns.size() - 1;
+                               }
+                               TTableColumn tcol = columns.get(lastAutoColumn);
+                               tcol.expandWidthTo(tcol.getWidth() + extraWidth);
+                               rowWidth += extraWidth;
+                       }
+               }
+       }
+
+       @Override
+       public void draw() {
+               int begin = vScroller.getValue();
+               int y = this.showHeader ? 2 : 0;
+
+               if (showHeader) {
+                       CellAttributes colorHeaders = getHeaderRenderer()
+                                       .getCellAttributes(getTheme(), false, isAbsoluteActive());
+                       drawRow(-1, 0);
+                       String formatString = "%-" + Integer.toString(getWidth()) + "s";
+                       String data = String.format(formatString, "");
+                       getScreen().putStringXY(0, 1, data, colorHeaders);
+               }
+
+               // draw the actual rows until no more,
+               // then pad the rest with blank rows
+               for (int i = begin; i < getRowCount(); i++) {
+                       drawRow(i, y);
+                       y++;
+
+                       // -2: window borders
+                       if (y >= getHeight() - 2 - getHorizontalScroller().getHeight()) {
+                               break;
+                       }
+               }
+
+               CellAttributes emptyRowColor = getSeparatorRenderer()
+                               .getCellAttributes(getTheme(), false, isAbsoluteActive());
+               for (int i = getRowCount(); i < getHeight(); i++) {
+                       getScreen().hLineXY(0, y, getWidth() - 1, ' ', emptyRowColor);
+                       y++;
+               }
+       }
+
+       @Override
+       protected int getVirtualWidth() {
+               int width = 0;
+
+               if (getColumns() != null) {
+                       for (TTableColumn tcol : getColumns()) {
+                               width += tcol.getWidth();
+                       }
+
+                       if (getColumnCount() > 0) {
+                               width += (getColumnCount() - 1)
+                                               * getSeparatorRenderer().getWidthOf(null);
+                       }
+               }
+
+               return width;
+       }
+
+       @Override
+       protected int getVirtualHeight() {
+               // TODO: allow changing the height of one row
+               return (showHeader ? 2 : 0) + (getRowCount() * 1);
+       }
+
+       /**
+        * Draw the given row (it <b>MUST</b> exist) at the specified index and
+        * offset.
+        * 
+        * @param rowIndex
+        *            the index of the row to draw or -1 for the headers
+        * @param y
+        *            the Y position
+        */
+       private void drawRow(int rowIndex, int y) {
+               for (int i = 0; i < getColumnCount(); i++) {
+                       TTableColumn tcol = columns.get(i);
+                       Object value;
+                       if (rowIndex < 0) {
+                               value = tcol.getHeaderValue();
+                       } else {
+                               value = model.getValueAt(rowIndex, tcol.getModelIndex());
+                       }
+
+                       if (i > 0) {
+                               TTableCellRenderer sep = rowIndex < 0 ? getHeaderSeparatorRenderer()
+                                               : getSeparatorRenderer();
+                               sep.renderTableCell(this, null, rowIndex, i - 1, y);
+                       }
+
+                       if (rowIndex < 0) {
+                               getHeaderRenderer()
+                                               .renderTableCell(this, value, rowIndex, i, y);
+                       } else {
+                               tcol.getRenderer().renderTableCell(this, value, rowIndex, i, y);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableCellRenderer.java b/src/be/nikiroo/jexer/TTableCellRenderer.java
new file mode 100644 (file)
index 0000000..6d7b3b3
--- /dev/null
@@ -0,0 +1,240 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ *
+ * @author David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.ColorTheme;
+
+/**
+ * A {@link TTable} cell renderer allows you to customize the way a single cell
+ * will be displayed on screen.
+ * <p>
+ * It can be used in a {@link TTable} for the haeders or the separators or in a
+ * {@link TTableColumn} for the data.
+ * 
+ * @author niki
+ */
+abstract public class TTableCellRenderer {
+       private CellRendererMode mode;
+
+       /**
+        * The simple renderer mode.
+        * 
+        * @author niki
+        */
+       public enum CellRendererMode {
+               /** Normal text mode */
+               NORMAL,
+               /** Only display a separator */
+               SEPARATOR,
+               /** Header text mode */
+               HEADER,
+               /** Both HEADER and SEPARATOR at once */
+               HEADER_SEPARATOR;
+
+               /**
+                * This mode represents a separator.
+                * 
+                * @return TRUE for separators
+                */
+               public boolean isSeparator() {
+                       return this == SEPARATOR || this == HEADER_SEPARATOR;
+               }
+
+               /**
+                * This mode represents a header.
+                * 
+                * @return TRUE for headers
+                */
+               public boolean isHeader() {
+                       return this == HEADER || this == HEADER_SEPARATOR;
+               }
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode, cannot be NULL
+        */
+       public TTableCellRenderer(CellRendererMode mode) {
+               if (mode == null) {
+                       throw new IllegalArgumentException(
+                                       "Cannot create a renderer of type NULL");
+               }
+
+               this.mode = mode;
+       }
+
+       /**
+        * Render the given value.
+        * 
+        * @param table
+        *            the table to write on
+        * @param value
+        *            the value to write
+        * @param rowIndex
+        *            the row index in the table
+        * @param colIndex
+        *            the column index in the table
+        * @param y
+        *            the Y position at which to draw this row
+        */
+       abstract public void renderTableCell(TTable table, Object value,
+                       int rowIndex, int colIndex, int y);
+
+       /**
+        * The mode of this {@link TTableCellRenderer}.
+        * 
+        * @return the mode
+        */
+       public CellRendererMode getMode() {
+               return mode;
+       }
+
+       /**
+        * The cell attributes to use for the given state.
+        * 
+        * @param theme
+        *            the color theme to use
+        * @param isSelected
+        *            TRUE if the cell is selected
+        * @param hasFocus
+        *            TRUE if the cell has focus
+        * 
+        * @return the attributes
+        */
+       public CellAttributes getCellAttributes(ColorTheme theme,
+                       boolean isSelected, boolean hasFocus) {
+               return theme.getColor(getColorKey(isSelected, hasFocus));
+       }
+
+       /**
+        * Measure the width of the value.
+        * 
+        * @param value
+        *            the value to measure
+        * 
+        * @return its width
+        */
+       public int getWidthOf(Object value) {
+               if (getMode().isSeparator()) {
+                       return asText(null, 0, false).length();
+               }
+               return ("" + value).length();
+       }
+
+       /**
+        * The colour to use for the given state, specified as a Jexer colour key.
+        * 
+        * @param isSelected
+        *            TRUE if the cell is selected
+        * @param hasFocus
+        *            TRUE if the cell has focus
+        * 
+        * @return the colour key
+        */
+       protected String getColorKey(boolean isSelected, boolean hasFocus) {
+               if (mode.isHeader()) {
+                       return "tlabel";
+               }
+
+               String colorKey = "tlist";
+               if (isSelected) {
+                       colorKey += ".selected";
+               } else if (!hasFocus) {
+                       colorKey += ".inactive";
+               }
+
+               return colorKey;
+       }
+
+       /**
+        * Return the X offset to use to draw a column at the given index.
+        * 
+        * @param table
+        *            the table to draw into
+        * @param colIndex
+        *            the column index
+        * 
+        * @return the offset
+        */
+       protected int getXOffset(TTable table, int colIndex) {
+               int xOffset = -table.getHorizontalValue();
+               for (int i = 0; i <= colIndex; i++) {
+                       TTableColumn tcol = table.getColumns().get(i);
+                       xOffset += tcol.getWidth();
+                       if (i > 0) {
+                               xOffset += table.getSeparatorRenderer().getWidthOf(null);
+                       }
+               }
+
+               TTableColumn tcol = table.getColumns().get(colIndex);
+               if (!getMode().isSeparator()) {
+                       xOffset -= tcol.getWidth();
+               }
+
+               return xOffset;
+       }
+
+       /**
+        * Return the text to use (usually the converted-to-text value, except for
+        * the special separator mode).
+        * 
+        * @param value
+        *            the value to get the text of
+        * @param width
+        *            the width we should tale
+        * @param align
+        *            the text to the right
+        * 
+        * @return the {@link String} to display
+        */
+       protected String asText(Object value, int width, boolean rightAlign) {
+               if (getMode().isSeparator()) {
+                       // some nice characters for the separator: ┃ │ |
+                       return " │ ";
+               }
+
+               if (width <= 0) {
+                       return "";
+               }
+
+               String format;
+               if (!rightAlign) {
+                       // Left align
+                       format = "%-" + width + "s";
+               } else {
+                       // right align
+                       format = "%" + width + "s";
+               }
+
+               return String.format(format, value);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/jexer/TTableCellRendererText.java b/src/be/nikiroo/jexer/TTableCellRendererText.java
new file mode 100644 (file)
index 0000000..8f81883
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ *
+ * @author David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import jexer.bits.CellAttributes;
+
+/**
+ * A simple {@link TTableCellRenderer} that display the values within a
+ * {@link TLabel}.
+ * <p>
+ * It supports a few different modes, see
+ * {@link TTableOldSimpleTextCellRenderer.CellRendererMode}.
+ * 
+ * @author niki
+ */
+public class TTableCellRendererText extends TTableCellRenderer {
+       private boolean rightAlign;
+
+       /**
+        * Create a new renderer for normal text mode.
+        */
+       public TTableCellRendererText() {
+               this(CellRendererMode.NORMAL);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode
+        */
+       public TTableCellRendererText(CellRendererMode mode) {
+               this(mode, false);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode, cannot be NULL
+        */
+       public TTableCellRendererText(CellRendererMode mode,
+                       boolean rightAlign) {
+               super(mode);
+
+               this.rightAlign = rightAlign;
+       }
+
+       @Override
+       public void renderTableCell(TTable table, Object value, int rowIndex,
+                       int colIndex, int y) {
+
+               int xOffset = getXOffset(table, colIndex);
+               TTableColumn tcol = table.getColumns().get(colIndex);
+               String data = asText(value, tcol.getWidth(), rightAlign);
+
+               if (!data.isEmpty()) {
+                       boolean isSelected = table.getSelectedRow() == rowIndex;
+                       boolean hasFocus = table.isAbsoluteActive();
+                       CellAttributes color = getCellAttributes(table.getWindow()
+                                       .getApplication().getTheme(), isSelected, hasFocus);
+                       table.getScreen().putStringXY(xOffset, y, data, color);
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableCellRendererWidget.java b/src/be/nikiroo/jexer/TTableCellRendererWidget.java
new file mode 100644 (file)
index 0000000..22c6f47
--- /dev/null
@@ -0,0 +1,170 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ *
+ * @author David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jexer.TLabel;
+import jexer.TWidget;
+
+/**
+ * A simple {@link TTableCellRenderer} that display the values within a
+ * {@link TLabel}.
+ * <p>
+ * It supports a few different modes, see
+ * {@link TTableSimpleTextCellRenderer.CellRendererMode}.
+ * 
+ * @author niki
+ */
+public class TTableCellRendererWidget extends TTableCellRenderer {
+       private boolean rightAlign;
+       private Map<String, TWidget> widgets = new HashMap<String, TWidget>();
+
+       /**
+        * Create a new renderer for normal text mode.
+        */
+       public TTableCellRendererWidget() {
+               this(CellRendererMode.NORMAL);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode
+        */
+       public TTableCellRendererWidget(CellRendererMode mode) {
+               this(mode, false);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode, cannot be NULL
+        */
+       public TTableCellRendererWidget(CellRendererMode mode, boolean rightAlign) {
+               super(mode);
+
+               this.rightAlign = rightAlign;
+       }
+
+       @Override
+       public void renderTableCell(TTable table, Object value, int rowIndex,
+                       int colIndex, int y) {
+
+               String wkey = "[Row " + y + " " + getMode() + "]";
+               TWidget widget = widgets.get(wkey);
+
+               TTableColumn tcol = table.getColumns().get(colIndex);
+               boolean isSelected = table.getSelectedRow() == rowIndex;
+               boolean hasFocus = table.isAbsoluteActive();
+               int width = tcol.getWidth();
+
+               int xOffset = getXOffset(table, colIndex);
+
+               if (widget != null
+                               && !updateTableCellRendererComponent(widget, value, isSelected,
+                                               hasFocus, y, xOffset, width)) {
+                       table.removeChild(widget);
+                       widget = null;
+               }
+
+               if (widget == null) {
+                       widget = getTableCellRendererComponent(table, value, isSelected,
+                                       hasFocus, y, xOffset, width);
+               }
+
+               widgets.put(wkey, widget);
+       }
+
+       /**
+        * Create a new {@link TWidget} to represent the given value.
+        * 
+        * @param table
+        *            the parent {@link TTable}
+        * @param value
+        *            the value to represent
+        * @param isSelected
+        *            TRUE if selected
+        * @param hasFocus
+        *            TRUE if focused
+        * @param row
+        *            the row to draw it at
+        * @param column
+        *            the column to draw it at
+        * @param width
+        *            the width of the control
+        * 
+        * @return the widget
+        */
+       protected TWidget getTableCellRendererComponent(TTable table, Object value,
+                       boolean isSelected, boolean hasFocus, int row, int column, int width) {
+               return new TLabel(table, asText(value, width, rightAlign), column, row,
+                               getColorKey(isSelected, hasFocus), false);
+       }
+
+       /**
+        * Update the content of the widget if at all possible.
+        * 
+        * @param component
+        *            the component to update
+        * @param value
+        *            the value to represent
+        * @param isSelected
+        *            TRUE if selected
+        * @param hasFocus
+        *            TRUE if focused
+        * @param row
+        *            the row to draw it at
+        * @param column
+        *            the column to draw it at
+        * @param width
+        *            the width of the control
+        * 
+        * @return TRUE if the operation was possible, FALSE if it failed
+        */
+       protected boolean updateTableCellRendererComponent(TWidget component,
+                       Object value, boolean isSelected, boolean hasFocus, int row,
+                       int column, int width) {
+
+               if (component instanceof TLabel) {
+                       TLabel widget = (TLabel) component;
+                       widget.setLabel(asText(value, width, rightAlign));
+                       widget.setColorKey(getColorKey(isSelected, hasFocus));
+                       widget.setWidth(width);
+                       widget.setX(column);
+                       widget.setY(row);
+                       return true;
+               }
+
+               return false;
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableColumn.java b/src/be/nikiroo/jexer/TTableColumn.java
new file mode 100644 (file)
index 0000000..3eea230
--- /dev/null
@@ -0,0 +1,129 @@
+package be.nikiroo.jexer;
+
+import javax.swing.table.TableModel;
+
+import be.nikiroo.jexer.TTableCellRenderer.CellRendererMode;
+
+public class TTableColumn {
+       static private TTableCellRenderer defaultrenderer = new TTableCellRendererText(
+                       CellRendererMode.NORMAL);
+
+       private TableModel model;
+       private int modelIndex;
+       private int width;
+       private boolean forcedWidth;
+
+       private TTableCellRenderer renderer;
+
+       /** The auto-computed width of the column (the width of the largest value) */
+       private int autoWidth;
+
+       private Object headerValue;
+
+       public TTableColumn(int modelIndex) {
+               this(modelIndex, null);
+       }
+
+       public TTableColumn(int modelIndex, String colName) {
+               this(modelIndex, colName, null);
+       }
+
+       // set the width and preferred with the the max data size
+       public TTableColumn(int modelIndex, Object colValue, TableModel model) {
+               this.model = model;
+               this.modelIndex = modelIndex;
+
+               reflowData();
+
+               if (colValue != null) {
+                       setHeaderValue(colValue);
+               }
+       }
+
+       // never null
+       public TTableCellRenderer getRenderer() {
+               return renderer != null ? renderer : defaultrenderer;
+       }
+
+       public void setCellRenderer(TTableCellRenderer renderer) {
+               this.renderer = renderer;
+       }
+
+       /**
+        * Recompute whatever data is displayed by this widget.
+        * <p>
+        * Will just update the sizes in this case.
+        */
+       public void reflowData() {
+               if (model != null) {
+                       int maxDataSize = 0;
+                       for (int i = 0; i < model.getRowCount(); i++) {
+                               maxDataSize = Math.max(
+                                               maxDataSize,
+                                               getRenderer().getWidthOf(
+                                                               model.getValueAt(i, modelIndex)));
+                       }
+
+                       autoWidth = maxDataSize;
+                       if (!forcedWidth) {
+                               setWidth(maxDataSize);
+                       }
+               } else {
+                       autoWidth = 0;
+                       forcedWidth = false;
+                       width = 0;
+               }
+       }
+
+       public int getModelIndex() {
+               return modelIndex;
+       }
+
+       /**
+        * The actual size of the column. This can be auto-computed in some cases.
+        * 
+        * @return the width (never &lt; 0)
+        */
+       public int getWidth() {
+               return width;
+       }
+
+       /**
+        * Set the actual size of the column or -1 for auto size.
+        * 
+        * @param width
+        *            the width (or -1 for auto)
+        */
+       public void setWidth(int width) {
+               forcedWidth = width >= 0;
+
+               if (forcedWidth) {
+                       this.width = width;
+               } else {
+                       this.width = autoWidth;
+               }
+       }
+
+       /**
+        * The width was forced by the user (using
+        * {@link TTableColumn#setWidth(int)} with a positive value).
+        * 
+        * @return TRUE if it was
+        */
+       public boolean isForcedWidth() {
+               return forcedWidth;
+       }
+
+       // not an actual forced width, but does change the width return
+       void expandWidthTo(int width) {
+               this.width = width;
+       }
+
+       public Object getHeaderValue() {
+               return headerValue;
+       }
+
+       public void setHeaderValue(Object headerValue) {
+               this.headerValue = headerValue;
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableLine.java b/src/be/nikiroo/jexer/TTableLine.java
new file mode 100644 (file)
index 0000000..f393621
--- /dev/null
@@ -0,0 +1,135 @@
+package be.nikiroo.jexer;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+
+public class TTableLine implements List<String> {
+       //TODO: in TTable: default to header of size 1
+       private List<String> list;
+
+       public TTableLine(List<String> list) {
+               this.list = list;
+       }
+
+       // TODO: override this and the rest shall follow
+       protected List<String> getList() {
+               return list;
+       }
+
+       @Override
+       public int size() {
+               return getList().size();
+       }
+
+       @Override
+       public boolean isEmpty() {
+               return getList().isEmpty();
+       }
+
+       @Override
+       public boolean contains(Object o) {
+               return getList().contains(o);
+       }
+
+       @Override
+       public Iterator<String> iterator() {
+               return getList().iterator();
+       }
+
+       @Override
+       public Object[] toArray() {
+               return getList().toArray();
+       }
+
+       @Override
+       public <T> T[] toArray(T[] a) {
+               return getList().toArray(a);
+       }
+
+       @Override
+       public boolean containsAll(Collection<?> c) {
+               return getList().containsAll(c);
+       }
+
+       @Override
+       public String get(int index) {
+               return getList().get(index);
+       }
+
+       @Override
+       public int indexOf(Object o) {
+               return getList().indexOf(o);
+       }
+
+       @Override
+       public int lastIndexOf(Object o) {
+               return getList().lastIndexOf(o);
+       }
+
+       @Override
+       public List<String> subList(int fromIndex, int toIndex) {
+               return getList().subList(fromIndex, toIndex);
+       }
+
+       @Override
+       public ListIterator<String> listIterator() {
+               return getList().listIterator();
+       }
+
+       @Override
+       public ListIterator<String> listIterator(int index) {
+               return getList().listIterator(index);
+       }
+
+       @Override
+       public boolean add(String e) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean remove(Object o) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean addAll(Collection<? extends String> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean addAll(int index, Collection<? extends String> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean removeAll(Collection<?> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean retainAll(Collection<?> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public void clear() {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public String set(int index, String element) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public void add(int index, String element) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public String remove(int index) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableModel.java b/src/be/nikiroo/jexer/TTableModel.java
new file mode 100644 (file)
index 0000000..cd86d35
--- /dev/null
@@ -0,0 +1,176 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ *
+ * @author David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+
+import javax.swing.event.TableModelListener;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.table.TableModel;
+
+/**
+ * The model of a {@link TTable}. It contains the data of the table and allows
+ * you access to it.
+ * <p>
+ * Note that you don't need to send it the representation of the data, but the
+ * data itself; {@link TTableCellRenderer} is the class responsible of
+ * representing that data (you can change the headers renderer on a
+ * {@link TTable} and the cells renderer on each of its {@link TTableColumn}).
+ * <p>
+ * It works in a similar way to the Java Swing version of it.
+ * 
+ * @author niki
+ */
+public class TTableModel implements TableModel {
+       private TableModel model;
+
+       /**
+        * Create a new {@link TTableModel} with the given data inside.
+        * 
+        * @param data
+        *            the data
+        */
+       public TTableModel(Object[][] data) {
+               this(convert(data));
+       }
+
+       /**
+        * Create a new {@link TTableModel} with the given data inside.
+        * 
+        * @param data
+        *            the data
+        */
+       public TTableModel(
+                       final Collection<? extends Collection<? extends Object>> data) {
+
+               int maxItemsPerRow = 0;
+               for (Collection<? extends Object> rowOfData : data) {
+                       maxItemsPerRow = Math.max(maxItemsPerRow, rowOfData.size());
+               }
+
+               int i = 0;
+               final Object[][] odata = new Object[data.size()][maxItemsPerRow];
+               for (Collection<? extends Object> rowOfData : data) {
+                       odata[i] = new String[maxItemsPerRow];
+                       int j = 0;
+                       for (Object pieceOfData : rowOfData) {
+                               odata[i][j] = pieceOfData;
+                               j++;
+                       }
+                       i++;
+               }
+
+               final int maxItemsPerRowFinal = maxItemsPerRow;
+               this.model = new AbstractTableModel() {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public Object getValueAt(int rowIndex, int columnIndex) {
+                               return odata[rowIndex][columnIndex];
+                       }
+
+                       @Override
+                       public int getRowCount() {
+                               return odata.length;
+                       }
+
+                       @Override
+                       public int getColumnCount() {
+                               return maxItemsPerRowFinal;
+                       }
+               };
+       }
+
+       @Override
+       public int getRowCount() {
+               return model.getRowCount();
+       }
+
+       @Override
+       public int getColumnCount() {
+               return model.getColumnCount();
+       }
+
+       @Override
+       public String getColumnName(int columnIndex) {
+               return model.getColumnName(columnIndex);
+       }
+
+       @Override
+       public Class<?> getColumnClass(int columnIndex) {
+               return model.getColumnClass(columnIndex);
+       }
+
+       @Override
+       public boolean isCellEditable(int rowIndex, int columnIndex) {
+               return model.isCellEditable(rowIndex, columnIndex);
+       }
+
+       @Override
+       public Object getValueAt(int rowIndex, int columnIndex) {
+               return model.getValueAt(rowIndex, columnIndex);
+       }
+
+       @Override
+       public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
+               model.setValueAt(aValue, rowIndex, columnIndex);
+       }
+
+       @Override
+       public void addTableModelListener(TableModelListener l) {
+               model.addTableModelListener(l);
+       }
+
+       @Override
+       public void removeTableModelListener(TableModelListener l) {
+               model.removeTableModelListener(l);
+       }
+
+       /**
+        * Helper method to convert an array to a collection.
+        * 
+        * @param <T>
+        * 
+        * @param data
+        *            the data
+        * 
+        * @return the data in another format
+        */
+       static <T> Collection<Collection<T>> convert(T[][] data) {
+               Collection<Collection<T>> dataCollection = new ArrayList<Collection<T>>(
+                               data.length);
+               for (T pieceOfData[] : data) {
+                       dataCollection.add(Arrays.asList(pieceOfData));
+               }
+
+               return dataCollection;
+       }
+}
similarity index 100%
rename from Cache.java
rename to src/be/nikiroo/utils/Cache.java
similarity index 100%
rename from Image.java
rename to src/be/nikiroo/utils/Image.java
similarity index 100%
rename from Proxy.java
rename to src/be/nikiroo/utils/Proxy.java
diff --git a/test/expected_test.story/cbz.cbz b/test/expected_test.story/cbz.cbz
new file mode 100644 (file)
index 0000000..28cc25b
Binary files /dev/null and b/test/expected_test.story/cbz.cbz differ
diff --git a/test/expected_test.story/epub.epub b/test/expected_test.story/epub.epub
new file mode 100644 (file)
index 0000000..776d05d
Binary files /dev/null and b/test/expected_test.story/epub.epub differ
diff --git a/test/expected_test.story/html/html.info b/test/expected_test.story/html/html.info
new file mode 100644 (file)
index 0000000..506e477
--- /dev/null
@@ -0,0 +1,18 @@
+TITLE="The trials of Fanfan"
+AUTHOR="UnknownArtist366"
+DATE="2018"
+SUBJECT="test"
+SOURCE="text"
+URL="file:/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+TAGS=""
+UUID="/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+LUID=""
+LANG="en"
+IMAGES_DOCUMENT="false"
+TYPE="html"
+COVER=""
+EPUBCREATOR="Fanfix (by Niki)"
+PUBLISHER=""
+WORDCOUNT="57"
+CREATION_DATE="2018-03-28 08:40:18"
+FAKE_COVER="false"
diff --git a/test/expected_test.story/html/html.txt b/test/expected_test.story/html/html.txt
new file mode 100644 (file)
index 0000000..a3a3af3
--- /dev/null
@@ -0,0 +1,27 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+——————————————————————
+
+This ‘story’ is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+—————————————————
+
+“Yes, quotes!”, I said.
+‘Those can start with a single- or double quote sign’, I continued.
+“They also supports other characters, and an optionnal leading dash.”
+“The optionnal leading dash is enough to signify “quote”.”
+
+Chapter 2: “Quote” test
+———————————————————————
+
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
diff --git a/test/expected_test.story/html/index.html b/test/expected_test.story/html/index.html
new file mode 100644 (file)
index 0000000..aade9c3
--- /dev/null
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html>
+<head>
+       <meta http-equiv='content-type' content='text/html; charset=utf-8'>
+       <meta name='viewport' content='width=device-width, initial-scale=1.0'>
+       <link rel='stylesheet' type='text/css' href='style.css'>
+       <title>The trials of Fanfan</title>
+</head>
+<body>
+
+       <div class="titlepage">
+               <h1>The trials of Fanfan</h1>
+                       <div class="type"></div>
+               <div class="cover">
+                       <img src="cover.png"></img>
+               </div>
+               <div class="author">UnknownArtist366</div>
+       </div>
+       <hr/><br/>              <div class='normals'>
+               <span class='normal'>This ‘story’ is nothing more than a test file to check</span>
+               <span class='normal'>that the program can convert it into different</span>
+               <span class='normal'>formats correctly.</span>
+               </div>
+
+       <br/>
+       <h2>
+               <span class='chap'>Chapter <span class='chapnumber'>1</span>:</span> 
+               <span class='chaptitle'>Quotes</span>
+       </h2>
+       
+       <div class='chapter_content'>
+               <div class='dialogues'>
+                       <div class='dialogue'>&mdash; “Yes, quotes!”, I said.</div>
+                       <div class='dialogue'>&mdash; ‘Those can start with a single- or double quote sign’, I continued.</div>
+                       <div class='dialogue'>&mdash; “They also supports other characters, and an optionnal leading dash.”</div>
+                       <div class='dialogue'>&mdash; “The optionnal leading dash is enough to signify “quote”.”</div>
+               </div>
+
+       </div>
+       <h2>
+               <span class='chap'>Chapter <span class='chapnumber'>2</span>:</span> 
+               <span class='chaptitle'>“Quote” test</span>
+       </h2>
+       
+       <div class='chapter_content'>
+               <div class='normals'>
+               <span class='normal'>This test was just for the chapter title.</span>
+               <span class='normal'>We can also check for breaks.</span>
+               </div>
+               <hr class='break'/>
+               <div class='normals'>
+               <span class='normal'>This was a break space.</span>
+               </div>
+
+       </div></body>
diff --git a/test/expected_test.story/html/style.css b/test/expected_test.story/html/style.css
new file mode 100644 (file)
index 0000000..6b6d0d2
--- /dev/null
@@ -0,0 +1,112 @@
+html {
+       text-align: justify;
+       max-width: 800px;
+       margin: auto;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       NO_text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+}
+
+.normal {
+       /* Can be removed if you want a more "compact" view */
+       display: block;
+}
+
+.blank {
+       /* Can be removed if you want a more "compact" view */
+       height: 24px;
+       width: 100%;
+}
+
+hr.break {
+       /* Can be removed if you want a more "compact" view */
+       margin-top: 48px;
+       margin-bottom: 48px;
+}
+
+.dialogues {
+}
+
+.dialogue {
+       font-style: italic;
+}
diff --git a/test/expected_test.story/info_text b/test/expected_test.story/info_text
new file mode 100644 (file)
index 0000000..768b3cc
--- /dev/null
@@ -0,0 +1,24 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+
+This 'story' is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+
+"Yes, quotes!", I said.
+'Those can start with a single- or double quote sign', I continued.
+"They also supports other characters, and an optionnal leading dash."
+"The optionnal leading dash is enough to signify "quote"."
+
+Chapter 2: “Quote” test
+
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
diff --git a/test/expected_test.story/info_text.info b/test/expected_test.story/info_text.info
new file mode 100644 (file)
index 0000000..cc522dd
--- /dev/null
@@ -0,0 +1,18 @@
+TITLE="The trials of Fanfan"
+AUTHOR="UnknownArtist366"
+DATE="2018"
+SUBJECT="test"
+SOURCE="text"
+URL="file:/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+TAGS=""
+UUID="/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+LUID=""
+LANG="en"
+IMAGES_DOCUMENT="false"
+TYPE="info_text"
+COVER=""
+EPUBCREATOR="Fanfix (by Niki)"
+PUBLISHER=""
+WORDCOUNT="57"
+CREATION_DATE="2018-03-28 08:39:39"
+FAKE_COVER="false"
diff --git a/test/expected_test.story/latex.tex b/test/expected_test.story/latex.tex
new file mode 100644 (file)
index 0000000..d4dd68f
--- /dev/null
@@ -0,0 +1,44 @@
+%
+% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.
+%
+
+\documentclass[a4paper]{book}
+\usepackage[english]{babel}
+\usepackage[utf8]{inputenc}
+\usepackage[T1]{fontenc}
+\usepackage{lmodern}
+\newcommand{\br}{\vspace{10 mm}}
+\newcommand{\say}{--- \noindent\emph}
+\hyphenpenalty=1000
+\tolerance=5000
+\begin{document}
+\date{2018}
+\title{The trials of Fanfan}
+\author{UnknownArtist366}
+\maketitle
+
+
+
+\chapter{Quotes}
+
+\say{``Yes, quotes!'', I said.}
+
+\say{`Those can start with a single- or double quote sign', I continued.}
+
+\noindent{}
+\say{``They also supports other characters, and an optionnal leading dash.''}
+
+\noindent{}
+\say{``The optionnal leading dash is enough to signify ``quote''.''}
+
+\noindent{}
+
+
+\chapter{``Quote'' test}
+This test was just for the chapter title.
+We can also check for breaks.
+
+\br
+This was a break space.
+
+\end{document}
diff --git a/test/expected_test.story/text.txt b/test/expected_test.story/text.txt
new file mode 100644 (file)
index 0000000..a3a3af3
--- /dev/null
@@ -0,0 +1,27 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+——————————————————————
+
+This ‘story’ is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+—————————————————
+
+“Yes, quotes!”, I said.
+‘Those can start with a single- or double quote sign’, I continued.
+“They also supports other characters, and an optionnal leading dash.”
+“The optionnal leading dash is enough to signify “quote”.”
+
+Chapter 2: “Quote” test
+———————————————————————
+
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
diff --git a/test/test.story b/test/test.story
new file mode 100644 (file)
index 0000000..7365b60
--- /dev/null
@@ -0,0 +1,23 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+
+This 'story' is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+- "Yes, quotes!", I said.
+'Those can start with a single- or double quote sign', I continued.
+- «They also supports other characters, and an optionnal leading dash.»
+- The optionnal leading dash is enough to signify "quote".
+
+Chapter 2: "Quote" test
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
+