--- /dev/null
+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);
+ }
+ });
+ }
+}