Merge branch 'master' of github.com:nikiroo/fanfix
authorNiki Roo <niki@nikiroo.be>
Sat, 2 May 2020 20:18:41 +0000 (22:18 +0200)
committerNiki Roo <niki@nikiroo.be>
Sat, 2 May 2020 20:18:41 +0000 (22:18 +0200)
56 files changed:
TODO.md
VERSION
changelog-fr.md
changelog.md
derename.sh [new file with mode: 0755]
libs/JSON-java-20190722-sources.jar [new file with mode: 0644]
libs/licenses/JSON-java-20190722_LICENSE.txt [new file with mode: 0644]
src/.gitattributes [deleted file]
src/.gitignore [deleted file]
src/be/nikiroo/fanfix/Instance.java
src/be/nikiroo/fanfix/bundles/Config.java
src/be/nikiroo/fanfix/bundles/StringIdGui.java
src/be/nikiroo/fanfix/bundles/UiConfig.java
src/be/nikiroo/fanfix/bundles/resources_gui.properties
src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties
src/be/nikiroo/fanfix/bundles/ui_description.properties
src/be/nikiroo/fanfix/data/MetaData.java
src/be/nikiroo/fanfix/library/BasicLibrary.java
src/be/nikiroo/fanfix/library/CacheLibrary.java
src/be/nikiroo/fanfix/library/LocalLibrary.java
src/be/nikiroo/fanfix/library/MetaResultList.java
src/be/nikiroo/fanfix/library/RemoteLibrary.java
src/be/nikiroo/fanfix/library/RemoteLibraryServer.java
src/be/nikiroo/fanfix/supported/BasicSupport.java
src/be/nikiroo/fanfix/supported/BasicSupportHelper.java
src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java
src/be/nikiroo/fanfix/supported/Cbz.java
src/be/nikiroo/fanfix/supported/E621.java
src/be/nikiroo/fanfix/supported/Epub.java
src/be/nikiroo/fanfix/supported/FimfictionApi.java
src/be/nikiroo/fanfix/supported/InfoReader.java
src/be/nikiroo/fanfix/supported/InfoText.java
src/be/nikiroo/fanfix/supported/MangaLel.java
src/be/nikiroo/fanfix/supported/Text.java
src/be/nikiroo/utils/Progress.java
src/be/nikiroo/utils/android/ImageUtilsAndroid.class [new file with mode: 0644]
src/be/nikiroo/utils/android/test/TestAndroid.class [new file with mode: 0644]
src/be/nikiroo/utils/compat/DefaultListModel6.java [new file with mode: 0644]
src/be/nikiroo/utils/compat/JList6.java [new file with mode: 0644]
src/be/nikiroo/utils/compat/ListCellRenderer6.java [new file with mode: 0644]
src/be/nikiroo/utils/compat/ListModel6.java [new file with mode: 0644]
src/be/nikiroo/utils/resources/Meta.java
src/be/nikiroo/utils/resources/MetaInfo.java
src/be/nikiroo/utils/ui/BreadCrumbsBar.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/DataNode.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/DataTree.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/DelayWorker.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ListModel.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ListSnapshot.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ListenerItem.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ListenerPanel.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/ProgressBar.java
src/be/nikiroo/utils/ui/TreeCellSpanner.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/TreeModelTransformer.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/TreeSnapshot.java [new file with mode: 0644]
src/be/nikiroo/utils/ui/UIUtils.java

