From: Niki Roo Date: Sat, 2 May 2020 20:18:41 +0000 (+0200) Subject: Merge branch 'master' of github.com:nikiroo/fanfix X-Git-Url: http://git.nikiroo.be/?p=fanfix.git;a=commitdiff_plain;h=bbeec1a33d25bb3d00ba385099368422cc6ef603;hp=de610c829d2e36992a3cc8d1174ac0a4733ed5bc Merge branch 'master' of github.com:nikiroo/fanfix --- diff --git a/TODO.md b/TODO.md index af17b53..85f2deb 100644 --- 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 0f9d6b1..ef538c2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.0-dev +3.1.2 diff --git a/changelog-fr.md b/changelog-fr.md index a422409..bdcc91c 100644 --- a/changelog-fr.md +++ b/changelog-fr.md @@ -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) diff --git a/changelog.md b/changelog.md index 200b5fa..cd63d1f 100644 --- a/changelog.md +++ b/changelog.md @@ -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 index 0000000..6c8cbff --- /dev/null +++ b/derename.sh @@ -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 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 index 0000000..02ee0ef --- /dev/null +++ b/libs/licenses/JSON-java-20190722_LICENSE.txt @@ -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 index 409851f..0000000 --- a/src/.gitattributes +++ /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 index 5c79834..0000000 --- a/src/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.classpath -.project -target/ -bin/ -.settings/ -.idea/ -*.iml - diff --git a/src/be/nikiroo/fanfix/Instance.java b/src/be/nikiroo/fanfix/Instance.java index f48d05b..d0d1c84 100644 --- a/src/be/nikiroo/fanfix/Instance.java +++ b/src/be/nikiroo/fanfix/Instance.java @@ -48,8 +48,19 @@ public class Instance { /** * Initialise the instance -- if already initialised, nothing will happen. *

- * 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. + *

+ * Note that this method will honour some environment variables, the 3 most + * important ones probably being: + *

*/ 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; diff --git a/src/be/nikiroo/fanfix/bundles/Config.java b/src/be/nikiroo/fanfix/bundles/Config.java index 7360f39..fd27a83 100644 --- a/src/be/nikiroo/fanfix/bundles/Config.java +++ b/src/be/nikiroo/fanfix/bundles/Config.java @@ -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, // } diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGui.java b/src/be/nikiroo/fanfix/bundles/StringIdGui.java index 2c9d222..c109f42 100644 --- a/src/be/nikiroo/fanfix/bundles/StringIdGui.java +++ b/src/be/nikiroo/fanfix/bundles/StringIdGui.java @@ -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, // diff --git a/src/be/nikiroo/fanfix/bundles/UiConfig.java b/src/be/nikiroo/fanfix/bundles/UiConfig.java index 0640db8..0f3142d 100644 --- a/src/be/nikiroo/fanfix/bundles/UiConfig.java +++ b/src/be/nikiroo/fanfix/bundles/UiConfig.java @@ -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, // } diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui.properties b/src/be/nikiroo/fanfix/bundles/resources_gui.properties index 6d46af4..40be5eb 100644 --- a/src/be/nikiroo/fanfix/bundles/resources_gui.properties +++ b/src/be/nikiroo/fanfix/bundles/resources_gui.properties @@ -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 diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties index 1ede37c..25ff542 100644 --- a/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties +++ b/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties @@ -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 diff --git a/src/be/nikiroo/fanfix/bundles/ui_description.properties b/src/be/nikiroo/fanfix/bundles/ui_description.properties index 5cb2a9f..c8def83 100644 --- a/src/be/nikiroo/fanfix/bundles/ui_description.properties +++ b/src/be/nikiroo/fanfix/bundles/ui_description.properties @@ -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" diff --git a/src/be/nikiroo/fanfix/data/MetaData.java b/src/be/nikiroo/fanfix/data/MetaData.java index 2c40beb..586196a 100644 --- a/src/be/nikiroo/fanfix/data/MetaData.java +++ b/src/be/nikiroo/fanfix/data/MetaData.java @@ -390,12 +390,14 @@ public class MetaData implements Cloneable, Comparable, 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 diff --git a/src/be/nikiroo/fanfix/library/BasicLibrary.java b/src/be/nikiroo/fanfix/library/BasicLibrary.java index c2ab12b..d435f8d 100644 --- a/src/be/nikiroo/fanfix/library/BasicLibrary.java +++ b/src/be/nikiroo/fanfix/library/BasicLibrary.java @@ -98,7 +98,7 @@ abstract public class BasicLibrary { * Do NOT 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. *

