cached lib can now getStory()
[fanfix.git] / src / be / nikiroo / fanfix / library / BasicLibrary.java
index cf17a2bef49fce8591150c6e93eb50eb9b41a30b..b8b8185cd50f75b915d732901ce8a79814bb9727 100644 (file)
@@ -1,12 +1,14 @@
 package be.nikiroo.fanfix.library;
 
-import java.awt.image.BufferedImage;
 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;
@@ -14,8 +16,10 @@ 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.BasicSupport.SupportType;
+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.
@@ -29,6 +33,42 @@ import be.nikiroo.utils.Progress;
  * @author niki
  */
 abstract public class BasicLibrary {
+       /**
+        * A {@link BasicLibrary} status.
+        * 
+        * @author niki
+        */
+       public enum Status {
+               /** The library is ready. */
+               READY,
+               /** The library is invalid (not correctly set up). */
+               INVALID,
+               /** You are not allowed to access this library. */
+               UNAUTORIZED,
+               /** The library is currently out of commission. */
+               UNAVAILABLE,
+       }
+
+       /**
+        * 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.READY;
+       }
+
        /**
         * Retrieve the main {@link File} corresponding to the given {@link Story},
         * which can be passed to an external reader or instance.
@@ -37,10 +77,12 @@ abstract public class BasicLibrary {
         * 
         * @param luid
         *            the Library UID of the story
+        * @param pg
+        *            the optional {@link Progress}
         * 
         * @return the corresponding {@link Story}
         */
-       public abstract File getFile(String luid);
+       public abstract File getFile(String luid, Progress pg);
 
        /**
         * Return the cover image associated to this story.
@@ -50,19 +92,25 @@ abstract public class BasicLibrary {
         * 
         * @return the cover image
         */
-       public abstract BufferedImage getCover(String luid);
+       public abstract Image getCover(String luid);
 
        /**
         * Return the cover image associated to this source.
         * <p>
-        * By default, return the cover of the first story with this source.
+        * By default, return the custom cover if any, and if not, return the cover
+        * of the first story with this source.
         * 
         * @param source
         *            the source
         * 
         * @return the cover image or NULL
         */
-       public BufferedImage getSourceCover(String source) {
+       public Image getSourceCover(String source) {
+               Image custom = getCustomSourceCover(source);
+               if (custom != null) {
+                       return custom;
+               }
+
                List<MetaData> metas = getListBySource(source);
                if (metas.size() > 0) {
                        return getCover(metas.get(0).getLuid());
@@ -72,7 +120,60 @@ abstract public class BasicLibrary {
        }
 
        /**
-        * Fix the source cover to the given story cover.
+        * 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
+        */
+       public Image getAuthorCover(String author) {
+               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
+        */
+       public Image getCustomSourceCover(@SuppressWarnings("unused") String source) {
+               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
+        */
+       public Image getCustomAuthorCover(@SuppressWarnings("unused") String author) {
+               return null;
+       }
+
+       /**
+        * Set the source cover to the given story cover.
         * 
         * @param source
         *            the source to change
@@ -81,6 +182,16 @@ abstract public class BasicLibrary {
         */
        public abstract void setSourceCover(String source, String luid);
 
+       /**
+        * Set the author cover to the given story cover.
+        * 
+        * @param source
+        *            the author to change
+        * @param luid
+        *            the story LUID
+        */
+       public abstract void setAuthorCover(String author, String luid);
+
        /**
         * Return the list of stories (represented by their {@link MetaData}, which
         * <b>MAY</b> not have the cover included).
@@ -96,7 +207,29 @@ abstract public class BasicLibrary {
         * Invalidate the {@link Story} cache (when the content should be re-read
         * because it was changed).
         */
-       protected abstract void clearCache();
+       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
+        */
+       protected abstract void updateInfo(MetaData meta);
 
        /**
         * Return the next LUID that can be used.
@@ -134,26 +267,14 @@ abstract public class BasicLibrary {
                        throws IOException;
 
        /**
-        * Refresh the {@link BasicLibrary}, that is, make sure all stories are
+        * Refresh the {@link BasicLibrary}, that is, make sure all metas are
         * loaded.
         * 
-        * @param full
-        *            force the full content of the stories to be loaded, not just
-        *            the {@link MetaData}
-        * 
         * @param pg
         *            the optional progress reporter
         */
-       public void refresh(boolean full, Progress pg) {
-               if (full) {
-                       // TODO: progress
-                       List<MetaData> metas = getMetas(pg);
-                       for (MetaData meta : metas) {
-                               getStory(meta.getLuid(), null);
-                       }
-               } else {
-                       getMetas(pg);
-               }
+       public void refresh(Progress pg) {
+               getMetas(pg);
        }
 
        /**
@@ -174,6 +295,47 @@ abstract public class BasicLibrary {
                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
+        */
+       public synchronized Map<String, List<String>> getSourcesGrouped() {
+               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.
         * 
@@ -192,10 +354,130 @@ abstract public class BasicLibrary {
                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)
+        */
+       public Map<String, List<String>> getAuthorsGrouped() {
+               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 fulfill 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 not included.
+        * Cover images <b>MAYBE</b> not included.
         * 
         * @return the stories
         */
@@ -283,35 +565,80 @@ abstract public class BasicLibrary {
         * @return the corresponding {@link Story} or NULL if not found
         */
        public synchronized Story getStory(String luid, Progress pg) {
-               // TODO: pg
+               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
+        */
+       public synchronized Story getStory(String luid, MetaData meta, Progress pg) {
+
                if (pg == null) {
                        pg = new Progress();
                }
 
-               Story story = null;
-               for (MetaData meta : getMetas(null)) {
-                       if (meta.getLuid().equals(luid)) {
-                               File file = getFile(luid);
-                               try {
-                                       SupportType type = SupportType.valueOfAllOkUC(meta
-                                                       .getType());
-                                       URL url = file.toURI().toURL();
-                                       if (type != null) {
-                                               story = BasicSupport.getSupport(type).process(url, pg);
-                                       } else {
-                                               throw new IOException("Unknown type: " + meta.getType());
-                                       }
-                               } catch (IOException e) {
-                                       // We should not have not-supported files in the
-                                       // library
-                                       Instance.syserr(new IOException(
-                                                       "Cannot load file from library: " + file, e));
-                               } finally {
-                                       pg.done();
-                               }
+               Progress pgGet = new Progress();
+               Progress pgProcess = new Progress();
 
-                               break;
+               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("Cannot load file from library: "
+                                                       + file, e));
+               } finally {
+                       pgProcess.done();
+                       pg.done();
                }
 
                return story;
@@ -328,16 +655,30 @@ abstract public class BasicLibrary {
         * 
         * @return the imported {@link Story}
         * 
+        * @throws UnknownHostException
+        *             if the host is not supported
         * @throws IOException
         *             in case of I/O error
         */
        public Story 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 IOException("URL not supported: " + url.toString());
+                       throw new UnknownHostException("" + url);
                }
 
-               return save(support.process(url, pg), null);
+               Story story = save(support.process(pgProcess), pgSave);
+               pg.done();
+
+               return story;
        }
 
        /**
@@ -402,7 +743,7 @@ abstract public class BasicLibrary {
                        pg.addProgress(pgOut, 1);
                }
 
-               BasicOutput out = BasicOutput.getOutput(type, false);
+               BasicOutput out = BasicOutput.getOutput(type, false, false);
                if (out == null) {
                        throw new IOException("Output type not supported: " + type);
                }
@@ -452,6 +793,10 @@ abstract public class BasicLibrary {
         */
        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);
@@ -462,13 +807,17 @@ abstract public class BasicLibrary {
                        meta.setLuid(luid);
                }
 
-               if (getInfo(luid) != null) {
+               if (luid != null && getInfo(luid) != null) {
                        delete(luid);
                }
 
-               doSave(story, pg);
+               story = doSave(story, pg);
+
+               updateInfo(story.getMeta());
 
-               clearCache();
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": story saved (" + luid
+                                               + ")");
 
                return story;
        }
@@ -483,8 +832,15 @@ abstract public class BasicLibrary {
         *             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);
-               clearCache();
+               invalidateInfo(luid);
+
+               Instance.getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": story deleted (" + luid
+                                               + ")");
        }
 
        /**
@@ -507,7 +863,83 @@ abstract public class BasicLibrary {
                        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);
        }
 
@@ -518,7 +950,7 @@ abstract public class BasicLibrary {
         * By default, delete the old {@link Story} then recreate a new
         * {@link Story}.
         * <p>
-        * Note that this behaviour can lead to data loss.
+        * 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)
@@ -544,8 +976,8 @@ abstract public class BasicLibrary {
                        throw new IOException("Story not found: " + meta.getLuid());
                }
 
+               // TODO: this is not safe!
                delete(meta.getLuid());
-
                story.setMeta(meta);
                save(story, meta.getLuid(), pgSet);