diff --git a/TODO.md b/TODO.md
index af17b53ad0b7c12fd072aea2436a47956fc438df..85f2deb3775573c0fe5f7160563a6c98efa484fa 100644 (file)
--- a/TODO.md
+++ b/TODO.md
@@ -5,6 +5,7 @@ My current planning for Fanfix (but not everything appears on this list):
   - [ ] [Two Kinds](http://twokinds.keenspot.com/)
   - [ ] [Slightly damned](http://www.sdamned.com/)
   - [x] New API on FimFiction.net (faster)
+  - [x] Replace MangaFox which is causing issues all the time
   - [ ] Others? Any ideas? I'm open for requests
     - [x] [e-Hentai](https://e-hentai.org/) requested
     - [x] Find some FR comics/manga websites
@@ -41,6 +42,16 @@ My current planning for Fanfix (but not everything appears on this list):
     - [x] properties page
     - [x] external launcher
     - [ ] jexer sixels?
+- [ ] Move the GUI parts out of fanfix itself (see fanfix-swing)
+    - [x] Make new project: fanfix-swing
+    - [x] Fix the UI issues we had (UI thread)
+    - [x] Make it able to browse already downloaded stories
+    - [x] Make it able to download stories
+    - [ ] See what config options to use
+    - [ ] Import all previous menus end functions
+    - [ ] Feature parity with original GUI
+- [ ] Move the TUI parts out of fanfix itself
+    - [ ] Make new project
 - [x] Network support
   - [x] A server that can send the stories
   - [x] A network implementation of the Library
diff --git a/VERSION b/VERSION
index 0f9d6b15dc041109bafac33fa77f79b8214fe667..ef538c2810938c03ced86f0380977b308a55b37b 100644 (file)
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.1.0-dev
+3.1.2
index a42240947771fa96ca502da7dc07a5abbec7eb6f..bdcc91c210739ae64f3f19cc3f5de533f2072fb8 100644 (file)
@@ -1,5 +1,18 @@
 # Fanfix
 
+# Version 3.1.2
+
+- fix: date de publication/création formatée
+- e621: correction sur l'ordre, encore
+- e621: possibilité d'utiliser un Login/API key pour éviter les blacklists
+- e621: meilleures meta-data
+
+# Version 3.1.1
+
+- e621: correction pour les chapitres des pools dans l'ordre inverse
+- fix: trie les histores par nom et plus par date
+- fix: affiche le nom de l'histoire dans les barres de progrès
+
 # Version 3.1.0
 
 - MangaHub: un nouvau site de manga (English)
index 200b5faab8f448f1a25ff6bc9f2431bfbe2f3772..cd63d1f37f2d0801754604108361ed78e4f9d37d 100644 (file)
@@ -1,5 +1,18 @@
 # Fanfix
 
+# Version 3.1.2
+
+- fix: publication/creation date formated 
+- e621: fix order again
+- e621: option to use a Login/API key to evade blacklists
+- e621: better metadata
+
+# Version 3.1.1
+
+- e621: fix chapters in reverse order for pools
+- fix: sort the stories by title and not by date
+- fix: expose the story name on progress bars
+
 # Version 3.1.0
 
 - MangaHub: a manga website (English)
diff --git a/derename.sh b/derename.sh
new file mode 100755 (executable)
index 0000000..6c8cbff
--- /dev/null
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+git status | grep renamed: | sed 's/[^:]*: *\([^>]*\) -> \(.*\)/\1>\2/g' | while read -r ln; do
+       old="`echo "$ln" | cut -f1 -d'>'`"
+       new="`echo "$ln" | cut -f2 -d'>'`"
+       mkdir -p "`dirname "$old"`"
+       git mv "$new" "$old"
+       rmdir "`dirname "$new"`" 2>/dev/null
+       true
+done
+
diff --git a/libs/JSON-java-20190722-sources.jar b/libs/JSON-java-20190722-sources.jar
new file mode 100644 (file)
index 0000000..22a416d
Binary files /dev/null and b/libs/JSON-java-20190722-sources.jar differ
diff --git a/libs/licenses/JSON-java-20190722_LICENSE.txt b/libs/licenses/JSON-java-20190722_LICENSE.txt
new file mode 100644 (file)
index 0000000..02ee0ef
--- /dev/null
@@ -0,0 +1,23 @@
+============================================================================
+
+Copyright (c) 2002 JSON.org
+
+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 shall be used for Good, not Evil.
+
+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/src/.gitattributes b/src/.gitattributes
deleted file mode 100644 (file)
index 409851f..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-# 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
deleted file mode 100644 (file)
index 5c79834..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-.classpath
-.project
-target/
-bin/
-.settings/
-.idea/
-*.iml
-
index f48d05b7d4cd84994eeb7e5e238018e99317afbd..d0d1c84ab7643b65b34a91859a1b75ca59533091 100644 (file)
@@ -48,8 +48,19 @@ public class Instance {
        /**
         * Initialise the instance -- if already initialised, nothing will happen.
         * <p>
-        * Before calling this method, you may call {@link Bundles#setDirectory(String)}
-        * if wanted.
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
+        * <p>
+        * Note that this method will honour some environment variables, the 3 most
+        * important ones probably being:
+        * <ul>
+        * <li><tt>DEBUG</tt>: will enable DEBUG output if set to 1 (or Y or TRUE or
+        * ON, case insensitive)</li>
+        * <li><tt>CONFIG_DIR</tt>: will use this directory as configuration
+        * directory (supports $HOME notation, defaults to $HOME/.fanfix</li>
+        * <li><tt>BOOKS_DIR</tt>: will use this directory as library directory
+        * (supports $HOME notation, defaults to $HOME/Books</li>
+        * </ul>
         */
        static public void init() {
                init(false);
@@ -465,7 +476,6 @@ public class Instance {
                BasicLibrary lib = null;
 
                boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED, false);
-
                if (useRemote) {
                        String host = null;
                        int port = -1;
index 7360f3933a8a5c9e886c0194d04b3f1dcc26fdd9..fd27a83fba9179d01d1dd4955ffe7834906709e0 100644 (file)
@@ -10,11 +10,14 @@ import be.nikiroo.utils.resources.Meta.Format;
  */
 @SuppressWarnings("javadoc")
 public enum Config {
+       
+       // Note: all hidden values are subject to be removed in a later version
+       
        @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")
+       hidden = true, format = Format.FIXED_LIST, list = { "CLI", "GUI", "TUI" }, def = "GUI")
        READER_TYPE, //
 
        @Meta(description = "File format options",//
@@ -49,7 +52,7 @@ public enum Config {
        @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",//
+       @Meta(description = "The directory where to store the library (can be overriden by the environment 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, //
 
@@ -112,28 +115,28 @@ public enum Config {
        DEBUG_TRACE, //
 
        @Meta(description = "Internal configuration\nThose options are internal to the program and should probably not be changed",//
-       group = true)
+       hidden = true, group = true)
        CONF, //
        @Meta(description = "LaTeX configuration",//
-       group = true)
+       hidden = true, group = true)
        CONF_LATEX_LANG, //
        @Meta(description = "LaTeX output language (full name) for \"English\"",//
-       format = Format.STRING, def = "english")
+       hidden = true, format = Format.STRING, def = "english")
        CONF_LATEX_LANG_EN, //
        @Meta(description = "LaTeX output language (full name) for \"French\"",//
-       format = Format.STRING, def = "french")
+       hidden = true, 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)\"")
+       hidden = true, 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\"")
+       hidden = true, 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")
+       hidden = true, 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")
+       hidden = true, 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)",//
@@ -161,4 +164,14 @@ public enum Config {
        @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, //
+       
+       @Meta(description = "e621.net credentials\nYou can give your e621.net credentials here to have access to all the comics and ignore the default blacklist",//
+       group = true)
+       LOGIN_E621, //
+       @Meta(description = "Your e621.net login",//
+       format = Format.STRING)
+       LOGIN_E621_LOGIN, //
+       @Meta(description = "Your e621.net API KEY",//
+       format = Format.PASSWORD)
+       LOGIN_E621_APIKEY, //
 }
index 2c9d222a84d41e9d3bc50ad58d107f42dfaed64e..c109f4257aec7c640a74f7af57277071964f3480 100644 (file)
@@ -109,7 +109,7 @@ public enum StringIdGui {
        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")
+       @Meta(def = "Prefetch 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, //
index 0640db830bbbfb477c04843b01c3739c51b53d5d..0f3142d3f5552833c162c846122061b9a347e9da 100644 (file)
@@ -31,7 +31,26 @@ public enum UiConfig {
        @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, //
+       //
+       // GUI settings (hidden in config)
+       //
+       @Meta(description = "Show the side panel by default",//
+       hidden = true, format = Format.BOOLEAN, def = "true")
+       SHOW_SIDE_PANEL, //
+       @Meta(description = "Show the details panel by default",//
+       hidden = true, format = Format.BOOLEAN, def = "true")
+       SHOW_DETAILS_PANEL, //
+       @Meta(description = "Show thumbnails by default in the books view",//
+       hidden = true, format = Format.BOOLEAN, def = "false")
+       SHOW_THUMBNAILS, //
+       @Meta(description = "Show a words/images count instead of the author by default in the books view",//
+       hidden = true, format = Format.BOOLEAN, def = "false")
+       SHOW_WORDCOUNT, //
+       //
+       // Deprecated
+       //
        @Meta(description = "The background colour of the library if you don't like the default system one",//
-       format = Format.COLOR)
+       hidden = true, format = Format.COLOR)
+       @Deprecated
        BACKGROUND_COLOR, //
 }
index 6d46af41f5884c46e5af0eb83e4da3c062ed72cd..40be5eb77e4f2e448573502648d5bb6c965659da 100644 (file)
@@ -114,7 +114,7 @@ MENU_FILE_OPEN = Open
 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 
+MENU_EDIT_DOWNLOAD_TO_CACHE = Prefetch to cache 
 # the clear cache menu button, to clear the cache for a single book
 # (FORMAT: STRING) 
 MENU_EDIT_CLEAR_CACHE = Clear cache
index 1ede37c052d271ef10aa2161804b7c036fb9b5ed..25ff542d6fa67cf410d8bc3c5e043e8bf0985ddc 100644 (file)
@@ -114,7 +114,7 @@ MENU_FILE_OPEN = Ouvrir
 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 
+MENU_EDIT_DOWNLOAD_TO_CACHE = Pré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
index 5cb2a9fb9e9f4e130982001ab18e83ae6c425240..c8def8346af330b68d1cbb3460c536e64e71b36d 100644 (file)
@@ -7,9 +7,9 @@
 # 
 
 
-# The directory where to store temporary files, defaults to directory 'tmp.reader' in the config directory (usually $HOME/.fanfix)
+# 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: 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
+CACHE_DIR_LOCAL_READER = 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
 # 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"
index 2c40beb1758b36f5b1415b3adf4b7ef86bd42fe6..586196a663fea88aa39fe45111445d76df0d5651 100644 (file)
@@ -390,12 +390,14 @@ public class MetaData implements Cloneable, Comparable<MetaData>, Serializable {
                        return 1;
                }
 
-               String id = (getUuid() == null ? "" : getUuid())
+               String id = (getTitle() == null ? "" : getTitle())
+                               + (getUuid() == null ? "" : getUuid())
                                + (getLuid() == null ? "" : getLuid());
-               String oId = (getUuid() == null ? "" : o.getUuid())
+               String oId = (getTitle() == null ? "" : o.getTitle())
+                               + (getUuid() == null ? "" : o.getUuid())
                                + (o.getLuid() == null ? "" : o.getLuid());
 
-               return id.compareTo(oId);
+               return id.compareToIgnoreCase(oId);
        }
 
        @Override
index c2ab12b7b735d8349b51ea1beaaeab3ca7bac8e8..d435f8d7ec7fd3e2f09242339eefb9ff6fa74d93 100644 (file)
@@ -98,7 +98,7 @@ abstract public class BasicLibrary {
         * Do <b>NOT</b> alter this file.
         * 
         * @param luid
-        *            the Library UID of the story
+        *            the Library UID of the story, can be NULL
         * @param pg
         *            the optional {@link Progress}
         * 
@@ -123,12 +123,12 @@ abstract public class BasicLibrary {
        public abstract Image getCover(String luid) throws IOException;
 
        // TODO: ensure it is the main used interface
-       public synchronized MetaResultList getList(Progress pg) throws IOException {
+       public MetaResultList getList(Progress pg) throws IOException {
                return new MetaResultList(getMetas(pg));
        }
-       
-       //TODO: make something for (normal and custom) not-story covers
-       
+
+       // TODO: make something for (normal and custom) not-story covers
+
        /**
         * Return the cover image associated to this source.
         * <p>
@@ -338,28 +338,28 @@ abstract public class BasicLibrary {
         * @param pg
         *            the optional progress reporter
         */
-       public synchronized void refresh(Progress pg) {
+       public void refresh(Progress pg) {
                try {
                        getMetas(pg);
                } catch (IOException e) {
                        // We will let it fail later
                }
        }
-       
+
        /**
         * Check if the {@link Story} denoted by this Library UID is present in the
-        * cache (if we have no cache, we default to </t>true</tt>).
+        * cache (if we have no cache, we default to </tt>true</tt>).
         * 
         * @param luid
         *            the Library UID
         * 
         * @return TRUE if it is
         */
-       public boolean isCached(String luid) {
+       public boolean isCached(@SuppressWarnings("unused") String luid) {
                // By default, everything is cached
                return true;
        }
-       
+
        /**
         * Clear the {@link Story} from the cache, if needed.
         * <p>
@@ -372,219 +372,44 @@ abstract public class BasicLibrary {
         * @throws IOException
         *             in case of I/O error
         */
+       @SuppressWarnings("unused")
        public void clearFromCache(String luid) throws IOException {
                // By default, this is a noop.
        }
 
        /**
-        * List all the known types (sources) of stories.
-        * 
-        * @return the sources
-        * 
-        * @throws IOException
-        *             in case of IOException
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getSources()} instead.
         */
-       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;
+       @Deprecated
+       public List<String> getSources() throws IOException {
+               return getList().getSources();
        }
 
        /**
-        * 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
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getSourcesGrouped()} instead.
         */
-       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;
+       @Deprecated
+       public Map<String, List<String>> getSourcesGrouped() throws IOException {
+               return getList().getSourcesGrouped();
        }
 
        /**
-        * List all the known authors of stories.
-        * 
-        * @return the authors
-        * 
-        * @throws IOException
-        *             in case of IOException
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getAuthors()} instead.
         */
-       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;
+       @Deprecated
+       public List<String> getAuthors() throws IOException {
+               return getList().getAuthors();
        }
 
        /**
-        * 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
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getAuthorsGrouped()} instead.
         */
        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;
+               return getList().getAuthorsGrouped();
        }
 
        /**
@@ -606,14 +431,14 @@ abstract public class BasicLibrary {
         * cover image <b>MAY</b> not be included.
         * 
         * @param luid
-        *            the Library UID of the story
+        *            the Library UID of the story, can be NULL
         * 
-        * @return the corresponding {@link Story}
+        * @return the corresponding {@link Story} or NULL if not found
         * 
         * @throws IOException
         *             in case of IOException
         */
-       public synchronized MetaData getInfo(String luid) throws IOException {
+       public MetaData getInfo(String luid) throws IOException {
                if (luid != null) {
                        for (MetaData meta : getMetas(null)) {
                                if (luid.equals(meta.getLuid())) {
@@ -638,8 +463,7 @@ abstract public class BasicLibrary {
         * @throws IOException
         *             in case of IOException
         */
-       public synchronized Story getStory(String luid, Progress pg)
-                       throws IOException {
+       public Story getStory(String luid, Progress pg) throws IOException {
                Progress pgMetas = new Progress();
                Progress pgStory = new Progress();
                if (pg != null) {
@@ -668,6 +492,8 @@ abstract public class BasicLibrary {
         * Retrieve a specific {@link Story}.
         * 
         * @param luid
+        *            the LUID of the story
+        * @param meta
         *            the meta of the story
         * @param pg
         *            the optional progress reporter
@@ -677,8 +503,7 @@ abstract public class BasicLibrary {
         * @throws IOException
         *             in case of IOException
         */
-       public synchronized Story getStory(String luid,
-                       @SuppressWarnings("javadoc") MetaData meta, Progress pg)
+       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
                        throws IOException {
 
                if (pg == null) {
@@ -693,12 +518,21 @@ abstract public class BasicLibrary {
                pg.addProgress(pgProcess, 1);
 
                Story story = null;
-               File file = getFile(luid, pgGet);
+               File file = null;
+
+               if (luid != null && meta != null) {
+                       file = getFile(luid, pgGet);
+               }
+
                pgGet.done();
                try {
-                       SupportType type = SupportType.valueOfAllOkUC(meta.getType());
-                       URL url = file.toURI().toURL();
-                       if (type != null) {
+                       if (file != null) {
+                               SupportType type = SupportType.valueOfAllOkUC(meta.getType());
+                               if (type == null) {
+                                       throw new IOException("Unknown type: " + meta.getType());
+                               }
+
+                               URL url = file.toURI().toURL();
                                story = BasicSupport.getSupport(type, url) //
                                                .process(pgProcess);
 
@@ -706,15 +540,13 @@ abstract public class BasicLibrary {
                                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.getInstance().getTraceHandler().error(new IOException(
-                                       String.format("Cannot load file of type '%s' from library: %s", meta.getType(), file), e));
+                       // We should not have not-supported files in the library
+                       Instance.getInstance().getTraceHandler()
+                                       .error(new IOException(String.format(
+                                                       "Cannot load file of type '%s' from library: %s",
+                                                       meta.getType(), file), e));
                } finally {
                        pgProcess.done();
                        pg.done();
@@ -872,13 +704,19 @@ abstract public class BasicLibrary {
         */
        public synchronized Story save(Story story, String luid, Progress pg)
                        throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
 
-               Instance.getInstance().getTraceHandler().trace(this.getClass().getSimpleName() + ": saving story " + luid);
+               Instance.getInstance().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);
 
+               pg.setName("Saving story");
+
                if (luid == null || luid.isEmpty()) {
                        meta.setLuid(String.format("%03d", getNextId()));
                } else {
@@ -894,8 +732,11 @@ abstract public class BasicLibrary {
                updateInfo(story.getMeta());
 
                Instance.getInstance().getTraceHandler()
-                               .trace(this.getClass().getSimpleName() + ": story saved (" + luid + ")");
+                               .trace(this.getClass().getSimpleName() + ": story saved ("
+                                               + luid + ")");
 
+               pg.setName(meta.getTitle());
+               pg.done();
                return story;
        }
 
@@ -909,14 +750,15 @@ abstract public class BasicLibrary {
         *             in case of I/O error
         */
        public synchronized void delete(String luid) throws IOException {
-               Instance.getInstance().getTraceHandler().trace(this.getClass().getSimpleName() + ": deleting story " + luid);
+               Instance.getInstance().getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": deleting story " + luid);
 
                doDelete(luid);
                invalidateInfo(luid);
 
                Instance.getInstance().getTraceHandler()
-                               .trace(this.getClass().getSimpleName() + ": story deleted (" + luid
-                                               + ")");
+                               .trace(this.getClass().getSimpleName() + ": story deleted ("
+                                               + luid + ")");
        }
 
        /**
index 694f9ec267501d546ba8b44413cfeb346ef8963d..a3c3b5e3b7bb6f5a61962e8837158e28692feb56 100644 (file)
@@ -24,21 +24,27 @@ import be.nikiroo.utils.Progress;
 public class CacheLibrary extends BasicLibrary {
        private List<MetaData> metasReal;
        private List<MetaData> metasMixed;
+       private Object metasLock = new Object();
+
        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.
+        * 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
-        * @param config   the configuration used to know which kind of default
-        *                 {@link OutputType} to use for images and non-images stories
+        * @param cacheDir
+        *            the cache directory where to save the files to disk
+        * @param lib
+        *            the original library to wrap
+        * @param config
+        *            the configuration used to know which kind of default
+        *            {@link OutputType} to use for images and non-images stories
         */
-       public CacheLibrary(File cacheDir, BasicLibrary lib, UiConfigBundle config) {
+       public CacheLibrary(File cacheDir, BasicLibrary lib,
+                       UiConfigBundle config) {
                this.cacheLib = new LocalLibrary(cacheDir, //
                                config.getString(UiConfig.GUI_NON_IMAGES_DOCUMENT_TYPE),
                                config.getString(UiConfig.GUI_IMAGES_DOCUMENT_TYPE), true);
@@ -56,37 +62,42 @@ public class CacheLibrary extends BasicLibrary {
        }
 
        @Override
-       protected synchronized List<MetaData> getMetas(Progress pg) throws IOException {
-               // We make sure that cached metas have precedence
-
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
                if (pg == null) {
                        pg = new Progress();
                }
 
-               if (metasMixed == null) {
-                       if (metasReal == null) {
-                               metasReal = lib.getMetas(pg);
-                       }
-                       
-                       metasMixed = new ArrayList<MetaData>();
-                       TreeSet<String> cachedLuids = new TreeSet<String>();
-                       for (MetaData cachedMeta : cacheLib.getMetas(null)) {
-                               metasMixed.add(cachedMeta);
-                               cachedLuids.add(cachedMeta.getLuid());
-                       }
-                       for (MetaData realMeta : metasReal) {
-                               if (!cachedLuids.contains(realMeta.getLuid())) {
-                                       metasMixed.add(realMeta);
+               List<MetaData> copy;
+               synchronized (metasLock) {
+                       // We make sure that cached metas have precedence
+                       if (metasMixed == null) {
+                               if (metasReal == null) {
+                                       metasReal = lib.getMetas(pg);
+                               }
+
+                               metasMixed = new ArrayList<MetaData>();
+                               TreeSet<String> cachedLuids = new TreeSet<String>();
+                               for (MetaData cachedMeta : cacheLib.getMetas(null)) {
+                                       metasMixed.add(cachedMeta);
+                                       cachedLuids.add(cachedMeta.getLuid());
+                               }
+                               for (MetaData realMeta : metasReal) {
+                                       if (!cachedLuids.contains(realMeta.getLuid())) {
+                                               metasMixed.add(realMeta);
+                                       }
                                }
                        }
+
+                       copy = new ArrayList<MetaData>(metasMixed);
                }
 
                pg.done();
-               return new ArrayList<MetaData>(metasMixed);
+               return copy;
        }
 
        @Override
-       public synchronized Story getStory(String luid, MetaData meta, Progress pg) throws IOException {
+       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+                       throws IOException {
                if (pg == null) {
                        pg = new Progress();
                }
@@ -119,7 +130,8 @@ public class CacheLibrary extends BasicLibrary {
        }
 
        @Override
-       public synchronized File getFile(final String luid, Progress pg) throws IOException {
+       public synchronized File getFile(final String luid, Progress pg)
+                       throws IOException {
                if (pg == null) {
                        pg = new Progress();
                }
@@ -225,8 +237,8 @@ public class CacheLibrary extends BasicLibrary {
         * Invalidate the {@link Story} cache (when the content has changed, but we
         * already have it) with the new given meta.
         * <p>
-        * <b>Make sure to always use {@link MetaData} from the cached library 
-        * in priority, here.</b>
+        * <b>Make sure to always use {@link MetaData} from the cached library in
+        * priority, here.</b>
         * 
         * @param meta
         *            the {@link Story} to clear from the cache
@@ -240,33 +252,37 @@ public class CacheLibrary extends BasicLibrary {
                throw new IOException(
                                "This method is not supported in a CacheLibrary, please use updateMetaCache");
        }
-       
+
        // relplace the meta in Metas by Meta, add it if needed
        // return TRUE = added
        private boolean updateMetaCache(List<MetaData> metas, MetaData meta) {
                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;
+                       synchronized (metasLock) {
+                               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);
-                               return true;
+                               if (!changed) {
+                                       metas.add(meta);
+                                       return true;
+                               }
                        }
                }
-               
+
                return false;
        }
 
        @Override
        protected void invalidateInfo(String luid) {
                if (luid == null) {
-                       metasReal = null;
-                       metasMixed = null;
+                       synchronized (metasLock) {
+                               metasReal = null;
+                               metasMixed = null;
+                       }
                } else {
                        invalidateInfo(metasReal, luid);
                        invalidateInfo(metasMixed, luid);
@@ -279,16 +295,19 @@ public class CacheLibrary extends BasicLibrary {
        // luid cannot be null
        private void invalidateInfo(List<MetaData> metas, String luid) {
                if (metas != null) {
-                       for (int i = 0; i < metas.size(); i++) {
-                               if (metas.get(i).getLuid().equals(luid)) {
-                                       metas.remove(i--);
+                       synchronized (metasLock) {
+                               for (int i = 0; i < metas.size(); i++) {
+                                       if (metas.get(i).getLuid().equals(luid)) {
+                                               metas.remove(i--);
+                                       }
                                }
                        }
                }
        }
 
        @Override
-       public synchronized Story save(Story story, String luid, Progress pg) throws IOException {
+       public synchronized Story save(Story story, String luid, Progress pg)
+                       throws IOException {
                Progress pgLib = new Progress();
                Progress pgCacheLib = new Progress();
 
@@ -302,7 +321,7 @@ public class CacheLibrary extends BasicLibrary {
 
                story = lib.save(story, luid, pgLib);
                updateMetaCache(metasReal, story.getMeta());
-               
+
                story = cacheLib.save(story, story.getMeta().getLuid(), pgCacheLib);
                updateMetaCache(metasMixed, story.getMeta());
 
@@ -320,8 +339,8 @@ public class CacheLibrary extends BasicLibrary {
        }
 
        @Override
-       protected synchronized void changeSTA(String luid, String newSource, String newTitle, String newAuthor, Progress pg)
-                       throws IOException {
+       protected synchronized void changeSTA(String luid, String newSource,
+                       String newTitle, String newAuthor, Progress pg) throws IOException {
                if (pg == null) {
                        pg = new Progress();
                }
@@ -375,7 +394,7 @@ public class CacheLibrary extends BasicLibrary {
        }
 
        @Override
-       public synchronized MetaData imprt(URL url, Progress pg) throws IOException {
+       public MetaData imprt(URL url, Progress pg) throws IOException {
                if (pg == null) {
                        pg = new Progress();
                }
@@ -389,6 +408,7 @@ public class CacheLibrary extends BasicLibrary {
                MetaData meta = lib.imprt(url, pgImprt);
                updateMetaCache(metasReal, meta);
                metasMixed = null;
+
                clearFromCache(meta.getLuid());
 
                pg.done();
index 80d216b84c216fd32a5dc8cf26f9e4dc2a28353c..6720972682939785226de86a201ef2c4c4c7d68f 100644 (file)
@@ -14,7 +14,6 @@ import java.util.Map;
 import be.nikiroo.fanfix.Instance;
 import be.nikiroo.fanfix.bundles.Config;
 import be.nikiroo.fanfix.bundles.ConfigBundle;
-import be.nikiroo.fanfix.bundles.UiConfigBundle;
 import be.nikiroo.fanfix.data.MetaData;
 import be.nikiroo.fanfix.data.Story;
 import be.nikiroo.fanfix.output.BasicOutput;
@@ -33,6 +32,7 @@ import be.nikiroo.utils.StringUtils;
  */
 public class LocalLibrary extends BasicLibrary {
        private int lastId;
+       private Object lock = new Object();
        private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
        private Map<String, Image> sourceCovers;
        private Map<String, Image> authorCovers;
@@ -44,14 +44,17 @@ public class LocalLibrary extends BasicLibrary {
        /**
         * Create a new {@link LocalLibrary} with the given back-end directory.
         * 
-        * @param baseDir the directory where to find the {@link Story} objects
-        * @param config  the configuration used to know which kind of default
-        *                {@link OutputType} to use for images and non-images stories
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        * @param config
+        *            the configuration used to know which kind of default
+        *            {@link OutputType} to use for images and non-images stories
         */
        public LocalLibrary(File baseDir, ConfigBundle config) {
                this(baseDir, //
                                config.getString(Config.FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE),
-                               config.getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE), false);
+                               config.getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE),
+                               false);
        }
 
        /**
@@ -69,8 +72,9 @@ public class LocalLibrary extends BasicLibrary {
         */
        public LocalLibrary(File baseDir, String text, String image,
                        boolean defaultIsHtml) {
-               this(baseDir, OutputType.valueOfAllOkUC(text,
-                               defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
+               this(baseDir,
+                               OutputType.valueOfAllOkUC(text,
+                                               defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
                                OutputType.valueOfAllOkUC(image,
                                                defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
        }
@@ -98,26 +102,30 @@ public class LocalLibrary extends BasicLibrary {
        }
 
        @Override
-       protected synchronized List<MetaData> getMetas(Progress pg) {
+       protected List<MetaData> getMetas(Progress pg) {
                return new ArrayList<MetaData>(getStories(pg).keySet());
        }
 
        @Override
        public File getFile(String luid, Progress pg) throws IOException {
-               Instance.getInstance().getTraceHandler().trace(this.getClass().getSimpleName() + ": get file for " + luid);
+               Instance.getInstance().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];
+               if (meta != null) {
+                       File[] files = getStories(pg).get(meta);
+                       if (files != null) {
+                               mess = "file retrieved for ";
+                               file = files[1];
+                       }
                }
 
                Instance.getInstance().getTraceHandler()
-                               .trace(this.getClass().getSimpleName() + ": " + mess + luid + " (" + meta.getTitle() + ")");
+                               .trace(this.getClass().getSimpleName() + ": " + mess + luid
+                                               + " (" + meta.getTitle() + ")");
 
                return file;
        }
@@ -147,20 +155,25 @@ public class LocalLibrary extends BasicLibrary {
        }
 
        @Override
-       protected synchronized void updateInfo(MetaData meta) {
+       protected void updateInfo(MetaData meta) {
                invalidateInfo();
        }
 
        @Override
        protected void invalidateInfo(String luid) {
-               stories = null;
-               sourceCovers = null;
+               synchronized (lock) {
+                       stories = null;
+                       sourceCovers = null;
+               }
        }
 
        @Override
-       protected synchronized int getNextId() {
+       protected int getNextId() {
                getStories(null); // make sure lastId is set
-               return ++lastId;
+
+               synchronized (lock) {
+                       return ++lastId;
+               }
        }
 
        @Override
@@ -200,8 +213,8 @@ public class LocalLibrary extends BasicLibrary {
                        // Maybe also adding some rollback cleanup if possible
                        if (relatedFile.getName().endsWith(".info")) {
                                try {
-                                       String name = relatedFile.getName().replaceFirst(
-                                                       "\\.info$", "");
+                                       String name = relatedFile.getName().replaceFirst("\\.info$",
+                                                       "");
                                        relatedFile.delete();
                                        InfoCover.writeInfo(newDir, name, meta);
                                        relatedFile.getParentFile().delete();
@@ -218,14 +231,18 @@ public class LocalLibrary extends BasicLibrary {
        }
 
        @Override
-       public synchronized Image getCustomSourceCover(String source) {
-               if (sourceCovers == null) {
-                       sourceCovers = new HashMap<String, Image>();
+       public Image getCustomSourceCover(String source) {
+               synchronized (lock) {
+                       if (sourceCovers == null) {
+                               sourceCovers = new HashMap<String, Image>();
+                       }
                }
 
-               Image img = sourceCovers.get(source);
-               if (img != null) {
-                       return img;
+               synchronized (lock) {
+                       Image img = sourceCovers.get(source);
+                       if (img != null) {
+                               return img;
+                       }
                }
 
                File coverDir = getExpectedDir(source);
@@ -236,7 +253,9 @@ public class LocalLibrary extends BasicLibrary {
                                try {
                                        in = new FileInputStream(cover);
                                        try {
-                                               sourceCovers.put(source, new Image(in));
+                                               synchronized (lock) {
+                                                       sourceCovers.put(source, new Image(in));
+                                               }
                                        } finally {
                                                in.close();
                                        }
@@ -244,23 +263,32 @@ public class LocalLibrary extends BasicLibrary {
                                        e.printStackTrace();
                                } catch (IOException e) {
                                        Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException("Cannot load the existing custom source cover: " + cover, e));
+                                                       .error(new IOException(
+                                                                       "Cannot load the existing custom source cover: "
+                                                                                       + cover,
+                                                                       e));
                                }
                        }
                }
 
-               return sourceCovers.get(source);
+               synchronized (lock) {
+                       return sourceCovers.get(source);
+               }
        }
 
        @Override
-       public synchronized Image getCustomAuthorCover(String author) {
-               if (authorCovers == null) {
-                       authorCovers = new HashMap<String, Image>();
+       public Image getCustomAuthorCover(String author) {
+               synchronized (lock) {
+                       if (authorCovers == null) {
+                               authorCovers = new HashMap<String, Image>();
+                       }
                }
 
-               Image img = authorCovers.get(author);
-               if (img != null) {
-                       return img;
+               synchronized (lock) {
+                       Image img = authorCovers.get(author);
+                       if (img != null) {
+                               return img;
+                       }
                }
 
                File cover = getAuthorCoverFile(author);
@@ -269,7 +297,9 @@ public class LocalLibrary extends BasicLibrary {
                        try {
                                in = new FileInputStream(cover);
                                try {
-                                       authorCovers.put(author, new Image(in));
+                                       synchronized (lock) {
+                                               authorCovers.put(author, new Image(in));
+                                       }
                                } finally {
                                        in.close();
                                }
@@ -277,11 +307,16 @@ public class LocalLibrary extends BasicLibrary {
                                e.printStackTrace();
                        } catch (IOException e) {
                                Instance.getInstance().getTraceHandler()
-                                               .error(new IOException("Cannot load the existing custom author cover: " + cover, e));
+                                               .error(new IOException(
+                                                               "Cannot load the existing custom author cover: "
+                                                                               + cover,
+                                                               e));
                        }
                }
 
-               return authorCovers.get(author);
+               synchronized (lock) {
+                       return authorCovers.get(author);
+               }
        }
 
        @Override
@@ -302,14 +337,17 @@ public class LocalLibrary extends BasicLibrary {
         * @param coverImage
         *            the cover image
         */
-       synchronized void setSourceCover(String source, Image coverImage) {
+       void setSourceCover(String source, Image coverImage) {
                File dir = getExpectedDir(source);
                dir.mkdirs();
                File cover = new File(dir, ".cover");
                try {
-                       Instance.getInstance().getCache().saveAsImage(coverImage, cover, true);
-                       if (sourceCovers != null) {
-                               sourceCovers.put(source, coverImage);
+                       Instance.getInstance().getCache().saveAsImage(coverImage, cover,
+                                       true);
+                       synchronized (lock) {
+                               if (sourceCovers != null) {
+                                       sourceCovers.put(source, coverImage);
+                               }
                        }
                } catch (IOException e) {
                        Instance.getInstance().getTraceHandler().error(e);
@@ -324,13 +362,16 @@ public class LocalLibrary extends BasicLibrary {
         * @param coverImage
         *            the cover image
         */
-       synchronized void setAuthorCover(String author, Image coverImage) {
+       void setAuthorCover(String author, Image coverImage) {
                File cover = getAuthorCoverFile(author);
                cover.getParentFile().mkdirs();
                try {
-                       Instance.getInstance().getCache().saveAsImage(coverImage, cover, true);
-                       if (authorCovers != null) {
-                               authorCovers.put(author, coverImage);
+                       Instance.getInstance().getCache().saveAsImage(coverImage, cover,
+                                       true);
+                       synchronized (lock) {
+                               if (authorCovers != null) {
+                                       authorCovers.put(author, coverImage);
+                               }
                        }
                } catch (IOException e) {
                        Instance.getInstance().getTraceHandler().error(e);
@@ -465,8 +506,8 @@ public class LocalLibrary extends BasicLibrary {
                if (title.length() > 40) {
                        title = title.substring(0, 40);
                }
-               return new File(getExpectedDir(key.getSource()), key.getLuid() + "_"
-                               + title);
+               return new File(getExpectedDir(key.getSource()),
+                               key.getLuid() + "_" + title);
        }
 
        /**
@@ -513,7 +554,8 @@ public class LocalLibrary extends BasicLibrary {
        private File getAuthorCoverFile(String author) {
                File aDir = new File(baseDir, "_AUTHORS");
                String hash = StringUtils.getMd5Hash(author);
-               String ext = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
+               String ext = Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
                return new File(aDir, hash + "." + ext.toLowerCase());
        }
 
@@ -558,13 +600,13 @@ public class LocalLibrary extends BasicLibrary {
                        }
                }
 
-               String coverExt = "."
-                               + Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+               String coverExt = "." + Instance.getInstance().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);
+                       coverFile = new File(
+                                       path.substring(0, path.length() - fileExt.length())
+                                                       + coverExt);
                }
 
                if (coverFile.exists()) {
@@ -587,48 +629,79 @@ public class LocalLibrary extends BasicLibrary {
         * @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) {
+       private Map<MetaData, File[]> getStories(Progress pg) {
                if (pg == null) {
                        pg = new Progress();
                } else {
                        pg.setMinMax(0, 100);
                }
 
+               Map<MetaData, File[]> stories = this.stories;
                if (stories == null) {
-                       stories = new HashMap<MetaData, File[]>();
+                       stories = getStoriesDo(pg);
+                       synchronized (lock) {
+                               if (this.stories == null)
+                                       this.stories = stories;
+                               else
+                                       stories = this.stories;
+                       }
+               }
+
+               pg.done();
+               return stories;
 
-                       lastId = 0;
+       }
 
-                       File[] dirs = baseDir.listFiles(new FileFilter() {
-                               @Override
-                               public boolean accept(File file) {
-                                       return file != null && file.isDirectory();
-                               }
-                       });
+       /**
+        * Actually do the work of {@link LocalLibrary#getStories(Progress)} (i.e.,
+        * do not retrieve the cache).
+        * 
+        * @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[]> getStoriesDo(Progress pg) {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
 
-                       if (dirs != null) {
-                               Progress pgDirs = new Progress(0, 100 * dirs.length);
-                               pg.addProgress(pgDirs, 100);
+               Map<MetaData, File[]> stories = new HashMap<MetaData, File[]>();
 
-                               for (File dir : dirs) {
-                                       Progress pgFiles = new Progress();
-                                       pgDirs.addProgress(pgFiles, 100);
-                                       pgDirs.setName("Loading from: " + dir.getName());
+               File[] dirs = baseDir.listFiles(new FileFilter() {
+                       @Override
+                       public boolean accept(File file) {
+                               return file != null && file.isDirectory();
+                       }
+               });
 
-                                       addToStories(pgFiles, dir);
+               if (dirs != null) {
+                       Progress pgDirs = new Progress(0, 100 * dirs.length);
+                       pg.addProgress(pgDirs, 100);
 
-                                       pgFiles.setName(null);
-                               }
+                       for (File dir : dirs) {
+                               Progress pgFiles = new Progress();
+                               pgDirs.addProgress(pgFiles, 100);
+                               pgDirs.setName("Loading from: " + dir.getName());
+
+                               addToStories(stories, pgFiles, dir);
 
-                               pgDirs.setName("Loading directories");
+                               pgFiles.setName(null);
                        }
+
+                       pgDirs.setName("Loading directories");
                }
 
                pg.done();
+
                return stories;
        }
 
-       private void addToStories(Progress pgFiles, File dir) {
+       private void addToStories(Map<MetaData, File[]> stories, Progress pgFiles,
+                       File dir) {
                File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
                        @Override
                        public boolean accept(File file) {
@@ -645,16 +718,12 @@ public class LocalLibrary extends BasicLibrary {
                }
 
                for (File infoFileOrSubdir : infoFilesAndSubdirs) {
-                       if (pgFiles != null) {
-                               pgFiles.setName(infoFileOrSubdir.getName());
-                       }
-
                        if (infoFileOrSubdir.isDirectory()) {
-                               addToStories(null, infoFileOrSubdir);
+                               addToStories(stories, null, infoFileOrSubdir);
                        } else {
                                try {
-                                       MetaData meta = InfoReader
-                                                       .readMeta(infoFileOrSubdir, false);
+                                       MetaData meta = InfoReader.readMeta(infoFileOrSubdir,
+                                                       false);
                                        try {
                                                int id = Integer.parseInt(meta.getLuid());
                                                if (id > lastId) {
@@ -671,8 +740,9 @@ public class LocalLibrary extends BasicLibrary {
                                } catch (IOException e) {
                                        // We should not have not-supported files in the
                                        // library
-                                       Instance.getInstance().getTraceHandler()
-                                                       .error(new IOException("Cannot load file from library: " + infoFileOrSubdir, e));
+                                       Instance.getInstance().getTraceHandler().error(
+                                                       new IOException("Cannot load file from library: "
+                                                                       + infoFileOrSubdir, e));
                                }
                        }
 
index 886defe8b1c6a9df42639f04c82ebd87d5396bbd..0903740cf9902b77ad8140b04e6a0fdb72c700db 100644 (file)
@@ -1,13 +1,21 @@
 package be.nikiroo.fanfix.library;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
 
 import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.StringUtils;
 
 public class MetaResultList {
+       /** Max number of items before splitting in [A-B] etc. for eligible items */
+       static private final int MAX = 20;
+
        private List<MetaData> metas;
 
        // Lazy lists:
@@ -39,6 +47,7 @@ public class MetaResultList {
                                if (!sources.contains(meta.getSource()))
                                        sources.add(meta.getSource());
                        }
+                       sort(sources);
                }
 
                return sources;
@@ -60,9 +69,54 @@ public class MetaResultList {
                        }
                }
 
+               sort(linked);
                return linked;
        }
 
+       /**
+        * 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 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;
+       }
+
        public List<String> getAuthors() {
                if (authors == null) {
                        authors = new ArrayList<String>();
@@ -70,11 +124,38 @@ public class MetaResultList {
                                if (!authors.contains(meta.getAuthor()))
                                        authors.add(meta.getAuthor());
                        }
+                       sort(authors);
                }
 
                return authors;
        }
 
+       /**
+        * Return the list of authors, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of authors 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 author 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 {
+               return group(getAuthors());
+       }
+
        public List<String> getTags() {
                if (tags == null) {
                        tags = new ArrayList<String>();
@@ -84,9 +165,36 @@ public class MetaResultList {
                                                tags.add(tag);
                                }
                        }
+                       sort(tags);
                }
 
-               return authors;
+               return tags;
+       }
+
+       /**
+        * Return the list of tags, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of tags is not too high, only one group with an empty name
+        * and all the tags will be returned.
+        * <p>
+        * If not, the tags will be separated into groups:
+        * <ul>
+        * <li><tt>*</tt>: any tag which name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0-9</tt>: any tag which name starts with a number</li>
+        * <li><tt>A-C</tt> (for instance): any tag which 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 tags' names, grouped by letter(s)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Map<String, List<String>> getTagsGrouped() throws IOException {
+               return group(getTags());
        }
 
        // helper
@@ -99,10 +207,12 @@ public class MetaResultList {
        }
 
        // null or empty -> no check, rest = must be included
-       // source: a source ending in "/" means "this or any source starting with this",
+       // source: a source ending in "/" means "this or any source starting with
+       // this",
        // i;e., to enable source hierarchy
        // + sorted
-       public List<MetaData> filter(List<String> sources, List<String> authors, List<String> tags) {
+       public List<MetaData> filter(List<String> sources, List<String> authors,
+                       List<String> tags) {
                if (sources != null && sources.isEmpty())
                        sources = null;
                if (authors != null && authors.isEmpty())
@@ -165,4 +275,145 @@ public class MetaResultList {
                Collections.sort(result);
                return result;
        }
+
+       /**
+        * Return the list of values, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of values is not too high, only one group with an empty
+        * name and all the values will be returned (see
+        * {@link MetaResultList#MAX}).
+        * <p>
+        * If not, the values will be separated into groups:
+        * <ul>
+        * <li><tt>*</tt>: any value which name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0-9</tt>: any value which name starts with a number</li>
+        * <li><tt>A-C</tt> (for instance): any value which 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).
+        * 
+        * @param values
+        *            the values to group
+        * 
+        * @return the values, grouped by letter(s)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       private Map<String, List<String>> group(List<String> values)
+                       throws IOException {
+               Map<String, List<String>> groups = new TreeMap<String, List<String>>();
+
+               // If all authors fit the max, just report them as is
+               if (values.size() <= MAX) {
+                       groups.put("", values);
+                       return groups;
+               }
+
+               // Create groups A to Z, which can be empty here
+               for (char car = 'A'; car <= 'Z'; car++) {
+                       groups.put(Character.toString(car), find(values, 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("*", find(values, '*'));
+               groups.put("0-9", find(values, '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 values
+        *            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> find(List<String> values, char car) {
+               List<String> accepted = new ArrayList<String>();
+               for (String value : values) {
+                       char first = '*';
+                       for (int i = 0; first == '*' && i < value.length(); i++) {
+                               String san = StringUtils.sanitize(value, 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(value);
+                       }
+               }
+
+               return accepted;
+       }
+
+       /**
+        * Sort the given {@link String} values, ignoring case.
+        * 
+        * @param values
+        *            the values to sort
+        */
+       private void sort(List<String> values) {
+               Collections.sort(values, new Comparator<String>() {
+                       @Override
+                       public int compare(String o1, String o2) {
+                               return ("" + o1).compareToIgnoreCase("" + o2);
+                       }
+               });
+       }
 }
index 65be7b1759e4893b2412f2c74cc00c0b3a95a2a7..bbe772a8c90168c658fa985461e92c58f13bb8d0 100644 (file)
@@ -426,7 +426,7 @@ public class RemoteLibrary extends BasicLibrary {
        }
 
        @Override
-       public synchronized File getFile(final String luid, Progress pg) {
+       public File getFile(final String luid, Progress pg) {
                throw new java.lang.InternalError(
                                "Operation not supportorted on remote Libraries");
        }
@@ -449,7 +449,7 @@ public class RemoteLibrary extends BasicLibrary {
        }
 
        @Override
-       public synchronized MetaData getInfo(String luid) throws IOException {
+       public MetaData getInfo(String luid) throws IOException {
                List<MetaData> metas = getMetasList(luid, null);
                if (!metas.isEmpty()) {
                        return metas.get(0);
@@ -459,7 +459,7 @@ public class RemoteLibrary extends BasicLibrary {
        }
 
        @Override
-       protected synchronized List<MetaData> getMetas(Progress pg) throws IOException {
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
                return getMetasList("*", pg);
        }
 
index 4f89a1fa19263c9d4bedf513274442660d3a023f..f92c37e8e2ecaf6a1b7604d6b0c914a56b03131a 100644 (file)
@@ -202,15 +202,7 @@ public class RemoteLibraryServer extends ServerObject {
                                Progress pg = createPgForwarder(action);
 
                                for (MetaData meta : Instance.getInstance().getLibrary().getMetas(pg)) {
-                                       MetaData light;
-                                       if (meta.getCover() == null) {
-                                               light = meta;
-                                       } else {
-                                               light = meta.clone();
-                                               light.setCover(null);
-                                       }
-
-                                       metas.add(light);
+                                       metas.add(removeCover(meta));
                                }
 
                                forcePgDoneSent(pg);
@@ -413,19 +405,45 @@ public class RemoteLibraryServer extends ServerObject {
         * @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;
+               boolean updateProgress = false;
+               if (rep instanceof Integer[] && ((Integer[]) rep).length == 3)
+                       updateProgress = true;
+               if (rep instanceof Object[] && ((Object[]) rep).length >= 5
+                               && "UPDATE".equals(((Object[]) rep)[0]))
+                       updateProgress = true;
+
+               if (updateProgress) {
+                       Object[] a = (Object[]) rep;
+
+                       int offset = 0;
+                       if (a[0] instanceof String) {
+                               offset = 1;
+                       }
+
+                       int min = (Integer) a[0 + offset];
+                       int max = (Integer) a[1 + offset];
+                       int progress = (Integer) a[2 + offset];
+                       
+                       Object meta = null;
+                       if (a.length > (3 + offset)) {
+                               meta = a[3 + offset];
+                       }
+                       
+                       String name = null;
+                       if (a.length > (4 + offset)) {
+                               name = a[4 + offset] == null ? "" : a[4 + offset].toString();
+                       }
+                       
+
+                       if (min >= 0 && min <= max) {
+                               pg.setName(name);
+                               pg.setMinMax(min, max);
+                               pg.setProgress(progress);
+                               if (meta != null) {
+                                       pg.put("meta", meta);
                                }
+
+                               return true;
                        }
                }
 
@@ -451,27 +469,40 @@ public class RemoteLibraryServer extends ServerObject {
                };
 
                final Integer[] p = new Integer[] { -1, -1, -1 };
+               final Object[] pMeta = new MetaData[1];
+               final String[] pName = new String[1];
                final Long[] lastTime = new Long[] { new Date().getTime() };
                pg.addProgressListener(new ProgressListener() {
                        @Override
                        public void progress(Progress progress, String name) {
+                               Object meta = pg.get("meta");
+                               if (meta instanceof MetaData) {
+                                       meta = removeCover((MetaData)meta);
+                               }
+                               
                                int min = pg.getMin();
                                int max = pg.getMax();
-                               int relativeProgress = min
+                               int rel = min
                                                + (int) Math.round(pg.getRelativeProgress()
                                                                * (max - min));
-
+                               
+                               boolean samePg = p[0] == min && p[1] == max && p[2] == rel;
+                               
                                // 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)
+                               if (!samePg || !same(pMeta[0], meta)
+                                               || !same(pName[0], name) //
                                                || (new Date().getTime() - lastTime[0] > 2000)) {
                                        p[0] = min;
                                        p[1] = max;
-                                       p[2] = relativeProgress;
+                                       p[2] = rel;
+                                       pMeta[0] = meta;
+                                       pName[0] = name;
 
                                        try {
-                                               action.send(new Integer[] { min, max, relativeProgress });
+                                               action.send(new Object[] { "UPDATE", min, max, rel,
+                                                               meta, name });
                                                action.rec();
                                        } catch (Exception e) {
                                                getTraceHandler().error(e);
@@ -486,6 +517,13 @@ public class RemoteLibraryServer extends ServerObject {
 
                return pg;
        }
+       
+       private boolean same(Object obj1, Object obj2) {
+               if (obj1 == null || obj2 == null)
+                       return obj1 == null && obj2 == null;
+
+               return obj1.equals(obj2);
+       }
 
        // with 30 seconds timeout
        private void forcePgDoneSent(Progress pg) {
@@ -499,4 +537,18 @@ public class RemoteLibraryServer extends ServerObject {
                        }
                }
        }
+       
+       private MetaData removeCover(MetaData meta) {
+               MetaData light = null;
+               if (meta != null) {
+                       if (meta.getCover() == null) {
+                               light = meta;
+                       } else {
+                               light = meta.clone();
+                               light.setCover(null);
+                       }
+               }
+               
+               return light;
+       }
 }
index bc91e8b40d0688e96b8ae8698f9e252ae2fe3bec..0a5ec3686e1be43c52a328d1a1be86e805ad21fe 100644 (file)
@@ -2,14 +2,17 @@ package be.nikiroo.fanfix.supported;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.MalformedURLException;
 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.Scanner;
 import java.util.Map.Entry;
 
+import org.json.JSONObject;
 import org.jsoup.helper.DataUtil;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
@@ -269,10 +272,13 @@ public abstract class BasicSupport {
 
                Story story = new Story();
                MetaData meta = getMeta();
-               if (meta.getCreationDate() == null || meta.getCreationDate().isEmpty()) {
-                       meta.setCreationDate(StringUtils.fromTime(new Date().getTime()));
+               if (meta.getCreationDate() == null
+                               || meta.getCreationDate().trim().isEmpty()) {
+                       meta.setCreationDate(bsHelper
+                                       .formatDate(StringUtils.fromTime(new Date().getTime())));
                }
                story.setMeta(meta);
+               pg.put("meta", meta);
 
                pg.setProgress(50);
 
@@ -292,6 +298,63 @@ public abstract class BasicSupport {
                return story;
        }
 
+       /**
+        * Utility method to convert the given URL into a JSON object.
+        * <p>
+        * Note that this method expects small JSON files (everything is copied into
+        * memory at least twice).
+        * 
+        * @param url
+        *            the URL to parse
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the JSON object
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected JSONObject getJson(String url, boolean stable)
+                       throws IOException {
+               try {
+                       return getJson(new URL(url), stable);
+               } catch (MalformedURLException e) {
+                       throw new IOException("Malformed URL: " + url, e);
+               }
+       }
+
+       /**
+        * Utility method to convert the given URL into a JSON object.
+        * <p>
+        * Note that this method expects small JSON files (everything is copied into
+        * memory at least twice).
+        * 
+        * @param url
+        *            the URL to parse
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the JSON object
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected JSONObject getJson(URL url, boolean stable) throws IOException {
+               InputStream in = Instance.getInstance().getCache().open(url, null,
+                               stable);
+               try {
+                       Scanner scan = new Scanner(in);
+                       scan.useDelimiter("\0");
+                       try {
+                               return new JSONObject(scan.next());
+                       } finally {
+                               scan.close();
+                       }
+               } finally {
+                       in.close();
+               }
+       }
+
        /**
         * Process the given story resource into a fully filled {@link Story}
         * object.
@@ -336,14 +399,15 @@ public abstract class BasicSupport {
                } else {
                        pg.setMinMax(0, 100);
                }
+               
+               pg.setName("Initialising");
 
                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());
+               pg.put("meta", story.getMeta());
 
                Progress pgGetChapters = new Progress();
                pg.addProgress(pgGetChapters, 10);
index b5c7bb9cdee9ccaf96c1de6c38eeb4aaa76a9537..7768052cafc758094e38c1968f5919f6b853b8bc 100644 (file)
@@ -5,10 +5,14 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
 
 import be.nikiroo.fanfix.Instance;
 import be.nikiroo.fanfix.bundles.Config;
 import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
 
 /**
  * Helper class for {@link BasicSupport}, mostly dedicated to text formating for
@@ -220,4 +224,58 @@ public class BasicSupportHelper {
 
                return author;
        }
+       
+       /**
+        * Try to convert the date to a known, fixed format.
+        * <p>
+        * If it fails to do so, it will return the date as-is.
+        * 
+        * @param date
+        *            the date to convert
+        * 
+        * @return the converted date, or the date as-is
+        */
+       public String formatDate(String date) {
+               long ms = 0;
+
+               if (date != null && !date.isEmpty()) {
+                       // Default Fanfix format:
+                       try {
+                               ms = StringUtils.toTime(date);
+                       } catch (ParseException e) {
+                       }
+
+                       // Second chance:
+                       if (ms <= 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat(
+                                               "yyyy-MM-dd'T'HH:mm:ssSSS");
+                               try {
+                                       ms = sdf.parse(date).getTime();
+                               } catch (ParseException e) {
+                               }
+                       }
+
+                       // Last chance:
+                       if (ms <= 0 && date.length() >= 10) {
+                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                               try {
+                                       ms = sdf.parse(date.substring(0, 10)).getTime();
+                               } catch (ParseException e) {
+                               }
+                       }
+
+                       // If we found something, use THIS format:
+                       if (ms > 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                               return sdf.format(new Date(ms));
+                       }
+               }
+
+               if (date == null) {
+                       date = "";
+               }
+
+               // :(
+               return date;
+       }
 }
index 4a7b65b93d66c1899853888a759842e49e92ff9f..bc3738a211bc22b92ac8811e85a549dd17777618 100644 (file)
@@ -205,10 +205,12 @@ public abstract class BasicSupport_Deprecated extends BasicSupport {
                        Story story = new Story();
                        MetaData meta = getMeta(url, getInput());
                        if (meta.getCreationDate() == null
-                                       || meta.getCreationDate().isEmpty()) {
-                               meta.setCreationDate(StringUtils.fromTime(new Date().getTime()));
+                                       || meta.getCreationDate().trim().isEmpty()) {
+                               meta.setCreationDate(bsHelper.formatDate(
+                                               StringUtils.fromTime(new Date().getTime())));
                        }
                        story.setMeta(meta);
+                       pg.put("meta", meta);
 
                        pg.setProgress(50);
 
@@ -263,12 +265,11 @@ public abstract class BasicSupport_Deprecated extends BasicSupport {
                        Progress pgMeta = new Progress();
                        pg.addProgress(pgMeta, 10);
                        Story story = processMeta(url, false, true, pgMeta);
+                       pg.put("meta", story.getMeta());
                        if (!pgMeta.isDone()) {
                                pgMeta.setProgress(pgMeta.getMax()); // 10%
                        }
 
-                       pg.setName("Retrieving " + story.getMeta().getTitle());
-
                        setCurrentReferer(url);
 
                        Progress pgGetChapters = new Progress();
index 76b66aba7c75fa24317eb4daca6ff75854658326..a6188ec55085467b1306f5a9ac91e0083bdf014d 100644 (file)
@@ -63,6 +63,8 @@ class Cbz extends Epub {
                } else {
                        pg.setMinMax(0, 100);
                }
+               
+               pg.setName("Initialising");
 
                Progress pgMeta = new Progress();
                pg.addProgress(pgMeta, 10);
@@ -70,7 +72,7 @@ class Cbz extends Epub {
                MetaData meta = story.getMeta();
 
                pgMeta.done(); // 10%
-
+               
                File tmpDir = Instance.getInstance().getTempFiles().createTempDir("info-text");
                String basename = null;
 
@@ -193,7 +195,7 @@ class Cbz extends Epub {
                        }
                }
 
-               pg.setProgress(100);
+               pg.done();
                return story;
        }
 
index f1660e18cb34a9da6373ac03e6918ccc630ff25f..dc7cb1b3b8b6e468f0a44ecc091dabf6c5f9a6e7 100644 (file)
@@ -1,7 +1,6 @@
 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;
@@ -14,16 +13,20 @@ import java.util.LinkedList;
 import java.util.List;
 import java.util.Map.Entry;
 
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
 import org.jsoup.helper.DataUtil;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 
 import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
 import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.IOUtils;
 import be.nikiroo.utils.Image;
 import be.nikiroo.utils.Progress;
 import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
 
 /**
  * Support class for <a href="http://e621.net/">e621.net</a> and
@@ -43,7 +46,8 @@ class E621 extends BasicSupport {
                        host = host.substring("www.".length());
                }
 
-               return ("e621.net".equals(host) || "e926.net".equals(host)) && (isPool(url) || isSearchOrSet(url));
+               return ("e621.net".equals(host) || "e926.net".equals(host))
+                               && (isPool(url) || isSearchOrSet(url));
        }
 
        @Override
@@ -57,7 +61,7 @@ class E621 extends BasicSupport {
 
                meta.setTitle(getTitle());
                meta.setAuthor(getAuthor());
-               meta.setDate("");
+               meta.setDate(bsHelper.formatDate(getDate()));
                meta.setTags(getTags());
                meta.setSource(getType().getSourceName());
                meta.setUrl(getSource().toString());
@@ -78,8 +82,11 @@ class E621 extends BasicSupport {
        protected String getDesc() throws IOException {
                if (isSearchOrSet(getSource())) {
                        StringBuilder builder = new StringBuilder();
-                       builder.append("A collection of images from ").append(getSource().getHost()).append("\n") //
-                                       .append("\tTime of creation: " + StringUtils.fromTime(new Date().getTime())).append("\n") //
+                       builder.append("A collection of images from ")
+                                       .append(getSource().getHost()).append("\n") //
+                                       .append("\tTime of creation: "
+                                                       + StringUtils.fromTime(new Date().getTime()))
+                                       .append("\n") //
                                        .append("\tTags: ");//
                        for (String tag : getTags()) {
                                builder.append("\t\t").append(tag);
@@ -99,57 +106,84 @@ class E621 extends BasicSupport {
        }
 
        @Override
-       protected List<Entry<String, URL>> getChapters(Progress pg) throws IOException {
-               if (isPool(getSource())) {
-                       String baseUrl = "https://e621.net/" + getSource().getPath() + "?page=";
-                       return getChapters(getSource(), pg, baseUrl, "");
-               } else if (isSearchOrSet(getSource())) {
-                       String baseUrl = "https://e621.net/posts/?page=";
-                       String search = "&tags=" + getTagsFromUrl(getSource());
-                       return getChapters(getSource(), pg, baseUrl, search);
-               }
-
-               return new LinkedList<Entry<String, URL>>();
-       }
-
-       private List<Entry<String, URL>> getChapters(URL source, Progress pg, String baseUrl, String parameters)
+       protected List<Entry<String, URL>> getChapters(Progress pg)
                        throws IOException {
-               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-
-               if (source.getHost().contains("e926")) {
-                       baseUrl = baseUrl.replace("e621", "e926");
-               }
+               int i = 1;
+               String jsonUrl = getJsonUrl();
+               if (jsonUrl != null) {
+                       for (i = 1; true; i++) {
+                               if (i > 1) {
+                                       try {
+                                               // The API does not accept more than 2 request per sec,
+                                               // and asks us to limit at one per sec when possible
+                                               Thread.sleep(1000);
+                                       } catch (InterruptedException e) {
+                                       }
+                               }
 
-               for (int i = 1; true; i++) {
-                       URL url = new URL(baseUrl + i + parameters);
-                       try {
-                               InputStream pageI = Instance.getInstance().getCache().open(url, this, false);
                                try {
-                                       if (IOUtils.readSmallStream(pageI).contains("Nobody here but us chickens!")) {
+                                       JSONObject json = getJson(jsonUrl + "&page=" + i, false);
+                                       if (!json.has("posts"))
                                                break;
-                                       }
-                                       urls.add(new AbstractMap.SimpleEntry<String, URL>("Page " + Integer.toString(i), url));
-                               } finally {
-                                       pageI.close();
+                                       JSONArray posts = json.getJSONArray("posts");
+                                       if (posts.isEmpty())
+                                               break;
+                               } catch (Exception e) {
+                                       e.printStackTrace();
                                }
-                       } catch (Exception e) {
-                               break;
                        }
+
+                       // The last page was empty:
+                       i--;
                }
 
-               // They are sorted in reverse order on the website
-               Collections.reverse(urls);
-               return urls;
+               // The pages and images are in reverse order on /posts/
+               List<Entry<String, URL>> chapters = new LinkedList<Entry<String, URL>>();
+               for (int page = i; page > 0; page--) {
+                       chapters.add(new AbstractMap.SimpleEntry<String, URL>(
+                                       "Page " + Integer.toString(i - page + 1),
+                                       new URL(jsonUrl + "&page=" + page)));
+               }
+
+               return chapters;
        }
 
        @Override
-       protected String getChapterContent(URL chapUrl, int number, Progress pg) throws IOException {
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
                StringBuilder builder = new StringBuilder();
-               Document chapterNode = loadDocument(chapUrl);
-               for (Element el : chapterNode.getElementsByTag("article")) {
-                       builder.append("[");
-                       builder.append(el.attr("data-file-url"));
-                       builder.append("]<br/>");
+
+               JSONObject json = getJson(chapUrl, false);
+               JSONArray postsArr = json.getJSONArray("posts");
+
+               // The pages and images are in reverse order on /posts/
+               List<JSONObject> posts = new ArrayList<JSONObject>(postsArr.length());
+               for (int i = postsArr.length() - 1; i >= 0; i--) {
+                       Object o = postsArr.get(i);
+                       if (o instanceof JSONObject)
+                               posts.add((JSONObject) o);
+               }
+
+               for (JSONObject post : posts) {
+                       if (!post.has("file"))
+                               continue;
+                       JSONObject file = post.getJSONObject("file");
+                       if (!file.has("url"))
+                               continue;
+
+                       try {
+                               String url = file.getString("url");
+                               builder.append("[");
+                               builder.append(url);
+                               builder.append("]<br/>");
+                       } catch (JSONException e) {
+                               // Can be NULL if filtered
+                               // When the value is NULL, we get an exception
+                               // but the "has" method still returns true
+                               Instance.getInstance().getTraceHandler()
+                                               .error("Cannot get image for chapter " + number + " of "
+                                                               + getSource());
+                       }
                }
 
                return builder.toString();
@@ -157,10 +191,31 @@ class E621 extends BasicSupport {
 
        @Override
        protected URL getCanonicalUrl(URL source) {
+               // Convert search-pools into proper pools
+               if (source.getPath().equals("/posts") && source.getQuery() != null
+                               && source.getQuery().startsWith("tags=pool%3A")) {
+                       String poolNumber = source.getQuery()
+                                       .substring("tags=pool%3A".length());
+                       try {
+                               Integer.parseInt(poolNumber);
+                               String base = source.getProtocol() + "://" + source.getHost();
+                               if (source.getPort() != -1) {
+                                       base = base + ":" + source.getPort();
+                               }
+                               source = new URL(base + "/pools/" + poolNumber);
+                       } catch (NumberFormatException e) {
+                               // Not a simple pool, skip
+                       } catch (MalformedURLException e) {
+                               // Cannot happen
+                       }
+               }
+
                if (isSetOriginalUrl(source)) {
                        try {
-                               Document doc = DataUtil.load(Instance.getInstance().getCache().open(source, this, false), "UTF-8", source.toString());
-                               for (Element shortname : doc.getElementsByClass("set-shortname")) {
+                               Document doc = DataUtil.load(Instance.getInstance().getCache()
+                                               .open(source, this, false), "UTF-8", source.toString());
+                               for (Element shortname : doc
+                                               .getElementsByClass("set-shortname")) {
                                        for (Element el : shortname.getElementsByTag("a")) {
                                                if (!el.attr("href").isEmpty())
                                                        return new URL(el.absUrl("href"));
@@ -173,7 +228,8 @@ class E621 extends BasicSupport {
 
                if (isPool(source)) {
                        try {
-                               return new URL(source.toString().replace("/pool/show/", "/pools/"));
+                               return new URL(
+                                               source.toString().replace("/pool/show/", "/pools/"));
                        } catch (MalformedURLException e) {
                        }
                }
@@ -181,29 +237,6 @@ class E621 extends BasicSupport {
                return super.getCanonicalUrl(source);
        }
 
-       // returns "xxx+ddd+ggg" if "tags=xxx+ddd+ggg" was present in the query
-       private String getTagsFromUrl(URL url) {
-               String tags = url == null ? "" : url.getQuery();
-               int pos = tags.indexOf("tags=");
-
-               if (pos >= 0) {
-                       tags = tags.substring(pos).substring("tags=".length());
-               } else {
-                       return "";
-               }
-
-               pos = tags.indexOf('&');
-               if (pos > 0) {
-                       tags = tags.substring(0, pos);
-               }
-               pos = tags.indexOf('/');
-               if (pos > 0) {
-                       tags = tags.substring(0, pos);
-               }
-
-               return tags;
-       }
-
        private String getTitle() {
                String title = "";
 
@@ -212,54 +245,87 @@ class E621 extends BasicSupport {
                        title = el.text().trim();
                }
 
-               for (String s : new String[] { "e621", "-", "e621" }) {
+               for (String s : new String[] { "e621", "-", "e621", "Pool", "-" }) {
                        if (title.startsWith(s)) {
                                title = title.substring(s.length()).trim();
                        }
                        if (title.endsWith(s)) {
                                title = title.substring(0, title.length() - s.length()).trim();
                        }
-
                }
 
                if (isSearchOrSet(getSource())) {
                        title = title.isEmpty() ? "e621" : "[e621] " + title;
                }
+
                return title;
        }
 
-       private String getAuthor() throws IOException {
-               StringBuilder builder = new StringBuilder();
-
-               if (isSearchOrSet(getSource())) {
-                       for (Element el : getSourceNode().getElementsByClass("search-tag")) {
-                               if (el.attr("itemprop").equals("author")) {
-                                       if (builder.length() > 0) {
-                                               builder.append(", ");
+       private String getAuthor() {
+               List<String> list = new ArrayList<String>();
+               String jsonUrl = getJsonUrl();
+               if (jsonUrl != null) {
+                       try {
+                               JSONObject json = getJson(jsonUrl, false);
+                               JSONArray posts = json.getJSONArray("posts");
+                               for (Object obj : posts) {
+                                       if (!(obj instanceof JSONObject))
+                                               continue;
+
+                                       JSONObject post = (JSONObject) obj;
+                                       if (!post.has("tags"))
+                                               continue;
+
+                                       JSONObject tags = post.getJSONObject("tags");
+                                       if (!tags.has("artist"))
+                                               continue;
+
+                                       JSONArray artists = tags.getJSONArray("artist");
+                                       for (Object artist : artists) {
+                                               if (list.contains(artist.toString()))
+                                                       continue;
+
+                                               list.add(artist.toString());
                                        }
-                                       builder.append(el.text().trim());
                                }
+                       } catch (Exception e) {
+                               e.printStackTrace();
                        }
                }
 
-               if (isPool(getSource())) {
-                       String desc = getDesc();
-                       String descL = desc.toLowerCase();
+               StringBuilder builder = new StringBuilder();
+               for (String artist : list) {
+                       if (builder.length() > 0) {
+                               builder.append(", ");
+                       }
+                       builder.append(artist);
+               }
 
-                       if (descL.startsWith("by:") || descL.startsWith("by ")) {
-                               desc = desc.substring(3).trim();
-                               desc = desc.split("\n")[0];
+               return builder.toString();
+       }
 
-                               String tab[] = desc.split(" ");
-                               for (int i = 0; i < Math.min(tab.length, 5); i++) {
-                                       if (tab[i].startsWith("http"))
-                                               break;
-                                       builder.append(" ").append(tab[i]);
+       private String getDate() {
+               String jsonUrl = getJsonUrl();
+               if (jsonUrl != null) {
+                       try {
+                               JSONObject json = getJson(jsonUrl, false);
+                               JSONArray posts = json.getJSONArray("posts");
+                               for (Object obj : posts) {
+                                       if (!(obj instanceof JSONObject))
+                                               continue;
+
+                                       JSONObject post = (JSONObject) obj;
+                                       if (!post.has("created_at"))
+                                               continue;
+
+                                       return post.getString("created_at");
                                }
+                       } catch (Exception e) {
+                               e.printStackTrace();
                        }
                }
 
-               return builder.toString();
+               return "";
        }
 
        // no tags for pools
@@ -278,6 +344,29 @@ class E621 extends BasicSupport {
                return tags;
        }
 
+       // returns "xxx+ddd+ggg" if "tags=xxx+ddd+ggg" was present in the query
+       private String getTagsFromUrl(URL url) {
+               String tags = url == null ? "" : url.getQuery();
+               int pos = tags.indexOf("tags=");
+
+               if (pos >= 0) {
+                       tags = tags.substring(pos).substring("tags=".length());
+               } else {
+                       return "";
+               }
+
+               pos = tags.indexOf('&');
+               if (pos > 0) {
+                       tags = tags.substring(0, pos);
+               }
+               pos = tags.indexOf('/');
+               if (pos > 0) {
+                       tags = tags.substring(0, pos);
+               }
+
+               return tags;
+       }
+
        private Image getCover() throws IOException {
                Image image = null;
                List<Entry<String, URL>> chapters = getChapters(null);
@@ -293,13 +382,44 @@ class E621 extends BasicSupport {
                return image;
        }
 
+       // always /posts.json/ url
+       private String getJsonUrl() {
+               String url = null;
+               if (isSearchOrSet(getSource())) {
+                       url = getSource().toString().replace("/posts", "/posts.json");
+               }
+
+               if (isPool(getSource())) {
+                       String poolNumber = getSource().getPath()
+                                       .substring("/pools/".length());
+                       url = "https://e621.net/posts.json" + "?tags=pool%3A" + poolNumber;
+               }
+
+               if (url != null) {
+                       // Note: one way to override the blacklist
+                       String login = Instance.getInstance().getConfig()
+                                       .getString(Config.LOGIN_E621_LOGIN);
+                       String apk = Instance.getInstance().getConfig()
+                                       .getString(Config.LOGIN_E621_APIKEY);
+
+                       if (login != null && !login.isEmpty() && apk != null
+                                       && !apk.isEmpty()) {
+                               url = String.format("%s&login=%s&api_key=%s&_client=%s", url,
+                                               login, apk, "fanfix-" + Version.getCurrentVersion());
+                       }
+               }
+
+               return url;
+       }
+
        // note: will be removed at getCanonicalUrl()
        private boolean isSetOriginalUrl(URL originalUrl) {
                return originalUrl.getPath().startsWith("/post_sets/");
        }
 
        private boolean isPool(URL url) {
-               return url.getPath().startsWith("/pools/") || url.getPath().startsWith("/pool/show/");
+               return url.getPath().startsWith("/pools/")
+                               || url.getPath().startsWith("/pool/show/");
        }
 
        // set will be renamed into search by canonical url
index f8e467831381185244eae24b15d3a96a6cb0f6cb..90a9f458e4fd3ce9c0f8ad331bc407a27dc760ca 100644 (file)
@@ -42,8 +42,8 @@ class Epub extends InfoText {
                try {
                        return new File(fakeSource.toURI());
                } catch (URISyntaxException e) {
-                       Instance.getInstance().getTraceHandler()
-                                       .error(new IOException("Cannot get the source file from the info-text URL", e));
+                       Instance.getInstance().getTraceHandler().error(new IOException(
+                                       "Cannot get the source file from the info-text URL", e));
                }
 
                return null;
@@ -55,7 +55,8 @@ class Epub extends InfoText {
                        try {
                                fakeIn.reset();
                        } catch (IOException e) {
-                               Instance.getInstance().getTraceHandler().error(new IOException("Cannot reset the Epub Text stream", e));
+                               Instance.getInstance().getTraceHandler().error(new IOException(
+                                               "Cannot reset the Epub Text stream", e));
                        }
 
                        return fakeIn;
@@ -83,7 +84,8 @@ class Epub extends InfoText {
                ZipInputStream zipIn = null;
                try {
                        zipIn = new ZipInputStream(in);
-                       tmpDir = Instance.getInstance().getTempFiles().createTempDir("fanfic-reader-parser");
+                       tmpDir = Instance.getInstance().getTempFiles()
+                                       .createTempDir("fanfic-reader-parser");
                        File tmp = new File(tmpDir, "file.txt");
                        File tmpInfo = new File(tmpDir, "file.info");
 
@@ -99,8 +101,9 @@ class Epub extends InfoText {
                        String title = null;
                        String author = null;
 
-                       for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
-                                       .getNextEntry()) {
+                       for (ZipEntry entry = zipIn
+                                       .getNextEntry(); entry != null; entry = zipIn
+                                                       .getNextEntry()) {
                                if (!entry.isDirectory()
                                                && entry.getName().startsWith(getDataPrefix())) {
                                        String entryLName = entry.getName().toLowerCase();
@@ -124,18 +127,25 @@ class Epub extends InfoText {
                                                        try {
                                                                cover = new Image(zipIn);
                                                        } catch (Exception e) {
-                                                               Instance.getInstance().getTraceHandler().error(e);
+                                                               Instance.getInstance().getTraceHandler()
+                                                                               .error(e);
                                                        }
                                                }
-                                       } else if (entry.getName().equals(getDataPrefix() + "URL")) {
+                                       } 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")) {
+                                       } else if (entry.getName().endsWith(".desc")) {
+                                               // // For old files
+                                               // if (this.desc != null) {
+                                               // this.desc = IOUtils.readSmallStream(zipIn).trim();
+                                               // }
+                                       } else if (entry.getName()
+                                                       .equals(getDataPrefix() + "SUMMARY")) {
                                                String[] descArray = StringUtils
                                                                .unhtml(IOUtils.readSmallStream(zipIn)).trim()
                                                                .split("\n");
@@ -149,12 +159,12 @@ class Epub extends InfoText {
                                                                skip = 2;
                                                        }
                                                }
-                                               this.desc = "";
-                                               for (int i = skip; i < descArray.length; i++) {
-                                                       this.desc += descArray[i].trim() + "\n";
-                                               }
-
-                                               this.desc = this.desc.trim();
+                                               // 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);
@@ -198,9 +208,8 @@ class Epub extends InfoText {
                                if (cover != null) {
                                        meta.setCover(cover);
                                } else {
-                                       meta.setCover(InfoReader
-                                                       .getCoverByName(getSourceFileOriginal().toURI()
-                                                                       .toURL()));
+                                       meta.setCover(InfoReader.getCoverByName(
+                                                       getSourceFileOriginal().toURI().toURL()));
                                }
                        }
                } finally {
index 6c6d7ba31caf2197356b68168447c7871b257a52..43d01d19494f616c6811e5757da4a6f2e14c83cc 100644 (file)
@@ -124,7 +124,8 @@ class FimfictionApi extends BasicSupport {
 
                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.setDate(bsHelper.formatDate(
+                               getKeyJson(json, 0, "type", "story", "date_published")));
                meta.setTags(getTags());
                meta.setSource(getType().getSourceName());
                meta.setUrl(getSource().toString());
index 220350e6dc904495de7de918e4843cf3d15dda01..206464f45a0a2e7c989e6fa6719afa22ca61c9cb 100644 (file)
@@ -19,7 +19,8 @@ import be.nikiroo.utils.streams.MarkableFileInputStream;
 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());
+       // static protected BasicSupportPara bsPara = new BasicSupportPara(new
+       // BasicSupportHelper(), new BasicSupportImages());
 
        public static MetaData readMeta(File infoFile, boolean withCover)
                        throws IOException {
@@ -30,7 +31,79 @@ public class InfoReader {
                if (infoFile.exists()) {
                        InputStream in = new MarkableFileInputStream(infoFile);
                        try {
-                               return createMeta(infoFile.toURI().toURL(), in, withCover);
+                               MetaData meta = createMeta(infoFile.toURI().toURL(), in,
+                                               withCover);
+
+                               // Some old .info files were using UUID for URL...
+                               if (!hasIt(meta.getUrl()) && meta.getUuid() != null
+                                               && (meta.getUuid().startsWith("http://")
+                                                               || meta.getUuid().startsWith("https://"))) {
+                                       meta.setUrl(meta.getUuid());
+                               }
+
+                               // Some old .info files don't have those now required fields...
+                               // So we check if we can find the info in another way (many
+                               // formats have a copy of the original text file)
+                               if (!hasIt(meta.getTitle(), meta.getAuthor(), meta.getDate(),
+                                               meta.getUrl())) {
+
+                                       // TODO: not nice, would be better to do it properly...
+
+                                       String base = infoFile.getPath();
+                                       if (base.endsWith(".info")) {
+                                               base = base.substring(0,
+                                                               base.length() - ".info".length());
+                                       }
+                                       File textFile = new File(base);
+                                       if (!textFile.exists()) {
+                                               textFile = new File(base + ".txt");
+                                       }
+                                       if (!textFile.exists()) {
+                                               textFile = new File(base + ".text");
+                                       }
+
+                                       if (textFile.exists()) {
+                                               final URL source = textFile.toURI().toURL();
+                                               final MetaData[] superMetaA = new MetaData[1];
+                                               @SuppressWarnings("unused")
+                                               Text unused = new Text() {
+                                                       private boolean loaded = loadDocument();
+
+                                                       @Override
+                                                       public SupportType getType() {
+                                                               return SupportType.TEXT;
+                                                       }
+
+                                                       protected boolean loadDocument()
+                                                                       throws IOException {
+                                                               loadDocument(source);
+                                                               superMetaA[0] = getMeta();
+                                                               return true;
+                                                       }
+
+                                                       @Override
+                                                       protected Image getCover(File sourceFile) {
+                                                               return null;
+                                                       }
+                                               };
+
+                                               MetaData superMeta = superMetaA[0];
+                                               if (!hasIt(meta.getTitle())) {
+                                                       meta.setTitle(superMeta.getTitle());
+                                               }
+                                               if (!hasIt(meta.getAuthor())) {
+                                                       meta.setAuthor(superMeta.getAuthor());
+                                               }
+                                               if (!hasIt(meta.getDate())) {
+                                                       meta.setDate(superMeta.getDate());
+                                               }
+                                               if (!hasIt(meta.getUrl())) {
+                                                       meta.setUrl(superMeta.getUrl());
+                                               }
+                                       }
+                               }
+
+                               return meta;
                        } finally {
                                in.close();
                        }
@@ -41,13 +114,31 @@ public class InfoReader {
                                                + infoFile.getAbsolutePath());
        }
 
+       /**
+        * Check if we have non-empty values for all the given {@link String}s.
+        * 
+        * @param values
+        *            the values to check
+        * 
+        * @return TRUE if none of them was NULL or empty
+        */
+       static private boolean hasIt(String... values) {
+               for (String value : values) {
+                       if (value == null || value.trim().isEmpty()) {
+                               return false;
+                       }
+               }
+
+               return true;
+       }
+
        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.setDate(bsHelper.formatDate(getInfoTag(in, "DATE")));
                meta.setTags(getInfoTagList(in, "TAGS", ","));
                meta.setSource(getInfoTag(in, "SOURCE"));
                meta.setUrl(getInfoTag(in, "URL"));
@@ -61,8 +152,7 @@ public class InfoReader {
                if (withCover) {
                        String infoTag = getInfoTag(in, "COVER");
                        if (infoTag != null && !infoTag.trim().isEmpty()) {
-                               meta.setCover(bsHelper.getImage(null, sourceInfoFile,
-                                               infoTag));
+                               meta.setCover(bsHelper.getImage(null, sourceInfoFile, infoTag));
                        }
                        if (meta.getCover() == null) {
                                // Second chance: try to check for a cover next to the info file
@@ -74,7 +164,8 @@ public class InfoReader {
                } catch (NumberFormatException e) {
                        meta.setWords(0);
                }
-               meta.setCreationDate(getInfoTag(in, "CREATION_DATE"));
+               meta.setCreationDate(
+                               bsHelper.formatDate(getInfoTag(in, "CREATION_DATE")));
                meta.setFakeCover(Boolean.parseBoolean(getInfoTag(in, "FAKE_COVER")));
 
                if (withCover && meta.getCover() == null) {
@@ -97,8 +188,8 @@ public class InfoReader {
 
                File basefile = new File(sourceInfoFile.getFile());
 
-               String ext = "."
-                               + Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+               String ext = "." + Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
 
                // Without removing ext
                cover = bsHelper.getImage(null, sourceInfoFile,
@@ -169,11 +260,21 @@ public class InfoReader {
                        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("\"")) {
+                               if (value.length() > 1 && //
+                                               (value.startsWith("'") && value.endsWith("'")
+                                                               || value.startsWith("\"")
+                                                                               && value.endsWith("\""))) {
                                        value = value.substring(1, value.length() - 1).trim();
                                }
 
+                               // Some old files ended up with TITLE="'xxxxx'"
+                               if ("^TITLE=".equals(key)) {
+                                       if (value.startsWith("'") && value.endsWith("'")
+                                                       && value.length() > 1) {
+                                               value = value.substring(1, value.length() - 1).trim();
+                                       }
+                               }
+
                                return value;
                        }
                }
@@ -235,8 +336,8 @@ public class InfoReader {
 
                        if (index == -1) {
                                if (needle.startsWith("^")) {
-                                       if (lines.get(lines.size() - 1).startsWith(
-                                                       needle.substring(1))) {
+                                       if (lines.get(lines.size() - 1)
+                                                       .startsWith(needle.substring(1))) {
                                                index = lines.size() - 1;
                                        }
 
index 42e2c13b6f75a1e32afa10f560361c1670ea9572..2af8c7e2f4880139540aa6f7f4e4895fd6e4742d 100644 (file)
@@ -22,30 +22,7 @@ class InfoText extends Text {
 
        @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;
+               return InfoReader.readMeta(getInfoFile(), true);
        }
 
        @Override
index 9929699cc43839b93e1b98752d205d562a08a845..de0b871331ef313b6be628c382c9487f281b5c3e 100644 (file)
@@ -32,7 +32,7 @@ class MangaLel extends BasicSupport {
 
                meta.setTitle(getTitle());
                meta.setAuthor(getAuthor());
-               meta.setDate(getDate());
+               meta.setDate(bsHelper.formatDate(getDate()));
                meta.setTags(getTags());
                meta.setSource(getType().getSourceName());
                meta.setUrl(getSource().toString());
@@ -102,15 +102,6 @@ class MangaLel extends BasicSupport {
                        }
                }
 
-               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;
        }
 
index c54b6a5d44ce0a328d63451420c6a9c00a82bd3c..ade797fbf71ae3182a05ced4a6f0fdbb74b1f118 100644 (file)
@@ -85,7 +85,7 @@ class Text extends BasicSupport {
 
                meta.setTitle(getTitle());
                meta.setAuthor(getAuthor());
-               meta.setDate(getDate());
+               meta.setDate(bsHelper.formatDate(getDate()));
                meta.setTags(new ArrayList<String>());
                meta.setSource(getType().getSourceName());
                meta.setUrl(getSourceFile().toURI().toURL().toString());
@@ -97,7 +97,7 @@ class Text extends BasicSupport {
                meta.setType(getType().toString());
                meta.setImageDocument(false);
                meta.setCover(getCover(getSourceFile()));
-
+               
                return meta;
        }
 
@@ -188,7 +188,7 @@ class Text extends BasicSupport {
                return content;
        }
 
-       private Image getCover(File sourceFile) {
+       protected Image getCover(File sourceFile) {
                String path = sourceFile.getName();
 
                for (String ext : new String[] { ".txt", ".text", ".story" }) {
@@ -317,7 +317,7 @@ class Text extends BasicSupport {
        }
 
        /**
-        * Remove the ".txt" extension if it is present.
+        * Remove the ".txt" (or ".text") extension if it is present.
         * 
         * @param file
         *            the file to process
@@ -326,9 +326,11 @@ class Text extends BasicSupport {
         *         was present
         */
        protected File assureNoTxt(File file) {
-               if (file.getName().endsWith(".txt")) {
-                       file = new File(file.getPath().substring(0,
-                                       file.getPath().length() - 4));
+               for (String ext : new String[] { ".txt", ".text" }) {
+                       if (file.getName().endsWith(ext)) {
+                               file = new File(file.getPath().substring(0,
+                                               file.getPath().length() - ext.length()));
+                       }
                }
 
                return file;
index dea6be3fa011c351e4990b39e396368a50174e9d..748d4a666c377123ff2eadd29c840ec4baf402e5 100644 (file)
@@ -9,6 +9,16 @@ import java.util.Map.Entry;
 
 /**
  * Progress reporting system, possibly nested.
+ * <p>
+ * A {@link Progress} can have a name, and that name will be reported through
+ * the event system (it will report the first non-null name in the stack from
+ * the {@link Progress} from which the event originated to the parent the event
+ * is listened on).
+ * <p>
+ * The {@link Progress} also has a table of keys/values shared amongst all the
+ * hierarchy (note that when adding a {@link Progress} to others, its values
+ * will be prioritized if some with the same keys were already present in the
+ * hierarchy).
  * 
  * @author niki
  */
@@ -35,6 +45,7 @@ public class Progress {
                public void progress(Progress progress, String name);
        }
 
+       private Map<Object, Object> map = new HashMap<Object, Object>();
        private Progress parent = null;
        private Object lock = new Object();
        private String name;
@@ -425,9 +436,60 @@ public class Progress {
                };
 
                synchronized (lock) {
+                       // Should not happen but just in case
+                       if (this.map != progress.map) {
+                               this.map.putAll(progress.map);
+                       }
+                       progress.map = this.map;
                        progress.parent = this;
                        this.children.put(progress, weight);
                        progress.addProgressListener(progressListener);
                }
        }
+
+       /**
+        * Set the given value for the given key on this {@link Progress} and it's
+        * children.
+        * 
+        * @param key
+        *            the key
+        * @param value
+        *            the value
+        */
+       public void put(Object key, Object value) {
+               map.put(key, value);
+       }
+
+       /**
+        * Return the value associated with this key as a {@link String} if any,
+        * NULL if not.
+        * <p>
+        * If the value is not NULL but not a {@link String}, it will be converted
+        * via {@link Object#toString()}.
+        * 
+        * @param key
+        *            the key to check
+        * 
+        * @return the value or NULL
+        */
+       public String getString(Object key) {
+               Object value = map.get(key);
+               if (value == null) {
+                       return null;
+               }
+
+               return value.toString();
+       }
+
+       /**
+        * Return the value associated with this key if any, NULL if not.
+        * 
+        * @param key
+        *            the key to check
+        * 
+        * @return the value or NULL
+        */
+       public Object get(Object key) {
+               return map.get(key);
+       }
 }
diff --git a/src/be/nikiroo/utils/android/ImageUtilsAndroid.class b/src/be/nikiroo/utils/android/ImageUtilsAndroid.class
new file mode 100644 (file)
index 0000000..844712a
Binary files /dev/null and b/src/be/nikiroo/utils/android/ImageUtilsAndroid.class differ
diff --git a/src/be/nikiroo/utils/android/test/TestAndroid.class b/src/be/nikiroo/utils/android/test/TestAndroid.class
new file mode 100644 (file)
index 0000000..216aa20
Binary files /dev/null and b/src/be/nikiroo/utils/android/test/TestAndroid.class differ
diff --git a/src/be/nikiroo/utils/compat/DefaultListModel6.java b/src/be/nikiroo/utils/compat/DefaultListModel6.java
new file mode 100644 (file)
index 0000000..114ac42
--- /dev/null
@@ -0,0 +1,22 @@
+package be.nikiroo.utils.compat;
+
+import javax.swing.DefaultListModel;
+import javax.swing.JList;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link DefaultListModel} that you can parametrise also
+ * in Java 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public class DefaultListModel6<E> extends DefaultListModel
+               implements ListModel6<E> {
+       private static final long serialVersionUID = 1L;
+}
diff --git a/src/be/nikiroo/utils/compat/JList6.java b/src/be/nikiroo/utils/compat/JList6.java
new file mode 100644 (file)
index 0000000..ca44165
--- /dev/null
@@ -0,0 +1,84 @@
+package be.nikiroo.utils.compat;
+
+import javax.swing.JList;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListModel;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link JList} that you can parametrise also in Java
+ * 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public class JList6<E> extends JList {
+       private static final long serialVersionUID = 1L;
+
+       @Override
+       @Deprecated
+       /**
+        * @deprecated please use {@link JList6#setCellRenderer(ListCellRenderer6)}
+        *             instead
+        */
+       public void setCellRenderer(ListCellRenderer cellRenderer) {
+               super.setCellRenderer(cellRenderer);
+       }
+
+       /**
+        * Sets the delegate that is used to paint each cell in the list. The job of
+        * a cell renderer is discussed in detail in the <a href="#renderer">class
+        * level documentation</a>.
+        * <p>
+        * If the {@code prototypeCellValue} property is {@code non-null}, setting
+        * the cell renderer also causes the {@code fixedCellWidth} and
+        * {@code fixedCellHeight} properties to be re-calculated. Only one
+        * <code>PropertyChangeEvent</code> is generated however - for the
+        * <code>cellRenderer</code> property.
+        * <p>
+        * The default value of this property is provided by the {@code ListUI}
+        * delegate, i.e. by the look and feel implementation.
+        * <p>
+        * This is a JavaBeans bound property.
+        *
+        * @param cellRenderer
+        *            the <code>ListCellRenderer</code> that paints list cells
+        * @see #getCellRenderer
+        * @beaninfo bound: true attribute: visualUpdate true description: The
+        *           component used to draw the cells.
+        */
+       public void setCellRenderer(ListCellRenderer6<E> cellRenderer) {
+               super.setCellRenderer(cellRenderer);
+       }
+
+       @Override
+       @Deprecated
+       public void setModel(ListModel model) {
+               super.setModel(model);
+       }
+
+       /**
+        * Sets the model that represents the contents or "value" of the list,
+        * notifies property change listeners, and then clears the list's selection.
+        * <p>
+        * This is a JavaBeans bound property.
+        *
+        * @param model
+        *            the <code>ListModel</code> that provides the list of items for
+        *            display
+        * @exception IllegalArgumentException
+        *                if <code>model</code> is <code>null</code>
+        * @see #getModel
+        * @see #clearSelection
+        * @beaninfo bound: true attribute: visualUpdate true description: The
+        *           object that contains the data to be drawn by this JList.
+        */
+       public void setModel(ListModel6<E> model) {
+               super.setModel(model);
+       }
+}
diff --git a/src/be/nikiroo/utils/compat/ListCellRenderer6.java b/src/be/nikiroo/utils/compat/ListCellRenderer6.java
new file mode 100644 (file)
index 0000000..d004849
--- /dev/null
@@ -0,0 +1,65 @@
+package be.nikiroo.utils.compat;
+
+import java.awt.Component;
+
+import javax.swing.JList;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListModel;
+import javax.swing.ListSelectionModel;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link ListCellRenderer} that you can parametrise also
+ * in Java 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public abstract class ListCellRenderer6<E> implements ListCellRenderer {
+       @Override
+       @Deprecated
+       /**
+        * @deprecated please use@deprecated please use
+        *             {@link ListCellRenderer6#getListCellRendererComponent(JList6, Object, int, boolean, boolean)}
+        *             instead
+        *             {@link ListCellRenderer6#getListCellRendererComponent(JList6, Object, int, boolean, boolean)}
+        *             instead
+        */
+       public Component getListCellRendererComponent(JList list, Object value,
+                       int index, boolean isSelected, boolean cellHasFocus) {
+               return getListCellRendererComponent((JList6<E>) list, (E) value, index,
+                               isSelected, cellHasFocus);
+       }
+
+       /**
+        * Return a component that has been configured to display the specified
+        * value. That component's <code>paint</code> method is then called to
+        * "render" the cell. If it is necessary to compute the dimensions of a list
+        * because the list cells do not have a fixed size, this method is called to
+        * generate a component on which <code>getPreferredSize</code> can be
+        * invoked.
+        *
+        * @param list
+        *            The JList we're painting.
+        * @param value
+        *            The value returned by list.getModel().getElementAt(index).
+        * @param index
+        *            The cells index.
+        * @param isSelected
+        *            True if the specified cell was selected.
+        * @param cellHasFocus
+        *            True if the specified cell has the focus.
+        * @return A component whose paint() method will render the specified value.
+        *
+        * @see JList
+        * @see ListSelectionModel
+        * @see ListModel
+        */
+       public abstract Component getListCellRendererComponent(JList6<E> list,
+                       E value, int index, boolean isSelected, boolean cellHasFocus);
+}
diff --git a/src/be/nikiroo/utils/compat/ListModel6.java b/src/be/nikiroo/utils/compat/ListModel6.java
new file mode 100644 (file)
index 0000000..a1f8c60
--- /dev/null
@@ -0,0 +1,19 @@
+package be.nikiroo.utils.compat;
+
+import javax.swing.JList;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link javax.swing.ListModel} that you can parametrise
+ * also in Java 1.6.
+ * 
+ * @author niki
+ *
+ * @param <E>
+ *            the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public interface ListModel6<E> extends javax.swing.ListModel {
+}
index 8ed74dc565b9994f1a6ec7a1b530525dbcd4bdbb..fb4d4915015e2c1efbf4021c8dd7ef4d2623c98e 100644 (file)
@@ -60,6 +60,16 @@ public @interface Meta {
         * @return what it is
         */
        String description() default "";
+       
+       /**
+        * This item should be hidden from the user (she will still be able to
+        * modify it if she opens the file manually).
+        * <p>
+        * Defaults to FALSE (visible).
+        * 
+        * @return TRUE if it should stay hidden
+        */
+       boolean hidden() default false;
 
        /**
         * This item is only used as a group, not as an option.
index 917c21001c14482bc9292be0eaede3cd1c707bd5..70c6c43181bbff8f8885c1eca15a792eef03eac1 100644 (file)
@@ -27,6 +27,7 @@ public class MetaInfo<E extends Enum<E>> implements Iterable<MetaInfo<E>> {
        private List<Runnable> saveListeners = new ArrayList<Runnable>();
 
        private String name;
+       private boolean hidden;
        private String description;
 
        private boolean dirty;
@@ -90,6 +91,7 @@ public class MetaInfo<E extends Enum<E>> implements Iterable<MetaInfo<E>> {
                }
 
                this.name = name;
+               this.hidden = meta.hidden();
                this.description = description;
 
                reload();
@@ -110,6 +112,16 @@ public class MetaInfo<E extends Enum<E>> implements Iterable<MetaInfo<E>> {
        public String getName() {
                return name;
        }
+       
+       /**
+        * This item should be hidden from the user (she will still be able to
+        * modify it if she opens the file manually).
+        * 
+        * @return TRUE if it should stay hidden
+        */
+       public boolean isHidden() {
+               return hidden;
+       }
 
        /**
         * A description for this item: what it is or does, how to explain that item
@@ -682,8 +694,10 @@ public class MetaInfo<E extends Enum<E>> implements Iterable<MetaInfo<E>> {
                List<MetaInfo<E>> shadow = new ArrayList<MetaInfo<E>>();
                for (E id : type.getEnumConstants()) {
                        MetaInfo<E> info = new MetaInfo<E>(type, bundle, id);
-                       list.add(info);
-                       shadow.add(info);
+                       if (!info.hidden) {
+                               list.add(info);
+                               shadow.add(info);
+                       }
                }
 
                for (int i = 0; i < list.size(); i++) {
diff --git a/src/be/nikiroo/utils/ui/BreadCrumbsBar.java b/src/be/nikiroo/utils/ui/BreadCrumbsBar.java
new file mode 100644 (file)
index 0000000..a0e205c
--- /dev/null
@@ -0,0 +1,224 @@
+
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+import javax.swing.BoxLayout;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JToggleButton;
+import javax.swing.SwingWorker;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+
+public class BreadCrumbsBar<T> extends ListenerPanel {
+       private class BreadCrumb extends JPanel {
+               private JToggleButton button;
+               private JToggleButton down;
+
+               public BreadCrumb(final DataNode<T> node) {
+                       this.setLayout(new BorderLayout());
+
+                       if (!node.isRoot()) {
+                               button = new JToggleButton(node.toString());
+                               button.addActionListener(new ActionListener() {
+                                       @Override
+                                       public void actionPerformed(ActionEvent e) {
+                                               button.setSelected(false);
+                                               if (!node.isRoot()) {
+                                                       // TODO: allow clicking on root? option?
+                                                       setSelectedNode(node);
+                                               }
+                                       }
+                               });
+                               
+                               this.add(button, BorderLayout.CENTER);
+                       }
+
+                       if (!node.getChildren().isEmpty()) {
+                               // TODO (see things with icons included in viewer)
+                               down = new JToggleButton(">");
+                               final JPopupMenu popup = new JPopupMenu();
+
+                               for (final DataNode<T> child : node.getChildren()) {
+                                       popup.add(new AbstractAction(child.toString()) {
+                                               private static final long serialVersionUID = 1L;
+
+                                               @Override
+                                               public void actionPerformed(ActionEvent e) {
+                                                       setSelectedNode(child);
+                                               }
+                                       });
+                               }
+
+                               down.addActionListener(new ActionListener() {
+                                       @Override
+                                       public void actionPerformed(ActionEvent ev) {
+                                               if (down.isSelected()) {
+                                                       popup.show(down, 0, down.getBounds().height);
+                                               } else {
+                                                       popup.setVisible(false);
+                                               }
+                                       }
+                               });
+
+                               popup.addPopupMenuListener(new PopupMenuListener() {
+                                       @Override
+                                       public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+                                       }
+
+                                       @Override
+                                       public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
+                                               down.setSelected(false);
+                                       }
+
+                                       @Override
+                                       public void popupMenuCanceled(PopupMenuEvent e) {
+                                       }
+                               });
+
+                               this.add(down, BorderLayout.EAST);
+                       }
+               }
+       }
+
+       static public final String CHANGE_ACTION = "change";
+
+       private boolean vertical;
+       private DataNode<T> node;
+       private List<BreadCrumb> crumbs = new ArrayList<BreadCrumb>();
+
+       public BreadCrumbsBar(final DataTree<T> tree) {
+               vertical = true; // to force an update
+               setVertical(false);
+
+               addComponentListener(new ComponentAdapter() {
+                       @Override
+                       public void componentResized(ComponentEvent e) {
+                               super.componentResized(e);
+                               synchronized (crumbs) {
+                                       for (BreadCrumb crumb : crumbs) {
+                                               setCrumbSize(crumb);
+                                       }
+                               }
+                       }
+               });
+
+               new SwingWorker<DataNode<T>, Void>() {
+                       @Override
+                       protected DataNode<T> doInBackground() throws Exception {
+                               tree.loadData();
+                               return tree.getRoot();
+                       }
+
+                       @Override
+                       protected void done() {
+                               try {
+                                       node = get();
+                                       addCrumb(node);
+
+                                       // TODO: option?
+                                       if (node.size() > 0) {
+                                               setSelectedNode(node.getChildren().get(0));
+                                       } else {
+                                               revalidate();
+                                               repaint();
+                                       }
+                               } catch (Exception e) {
+                                       e.printStackTrace();
+                               }
+                       }
+               }.execute();
+       }
+
+       public void setVertical(boolean vertical) {
+               if (vertical != this.vertical) {
+                       synchronized (crumbs) {
+                               this.vertical = vertical;
+
+                               for (BreadCrumb crumb : crumbs) {
+                                       this.remove(crumb);
+                               }
+
+                               if (vertical) {
+                                       this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+                               } else {
+                                       this.setLayout(new WrapLayout(WrapLayout.LEADING));
+                               }
+
+                               for (BreadCrumb crumb : crumbs) {
+                                       this.add(crumb);
+                                       setCrumbSize(crumb);
+                               }
+                       }
+
+                       this.revalidate();
+                       this.repaint();
+               }
+       }
+
+       public DataNode<T> getSelectedNode() {
+               return node;
+       }
+
+       public void setSelectedNode(DataNode<T> node) {
+               if (this.node == node) {
+                       return;
+               }
+
+               synchronized (crumbs) {
+                       // clear until common ancestor (can clear all!)
+                       while (this.node != null && !this.node.isParentOf(node)) {
+                               this.node = this.node.getParent();
+                               this.remove(crumbs.remove(crumbs.size() - 1));
+                       }
+
+                       // switch root if needed and possible
+                       if (this.node == null && node != null) {
+                               this.node = node.getRoot();
+                               addCrumb(this.node);
+                       }
+
+                       // re-create until node
+                       while (node != null && this.node != node) {
+                               DataNode<T> ancestorOrNode = node;
+                               for (DataNode<T> child : this.node.getChildren()) {
+                                       if (child.isParentOf(node))
+                                               ancestorOrNode = child;
+                               }
+
+                               this.node = ancestorOrNode;
+                               addCrumb(this.node);
+                       }
+               }
+
+               this.revalidate();
+               this.repaint();
+
+               fireActionPerformed(CHANGE_ACTION);
+       }
+
+       private void addCrumb(DataNode<T> node) {
+               BreadCrumb crumb = new BreadCrumb(node);
+               this.crumbs.add(crumb);
+               setCrumbSize(crumb);
+               this.add(crumb);
+       }
+
+       private void setCrumbSize(BreadCrumb crumb) {
+               if (vertical) {
+                       crumb.setMaximumSize(new Dimension(this.getWidth(),
+                                       crumb.getMinimumSize().height));
+               } else {
+                       crumb.setMaximumSize(null);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/DataNode.java b/src/be/nikiroo/utils/ui/DataNode.java
new file mode 100644 (file)
index 0000000..b4dbe7b
--- /dev/null
@@ -0,0 +1,104 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.swing.Icon;
+
+public class DataNode<T> {
+       private DataNode<T> parent;
+       private List<? extends DataNode<T>> children;
+       private T userData;
+
+       public DataNode(List<? extends DataNode<T>> children, T userData) {
+               if (children == null) {
+                       children = new ArrayList<DataNode<T>>();
+               }
+
+               this.children = children;
+               this.userData = userData;
+
+               for (DataNode<T> child : children) {
+                       child.parent = this;
+               }
+       }
+
+       public DataNode<T> getRoot() {
+               DataNode<T> root = this;
+               while (root.parent != null) {
+                       root = root.parent;
+               }
+
+               return root;
+       }
+
+       public DataNode<T> getParent() {
+               return parent;
+       }
+
+       public List<? extends DataNode<T>> getChildren() {
+               return children;
+       }
+
+       public int size() {
+               return children.size();
+       }
+
+       public boolean isRoot() {
+               return this == getRoot();
+       }
+
+       public boolean isSiblingOf(DataNode<T> node) {
+               if (this == node) {
+                       return true;
+               }
+
+               return node != null && parent != null && parent.children.contains(node);
+       }
+
+       public boolean isParentOf(DataNode<T> node) {
+               if (node == null || node.parent == null)
+                       return false;
+
+               if (this == node.parent)
+                       return true;
+
+               return isParentOf(node.parent);
+       }
+
+       public boolean isChildOf(DataNode<T> node) {
+               if (node == null || node.size() == 0)
+                       return false;
+
+               return node.isParentOf(this);
+       }
+
+       public T getUserData() {
+               return userData;
+       }
+
+       /**
+        * The total number of nodes present in this {@link DataNode} (including
+        * itself and descendants).
+        * 
+        * @return the number
+        */
+       public int count() {
+               int s = 1;
+               for (DataNode<T> child : children) {
+                       s += child.count();
+               }
+
+               return s;
+       }
+
+       @Override
+       public String toString() {
+               if (userData == null) {
+                       return "";
+               }
+
+               return userData.toString();
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/utils/ui/DataTree.java b/src/be/nikiroo/utils/ui/DataTree.java
new file mode 100644 (file)
index 0000000..6b3657d
--- /dev/null
@@ -0,0 +1,68 @@
+package be.nikiroo.utils.ui;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.MutableTreeNode;
+
+public abstract class DataTree<E> {
+       protected DataNode<E> data;
+
+       public DataNode<E> loadData() throws IOException {
+               return this.data = extractData();
+       }
+
+       public DataNode<E> getRoot() {
+               return getRoot(null);
+       }
+
+       public DataNode<E> getRoot(String filter) {
+               return filterNode(data, filter);
+       }
+
+       protected abstract DataNode<E> extractData() throws IOException;
+
+       // filter cannot be null nor empty
+       protected abstract boolean checkFilter(String filter, E userData);
+
+       protected boolean checkFilter(DataNode<E> node, String filter) {
+               if (filter == null || filter.isEmpty()) {
+                       return true;
+               }
+
+               if (checkFilter(filter, node.getUserData()))
+                       return true;
+
+               for (DataNode<E> child : node.getChildren()) {
+                       if (checkFilter(child, filter))
+                               return true;
+               }
+
+               return false;
+       }
+
+       protected void sort(List<String> values) {
+               Collections.sort(values, new Comparator<String>() {
+                       @Override
+                       public int compare(String o1, String o2) {
+                               return ("" + o1).compareToIgnoreCase("" + o2);
+                       }
+               });
+       }
+
+       // note: we always send TAHT node, but filter children
+       private DataNode<E> filterNode(DataNode<E> source, String filter) {
+               List<DataNode<E>> children = new ArrayList<DataNode<E>>();
+               for (DataNode<E> child : source.getChildren()) {
+                       if (checkFilter(child, filter)) {
+                               children.add(filterNode(child, filter));
+                       }
+               }
+
+               return new DataNode<E>(children, source.getUserData());
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/DelayWorker.java b/src/be/nikiroo/utils/ui/DelayWorker.java
new file mode 100644 (file)
index 0000000..2a16c98
--- /dev/null
@@ -0,0 +1,220 @@
+package be.nikiroo.utils.ui;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeSet;
+
+import javax.swing.SwingWorker;
+
+/**
+ * This class helps you delay some graphical actions and execute the most recent
+ * ones when under contention.
+ * <p>
+ * How does it work?
+ * <ul>
+ * <li>it takes an ID and an associated {@link SwingWorker} and will call
+ * {@link SwingWorker#execute()} after a small delay (see
+ * {@link DelayWorker#DelayWorker(int)})</li>
+ * <li>if a second call to {@link DelayWorker#delay(String, SwingWorker)} comes
+ * with the same ID before the first one is done, it will be put on a waiting
+ * queue</li>
+ * <li>if a third call still with the same ID comes, its associated worker will
+ * <b>replace</b> the one in the queue (only one worker per ID in the queue,
+ * always the latest one)</li>
+ * <li>when the first worker is done, it will check the waiting queue and
+ * execute that latest worker if any</li>
+ * </ul>
+ * 
+ * @author niki
+ *
+ */
+@SuppressWarnings("rawtypes")
+public class DelayWorker {
+       private Map<String, SwingWorker> lazyEnCours;
+       private Object lazyEnCoursLock;
+
+       private TreeSet<String> wip;
+
+       private Object waiter;
+
+       private boolean cont;
+       private boolean paused;
+       private Thread loop;
+
+       /**
+        * Create a new {@link DelayWorker} with the given delay (in milliseconds)
+        * before each drain of the queue.
+        * 
+        * @param delayMs
+        *            the delay in milliseconds (can be 0, cannot be negative)
+        */
+       public DelayWorker(final int delayMs) {
+               if (delayMs < 0) {
+                       throw new IllegalArgumentException(
+                                       "A waiting delay cannot be negative");
+               }
+
+               lazyEnCours = new HashMap<String, SwingWorker>();
+               lazyEnCoursLock = new Object();
+               wip = new TreeSet<String>();
+               waiter = new Object();
+               cont = true;
+               paused = false;
+
+               loop = new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               while (cont) {
+                                       try {
+                                               Thread.sleep(delayMs);
+                                       } catch (InterruptedException e) {
+                                       }
+
+                                       Map<String, SwingWorker> workers = new HashMap<String, SwingWorker>();
+                                       synchronized (lazyEnCoursLock) {
+                                               for (String key : new ArrayList<String>(
+                                                               lazyEnCours.keySet())) {
+                                                       if (!wip.contains(key)) {
+                                                               workers.put(key, lazyEnCours.remove(key));
+                                                       }
+                                               }
+                                       }
+
+                                       for (final String key : workers.keySet()) {
+                                               SwingWorker worker = workers.get(key);
+
+                                               synchronized (lazyEnCoursLock) {
+                                                       wip.add(key);
+                                               }
+
+                                               worker.addPropertyChangeListener(
+                                                               new PropertyChangeListener() {
+                                                                       @Override
+                                                                       public void propertyChange(
+                                                                                       PropertyChangeEvent evt) {
+                                                                               synchronized (lazyEnCoursLock) {
+                                                                                       wip.remove(key);
+                                                                               }
+                                                                               wakeup();
+                                                                       }
+                                                               });
+
+                                               // Start it, at last
+                                               worker.execute();
+                                       }
+
+                                       synchronized (waiter) {
+                                               do {
+                                                       try {
+                                                               if (cont)
+                                                                       waiter.wait();
+                                                       } catch (InterruptedException e) {
+                                                       }
+                                               } while (cont && paused);
+                                       }
+                               }
+                       }
+               });
+
+               loop.setDaemon(true);
+               loop.setName("Loop for DelayWorker");
+       }
+
+       /**
+        * Start the internal loop that will drain the processing queue. <b>MUST
+        * NOT</b> be started twice (but see {@link DelayWorker#pause()} and
+        * {@link DelayWorker#resume()} instead).
+        */
+       public void start() {
+               loop.start();
+       }
+
+       /**
+        * Pause the system until {@link DelayWorker#resume()} is called -- note
+        * that it will still continue on the processes currently scheduled to run,
+        * but will pause after that.
+        * <p>
+        * Can be called even if already paused, will just do nothing in that
+        * context.
+        */
+       public void pause() {
+               paused = true;
+       }
+
+       /**
+        * Check if the {@link DelayWorker} is currently paused.
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isPaused() {
+               return paused;
+       }
+
+       /**
+        * Resume the system after a pause.
+        * <p>
+        * Can be called even if already running, will just do nothing in that
+        * context.
+        */
+       public void resume() {
+               synchronized (waiter) {
+                       paused = false;
+                       wakeup();
+               }
+       }
+
+       /**
+        * Stop the system.
+        * <p>
+        * Note: this is final, you <b>MUST NOT</b> call {@link DelayWorker#start()}
+        * a second time (but see {@link DelayWorker#pause()} and
+        * {@link DelayWorker#resume()} instead).
+        */
+       public void stop() {
+               synchronized (waiter) {
+                       cont = false;
+                       wakeup();
+               }
+       }
+
+       /**
+        * Clear all the processes that were put on the queue but not yet scheduled
+        * to be executed -- note that it will still continue on the processes
+        * currently scheduled to run.
+        */
+       public void clear() {
+               synchronized (lazyEnCoursLock) {
+                       lazyEnCours.clear();
+                       wip.clear();
+               }
+       }
+
+       /**
+        * Put a new process in the delay queue.
+        * 
+        * @param id
+        *            the ID of this process (if you want to skip workers when they
+        *            are superseded by a new one, you need to use the same ID key)
+        * @param worker
+        *            the process to delay
+        */
+       public void delay(String id, SwingWorker worker) {
+               synchronized (lazyEnCoursLock) {
+                       lazyEnCours.put(id, worker);
+               }
+
+               wakeup();
+       }
+
+       /**
+        * Wake up the loop thread.
+        */
+       private void wakeup() {
+               synchronized (waiter) {
+                       waiter.notifyAll();
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ListModel.java b/src/be/nikiroo/utils/ui/ListModel.java
new file mode 100644 (file)
index 0000000..cf16d5f
--- /dev/null
@@ -0,0 +1,610 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.Component;
+import java.awt.Point;
+import java.awt.Window;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.swing.JList;
+import javax.swing.JPopupMenu;
+import javax.swing.ListCellRenderer;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.utils.compat.DefaultListModel6;
+import be.nikiroo.utils.compat.JList6;
+import be.nikiroo.utils.compat.ListCellRenderer6;
+
+/**
+ * A {@link javax.swing.ListModel} that can maintain 2 lists; one with the
+ * actual data (the elements), and a second one with the items that are
+ * currently displayed (the items).
+ * <p>
+ * It also offers filter options, supports hovered changes and some more utility
+ * functions.
+ * 
+ * @author niki
+ *
+ * @param <T>
+ *            the type of elements and items (the same type)
+ */
+public class ListModel<T> extends DefaultListModel6<T> {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * A filter interface, to check for a condition (note that a Predicate class
+        * already exists in Java 1.8+, and is compatible with this one if you
+        * change the signatures -- but I support java 1.6+).
+        * 
+        * @author niki
+        *
+        * @param <T>
+        *            the type of elements and items (the same type)
+        */
+       public interface Predicate<T> {
+               /**
+                * Check if an item or an element pass a filter.
+                * 
+                * @param item
+                *            the item to test
+                * 
+                * @return TRUE if the test passed, FALSE if not
+                */
+               public boolean test(T item);
+       }
+
+       /**
+        * A simple interface your elements must implement if you want to use
+        * {@link ListModel#generateRenderer(ListModel)}.
+        * 
+        * @author niki
+        */
+       public interface Hoverable {
+               /**
+                * The element is currently selected.
+                * 
+                * @param selected
+                *            TRUE for selected, FALSE for unselected
+                */
+               public void setSelected(boolean selected);
+
+               /**
+                * The element is currently under the mouse cursor.
+                * 
+                * @param hovered
+                *            TRUE if it is, FALSE if not
+                */
+               public void setHovered(boolean hovered);
+       }
+
+       /**
+        * An interface required to support tooltips on this {@link ListModel}.
+        * 
+        * @author niki
+        *
+        * @param <T>
+        *            the type of elements and items (the same type)
+        */
+       public interface TooltipCreator<T> {
+               /**
+                * Generate a tooltip {@link Window} for this element.
+                * <p>
+                * Note that the tooltip can be of two modes: undecorated or standalone.
+                * An undecorated tooltip will be taken care of by this
+                * {@link ListModel}, but a standalone one is supposed to be its own
+                * Dialog or Frame (it won't be automatically closed).
+                * 
+                * @param t
+                *            the element to generate a tooltip for
+                * @param undecorated
+                *            TRUE for undecorated tooltip, FALSE for standalone
+                *            tooltips
+                * 
+                * @return the generated tooltip or NULL for none
+                */
+               public Window generateTooltip(T t, boolean undecorated);
+       }
+
+       private int hoveredIndex;
+       private List<T> items = new ArrayList<T>();
+       private boolean keepSelection = true;
+
+       private TooltipCreator<T> tooltipCreator;
+       private Window tooltip;
+
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       private JList list;
+
+       /**
+        * Create a new {@link ListModel}.
+        * 
+        * @param list
+        *            the {@link JList6} we will handle the data of (cannot be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       public ListModel(JList6<T> list) {
+               this((JList) list);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * 
+        * @param list
+        *            the {@link JList6} we will handle the data of (cannot be NULL)
+        * @param popup
+        *            the popup to use and keep track of (can be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       public ListModel(JList6<T> list, JPopupMenu popup) {
+               this((JList) list, popup);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * 
+        * @param list
+        *            the {@link JList6} we will handle the data of (cannot be NULL)
+        * @param tooltipCreator
+        *            use this if you want the list to display tooltips on hover
+        *            (can be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       public ListModel(JList6<T> list, TooltipCreator<T> tooltipCreator) {
+               this((JList) list, null, tooltipCreator);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * 
+        * @param list
+        *            the {@link JList6} we will handle the data of (cannot be NULL)
+        * @param popup
+        *            the popup to use and keep track of (can be NULL)
+        * @param tooltipCreator
+        *            use this if you want the list to display tooltips on hover
+        *            (can be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       public ListModel(JList6<T> list, JPopupMenu popup,
+                       TooltipCreator<T> tooltipCreator) {
+               this((JList) list, popup, tooltipCreator);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * <p>
+        * Note that you must take care of passing a {@link JList} that only handles
+        * elements of the type of this {@link ListModel} -- you can also use
+        * {@link ListModel#ListModel(JList6)} instead.
+        * 
+        * @param list
+        *            the {@link JList} we will handle the data of (cannot be NULL,
+        *            must only contain elements of the type of this
+        *            {@link ListModel})
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+       public ListModel(JList list) {
+               this(list, null, null);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * <p>
+        * Note that you must take care of passing a {@link JList} that only handles
+        * elements of the type of this {@link ListModel} -- you can also use
+        * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
+        * 
+        * @param list
+        *            the {@link JList} we will handle the data of (cannot be NULL,
+        *            must only contain elements of the type of this
+        *            {@link ListModel})
+        * @param popup
+        *            the popup to use and keep track of (can be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not in Java 1.6
+       public ListModel(JList list, JPopupMenu popup) {
+               this(list, popup, null);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * <p>
+        * Note that you must take care of passing a {@link JList} that only handles
+        * elements of the type of this {@link ListModel} -- you can also use
+        * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
+        * 
+        * @param list
+        *            the {@link JList} we will handle the data of (cannot be NULL,
+        *            must only contain elements of the type of this
+        *            {@link ListModel})
+        * @param tooltipCreator
+        *            use this if you want the list to display tooltips on hover
+        *            (can be NULL)
+        */
+       @SuppressWarnings("rawtypes") // JList<?> not in Java 1.6
+       public ListModel(JList list, TooltipCreator<T> tooltipCreator) {
+               this(list, null, tooltipCreator);
+       }
+
+       /**
+        * Create a new {@link ListModel}.
+        * <p>
+        * Note that you must take care of passing a {@link JList} that only handles
+        * elements of the type of this {@link ListModel} -- you can also use
+        * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
+        * 
+        * @param list
+        *            the {@link JList} we will handle the data of (cannot be NULL,
+        *            must only contain elements of the type of this
+        *            {@link ListModel})
+        * @param popup
+        *            the popup to use and keep track of (can be NULL)
+        * @param tooltipCreator
+        *            use this if you want the list to display tooltips on hover
+        *            (can be NULL)
+        */
+       @SuppressWarnings({ "unchecked", "rawtypes" }) // JList<?> not in Java 1.6
+       public ListModel(final JList list, final JPopupMenu popup,
+                       TooltipCreator<T> tooltipCreator) {
+               this.list = list;
+               this.tooltipCreator = tooltipCreator;
+
+               list.setModel(this);
+
+               final DelayWorker tooltipWatcher = new DelayWorker(500);
+               if (tooltipCreator != null) {
+                       tooltipWatcher.start();
+               }
+
+               list.addMouseMotionListener(new MouseAdapter() {
+                       @Override
+                       public void mouseMoved(final MouseEvent me) {
+                               if (popup != null && popup.isShowing())
+                                       return;
+
+                               Point p = new Point(me.getX(), me.getY());
+                               final int index = list.locationToIndex(p);
+                               if (index != hoveredIndex) {
+                                       int oldIndex = hoveredIndex;
+                                       hoveredIndex = index;
+                                       fireElementChanged(oldIndex);
+                                       fireElementChanged(index);
+
+                                       if (ListModel.this.tooltipCreator != null) {
+                                               tooltipWatcher.delay("tooltip",
+                                                               new SwingWorker<Void, Void>() {
+                                                                       @Override
+                                                                       protected Void doInBackground()
+                                                                                       throws Exception {
+                                                                               return null;
+                                                                       }
+
+                                                                       @Override
+                                                                       protected void done() {
+                                                                               Window oldTooltip = tooltip;
+                                                                               tooltip = null;
+                                                                               if (oldTooltip != null) {
+                                                                                       oldTooltip.setVisible(false);
+                                                                               }
+
+                                                                               if (index < 0
+                                                                                               || index != hoveredIndex) {
+                                                                                       return;
+                                                                               }
+
+                                                                               tooltip = newTooltip(index, me);
+                                                                       }
+                                                               });
+                                       }
+                               }
+                       }
+               });
+
+               list.addMouseListener(new MouseAdapter() {
+                       @Override
+                       public void mousePressed(MouseEvent e) {
+                               check(e);
+                       }
+
+                       @Override
+                       public void mouseReleased(MouseEvent e) {
+                               check(e);
+                       }
+
+                       @Override
+                       public void mouseExited(MouseEvent e) {
+                               if (popup != null && popup.isShowing())
+                                       return;
+
+                               if (hoveredIndex > -1) {
+                                       int oldIndex = hoveredIndex;
+                                       hoveredIndex = -1;
+                                       fireElementChanged(oldIndex);
+                               }
+                       }
+
+                       private void check(MouseEvent e) {
+                               if (popup == null) {
+                                       return;
+                               }
+
+                               if (e.isPopupTrigger()) {
+                                       if (list.getSelectedIndices().length <= 1) {
+                                               list.setSelectedIndex(
+                                                               list.locationToIndex(e.getPoint()));
+                                       }
+
+                                       popup.show(list, e.getX(), e.getY());
+                               }
+                       }
+
+               });
+       }
+
+       /**
+        * (Try and) keep the elements that were selected when filtering.
+        * <p>
+        * This will use toString on the elements to identify them, and can be a bit
+        * resource intensive.
+        * 
+        * @return TRUE if we do
+        */
+       public boolean isKeepSelection() {
+               return keepSelection;
+       }
+
+       /**
+        * (Try and) keep the elements that were selected when filtering.
+        * <p>
+        * This will use toString on the elements to identify them, and can be a bit
+        * resource intensive.
+        * 
+        * @param keepSelection
+        *            TRUE to try and keep them selected
+        */
+       public void setKeepSelection(boolean keepSelection) {
+               this.keepSelection = keepSelection;
+       }
+
+       /**
+        * Check if this element is currently under the mouse.
+        * 
+        * @param element
+        *            the element to check
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isHovered(T element) {
+               return indexOf(element) == hoveredIndex;
+       }
+
+       /**
+        * Check if this element is currently under the mouse.
+        * 
+        * @param index
+        *            the index of the element to check
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isHovered(int index) {
+               return index == hoveredIndex;
+       }
+
+       /**
+        * Add an item to the model.
+        * 
+        * @param item
+        *            the new item to add
+        */
+       public void addItem(T item) {
+               items.add(item);
+       }
+
+       /**
+        * Add items to the model.
+        * 
+        * @param items
+        *            the new items to add
+        */
+       public void addAllItems(Collection<T> items) {
+               this.items.addAll(items);
+       }
+
+       /**
+        * Removes the first occurrence of the specified element from this list, if
+        * it is present (optional operation).
+        * 
+        * @param item
+        *            the item to remove if possible (can be NULL)
+        * 
+        * @return TRUE if one element was removed, FALSE if not found
+        */
+       public boolean removeItem(T item) {
+               return items.remove(item);
+       }
+
+       /**
+        * Remove the items that pass the given filter (or all items if the filter
+        * is NULL).
+        * 
+        * @param filter
+        *            the filter (if the filter returns TRUE, the item will be
+        *            removed)
+        * 
+        * @return TRUE if at least one item was removed
+        */
+       public boolean removeItemIf(Predicate<T> filter) {
+               boolean changed = false;
+               if (filter == null) {
+                       changed = !items.isEmpty();
+                       clearItems();
+               } else {
+                       for (int i = 0; i < items.size(); i++) {
+                               if (filter.test(items.get(i))) {
+                                       items.remove(i--);
+                                       changed = true;
+                               }
+                       }
+               }
+
+               return changed;
+       }
+
+       /**
+        * Removes all the items from this model.
+        */
+       public void clearItems() {
+               items.clear();
+       }
+
+       /**
+        * Filter the current elements.
+        * <p>
+        * This method will clear all the elements then look into all the items:
+        * those that pass the given filter will be copied as elements.
+        * 
+        * @param filter
+        *            the filter to select which elements to keep; an item that pass
+        *            the filter will be copied as an element (can be NULL, in that
+        *            case all items will be copied as elements)
+        */
+       @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
+       public void filter(Predicate<T> filter) {
+               ListSnapshot snapshot = null;
+
+               if (keepSelection)
+                       snapshot = new ListSnapshot(list);
+
+               clear();
+               for (T item : items) {
+                       if (filter == null || filter.test(item)) {
+                               addElement(item);
+                       }
+               }
+
+               if (keepSelection)
+                       snapshot.apply();
+
+               list.repaint();
+       }
+
+       /**
+        * Return the currently selected elements.
+        * 
+        * @return the selected elements
+        */
+       public List<T> getSelectedElements() {
+               List<T> selected = new ArrayList<T>();
+               for (int index : list.getSelectedIndices()) {
+                       selected.add(get(index));
+               }
+
+               return selected;
+       }
+
+       /**
+        * Return the selected element if <b>one</b> and <b>only one</b> element is
+        * selected. I.E., if zero, two or more elements are selected, NULL will be
+        * returned.
+        * 
+        * @return the element if it is the only selected element, NULL otherwise
+        */
+       public T getUniqueSelectedElement() {
+               List<T> selected = getSelectedElements();
+               if (selected.size() == 1) {
+                       return selected.get(0);
+               }
+
+               return null;
+       }
+
+       /**
+        * Notify that this element has been changed.
+        * 
+        * @param index
+        *            the index of the element
+        */
+       public void fireElementChanged(int index) {
+               if (index >= 0) {
+                       fireContentsChanged(this, index, index);
+               }
+       }
+
+       /**
+        * Notify that this element has been changed.
+        * 
+        * @param element
+        *            the element
+        */
+       public void fireElementChanged(T element) {
+               int index = indexOf(element);
+               if (index >= 0) {
+                       fireContentsChanged(this, index, index);
+               }
+       }
+
+       @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
+       @Override
+       public T get(int index) {
+               return (T) super.get(index);
+       }
+
+       private Window newTooltip(final int index, final MouseEvent me) {
+               final T value = ListModel.this.get(index);
+
+               final Window newTooltip = tooltipCreator.generateTooltip(value, true);
+
+               if (newTooltip != null) {
+                       newTooltip.addMouseListener(new MouseAdapter() {
+                               @Override
+                               public void mouseClicked(MouseEvent e) {
+
+                                       Window promotedTooltip = tooltipCreator
+                                                       .generateTooltip(value, false);
+                                       promotedTooltip.setLocation(newTooltip.getLocation());
+                                       newTooltip.setVisible(false);
+                                       promotedTooltip.setVisible(true);
+                               }
+                       });
+                       newTooltip.setLocation(me.getXOnScreen(), me.getYOnScreen());
+
+                       newTooltip.setVisible(true);
+               }
+
+               return newTooltip;
+       }
+
+       /**
+        * Generate a {@link ListCellRenderer} that supports {@link Hoverable}
+        * elements.
+        * 
+        * @param <T>
+        *            the type of elements and items (the same type), which should
+        *            implement {@link Hoverable} (it will not cause issues if not,
+        *            but then, it will be a default renderer)
+        * @param model
+        *            the model to use
+        * 
+        * @return a suitable, {@link Hoverable} compatible renderer
+        */
+       static public <T extends Component> ListCellRenderer6<T> generateRenderer(
+                       final ListModel<T> model) {
+               return new ListCellRenderer6<T>() {
+                       @Override
+                       public Component getListCellRendererComponent(JList6<T> list,
+                                       T item, int index, boolean isSelected,
+                                       boolean cellHasFocus) {
+                               if (item instanceof Hoverable) {
+                                       Hoverable hoverable = (Hoverable) item;
+                                       hoverable.setSelected(isSelected);
+                                       hoverable.setHovered(model.isHovered(index));
+                               }
+
+                               return item;
+                       }
+               };
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ListSnapshot.java b/src/be/nikiroo/utils/ui/ListSnapshot.java
new file mode 100644 (file)
index 0000000..d2e89c8
--- /dev/null
@@ -0,0 +1,62 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JList;
+
+public class ListSnapshot {
+       private JList list;
+       private List<Object> elements = new ArrayList<Object>();
+
+       public ListSnapshot(JList list) {
+               this.list = list;
+
+               for (int index : list.getSelectedIndices()) {
+                       elements.add(list.getModel().getElementAt(index));
+               }
+       }
+
+       public void apply() {
+               applyTo(list);
+       }
+
+       public void applyTo(JList list) {
+               List<Integer> indices = new ArrayList<Integer>();
+               for (int i = 0; i < list.getModel().getSize(); i++) {
+                       Object newObject = list.getModel().getElementAt(i);
+                       for (Object oldObject : elements) {
+                               if (isSameElement(oldObject, newObject)) {
+                                       indices.add(i);
+                                       break;
+                               }
+                       }
+               }
+
+               int a[] = new int[indices.size()];
+               for (int i = 0; i < indices.size(); i++) {
+                       a[i] = indices.get(i);
+               }
+               list.setSelectedIndices(a);
+       }
+
+       // You can override this
+       protected boolean isSameElement(Object oldElement, Object newElement) {
+               if (oldElement == null || newElement == null)
+                       return oldElement == null && newElement == null;
+
+               return oldElement.toString().equals(newElement.toString());
+       }
+
+       @Override
+       public String toString() {
+               StringBuilder builder = new StringBuilder();
+               builder.append("List Snapshot of: ").append(list).append("\n");
+               builder.append("Selected elements:\n");
+               for (Object element : elements) {
+                       builder.append("\t").append(element).append("\n");
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/ListenerItem.java b/src/be/nikiroo/utils/ui/ListenerItem.java
new file mode 100644 (file)
index 0000000..3fa41c8
--- /dev/null
@@ -0,0 +1,53 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.event.ActionListener;
+
+/**
+ * The default {@link ActionListener} add/remove/fire methods.
+ * 
+ * @author niki
+ */
+public interface ListenerItem {
+       /**
+        * Check that this {@link ListenerItem} currently has
+        * {@link ActionListener}s that listen on it.
+        * 
+        * @return TRUE if it has
+        */
+       public boolean hasListeners();
+
+       /**
+        * Check how many events are currently waiting for an
+        * {@link ActionListener}.
+        * 
+        * @return the number of waiting events (can be 0)
+        */
+       public int getWaitingEventCount();
+
+       /**
+        * Adds the specified action listener to receive action events from this
+        * {@link ListenerItem}.
+        *
+        * @param listener
+        *            the action listener to be added
+        */
+       public void addActionListener(ActionListener listener);
+
+       /**
+        * Removes the specified action listener so that it no longer receives
+        * action events from this {@link ListenerItem}.
+        *
+        * @param listener
+        *            the action listener to be removed
+        */
+       public void removeActionListener(ActionListener listener);
+
+       /**
+        * Notify the listeners of an action.
+        * 
+        * @param listenerCommand
+        *            A string that may specify a command (possibly one of several)
+        *            associated with the event
+        */
+       public void fireActionPerformed(String listenerCommand);
+}
diff --git a/src/be/nikiroo/utils/ui/ListenerPanel.java b/src/be/nikiroo/utils/ui/ListenerPanel.java
new file mode 100644 (file)
index 0000000..ada0796
--- /dev/null
@@ -0,0 +1,73 @@
+package be.nikiroo.utils.ui;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.LinkedList;
+import java.util.Queue;
+
+import javax.swing.JPanel;
+
+/**
+ * A {@link JPanel} with the default {@link ActionListener} add/remove/fire
+ * methods.
+ * <p>
+ * Note that it will queue all events until at least one listener comes (or
+ * comes back!); this first (or at least currently unique) listener will drain
+ * the queue.
+ * 
+ * @author niki
+ */
+public class ListenerPanel extends JPanel implements ListenerItem {
+       private static final long serialVersionUID = 1L;
+
+       /** Waiting queue until at least one listener is here to get the events. */
+       private final Queue<ActionEvent> waitingQueue;
+
+       /**
+        * Create a new {@link ListenerPanel}.
+        */
+       public ListenerPanel() {
+               waitingQueue = new LinkedList<ActionEvent>();
+       }
+
+       @Override
+       public synchronized boolean hasListeners() {
+               return listenerList.getListenerList().length > 1;
+       }
+
+       @Override
+       public synchronized int getWaitingEventCount() {
+               return waitingQueue.size();
+       }
+
+       @Override
+       public synchronized void addActionListener(ActionListener listener) {
+               if (!hasListeners()) {
+                       while (!waitingQueue.isEmpty()) {
+                               listener.actionPerformed(waitingQueue.remove());
+                       }
+               }
+
+               listenerList.add(ActionListener.class, listener);
+       }
+
+       @Override
+       public synchronized void removeActionListener(ActionListener listener) {
+               listenerList.remove(ActionListener.class, listener);
+       }
+
+       @Override
+       public synchronized void fireActionPerformed(String listenerCommand) {
+               ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED,
+                               listenerCommand);
+
+               ActionListener[] listeners = getListeners(ActionListener.class);
+               if (listeners.length > 0) {
+                       for (ActionListener action : listeners) {
+                               action.actionPerformed(e);
+                       }
+               } else {
+                       waitingQueue.add(e);
+               }
+       }
+}
index 219cde9a2d2f5fc2555aa8cd7519b573e73e232d..11e1e6c05356638266a105762a1cf569f8797f36 100644 (file)
@@ -35,6 +35,11 @@ public class ProgressBar extends JPanel {
                actionListeners = new ArrayList<ActionListener>();
                updateListeners = new ArrayList<ActionListener>();
        }
+       
+       public ProgressBar(Progress pg) {
+               this();
+               setProgress(pg);
+       }
 
        public void setProgress(final Progress pg) {
                this.pg = pg;
diff --git a/src/be/nikiroo/utils/ui/TreeCellSpanner.java b/src/be/nikiroo/utils/ui/TreeCellSpanner.java
new file mode 100644 (file)
index 0000000..703bfa1
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ *    This program is free software: you can redistribute it and/or modify
+ *    it under the terms of the GNU Lesser 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 Lesser General Public License for more details.
+ *
+ *    You should have received a copy of the GNU Lesser General Public License
+ *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.utils.ui;
+
+import java.awt.*;
+import java.awt.event.*;
+
+import javax.swing.*;
+import javax.swing.tree.*;
+
+import java.util.*;
+
+public class TreeCellSpanner extends Container implements TreeCellRenderer, ComponentListener {
+       
+       public TreeCellSpanner(JTree tree, TreeCellRenderer renderer) {
+               if (tree == null || renderer == null)
+                       throw new NullPointerException();
+               this.tree = tree;
+               this.renderer = renderer;
+               treeParent = tree.getParent();
+               if (treeParent != null && treeParent instanceof JViewport) {
+                       treeParent.addComponentListener(this);
+               } else {
+                       treeParent = null;
+                       tree.addComponentListener(this);
+               }
+       }
+       
+       protected final JTree tree;
+       
+       private TreeCellRenderer renderer;
+       
+       private Component rendererComponent;
+       
+       private Container treeParent;
+       
+       private Map<TreePath,Integer> offsets = new HashMap<TreePath,Integer>();
+       
+       private TreePath path;
+       
+       public TreeCellRenderer getRenderer() {
+               return renderer;
+       }
+       
+       @Override
+       public Component getTreeCellRendererComponent(JTree tree, Object value,
+                       boolean selected, boolean expanded, boolean leaf, int row,
+                       boolean hasFocus) {
+               path = tree.getPathForRow(row);
+               if (path != null && path.getLastPathComponent() != value)
+                       path = null;
+               rendererComponent = renderer.getTreeCellRendererComponent(
+                               tree, value, selected, expanded, leaf, row, hasFocus);
+               if (getComponentCount() < 1 || getComponent(0) != rendererComponent) {
+                       removeAll();
+                       add(rendererComponent);
+               }
+               return this;
+       }
+       
+       @Override
+       public void doLayout() {
+               int x = getX();
+               if (x < 0)
+                       return;
+               if (path != null) {
+                       Integer offset = offsets.get(path);
+                       if (offset == null || offset.intValue() != x) {
+                               offsets.put(path, x);
+                               fireTreePathChanged(path);
+                       }
+               }
+               rendererComponent.setBounds(getX(), getY(), getWidth(), getHeight());
+       }
+       
+       @Override
+       public void paint(Graphics g) {
+               if (rendererComponent != null)
+                       rendererComponent.paint(g);
+       }
+       
+       @Override
+       public Dimension getPreferredSize() {
+               Dimension s = rendererComponent.getPreferredSize();
+               // check if path count is greater than 1 to exclude the root
+               if (path != null && path.getPathCount() > 1) {
+                       Integer offset = offsets.get(path);
+                       if (offset != null) {
+                               int width;
+                               if (tree.getParent() == treeParent) {
+                                       width = treeParent.getWidth();
+                               } else {
+                                       if (treeParent != null) {
+                                               treeParent.removeComponentListener(this);
+                                               tree.addComponentListener(this);
+                                               treeParent = null;
+                                       }
+                                       if (tree.getParent() instanceof JViewport) {
+                                               treeParent = tree.getParent();
+                                               tree.removeComponentListener(this);
+                                               treeParent.addComponentListener(this);
+                                               width = treeParent.getWidth();
+                                       } else {
+                                               width = tree.getWidth();
+                                       }
+                               }
+                               s.width = width - offset;
+                       }
+               }
+               return s;
+       }
+
+       
+       protected void fireTreePathChanged(TreePath path) {
+               if (path.getPathCount() > 1) {
+                       // this cannot be used for the root node or else
+                       // the entire tree will keep being revalidated ad infinitum
+                       TreeModel model = tree.getModel();
+                       Object node = path.getLastPathComponent();
+                       if (node instanceof TreeNode && (model instanceof DefaultTreeModel
+                                       || (model instanceof TreeModelTransformer<?> &&
+                                       (model=((TreeModelTransformer<?>)model).getModel()) instanceof DefaultTreeModel))) {
+                               ((DefaultTreeModel)model).nodeChanged((TreeNode)node);
+                       } else {
+                               model.valueForPathChanged(path, node.toString());
+                       }
+               } else {
+                       // root!
+                       
+               }
+       }
+
+       
+       private int lastWidth;
+
+       @Override
+       public void componentHidden(ComponentEvent e) {}
+
+       @Override
+       public void componentMoved(ComponentEvent e) {}
+
+       @Override
+       public void componentResized(ComponentEvent e) {
+               if (e.getComponent().getWidth() != lastWidth) {
+                       lastWidth = e.getComponent().getWidth();
+                       for (int row=tree.getRowCount(); --row>=0;) {
+                               fireTreePathChanged(tree.getPathForRow(row));
+                       }
+               }
+       }
+
+       @Override
+       public void componentShown(ComponentEvent e) {}
+       
+}
diff --git a/src/be/nikiroo/utils/ui/TreeModelTransformer.java b/src/be/nikiroo/utils/ui/TreeModelTransformer.java
new file mode 100644 (file)
index 0000000..9568f17
--- /dev/null
@@ -0,0 +1,1217 @@
+/*
+ *    This program is free software: you can redistribute it and/or modify
+ *    it under the terms of the GNU Lesser 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 Lesser General Public License for more details.
+ *
+ *    You should have received a copy of the GNU Lesser General Public License
+ *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.utils.ui;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.JTree;
+import javax.swing.SortOrder;
+import javax.swing.event.EventListenerList;
+import javax.swing.event.TreeExpansionEvent;
+import javax.swing.event.TreeExpansionListener;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+import javax.swing.event.TreeWillExpandListener;
+import javax.swing.tree.ExpandVetoException;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreePath;
+
+
+public class TreeModelTransformer<N> implements TreeModel {
+
+       public TreeModelTransformer(JTree tree, TreeModel model) {
+               if (tree == null)
+                       throw new IllegalArgumentException();
+               if (model == null)
+                       throw new IllegalArgumentException();
+               this.tree = tree;
+               this.model = model;
+               handler = createHandler();
+               addListeners();
+       }
+       
+       private JTree tree;
+       
+       private TreeModel model;
+
+       private Handler handler;
+       
+       private Filter<N> filter;
+       
+       private TreePath filterStartPath;
+       
+       private int filterDepthLimit;
+       
+       private SortOrder sortOrder = SortOrder.UNSORTED;
+       
+       private Map<Object,Converter> converters;
+       
+       protected EventListenerList listenerList = new EventListenerList();
+
+       protected Handler createHandler() {
+               return new Handler();
+       }
+       
+       protected void addListeners() {
+               tree.addTreeExpansionListener(handler);
+               model.addTreeModelListener(handler);
+       }
+
+       protected void removeListeners() {
+               tree.removeTreeExpansionListener(handler);
+               model.removeTreeModelListener(handler);
+       }
+       
+       public void dispose() {
+               removeListeners();
+       }
+       
+       public TreeModel getModel() {
+               return model;
+       }
+
+       private Converter getConverter(Object node) {
+               return converters == null ? null : converters.get(node);
+       }
+
+       int convertRowIndexToView(Object parent, int index) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.convertRowIndexToView(index);
+               return index;
+       }
+
+       int convertRowIndexToModel(Object parent, int index) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.convertRowIndexToModel(index);
+               return index;
+       }
+
+       @Override
+       public Object getChild(Object parent, int index) {
+               return model.getChild(parent, convertRowIndexToModel(parent, index));
+       }
+
+       @Override
+       public int getChildCount(Object parent) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.getChildCount();
+               return model.getChildCount(parent);
+       }
+
+       @Override
+       public int getIndexOfChild(Object parent, Object child) {
+               int index = model.getIndexOfChild(parent, child);
+               if (index < 0)
+                       return -1;
+               return convertRowIndexToView(parent, index);
+       }
+
+       @Override
+       public Object getRoot() {
+               return model.getRoot();
+       }
+
+       @Override
+       public boolean isLeaf(Object node) {
+               return model.isLeaf(node);
+       }
+
+       @Override
+       public void valueForPathChanged(TreePath path, Object newValue) {
+               model.valueForPathChanged(path, newValue);
+       }
+
+       @Override
+       public void addTreeModelListener(TreeModelListener l) {
+               listenerList.add(TreeModelListener.class, l);
+       }
+       
+       @Override
+       public void removeTreeModelListener(TreeModelListener l) {
+               listenerList.remove(TreeModelListener.class, l);
+       }
+       
+       /**
+        * Set the comparator that compares nodes in sorting.
+        * @param comparator
+        * @see #getComparator()
+        */
+       public void setComparator(Comparator<N> comparator) {
+               handler.setComparator(comparator);
+       }
+       
+       /**
+        * @return comparator that compares nodes
+        * @see #setComparator(Comparator)
+        */
+       public Comparator<N> getComparator() {
+               return handler.getComparator();
+       }
+       
+       public void setSortOrder(SortOrder newOrder) {
+               SortOrder oldOrder = sortOrder;
+               if (oldOrder == newOrder)
+                       return;
+               sortOrder = newOrder;
+               ArrayList<TreePath> paths = null;
+               switch (newOrder) {
+               case ASCENDING:
+                       if (oldOrder == SortOrder.DESCENDING) {
+                               flip();
+                       } else {
+                               paths = sort();
+                       }
+                       break;
+               case DESCENDING:
+                       if (oldOrder == SortOrder.ASCENDING) {
+                               flip();
+                       } else {
+                               paths = sort();
+                       }
+                       break;
+               case UNSORTED:
+                       unsort();
+                       break;
+               }
+               fireTreeStructureChangedAndExpand(new TreePath(getRoot()), paths, true);
+       }
+       
+       public SortOrder getSortOrder() {
+               return sortOrder;
+       }
+       
+       public void toggleSortOrder() {
+               setSortOrder(sortOrder == SortOrder.ASCENDING ?
+                               SortOrder.DESCENDING : SortOrder.ASCENDING);
+       }
+       
+       
+       /**
+        * Flip all sorted paths.
+        */
+       private void flip() {
+               for (Converter c : converters.values()) {
+                       flip(c.viewToModel);
+               }
+       }
+
+       /**
+        * Flip array.
+        * @param array
+        */
+       private static void flip(int[] array) {
+               for (int left=0, right=array.length-1;
+                               left<right; left++, right--) {
+                       int tmp = array[left];
+                       array[left] = array[right];
+                       array[right] = tmp;
+               }
+       }
+       
+       private void unsort() {
+               if (filter == null) {
+                       converters = null;
+               } else {
+                       Iterator<Converter> cons = converters.values().iterator();
+                       while (cons.hasNext()) {
+                               Converter converter = cons.next();
+                               if (!converter.isFiltered()) {
+                                       cons.remove();
+                               } else {
+                                       Arrays.sort(converter.viewToModel);
+                               }
+                       }
+               }
+       }
+       
+       /**
+        * Sort root and expanded descendants.
+        * @return list of paths that were sorted
+        */
+       private ArrayList<TreePath> sort() {
+               if (converters == null)
+                       converters = createConvertersMap(); //new IdentityHashMap<Object,Converter>();
+               return sortHierarchy(new TreePath(model.getRoot()));
+       }
+
+       /**
+        * Sort path and expanded descendants.
+        * @param path
+        * @return list of paths that were sorted
+        */
+       private ArrayList<TreePath> sortHierarchy(TreePath path) {
+               ValueIndexPair<N>[] pairs = createValueIndexPairArray(20);
+               ArrayList<TreePath> list = new ArrayList<TreePath>();
+               pairs = sort(path.getLastPathComponent(), pairs);
+               list.add(path);
+               Enumeration<TreePath> paths = tree.getExpandedDescendants(path);
+               if (paths != null)
+                       while (paths.hasMoreElements()) {
+                               path = paths.nextElement();
+                               pairs = sort(path.getLastPathComponent(), pairs);
+                               list.add(path);
+                       }
+               return list;
+       }
+       
+       private ValueIndexPair<N>[] sort(Object node, ValueIndexPair<N>[] pairs) {
+               Converter converter = getConverter(node);
+               TreeModel mdl = model;
+               int[] vtm;
+               if (converter != null) {
+                       vtm = converter.viewToModel;
+                       if (pairs.length < vtm.length)
+                               pairs = createValueIndexPairArray(vtm.length);
+                       for (int i=vtm.length; --i>=0;) {
+                               int idx = vtm[i];
+                               pairs[i].index = idx;
+                               pairs[i].value = (N)mdl.getChild(node, idx);
+                       }
+               } else {
+                       int count = mdl.getChildCount(node);
+                       if (count <= 0)
+                               return pairs;
+                       if (pairs.length < count)
+                               pairs = createValueIndexPairArray(count);
+                       for (int i=count; --i>=0;) {
+                               pairs[i].index = i;
+                               pairs[i].value = (N)mdl.getChild(node, i);
+                       }
+                       vtm = new int[count];
+               }
+               Arrays.sort(pairs, 0, vtm.length, handler);
+               for (int i=vtm.length; --i>=0;)
+                       vtm[i] = pairs[i].index;
+               if (converter == null) {
+                       converters.put(node, new Converter(vtm, false));
+               }
+               if (sortOrder == SortOrder.DESCENDING)
+                       flip(vtm);
+               return pairs;
+       }
+       
+       private ValueIndexPair<N>[] createValueIndexPairArray(int len) {
+               ValueIndexPair<N>[] pairs = new ValueIndexPair[len];
+               for (int i=len; --i>=0;)
+                       pairs[i] = new ValueIndexPair<N>();
+               return pairs;
+       }
+       
+       public void setFilter(Filter<N> filter) {
+               setFilter(filter, null);
+       }
+       
+       public void setFilter(Filter<N> filter, TreePath startingPath) {
+               setFilter(filter, null, -1);
+       }
+       
+       public void setFilter(Filter<N> filter, TreePath startingPath, int depthLimit) {
+               if (filter == null && startingPath != null)
+                       throw new IllegalArgumentException();
+               if (startingPath != null && startingPath.getPathCount() == 1)
+                       startingPath = null;
+               Filter<N> oldFilter = this.filter;
+               TreePath oldStartPath = filterStartPath;
+               this.filter = filter;
+               filterStartPath = startingPath;
+               filterDepthLimit = depthLimit;
+               applyFilter(oldFilter, oldStartPath, null, true);
+       }
+       
+       public Filter<N> getFilter() {
+               return filter;
+       }
+       
+       public TreePath getFilterStartPath() {
+               return filterStartPath;
+       }
+       
+       private void applyFilter(Filter<N> oldFilter, TreePath oldStartPath, Collection<TreePath> expanded, boolean sort) {
+               TreePath startingPath = filterStartPath;
+               ArrayList<TreePath> expand = null;
+               if (filter == null) {
+                       converters = null;
+               } else {
+                       if (converters == null || startingPath == null) {
+                               converters = createConvertersMap();
+                       } else if (oldFilter != null) {
+                               // unfilter the oldStartPath if oldStartPath isn't descendant of startingPath
+                               if (oldStartPath == null) {
+                                       converters = createConvertersMap();
+                                       fireTreeStructureChangedAndExpand(new TreePath(getRoot()), null, true);
+                               } else if (!startingPath.isDescendant(oldStartPath)) {
+                                       Object node = oldStartPath.getLastPathComponent();
+                                       handler.removeConverter(getConverter(node), node);
+                                       fireTreeStructureChangedAndExpand(oldStartPath, null, true);
+                               }
+                       }
+                       expand = new ArrayList<TreePath>();
+                       TreePath path = startingPath != null ? startingPath : new TreePath(getRoot());
+                       if (!applyFilter(filter, path, expand, filterDepthLimit)) {
+                               converters.put(path.getLastPathComponent(), new Converter(Converter.EMPTY, true));
+                       }
+               }
+               if (startingPath == null)
+                       startingPath = new TreePath(getRoot());
+               fireTreeStructureChanged(startingPath);
+               if (expanded != null)
+                       expand.retainAll(expanded);
+               expandPaths(expand);
+               if (sort && sortOrder != SortOrder.UNSORTED) {
+                       if (filter == null)
+                               converters = createConvertersMap();
+                       if (startingPath.getPathCount() > 1 && oldFilter != null) {
+                               // upgrade startingPath or sort oldStartPath
+                               if (oldStartPath == null) {
+                                       startingPath = new TreePath(getRoot());
+                               } else if (oldStartPath.isDescendant(startingPath)) {
+                                       startingPath = oldStartPath;
+                               } else if (!startingPath.isDescendant(oldStartPath)) {
+                                       expand = sortHierarchy(oldStartPath);
+                                       fireTreeStructureChanged(oldStartPath);
+                                       expandPaths(expand);
+                               }
+                       }
+                       expand = sortHierarchy(startingPath);
+                       fireTreeStructureChanged(startingPath);
+                       expandPaths(expand);
+               }
+
+       }
+       
+       private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand) {
+               int depthLimit = filterDepthLimit;
+               if (depthLimit >= 0) {
+                       depthLimit -= filterStartPath.getPathCount() - path.getPathCount();
+                       if (depthLimit <= 0)
+                               return false;
+               }
+               return applyFilter(filter, path, expand, depthLimit);
+       }
+       
+       private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand, int depthLimit) {
+               Object node = path.getLastPathComponent();
+               int count = model.getChildCount(node);
+               int[] viewToModel = null;
+               int viewIndex = 0;
+               boolean needsExpand = false;
+               boolean isExpanded = false;
+               if (depthLimit > 0)
+                       depthLimit--;
+               for (int i=0; i<count; i++) {
+                       Object child = model.getChild(node, i);
+                       boolean leaf = model.isLeaf(child);
+                       if (filter.acceptNode(path, (N)child, leaf)) {
+                               if (viewToModel == null)
+                                       viewToModel = new int[count-i];
+                               viewToModel[viewIndex++] = i;
+                               needsExpand = true;
+                       } else if (depthLimit != 0 && !leaf) {
+                               if (applyFilter(filter, path.pathByAddingChild(child), expand, depthLimit)) {
+                                       if (viewToModel == null)
+                                               viewToModel = new int[count-i];
+                                       viewToModel[viewIndex++] = i;
+                                       isExpanded = true;
+                               }
+                       }
+               }
+               if (needsExpand && expand != null && !isExpanded && path.getPathCount() > 1) {
+                       expand.add(path);
+               }
+               if (viewToModel != null) {
+                       if (viewIndex < viewToModel.length)
+                               viewToModel = Arrays.copyOf(viewToModel, viewIndex);
+                       // a node must have a converter to signify that tree modifications
+                       // need to query the filter, so have to put in converter
+                       // even if viewIndex == viewToModel.length
+                       converters.put(node, new Converter(viewToModel, true));
+                       return true;
+               }
+               return false;
+       }
+
+       
+       private void expandPaths(ArrayList<TreePath> paths) {
+               if (paths == null || paths.isEmpty())
+                       return;
+               JTree tre = tree;
+               for (TreePath path : paths)
+                       tre.expandPath(path);
+       }
+       
+       
+       private void fireTreeStructureChangedAndExpand(TreePath path, ArrayList<TreePath> list, boolean retainSelection) {
+               Enumeration<TreePath> paths = list != null ?
+                               Collections.enumeration(list) : tree.getExpandedDescendants(path);
+               TreePath[] sel = retainSelection ? tree.getSelectionPaths() : null;
+               fireTreeStructureChanged(path);
+               if (paths != null)
+                       while (paths.hasMoreElements())
+                               tree.expandPath(paths.nextElement());
+               if (sel != null)
+                       tree.setSelectionPaths(sel);
+       }
+
+       
+       
+       protected void fireTreeStructureChanged(TreePath path) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, null, null);
+                               ((TreeModelListener)listeners[i+1]).treeStructureChanged(e);
+                       }
+               }
+       }
+       
+       protected void fireTreeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesChanged(e);
+                       }
+               }
+       }
+       
+       protected void fireTreeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesInserted(e);
+                       }
+               }
+       }
+
+       protected void fireTreeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesRemoved(e);
+                       }
+               }
+       }
+       
+       
+       protected class Handler implements Comparator<ValueIndexPair<N>>,
+                       TreeModelListener, TreeExpansionListener {
+               
+               private Comparator<N> comparator;
+               
+               private Collator collator = Collator.getInstance();
+               
+               void setComparator(Comparator<N> cmp) {
+                       comparator = cmp;
+                       collator = cmp == null ? Collator.getInstance() : null;
+               }
+               
+               Comparator<N> getComparator() {
+                       return comparator;
+               }
+               
+               // TODO, maybe switch to TreeWillExpandListener?
+               // TreeExpansionListener was used in case an expanded node
+               // had children that would also be expanded, but it is impossible
+               // for hidden nodes' expansion state to survive a SortOrder change
+               // since they are all erased when the tree structure change event
+               // is fired after changing the SortOrder.
+               
+               @Override
+               public void treeCollapsed(TreeExpansionEvent e) {}
+
+               @Override
+               public void treeExpanded(TreeExpansionEvent e) {
+                       if (sortOrder != SortOrder.UNSORTED) {
+                               TreePath path = e.getPath();
+                               Converter converter = getConverter(path.getLastPathComponent());
+                               if (converter == null) {
+                                       ArrayList<TreePath> paths = sortHierarchy(path);
+                                       fireTreeStructureChangedAndExpand(path, paths, false);
+                               }
+                       }
+               }
+               
+               private boolean isFiltered(Object node) {
+                       Converter c = getConverter(node);
+                       return c == null ? false : c.isFiltered();
+               }
+               
+               private boolean acceptable(TreePath path, Object[] childNodes, int index, ArrayList<TreePath> expand) {
+                       return acceptable(path, childNodes, index) ||
+                                       applyFilter(filter, path.pathByAddingChild(childNodes[index]), expand);
+               }
+               
+               @Override
+               public void treeNodesChanged(TreeModelEvent e) {
+                       treeNodesChanged(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+               
+               protected void treeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+                       if (childIndices == null) {
+                               // path should be root path
+                               // reapply filter
+                               if (filter != null)
+                                       applyFilter(null, null, null, true);
+                               return;
+                       }
+                       Converter converter = getConverter(path.getLastPathComponent());
+                       ArrayList<TreePath> expand = null;
+                       if (converter != null) {
+                               expand = new ArrayList<TreePath>();
+                               int childIndex = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       int idx = converter.convertRowIndexToView(childIndices[i]);
+                                       if (idx >= 0) {
+                                               // see if the filter dislikes the nodes new state
+                                               if (converter.isFiltered() &&
+                                                               !isFiltered(childNodes[i]) &&
+                                                               !acceptable(path, childNodes, i)) {
+                                                       // maybe it likes a child nodes state
+                                                       if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+                                                               remove(path, childNodes[i]);
+                                                       continue;
+                                               }
+                                               childNodes[childIndex] = childNodes[i];
+                                               childIndices[childIndex++] = idx;
+                                       } else if (acceptable(path, childNodes, i, expand)) {
+                                               int viewIndex = insert(path.getLastPathComponent(),
+                                                               childNodes[i], childIndices[i], converter);
+                                               fireTreeNodesInserted(path, indices(viewIndex), nodes(childNodes[i]));
+                                       }
+                               }
+                               if (childIndex == 0) {
+                                       maybeFireStructureChange(path, expand);
+                                       return;
+                               }
+                               if (sortOrder != SortOrder.UNSORTED && converter.getChildCount() > 1) {
+                                       sort(path.getLastPathComponent(), createValueIndexPairArray(converter.getChildCount()));
+                                       fireTreeStructureChangedAndExpand(path, null, true);
+                                       expandPaths(expand);
+                                       return;
+                               }
+                               if (childIndex != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, childIndex);
+                                       childNodes = Arrays.copyOf(childNodes, childIndex);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               // see if the filter likes the nodes new states
+                               expand = new ArrayList<TreePath>();
+                               int[] vtm = null;
+                               int idx = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (acceptable(path, childNodes, i, expand)) {
+                                               if (vtm == null)
+                                                       vtm = new int[childIndices.length-i];
+                                               vtm[idx++] = childIndices[i];
+                                       }
+                               }
+                               // filter in path if appropriate
+                               if (vtm != null)
+                                       filterIn(vtm, idx, path, expand);
+                               return;
+                       }
+                       // must fire tree nodes changed even if a
+                       // structure change will be fired because the
+                       // expanded paths need to be updated first
+                       fireTreeNodesChanged(path, childIndices, childNodes);
+                       maybeFireStructureChange(path, expand);
+               }
+               
+               /**
+                * Helper method for treeNodesChanged...
+                * @param path
+                * @param expand
+                */
+               private void maybeFireStructureChange(TreePath path, ArrayList<TreePath> expand) {
+                       if (expand != null && !expand.isEmpty()) {
+                               Enumeration<TreePath> expanded = tree.getExpandedDescendants(path);
+                               fireTreeStructureChanged(path);
+                               if (expanded != null)
+                                       while (expanded.hasMoreElements())
+                                               tree.expandPath(expanded.nextElement());
+                               expandPaths(expand);
+                       }
+               }
+               
+               @Override
+               public void treeNodesInserted(TreeModelEvent e) {
+                       treeNodesInserted(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+
+               protected void treeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = getConverter(parent);
+                       ArrayList<TreePath> expand = null;
+                       if (converter != null) {
+//                             if (childIndices.length > 3 && !converter.isFiltered()
+//                                             && childIndices.length > converter.getChildCount()/10) {
+//                                     TreePath expand = sortHierarchy(path);
+//                                     fireTreeStructureChangedAndExpand(expand);
+//                                     return;
+//                             }
+                               int childIndex = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (converter.isFiltered()) {
+                                               // path hasn't met the filter criteria, so childNodes must be filtered
+                                               if (expand == null)
+                                                       expand = new ArrayList<TreePath>();
+                                               if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+                                                       continue;
+                                       }
+                                       // shift the appropriate cached modelIndices
+                                       int[] vtm = converter.viewToModel;
+                                       int modelIndex = childIndices[i];
+                                       for (int j=vtm.length; --j>=0;) {
+                                               if (vtm[j] >= modelIndex)
+                                                       vtm[j] += 1;
+                                       }
+                                       // insert modelIndex to converter
+                                       int viewIndex = insert(parent, childNodes[i], modelIndex, converter);
+                                       childNodes[childIndex] = childNodes[i];
+                                       childIndices[childIndex++] = viewIndex;
+                               }
+                               if (childIndex == 0)
+                                       return;
+                               if (childIndex != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, childIndex);
+                                       childNodes = Arrays.copyOf(childNodes, childIndex);
+                               }
+                               if (childIndex > 1 && sortOrder != SortOrder.UNSORTED) {
+                                       sort(childIndices, childNodes);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               // apply filter to inserted nodes
+                               int[] vtm = null;
+                               int idx = 0;
+                               expand = new ArrayList<TreePath>();
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (acceptable(path, childNodes, i, expand)) {
+                                               if (vtm == null)
+                                                       vtm = new int[childIndices.length-i];
+                                               vtm[idx++] = childIndices[i];
+                                       }
+                               }
+                               // filter in path if appropriate
+                               if (vtm != null)
+                                       filterIn(vtm, idx, path, expand);
+                               return;
+                       }
+                       fireTreeNodesInserted(path, childIndices, childNodes);
+                       expandPaths(expand);
+               }
+               
+               @Override
+               public void treeNodesRemoved(TreeModelEvent e) {
+                       treeNodesRemoved(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+               
+
+               private boolean isFilterStartPath(TreePath path) {
+                       if (filterStartPath == null)
+                               return path.getPathCount() == 1;
+                       return filterStartPath.equals(path);
+               }
+               
+               protected void treeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = getConverter(parent);
+                       if (converter != null) {
+                               int len = 0;
+                               for (int i=0; i<childNodes.length; i++) {
+                                       removeConverter(childNodes[i]);
+                                       int viewIndex = converter.convertRowIndexToView(childIndices[i]);
+                                       if (viewIndex >= 0) {
+                                               childNodes[len] = childNodes[i];
+                                               childIndices[len++] = viewIndex;
+                                       }
+                               }
+                               if (len == 0)
+                                       return;
+                               if (converter.isFiltered() && converter.getChildCount() == len) {
+                                       ArrayList<TreePath> expand = new ArrayList<TreePath>();
+                                       if (applyFilter(filter, path, expand)) {
+                                               expand.retainAll(getExpandedPaths(path));
+                                               if (sortOrder != SortOrder.UNSORTED)
+                                                       sortHierarchy(path);
+                                               fireTreeStructureChangedAndExpand(path, expand, true);
+                                       } else if (isFilterStartPath(path)) {
+                                               converters.put(parent, new Converter(Converter.EMPTY, true));
+                                               fireTreeStructureChanged(path);
+                                       } else {
+                                               remove(path.getParentPath(), parent);
+                                       }
+                                       return;
+                               }
+                               if (len != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, len);
+                                       childNodes = Arrays.copyOf(childNodes, len);
+                               }
+                               if (len > 1 && sortOrder != SortOrder.UNSORTED) {
+                                       sort(childIndices, childNodes);
+                               }
+                               if (childIndices.length == 1) {
+                                       converter.remove(converter.convertRowIndexToModel(childIndices[0]));
+                               } else {
+                                       converter.remove(childIndices);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               return;
+                       }
+                       fireTreeNodesRemoved(path, childIndices, childNodes);
+               }
+               
+               private Collection<TreePath> getExpandedPaths(TreePath path) {
+                       Enumeration<TreePath> en = tree.getExpandedDescendants(path);
+                       if (en == null)
+                               return Collections.emptySet();
+                       HashSet<TreePath> expanded = new HashSet<TreePath>();
+                       while (en.hasMoreElements())
+                               expanded.add(en.nextElement());
+                       return expanded;
+               }
+
+               @Override
+               public void treeStructureChanged(TreeModelEvent e) {
+                       if (converters != null) {
+                               // not enough information to properly clean up
+                               // reapply filter/sort
+                               converters = createConvertersMap();
+                               TreePath[] sel = tree.getSelectionPaths();
+                               if (filter != null) {
+                                       applyFilter(null, null, getExpandedPaths(new TreePath(getRoot())), false);
+                               }
+                               if (sortOrder != SortOrder.UNSORTED) {
+                                       TreePath path = new TreePath(getRoot());
+                                       ArrayList<TreePath> expand = sortHierarchy(path);
+                                       fireTreeStructureChangedAndExpand(path, expand, false);
+                               }
+                               if (sel != null) {
+                                       tree.clearSelection();
+                                       TreePath changedPath = e.getTreePath();
+                                       for (TreePath path : sel) {
+                                               if (!changedPath.isDescendant(path))
+                                                       tree.addSelectionPath(path);
+                                       }
+                               }
+                       } else {
+                               fireTreeStructureChanged(e.getTreePath());
+                       }
+               }
+               
+
+               @Override
+               public final int compare(ValueIndexPair<N> a, ValueIndexPair<N> b) {
+                       return compareNodes(a.value, b.value);
+               }
+
+               
+               protected int compareNodes(N a, N b) {
+                       if (comparator != null)
+                               return comparator.compare(a, b);
+                       return collator.compare(a.toString(), b.toString());
+               }
+
+               private void removeConverter(Object node) {
+                       Converter c = getConverter(node);
+                       if (c != null)
+                               removeConverter(c, node);
+               }
+               
+               private void removeConverter(Converter converter, Object node) {
+                       for (int i=converter.getChildCount(); --i>=0;) {
+                               int index = converter.convertRowIndexToModel(i);
+                               Object child = model.getChild(node, index);
+                               Converter c = getConverter(child);
+                               if (c != null)
+                                       removeConverter(c, child);
+                       }
+                       converters.remove(node);
+               }
+               
+               private boolean isFilteredOut(TreePath path) {
+                       if (filterStartPath != null && !filterStartPath.isDescendant(path))
+                               return false;
+                       TreePath parent = path.getParentPath();
+                       // root should always have a converter if filter is non-null,
+                       // so if parent is ever null, there is a bug somewhere else
+                       Converter c = getConverter(parent.getLastPathComponent());
+                       if (c != null) {
+                               return getIndexOfChild(
+                                               parent.getLastPathComponent(),
+                                               path.getLastPathComponent()) < 0;
+                       }
+                       return isFilteredOut(parent);
+               }
+               
+               private void filterIn(int[] vtm, int vtmLength, TreePath path, ArrayList<TreePath> expand) {
+                       Object node = path.getLastPathComponent();
+                       if (vtmLength != vtm.length)
+                               vtm = Arrays.copyOf(vtm, vtmLength);
+                       Converter converter = new Converter(vtm, true);
+                       converters.put(node, converter);
+                       insert(path.getParentPath(), node);
+                       tree.expandPath(path);
+                       expandPaths(expand);
+               }
+
+               private boolean acceptable(TreePath path, Object[] nodes, int index) {
+                       Object node = nodes[index];
+                       return filter.acceptNode(path, (N)node, model.isLeaf(node));
+               }
+               
+               private int ascInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+                       for (int i=vtm.length; --i>=0;) {
+                               int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+                               if (cmp > 0 || (cmp == 0 && idx > vtm[i])) {
+                                       return i+1;
+                               }
+                       }
+                       return 0;
+               }
+               
+               
+               private int dscInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+                       for (int i=vtm.length; --i>=0;) {
+                               int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+                               if (cmp < 0) {
+                                       return i+1;
+                               } else if (cmp == 0 && idx < vtm[i]) {
+                                       return i;
+                               }
+                       }
+                       return 0;
+               }
+
+               
+               /**
+                * Inserts the specified path and node and any parent paths as necessary.
+                * <p>
+                * Fires appropriate event.
+                * @param path
+                * @param node
+                */
+               private void insert(TreePath path, Object node) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = converters.get(parent);
+                       int modelIndex = model.getIndexOfChild(parent, node);
+                       if (converter == null) {
+                               converter = new Converter(indices(modelIndex), true);
+                               converters.put(parent, converter);
+                               insert(path.getParentPath(), parent);
+                       } else {
+                               int viewIndex = insert(parent, node, modelIndex, converter);
+                               fireTreeNodesInserted(path, indices(viewIndex), nodes(node));
+                       }
+               }
+               
+               /**
+                * Inserts node into parent in correct sort order.
+                * <p>
+                * Responsibility of caller to fire appropriate event with the returned viewIndex.
+                * @param path
+                * @param node
+                * @param modelIndex
+                * @param converter
+                * @return viewIndex
+                */
+               private int insert(Object parent, Object node, int modelIndex, Converter converter) {
+                       int[] vtm = converter.viewToModel;
+                       int viewIndex;
+                       switch (sortOrder) {
+                       case ASCENDING:
+                               viewIndex = ascInsertionIndex(vtm, parent, (N)node, modelIndex);
+                               break;
+                       case DESCENDING:
+                               viewIndex = dscInsertionIndex(vtm, parent, (N)node, modelIndex);
+                               break;
+                       default: case UNSORTED:
+                               viewIndex = unsortedInsertionIndex(vtm, modelIndex);
+                               break;
+                       }
+                       int[] a = new int[vtm.length+1];
+                       System.arraycopy(vtm, 0, a, 0, viewIndex);
+                       System.arraycopy(vtm, viewIndex, a, viewIndex+1, vtm.length-viewIndex);
+                       a[viewIndex] = modelIndex;
+                       converter.viewToModel = a;
+                       return viewIndex;
+               }
+               
+               private void remove(TreePath path, Object node) {
+                       Object parent = path.getLastPathComponent();
+                       if (path.getPathCount() == 1 || (filterStartPath != null && filterStartPath.equals(path))) {
+                               removeConverter(node);
+                               converters.put(parent, new Converter(Converter.EMPTY, true));
+                               fireTreeNodesRemoved(path, indices(0), nodes(node));
+                               return;
+                       }
+                       Converter converter = converters.get(parent);
+                       int modelIndex = model.getIndexOfChild(parent, node);
+                       int viewIndex = converter.remove(modelIndex);
+                       switch (viewIndex) {
+                       default:
+                               removeConverter(node);
+                               fireTreeNodesRemoved(path, indices(viewIndex), nodes(node));
+                               break;
+                       case Converter.ONLY_INDEX:
+//                             if (path.getParentPath() == null) {
+//                                     // reached filter root
+//                                     removeConverter(node);
+//                                     converters.put(parent, new Converter(Converter.EMPTY, true));
+//                                     fireTreeNodesRemoved(path, indices(0), nodes(node));
+//                                     return;
+//                             }
+                               remove(path.getParentPath(), parent);
+                               break;
+                       case Converter.INDEX_NOT_FOUND:
+                               removeConverter(node);
+                       }
+               }
+               
+               
+               
+       }
+       
+       
+
+       private static int unsortedInsertionIndex(int[] vtm, int idx) {
+               for (int i=vtm.length; --i>=0;)
+                       if (vtm[i] < idx)
+                               return i+1;
+               return 0;
+       }
+       
+       private static void sort(int[] childIndices, Object[] childNodes) {
+               int len = childIndices.length;
+               ValueIndexPair[] pairs = new ValueIndexPair[len];
+               for (int i=len; --i>=0;)
+                       pairs[i] = new ValueIndexPair<Object>(childIndices[i], childNodes[i]);
+               Arrays.sort(pairs);
+               for (int i=len; --i>=0;) {
+                       childIndices[i] = pairs[i].index;
+                       childNodes[i] = pairs[i].value;
+               }
+       }
+       
+       private static int[] indices(int...indices) {
+               return indices;
+       }
+       
+       private static Object[] nodes(Object...nodes) {
+               return nodes;
+       }
+       
+
+
+       
+       /**
+        * This class has a dual purpose, both related to comparing/sorting.
+        * <p>
+        * The Handler class sorts an array of ValueIndexPair based on the value.
+        * Used for sorting the view.
+        * <p>
+        * ValueIndexPair sorts itself based on the index.
+        * Used for sorting childIndices for fire* methods.
+        */
+       private static class ValueIndexPair<N> implements Comparable<ValueIndexPair<N>> {
+               ValueIndexPair() {}
+               
+               ValueIndexPair(int idx, N val) {
+                       index = idx;
+                       value = val;
+               }
+               
+               N value;
+               
+               int index;
+               
+               public int compareTo(ValueIndexPair<N> o) {
+                       return index - o.index;
+               }
+       }
+       
+       private static class Converter {
+               
+               static final int[] EMPTY = new int[0];
+               
+               static final int ONLY_INDEX = -2;
+               
+               static final int INDEX_NOT_FOUND = -1;
+               
+               Converter(int[] vtm, boolean filtered) {
+                       viewToModel = vtm;
+                       isFiltered = filtered;
+               }
+               
+               private int[] viewToModel;
+               
+               private boolean isFiltered;
+               
+//             public boolean equals(Converter conv) {
+//                     if (conv == null)
+//                             return false;
+//                     if (isFiltered != conv.isFiltered)
+//                             return false;
+//                     return Arrays.equals(viewToModel, conv.viewToModel);
+//             }
+               
+               boolean isFiltered() {
+                       return isFiltered;
+               }
+               
+               void remove(int viewIndices[]) {
+                       int len = viewToModel.length - viewIndices.length;
+                       if (len == 0) {
+                               viewToModel = EMPTY;
+                       } else {
+                               int[] oldVTM = viewToModel;
+                               int[] newVTM = new int[len];
+                               for (int oldIndex=0, newIndex=0, removeIndex=0;
+                                               newIndex<newVTM.length;
+                                               newIndex++, oldIndex++) {
+                                       while (removeIndex < viewIndices.length && oldIndex == viewIndices[removeIndex]) {
+                                               int idx = oldVTM[oldIndex];
+                                               removeIndex++;
+                                               oldIndex++;
+                                               for (int i=newIndex; --i>=0;)
+                                                       if (newVTM[i] > idx)
+                                                               newVTM[i]--;
+                                               for (int i=oldIndex; i<oldVTM.length; i++)
+                                                       if (oldVTM[i] > idx)
+                                                               oldVTM[i]--;
+                                       }
+                                       newVTM[newIndex] = oldVTM[oldIndex];
+                               }
+                               viewToModel = newVTM;
+                       }
+               }
+               
+               /**
+                * @param modelIndex
+                * @return viewIndex that was removed<br>
+                *              or <code>ONLY_INDEX</code> if the modelIndex is the only one in the view<br>
+                *              or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+                */
+               int remove(int modelIndex) {
+                       int[] vtm = viewToModel;
+                       for (int i=vtm.length; --i>=0;) {
+                               if (vtm[i] > modelIndex) {
+                                       vtm[i] -= 1;
+                               } else if (vtm[i] == modelIndex) {
+                                       if (vtm.length == 1) {
+                                               viewToModel = EMPTY;
+                                               return ONLY_INDEX;
+                                       }
+                                       int viewIndex = i;
+                                       while (--i>=0) {
+                                               if (vtm[i] > modelIndex)
+                                                       vtm[i] -= 1;
+                                       }
+                                       int[] a = new int[vtm.length-1];
+                                       if (viewIndex > 0)
+                                               System.arraycopy(vtm, 0, a, 0, viewIndex);
+                                       int len = a.length-viewIndex;
+                                       if (len > 0)
+                                               System.arraycopy(vtm, viewIndex+1, a, viewIndex, len);
+                                       viewToModel = a;
+                                       return viewIndex;
+                               }
+                       }
+                       return INDEX_NOT_FOUND;
+               }
+               
+               
+               int getChildCount() {
+                       return viewToModel.length;
+               }
+               
+               /**
+                * @param modelIndex
+                * @return viewIndex corresponding to modelIndex<br>
+                *              or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+                */
+               int convertRowIndexToView(int modelIndex) {
+                       int[] vtm = viewToModel;
+                       for (int i=vtm.length; --i>=0;) {
+                               if (vtm[i] == modelIndex)
+                                       return i;
+                       }
+                       return INDEX_NOT_FOUND;
+               }
+               
+               int convertRowIndexToModel(int viewIndex) {
+                       return viewToModel[viewIndex];
+               }
+       }
+
+       public interface Filter<N> {
+               boolean acceptNode(TreePath parent, N node, boolean leaf);
+       }
+
+       public static class RegexFilter<N> implements Filter<N> {
+               
+               public RegexFilter(Pattern pattern, boolean leaf) {
+                       matcher = pattern.matcher("");
+                       leafOnly = leaf;
+               }
+               
+               private Matcher matcher;
+               
+               private boolean leafOnly;
+               
+               public boolean acceptNode(TreePath parent, N node, boolean leaf) {
+                       if (leafOnly && !leaf)
+                               return false;
+                       matcher.reset(getStringValue(node));
+                       return matcher.find();
+               }
+               
+               protected String getStringValue(N node) {
+                       return node.toString();
+               }
+       }
+       
+
+       private static Map<Object,Converter> createConvertersMap() {
+               return new HashMap<Object,Converter>();
+       }
+}
diff --git a/src/be/nikiroo/utils/ui/TreeSnapshot.java b/src/be/nikiroo/utils/ui/TreeSnapshot.java
new file mode 100644 (file)
index 0000000..ef9a6fb
--- /dev/null
@@ -0,0 +1,127 @@
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import javax.swing.JTree;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreeNode;
+import javax.swing.tree.TreePath;
+
+public class TreeSnapshot {
+       private interface NodeAction {
+               public void run(TreeNode node);
+       }
+
+       private JTree tree;
+       private TreePath[] selectionPaths;
+       private List<TreePath> expanded;
+
+       public TreeSnapshot(JTree tree) {
+               this.tree = tree;
+
+               selectionPaths = tree.getSelectionPaths();
+               if (selectionPaths == null) {
+                       selectionPaths = new TreePath[0];
+               }
+
+               expanded = new ArrayList<TreePath>();
+               forEach(tree, new NodeAction() {
+                       @Override
+                       public void run(TreeNode node) {
+                               TreePath path = nodeToPath(node);
+                               if (path != null) {
+                                       if (TreeSnapshot.this.tree.isExpanded(path)) {
+                                               expanded.add(path);
+                                       }
+                               }
+                       }
+               });
+       }
+
+       public void apply() {
+               applyTo(tree);
+       }
+
+       public void applyTo(JTree tree) {
+               final List<TreePath> newExpanded = new ArrayList<TreePath>();
+               final List<TreePath> newSlectionPaths = new ArrayList<TreePath>();
+
+               forEach(tree, new NodeAction() {
+                       @Override
+                       public void run(TreeNode newNode) {
+                               TreePath newPath = nodeToPath(newNode);
+                               if (newPath != null) {
+                                       for (TreePath path : selectionPaths) {
+                                               if (isSamePath(path, newPath)) {
+                                                       newSlectionPaths.add(newPath);
+                                                       if (expanded.contains(path)) {
+                                                               newExpanded.add(newPath);
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               });
+
+               for (TreePath newPath : newExpanded) {
+                       tree.expandPath(newPath);
+               }
+
+               tree.setSelectionPaths(newSlectionPaths.toArray(new TreePath[0]));
+       }
+
+       // You can override this
+       protected boolean isSamePath(TreePath oldPath, TreePath newPath) {
+               return newPath.toString().equals(oldPath.toString());
+       }
+
+       private void forEach(JTree tree, NodeAction action) {
+               forEach(tree.getModel(), tree.getModel().getRoot(), action);
+       }
+
+       private void forEach(TreeModel model, Object parent, NodeAction action) {
+               if (!(parent instanceof TreeNode))
+                       return;
+
+               TreeNode node = (TreeNode) parent;
+
+               action.run(node);
+               int count = model.getChildCount(node);
+               for (int i = 0; i < count; i++) {
+                       Object child = model.getChild(node, i);
+                       forEach(model, child, action);
+               }
+       }
+
+       private static TreePath nodeToPath(TreeNode node) {
+               List<Object> nodes = new LinkedList<Object>();
+               if (node != null) {
+                       nodes.add(node);
+                       node = node.getParent();
+                       while (node != null) {
+                               nodes.add(0, node);
+                               node = node.getParent();
+                       }
+               }
+
+               return nodes.isEmpty() ? null : new TreePath(nodes.toArray());
+       }
+
+       @Override
+       public String toString() {
+               StringBuilder builder = new StringBuilder();
+               builder.append("Tree Snapshot of: ").append(tree).append("\n");
+               builder.append("Selected paths:\n");
+               for (TreePath path : selectionPaths) {
+                       builder.append("\t").append(path).append("\n");
+               }
+               builder.append("Expanded paths:\n");
+               for (TreePath epath : expanded) {
+                       builder.append("\t").append(epath).append("\n");
+               }
+
+               return builder.toString();
+       }
+}
index 24cbf64a5f138a26edc64230301ef401e5438482..5861d00fc3a6e3a254230c8bd5d43a17d26299db 100644 (file)
@@ -8,6 +8,8 @@ import java.awt.Paint;
 import java.awt.RadialGradientPaint;
 import java.awt.RenderingHints;
 
+import javax.swing.JComponent;
+import javax.swing.JScrollPane;
 import javax.swing.UIManager;
 import javax.swing.UnsupportedLookAndFeelException;
 
@@ -58,6 +60,31 @@ public class UIUtils {
         */
        static public void drawEllipse3D(Graphics g, Color color, int x, int y,
                        int width, int height) {
+               drawEllipse3D(g, color, x, y, width, height, true);
+       }
+
+       /**
+        * Draw a 3D-looking ellipse at the given location, if the given
+        * {@link Graphics} object is compatible (with {@link Graphics2D}); draw a
+        * simple ellipse if not.
+        * 
+        * @param g
+        *            the {@link Graphics} to draw on
+        * @param color
+        *            the base colour
+        * @param x
+        *            the X coordinate
+        * @param y
+        *            the Y coordinate
+        * @param width
+        *            the width radius
+        * @param height
+        *            the height radius
+        * @param fill
+        *            fill the content of the ellipse
+        */
+       static public void drawEllipse3D(Graphics g, Color color, int x, int y,
+                       int width, int height, boolean fill) {
                if (g instanceof Graphics2D) {
                        Graphics2D g2 = (Graphics2D) g;
                        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
@@ -68,12 +95,16 @@ public class UIUtils {
 
                        // Base shape
                        g2.setColor(color);
-                       g2.fillOval(x, y, width, height);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(x, y, width, height);
+                       }
 
                        // Compute dark/bright colours
                        Paint p = null;
-                       Color dark = color.darker();
-                       Color bright = color.brighter();
+                       Color dark = color.darker().darker();
+                       Color bright = color.brighter().brighter();
                        Color darkEnd = new Color(dark.getRed(), dark.getGreen(),
                                        dark.getBlue(), 0);
                        Color darkPartial = new Color(dark.getRed(), dark.getGreen(),
@@ -84,12 +115,19 @@ public class UIUtils {
                        // Adds shadows at the bottom left
                        p = new GradientPaint(0, height, dark, width, 0, darkEnd);
                        g2.setPaint(p);
-                       g2.fillOval(x, y, width, height);
-
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(x, y, width, height);
+                       }
                        // Adds highlights at the top right
                        p = new GradientPaint(width, 0, bright, 0, height, brightEnd);
                        g2.setPaint(p);
-                       g2.fillOval(x, y, width, height);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(x, y, width, height);
+                       }
 
                        // Darken the edges
                        p = new RadialGradientPaint(x + width / 2f, y + height / 2f,
@@ -97,7 +135,11 @@ public class UIUtils {
                                        new Color[] { darkEnd, darkPartial },
                                        RadialGradientPaint.CycleMethod.NO_CYCLE);
                        g2.setPaint(p);
-                       g2.fillOval(x, y, width, height);
+                       if (fill) {
+                               g2.fillOval(x, y, width, height);
+                       } else {
+                               g2.drawOval(x, y, width, height);
+                       }
 
                        // Adds inner highlight at the top right
                        p = new RadialGradientPaint(x + 3f * width / 4f, y + height / 4f,
@@ -106,13 +148,69 @@ public class UIUtils {
                                        new Color[] { bright, brightEnd },
                                        RadialGradientPaint.CycleMethod.NO_CYCLE);
                        g2.setPaint(p);
-                       g2.fillOval(x * 2, y, width, height);
+                       if (fill) {
+                               g2.fillOval(x * 2, y, width, height);
+                       } else {
+                               g2.drawOval(x * 2, y, width, height);
+                       }
 
                        // Reset original paint
                        g2.setPaint(oldPaint);
                } else {
                        g.setColor(color);
-                       g.fillOval(x, y, width, height);
+                       if (fill) {
+                               g.fillOval(x, y, width, height);
+                       } else {
+                               g.drawOval(x, y, width, height);
+                       }
                }
        }
+
+       /**
+        * Add a {@link JScrollPane} around the given panel and use a sensible (for
+        * me) increment for the mouse wheel.
+        * 
+        * @param pane
+        *            the panel to wrap in a {@link JScrollPane}
+        * @param allowHorizontal
+        *            allow horizontal scrolling (not always desired)
+        * 
+        * @return the {@link JScrollPane}
+        */
+       static public JScrollPane scroll(JComponent pane, boolean allowHorizontal) {
+               return scroll(pane, allowHorizontal, true);
+       }
+
+       /**
+        * Add a {@link JScrollPane} around the given panel and use a sensible (for
+        * me) increment for the mouse wheel.
+        * 
+        * @param pane
+        *            the panel to wrap in a {@link JScrollPane}
+        * @param allowHorizontal
+        *            allow horizontal scrolling (not always desired)
+        * @param allowVertical
+        *            allow vertical scrolling (usually yes, but sometimes you only
+        *            want horizontal)
+        * 
+        * @return the {@link JScrollPane}
+        */
+       static public JScrollPane scroll(JComponent pane, boolean allowHorizontal,
+                       boolean allowVertical) {
+               JScrollPane scroll = new JScrollPane(pane);
+
+               scroll.getVerticalScrollBar().setUnitIncrement(16);
+               scroll.getHorizontalScrollBar().setUnitIncrement(16);
+
+               if (!allowHorizontal) {
+                       scroll.setHorizontalScrollBarPolicy(
+                                       JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+               }
+               if (!allowVertical) {
+                       scroll.setVerticalScrollBarPolicy(
+                                       JScrollPane.VERTICAL_SCROLLBAR_NEVER);
+               }
+
+               return scroll;
+       }
 }