@@ -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 true). + * cache (if we have no cache, we default to true). * * @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. *

@@ -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 getSources() throws IOException { - List list = new ArrayList(); - for (MetaData meta : getMetas(null)) { - String storySource = meta.getSource(); - if (!list.contains(storySource)) { - list.add(storySource); - } - } - - Collections.sort(list); - return list; + @Deprecated + public List 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"). - *

- * Note that an empty item in the list means a non-grouped source (type) -- - * e.g., you could have for Source_1: - *

- * - * @return the grouped list - * - * @throws IOException - * in case of IOException + * @deprecated please use {@link BasicLibrary#getList()} and + * {@link MetaResultList#getSourcesGrouped()} instead. */ - public synchronized Map> getSourcesGrouped() - throws IOException { - Map> map = new TreeMap>(); - 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 list = map.get(name); - if (list == null) { - list = new ArrayList(); - map.put(name, list); - } - list.add(subname); - } - - return map; + @Deprecated + public Map> 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 getAuthors() throws IOException { - List list = new ArrayList(); - for (MetaData meta : getMetas(null)) { - String storyAuthor = meta.getAuthor(); - if (!list.contains(storyAuthor)) { - list.add(storyAuthor); - } - } - - Collections.sort(list); - return list; + @Deprecated + public List getAuthors() throws IOException { + return getList().getAuthors(); } /** - * Return the list of authors, grouped by starting letter(s) if needed. - *

- * If the number of author is not too high, only one group with an empty - * name and all the authors will be returned. - *

- * If not, the authors will be separated into groups: - *

    - *
  • *: any author whose name doesn't contain letters nor numbers - *
  • - *
  • 0-9: any authors whose name starts with a number
  • - *
  • A-C (for instance): any author whose name starts with - * A, B or C
  • - *
- * Note that the letters used in the groups can vary (except * and - * 0-9, 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> getAuthorsGrouped() throws IOException { - int MAX = 20; - - Map> groups = new TreeMap>(); - List 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 keys = new ArrayList(groups.keySet()); - for (int i = 0; i + 1 < keys.size(); i++) { - String keyNow = keys.get(i); - String keyNext = keys.get(i + 1); - - List now = groups.get(keyNow); - List next = groups.get(keyNext); - - int currentTotal = now.size() + next.size(); - if (currentTotal <= MAX) { - String key = keyNow.charAt(0) + "-" - + keyNext.charAt(keyNext.length() - 1); - - List all = new ArrayList(); - 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(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: - *
    - *
  • *: any author whose name doesn't contain letters nor numbers - *
  • - *
  • 0: any authors whose name starts with a number
  • - *
  • A (any capital latin letter): any author whose name starts - * with A
  • - *
- * - * @param authors - * the full list of authors - * @param car - * the starting character, *, 0 or a capital - * letter - * - * @return the authors that fulfil the starting letter - */ - private List getAuthorsGroup(List authors, char car) { - List accepted = new ArrayList(); - 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 MAY 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 + ")"); } /** diff --git a/src/be/nikiroo/fanfix/library/CacheLibrary.java b/src/be/nikiroo/fanfix/library/CacheLibrary.java index 694f9ec..a3c3b5e 100644 --- a/src/be/nikiroo/fanfix/library/CacheLibrary.java +++ b/src/be/nikiroo/fanfix/library/CacheLibrary.java @@ -24,21 +24,27 @@ import be.nikiroo.utils.Progress; public class CacheLibrary extends BasicLibrary { private List metasReal; private List metasMixed; + private Object metasLock = new Object(); + private BasicLibrary lib; private LocalLibrary cacheLib; /** * Create a cache library around the given one. *

- * 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 getMetas(Progress pg) throws IOException { - // We make sure that cached metas have precedence - + protected List getMetas(Progress pg) throws IOException { if (pg == null) { pg = new Progress(); } - if (metasMixed == null) { - if (metasReal == null) { - metasReal = lib.getMetas(pg); - } - - metasMixed = new ArrayList(); - TreeSet cachedLuids = new TreeSet(); - 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 copy; + synchronized (metasLock) { + // We make sure that cached metas have precedence + if (metasMixed == null) { + if (metasReal == null) { + metasReal = lib.getMetas(pg); + } + + metasMixed = new ArrayList(); + TreeSet cachedLuids = new TreeSet(); + 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(metasMixed); } pg.done(); - return new ArrayList(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. *

- * Make sure to always use {@link MetaData} from the cached library - * in priority, here. + * Make sure to always use {@link MetaData} from the cached library in + * priority, here. * * @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 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 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(); diff --git a/src/be/nikiroo/fanfix/library/LocalLibrary.java b/src/be/nikiroo/fanfix/library/LocalLibrary.java index 80d216b..6720972 100644 --- a/src/be/nikiroo/fanfix/library/LocalLibrary.java +++ b/src/be/nikiroo/fanfix/library/LocalLibrary.java @@ -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 stories; // Files: [ infoFile, TargetFile ] private Map sourceCovers; private Map 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 getMetas(Progress pg) { + protected List getMetas(Progress pg) { return new ArrayList(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(); + public Image getCustomSourceCover(String source) { + synchronized (lock) { + if (sourceCovers == null) { + sourceCovers = new HashMap(); + } } - 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(); + public Image getCustomAuthorCover(String author) { + synchronized (lock) { + if (authorCovers == null) { + authorCovers = new HashMap(); + } } - 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 getStories(Progress pg) { + private Map getStories(Progress pg) { if (pg == null) { pg = new Progress(); } else { pg.setMinMax(0, 100); } + Map stories = this.stories; if (stories == null) { - stories = new HashMap(); + 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 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 stories = new HashMap(); - 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 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)); } } diff --git a/src/be/nikiroo/fanfix/library/MetaResultList.java b/src/be/nikiroo/fanfix/library/MetaResultList.java index 886defe..0903740 100644 --- a/src/be/nikiroo/fanfix/library/MetaResultList.java +++ b/src/be/nikiroo/fanfix/library/MetaResultList.java @@ -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 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"). + *

+ * Note that an empty item in the list means a non-grouped source (type) -- + * e.g., you could have for Source_1: + *

    + *
  • : empty, so source is "Source_1"
  • + *
  • a: empty, so source is "Source_1/a"
  • + *
  • b: empty, so source is "Source_1/b"
  • + *
+ * + * @return the grouped list + * + * @throws IOException + * in case of IOException + */ + public Map> getSourcesGrouped() throws IOException { + Map> map = new TreeMap>(); + 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 list = map.get(name); + if (list == null) { + list = new ArrayList(); + map.put(name, list); + } + list.add(subname); + } + + return map; + } + public List getAuthors() { if (authors == null) { authors = new ArrayList(); @@ -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. + *

+ * If the number of authors is not too high, only one group with an empty + * name and all the authors will be returned. + *

+ * If not, the authors will be separated into groups: + *

    + *
  • *: any author whose name doesn't contain letters nor numbers + *
  • + *
  • 0-9: any author whose name starts with a number
  • + *
  • A-C (for instance): any author whose name starts with + * A, B or C
  • + *
+ * Note that the letters used in the groups can vary (except * and + * 0-9, which may only be present or not). + * + * @return the authors' names, grouped by letter(s) + * + * @throws IOException + * in case of IOException + */ + public Map> getAuthorsGrouped() throws IOException { + return group(getAuthors()); + } + public List getTags() { if (tags == null) { tags = new ArrayList(); @@ -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. + *

+ * If the number of tags is not too high, only one group with an empty name + * and all the tags will be returned. + *

+ * If not, the tags will be separated into groups: + *

    + *
  • *: any tag which name doesn't contain letters nor numbers + *
  • + *
  • 0-9: any tag which name starts with a number
  • + *
  • A-C (for instance): any tag which name starts with + * A, B or C
  • + *
+ * Note that the letters used in the groups can vary (except * and + * 0-9, which may only be present or not). + * + * @return the tags' names, grouped by letter(s) + * + * @throws IOException + * in case of IOException + */ + public Map> 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 filter(List sources, List authors, List tags) { + public List filter(List sources, List authors, + List 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. + *

+ * 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}). + *

+ * If not, the values will be separated into groups: + *

    + *
  • *: any value which name doesn't contain letters nor numbers + *
  • + *
  • 0-9: any value which name starts with a number
  • + *
  • A-C (for instance): any value which name starts with + * A, B or C
  • + *
+ * Note that the letters used in the groups can vary (except * and + * 0-9, 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> group(List values) + throws IOException { + Map> groups = new TreeMap>(); + + // 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 keys = new ArrayList(groups.keySet()); + for (int i = 0; i + 1 < keys.size(); i++) { + String keyNow = keys.get(i); + String keyNext = keys.get(i + 1); + + List now = groups.get(keyNow); + List next = groups.get(keyNext); + + int currentTotal = now.size() + next.size(); + if (currentTotal <= MAX) { + String key = keyNow.charAt(0) + "-" + + keyNext.charAt(keyNext.length() - 1); + + List all = new ArrayList(); + 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(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: + *
    + *
  • *: any author whose name doesn't contain letters nor numbers + *
  • + *
  • 0: any authors whose name starts with a number
  • + *
  • A (any capital latin letter): any author whose name starts + * with A
  • + *
+ * + * @param values + * the full list of authors + * @param car + * the starting character, *, 0 or a capital + * letter + * + * @return the authors that fulfil the starting letter + */ + private List find(List values, char car) { + List accepted = new ArrayList(); + 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 values) { + Collections.sort(values, new Comparator() { + @Override + public int compare(String o1, String o2) { + return ("" + o1).compareToIgnoreCase("" + o2); + } + }); + } } diff --git a/src/be/nikiroo/fanfix/library/RemoteLibrary.java b/src/be/nikiroo/fanfix/library/RemoteLibrary.java index 65be7b1..bbe772a 100644 --- a/src/be/nikiroo/fanfix/library/RemoteLibrary.java +++ b/src/be/nikiroo/fanfix/library/RemoteLibrary.java @@ -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 metas = getMetasList(luid, null); if (!metas.isEmpty()) { return metas.get(0); @@ -459,7 +459,7 @@ public class RemoteLibrary extends BasicLibrary { } @Override - protected synchronized List getMetas(Progress pg) throws IOException { + protected List getMetas(Progress pg) throws IOException { return getMetasList("*", pg); } diff --git a/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java b/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java index 4f89a1f..f92c37e 100644 --- a/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java +++ b/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java @@ -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; + } } diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport.java b/src/be/nikiroo/fanfix/supported/BasicSupport.java index bc91e8b..0a5ec36 100644 --- a/src/be/nikiroo/fanfix/supported/BasicSupport.java +++ b/src/be/nikiroo/fanfix/supported/BasicSupport.java @@ -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. + *

+ * 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. + *

+ * 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); diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java b/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java index b5c7bb9..7768052 100644 --- a/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java +++ b/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java @@ -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. + *

+ * 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; + } } diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java index 4a7b65b..bc3738a 100644 --- a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java +++ b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java @@ -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(); diff --git a/src/be/nikiroo/fanfix/supported/Cbz.java b/src/be/nikiroo/fanfix/supported/Cbz.java index 76b66ab..a6188ec 100644 --- a/src/be/nikiroo/fanfix/supported/Cbz.java +++ b/src/be/nikiroo/fanfix/supported/Cbz.java @@ -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; } diff --git a/src/be/nikiroo/fanfix/supported/E621.java b/src/be/nikiroo/fanfix/supported/E621.java index f1660e1..dc7cb1b 100644 --- a/src/be/nikiroo/fanfix/supported/E621.java +++ b/src/be/nikiroo/fanfix/supported/E621.java @@ -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 e621.net 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> 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>(); - } - - private List> getChapters(URL source, Progress pg, String baseUrl, String parameters) + protected List> getChapters(Progress pg) throws IOException { - List> urls = new ArrayList>(); - - 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("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> chapters = new LinkedList>(); + for (int page = i; page > 0; page--) { + chapters.add(new AbstractMap.SimpleEntry( + "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("]
"); + + JSONObject json = getJson(chapUrl, false); + JSONArray postsArr = json.getJSONArray("posts"); + + // The pages and images are in reverse order on /posts/ + List posts = new ArrayList(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("]
"); + } 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 list = new ArrayList(); + 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> 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 diff --git a/src/be/nikiroo/fanfix/supported/Epub.java b/src/be/nikiroo/fanfix/supported/Epub.java index f8e4678..90a9f45 100644 --- a/src/be/nikiroo/fanfix/supported/Epub.java +++ b/src/be/nikiroo/fanfix/supported/Epub.java @@ -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 { diff --git a/src/be/nikiroo/fanfix/supported/FimfictionApi.java b/src/be/nikiroo/fanfix/supported/FimfictionApi.java index 6c6d7ba..43d01d1 100644 --- a/src/be/nikiroo/fanfix/supported/FimfictionApi.java +++ b/src/be/nikiroo/fanfix/supported/FimfictionApi.java @@ -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()); diff --git a/src/be/nikiroo/fanfix/supported/InfoReader.java b/src/be/nikiroo/fanfix/supported/InfoReader.java index 220350e..206464f 100644 --- a/src/be/nikiroo/fanfix/supported/InfoReader.java +++ b/src/be/nikiroo/fanfix/supported/InfoReader.java @@ -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; } diff --git a/src/be/nikiroo/fanfix/supported/InfoText.java b/src/be/nikiroo/fanfix/supported/InfoText.java index 42e2c13..2af8c7e 100644 --- a/src/be/nikiroo/fanfix/supported/InfoText.java +++ b/src/be/nikiroo/fanfix/supported/InfoText.java @@ -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 diff --git a/src/be/nikiroo/fanfix/supported/MangaLel.java b/src/be/nikiroo/fanfix/supported/MangaLel.java index 9929699..de0b871 100644 --- a/src/be/nikiroo/fanfix/supported/MangaLel.java +++ b/src/be/nikiroo/fanfix/supported/MangaLel.java @@ -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; } diff --git a/src/be/nikiroo/fanfix/supported/Text.java b/src/be/nikiroo/fanfix/supported/Text.java index c54b6a5..ade797f 100644 --- a/src/be/nikiroo/fanfix/supported/Text.java +++ b/src/be/nikiroo/fanfix/supported/Text.java @@ -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()); 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; diff --git a/src/be/nikiroo/utils/Progress.java b/src/be/nikiroo/utils/Progress.java index dea6be3..748d4a6 100644 --- a/src/be/nikiroo/utils/Progress.java +++ b/src/be/nikiroo/utils/Progress.java @@ -9,6 +9,16 @@ import java.util.Map.Entry; /** * Progress reporting system, possibly nested. + *

+ * 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). + *

+ * 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 map = new HashMap(); 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. + *

+ * 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 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 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 index 0000000..114ac42 --- /dev/null +++ b/src/be/nikiroo/utils/compat/DefaultListModel6.java @@ -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). + *

+ * This class is merely a {@link DefaultListModel} that you can parametrise also + * in Java 1.6. + * + * @author niki + * + * @param + * the type to use + */ +@SuppressWarnings("rawtypes") // not compatible Java 1.6 +public class DefaultListModel6 extends DefaultListModel + implements ListModel6 { + 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 index 0000000..ca44165 --- /dev/null +++ b/src/be/nikiroo/utils/compat/JList6.java @@ -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). + *

+ * This class is merely a {@link JList} that you can parametrise also in Java + * 1.6. + * + * @author niki + * + * @param + * the type to use + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6 +public class JList6 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 class + * level documentation. + *

+ * 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 + * PropertyChangeEvent is generated however - for the + * cellRenderer property. + *

+ * The default value of this property is provided by the {@code ListUI} + * delegate, i.e. by the look and feel implementation. + *

+ * This is a JavaBeans bound property. + * + * @param cellRenderer + * the ListCellRenderer that paints list cells + * @see #getCellRenderer + * @beaninfo bound: true attribute: visualUpdate true description: The + * component used to draw the cells. + */ + public void setCellRenderer(ListCellRenderer6 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. + *

+ * This is a JavaBeans bound property. + * + * @param model + * the ListModel that provides the list of items for + * display + * @exception IllegalArgumentException + * if model is null + * @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 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 index 0000000..d004849 --- /dev/null +++ b/src/be/nikiroo/utils/compat/ListCellRenderer6.java @@ -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). + *

+ * This class is merely a {@link ListCellRenderer} that you can parametrise also + * in Java 1.6. + * + * @author niki + * + * @param + * the type to use + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6 +public abstract class ListCellRenderer6 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) list, (E) value, index, + isSelected, cellHasFocus); + } + + /** + * Return a component that has been configured to display the specified + * value. That component's paint 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 getPreferredSize 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 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 index 0000000..a1f8c60 --- /dev/null +++ b/src/be/nikiroo/utils/compat/ListModel6.java @@ -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). + *

+ * This class is merely a {@link javax.swing.ListModel} that you can parametrise + * also in Java 1.6. + * + * @author niki + * + * @param + * the type to use + */ +@SuppressWarnings("rawtypes") // not compatible Java 1.6 +public interface ListModel6 extends javax.swing.ListModel { +} diff --git a/src/be/nikiroo/utils/resources/Meta.java b/src/be/nikiroo/utils/resources/Meta.java index 8ed74dc..fb4d491 100644 --- a/src/be/nikiroo/utils/resources/Meta.java +++ b/src/be/nikiroo/utils/resources/Meta.java @@ -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). + *

+ * 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. diff --git a/src/be/nikiroo/utils/resources/MetaInfo.java b/src/be/nikiroo/utils/resources/MetaInfo.java index 917c210..70c6c43 100644 --- a/src/be/nikiroo/utils/resources/MetaInfo.java +++ b/src/be/nikiroo/utils/resources/MetaInfo.java @@ -27,6 +27,7 @@ public class MetaInfo> implements Iterable> { private List saveListeners = new ArrayList(); private String name; + private boolean hidden; private String description; private boolean dirty; @@ -90,6 +91,7 @@ public class MetaInfo> implements Iterable> { } this.name = name; + this.hidden = meta.hidden(); this.description = description; reload(); @@ -110,6 +112,16 @@ public class MetaInfo> implements Iterable> { 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> implements Iterable> { List> shadow = new ArrayList>(); for (E id : type.getEnumConstants()) { MetaInfo info = new MetaInfo(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 index 0000000..a0e205c --- /dev/null +++ b/src/be/nikiroo/utils/ui/BreadCrumbsBar.java @@ -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 extends ListenerPanel { + private class BreadCrumb extends JPanel { + private JToggleButton button; + private JToggleButton down; + + public BreadCrumb(final DataNode 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 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 node; + private List crumbs = new ArrayList(); + + public BreadCrumbsBar(final DataTree 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, Void>() { + @Override + protected DataNode 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 getSelectedNode() { + return node; + } + + public void setSelectedNode(DataNode 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 ancestorOrNode = node; + for (DataNode 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 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 index 0000000..b4dbe7b --- /dev/null +++ b/src/be/nikiroo/utils/ui/DataNode.java @@ -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 { + private DataNode parent; + private List> children; + private T userData; + + public DataNode(List> children, T userData) { + if (children == null) { + children = new ArrayList>(); + } + + this.children = children; + this.userData = userData; + + for (DataNode child : children) { + child.parent = this; + } + } + + public DataNode getRoot() { + DataNode root = this; + while (root.parent != null) { + root = root.parent; + } + + return root; + } + + public DataNode getParent() { + return parent; + } + + public List> getChildren() { + return children; + } + + public int size() { + return children.size(); + } + + public boolean isRoot() { + return this == getRoot(); + } + + public boolean isSiblingOf(DataNode node) { + if (this == node) { + return true; + } + + return node != null && parent != null && parent.children.contains(node); + } + + public boolean isParentOf(DataNode node) { + if (node == null || node.parent == null) + return false; + + if (this == node.parent) + return true; + + return isParentOf(node.parent); + } + + public boolean isChildOf(DataNode 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 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 index 0000000..6b3657d --- /dev/null +++ b/src/be/nikiroo/utils/ui/DataTree.java @@ -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 { + protected DataNode data; + + public DataNode loadData() throws IOException { + return this.data = extractData(); + } + + public DataNode getRoot() { + return getRoot(null); + } + + public DataNode getRoot(String filter) { + return filterNode(data, filter); + } + + protected abstract DataNode extractData() throws IOException; + + // filter cannot be null nor empty + protected abstract boolean checkFilter(String filter, E userData); + + protected boolean checkFilter(DataNode node, String filter) { + if (filter == null || filter.isEmpty()) { + return true; + } + + if (checkFilter(filter, node.getUserData())) + return true; + + for (DataNode child : node.getChildren()) { + if (checkFilter(child, filter)) + return true; + } + + return false; + } + + protected void sort(List values) { + Collections.sort(values, new Comparator() { + @Override + public int compare(String o1, String o2) { + return ("" + o1).compareToIgnoreCase("" + o2); + } + }); + } + + // note: we always send TAHT node, but filter children + private DataNode filterNode(DataNode source, String filter) { + List> children = new ArrayList>(); + for (DataNode child : source.getChildren()) { + if (checkFilter(child, filter)) { + children.add(filterNode(child, filter)); + } + } + + return new DataNode(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 index 0000000..2a16c98 --- /dev/null +++ b/src/be/nikiroo/utils/ui/DelayWorker.java @@ -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. + *

+ * How does it work? + *

    + *
  • it takes an ID and an associated {@link SwingWorker} and will call + * {@link SwingWorker#execute()} after a small delay (see + * {@link DelayWorker#DelayWorker(int)})
  • + *
  • 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
  • + *
  • if a third call still with the same ID comes, its associated worker will + * replace the one in the queue (only one worker per ID in the queue, + * always the latest one)
  • + *
  • when the first worker is done, it will check the waiting queue and + * execute that latest worker if any
  • + *
+ * + * @author niki + * + */ +@SuppressWarnings("rawtypes") +public class DelayWorker { + private Map lazyEnCours; + private Object lazyEnCoursLock; + + private TreeSet 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(); + lazyEnCoursLock = new Object(); + wip = new TreeSet(); + 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 workers = new HashMap(); + synchronized (lazyEnCoursLock) { + for (String key : new ArrayList( + 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. MUST + * NOT 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. + *

+ * 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. + *

+ * 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. + *

+ * Note: this is final, you MUST NOT 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 index 0000000..cf16d5f --- /dev/null +++ b/src/be/nikiroo/utils/ui/ListModel.java @@ -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). + *

+ * It also offers filter options, supports hovered changes and some more utility + * functions. + * + * @author niki + * + * @param + * the type of elements and items (the same type) + */ +public class ListModel extends DefaultListModel6 { + 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 + * the type of elements and items (the same type) + */ + public interface Predicate { + /** + * 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 + * the type of elements and items (the same type) + */ + public interface TooltipCreator { + /** + * Generate a tooltip {@link Window} for this element. + *

+ * 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 items = new ArrayList(); + private boolean keepSelection = true; + + private TooltipCreator 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 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 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 list, TooltipCreator 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 list, JPopupMenu popup, + TooltipCreator tooltipCreator) { + this((JList) list, popup, tooltipCreator); + } + + /** + * Create a new {@link ListModel}. + *

+ * 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}. + *

+ * 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}. + *

+ * 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 tooltipCreator) { + this(list, null, tooltipCreator); + } + + /** + * Create a new {@link ListModel}. + *

+ * 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 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() { + @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. + *

+ * 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. + *

+ * 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 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 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. + *

+ * 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 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 getSelectedElements() { + List selected = new ArrayList(); + for (int index : list.getSelectedIndices()) { + selected.add(get(index)); + } + + return selected; + } + + /** + * Return the selected element if one and only one 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 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 + * 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 ListCellRenderer6 generateRenderer( + final ListModel model) { + return new ListCellRenderer6() { + @Override + public Component getListCellRendererComponent(JList6 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 index 0000000..d2e89c8 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ListSnapshot.java @@ -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 elements = new ArrayList(); + + 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 indices = new ArrayList(); + 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 index 0000000..3fa41c8 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ListenerItem.java @@ -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 index 0000000..ada0796 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ListenerPanel.java @@ -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. + *

+ * 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 waitingQueue; + + /** + * Create a new {@link ListenerPanel}. + */ + public ListenerPanel() { + waitingQueue = new LinkedList(); + } + + @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); + } + } +} diff --git a/src/be/nikiroo/utils/ui/ProgressBar.java b/src/be/nikiroo/utils/ui/ProgressBar.java index 219cde9..11e1e6c 100644 --- a/src/be/nikiroo/utils/ui/ProgressBar.java +++ b/src/be/nikiroo/utils/ui/ProgressBar.java @@ -35,6 +35,11 @@ public class ProgressBar extends JPanel { actionListeners = new ArrayList(); updateListeners = new ArrayList(); } + + 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 index 0000000..703bfa1 --- /dev/null +++ b/src/be/nikiroo/utils/ui/TreeCellSpanner.java @@ -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 . + */ +// 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 offsets = new HashMap(); + + 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 index 0000000..9568f17 --- /dev/null +++ b/src/be/nikiroo/utils/ui/TreeModelTransformer.java @@ -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 . + */ +// 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 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 filter; + + private TreePath filterStartPath; + + private int filterDepthLimit; + + private SortOrder sortOrder = SortOrder.UNSORTED; + + private Map 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 comparator) { + handler.setComparator(comparator); + } + + /** + * @return comparator that compares nodes + * @see #setComparator(Comparator) + */ + public Comparator getComparator() { + return handler.getComparator(); + } + + public void setSortOrder(SortOrder newOrder) { + SortOrder oldOrder = sortOrder; + if (oldOrder == newOrder) + return; + sortOrder = newOrder; + ArrayList 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 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 sort() { + if (converters == null) + converters = createConvertersMap(); //new IdentityHashMap(); + return sortHierarchy(new TreePath(model.getRoot())); + } + + /** + * Sort path and expanded descendants. + * @param path + * @return list of paths that were sorted + */ + private ArrayList sortHierarchy(TreePath path) { + ValueIndexPair[] pairs = createValueIndexPairArray(20); + ArrayList list = new ArrayList(); + pairs = sort(path.getLastPathComponent(), pairs); + list.add(path); + Enumeration 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[] sort(Object node, ValueIndexPair[] 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[] createValueIndexPairArray(int len) { + ValueIndexPair[] pairs = new ValueIndexPair[len]; + for (int i=len; --i>=0;) + pairs[i] = new ValueIndexPair(); + return pairs; + } + + public void setFilter(Filter filter) { + setFilter(filter, null); + } + + public void setFilter(Filter filter, TreePath startingPath) { + setFilter(filter, null, -1); + } + + public void setFilter(Filter filter, TreePath startingPath, int depthLimit) { + if (filter == null && startingPath != null) + throw new IllegalArgumentException(); + if (startingPath != null && startingPath.getPathCount() == 1) + startingPath = null; + Filter oldFilter = this.filter; + TreePath oldStartPath = filterStartPath; + this.filter = filter; + filterStartPath = startingPath; + filterDepthLimit = depthLimit; + applyFilter(oldFilter, oldStartPath, null, true); + } + + public Filter getFilter() { + return filter; + } + + public TreePath getFilterStartPath() { + return filterStartPath; + } + + private void applyFilter(Filter oldFilter, TreePath oldStartPath, Collection expanded, boolean sort) { + TreePath startingPath = filterStartPath; + ArrayList 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 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 filter, TreePath path, ArrayList 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 filter, TreePath path, ArrayList 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 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 paths) { + if (paths == null || paths.isEmpty()) + return; + JTree tre = tree; + for (TreePath path : paths) + tre.expandPath(path); + } + + + private void fireTreeStructureChangedAndExpand(TreePath path, ArrayList list, boolean retainSelection) { + Enumeration 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>, + TreeModelListener, TreeExpansionListener { + + private Comparator comparator; + + private Collator collator = Collator.getInstance(); + + void setComparator(Comparator cmp) { + comparator = cmp; + collator = cmp == null ? Collator.getInstance() : null; + } + + Comparator 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 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 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 expand = null; + if (converter != null) { + expand = new ArrayList(); + int childIndex = 0; + for (int i=0; i= 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(); + int[] vtm = null; + int idx = 0; + for (int i=0; i expand) { + if (expand != null && !expand.isEmpty()) { + Enumeration 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 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(); + 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(); + for (int i=0; i= 0) { + childNodes[len] = childNodes[i]; + childIndices[len++] = viewIndex; + } + } + if (len == 0) + return; + if (converter.isFiltered() && converter.getChildCount() == len) { + ArrayList expand = new ArrayList(); + 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 getExpandedPaths(TreePath path) { + Enumeration en = tree.getExpandedDescendants(path); + if (en == null) + return Collections.emptySet(); + HashSet expanded = new HashSet(); + 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 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 a, ValueIndexPair 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 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. + *

+ * 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. + *

+ * 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(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. + *

+ * The Handler class sorts an array of ValueIndexPair based on the value. + * Used for sorting the view. + *

+ * ValueIndexPair sorts itself based on the index. + * Used for sorting childIndices for fire* methods. + */ + private static class ValueIndexPair implements Comparable> { + ValueIndexPair() {} + + ValueIndexPair(int idx, N val) { + index = idx; + value = val; + } + + N value; + + int index; + + public int compareTo(ValueIndexPair 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=0;) + if (newVTM[i] > idx) + newVTM[i]--; + for (int i=oldIndex; i idx) + oldVTM[i]--; + } + newVTM[newIndex] = oldVTM[oldIndex]; + } + viewToModel = newVTM; + } + } + + /** + * @param modelIndex + * @return viewIndex that was removed
+ * or ONLY_INDEX if the modelIndex is the only one in the view
+ * or INDEX_NOT_FOUND 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
+ * or INDEX_NOT_FOUND 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 { + boolean acceptNode(TreePath parent, N node, boolean leaf); + } + + public static class RegexFilter implements Filter { + + 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 createConvertersMap() { + return new HashMap(); + } +} diff --git a/src/be/nikiroo/utils/ui/TreeSnapshot.java b/src/be/nikiroo/utils/ui/TreeSnapshot.java new file mode 100644 index 0000000..ef9a6fb --- /dev/null +++ b/src/be/nikiroo/utils/ui/TreeSnapshot.java @@ -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 expanded; + + public TreeSnapshot(JTree tree) { + this.tree = tree; + + selectionPaths = tree.getSelectionPaths(); + if (selectionPaths == null) { + selectionPaths = new TreePath[0]; + } + + expanded = new ArrayList(); + 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 newExpanded = new ArrayList(); + final List newSlectionPaths = new ArrayList(); + + 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 nodes = new LinkedList(); + 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(); + } +} diff --git a/src/be/nikiroo/utils/ui/UIUtils.java b/src/be/nikiroo/utils/ui/UIUtils.java index 24cbf64..5861d00 100644 --- a/src/be/nikiroo/utils/ui/UIUtils.java +++ b/src/be/nikiroo/utils/ui/UIUtils.java @@ -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; + } }