- [ ] [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
- [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
# 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)
# 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)
--- /dev/null
+#!/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
+
--- /dev/null
+============================================================================
+
+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.
+++ /dev/null
-# 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
+++ /dev/null
-.classpath
-.project
-target/
-bin/
-.settings/
-.idea/
-*.iml
-
/**
* Initialise the instance -- if already initialised, nothing will happen.
* <p>
- * Before calling this method, you may call {@link Bundles#setDirectory(String)}
- * if wanted.
+ * Before calling this method, you may call
+ * {@link Bundles#setDirectory(String)} if wanted.
+ * <p>
+ * Note that this method will honour some environment variables, the 3 most
+ * important ones probably being:
+ * <ul>
+ * <li><tt>DEBUG</tt>: will enable DEBUG output if set to 1 (or Y or TRUE or
+ * ON, case insensitive)</li>
+ * <li><tt>CONFIG_DIR</tt>: will use this directory as configuration
+ * directory (supports $HOME notation, defaults to $HOME/.fanfix</li>
+ * <li><tt>BOOKS_DIR</tt>: will use this directory as library directory
+ * (supports $HOME notation, defaults to $HOME/Books</li>
+ * </ul>
*/
static public void init() {
init(false);
BasicLibrary lib = null;
boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED, false);
-
if (useRemote) {
String host = null;
int port = -1;
*/
@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",//
@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, //
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)",//
@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, //
}
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, //
@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, //
}
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
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
#
-# 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"
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
* Do <b>NOT</b> alter this file.
*
* @param luid
- * the Library UID of the story
+ * the Library UID of the story, can be NULL
* @param pg
* the optional {@link Progress}
*
public abstract Image getCover(String luid) throws IOException;
// TODO: ensure it is the main used interface
- public synchronized MetaResultList getList(Progress pg) throws IOException {
+ public MetaResultList getList(Progress pg) throws IOException {
return new MetaResultList(getMetas(pg));
}
-
- //TODO: make something for (normal and custom) not-story covers
-
+
+ // TODO: make something for (normal and custom) not-story covers
+
/**
* Return the cover image associated to this source.
* <p>
* @param pg
* the optional progress reporter
*/
- public synchronized void refresh(Progress pg) {
+ public void refresh(Progress pg) {
try {
getMetas(pg);
} catch (IOException e) {
// We will let it fail later
}
}
-
+
/**
* Check if the {@link Story} denoted by this Library UID is present in the
- * cache (if we have no cache, we default to </t>true</tt>).
+ * cache (if we have no cache, we default to </tt>true</tt>).
*
* @param luid
* the Library UID
*
* @return TRUE if it is
*/
- public boolean isCached(String luid) {
+ public boolean isCached(@SuppressWarnings("unused") String luid) {
// By default, everything is cached
return true;
}
-
+
/**
* Clear the {@link Story} from the cache, if needed.
* <p>
* @throws IOException
* in case of I/O error
*/
+ @SuppressWarnings("unused")
public void clearFromCache(String luid) throws IOException {
// By default, this is a noop.
}
/**
- * List all the known types (sources) of stories.
- *
- * @return the sources
- *
- * @throws IOException
- * in case of IOException
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getSources()} instead.
*/
- public synchronized List<String> getSources() throws IOException {
- List<String> list = new ArrayList<String>();
- for (MetaData meta : getMetas(null)) {
- String storySource = meta.getSource();
- if (!list.contains(storySource)) {
- list.add(storySource);
- }
- }
-
- Collections.sort(list);
- return list;
+ @Deprecated
+ public List<String> getSources() throws IOException {
+ return getList().getSources();
}
/**
- * List all the known types (sources) of stories, grouped by directory
- * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
- * <p>
- * Note that an empty item in the list means a non-grouped source (type) --
- * e.g., you could have for Source_1:
- * <ul>
- * <li><tt></tt>: empty, so source is "Source_1"</li>
- * <li><tt>a</tt>: empty, so source is "Source_1/a"</li>
- * <li><tt>b</tt>: empty, so source is "Source_1/b"</li>
- * </ul>
- *
- * @return the grouped list
- *
- * @throws IOException
- * in case of IOException
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getSourcesGrouped()} instead.
*/
- public synchronized Map<String, List<String>> getSourcesGrouped()
- throws IOException {
- Map<String, List<String>> map = new TreeMap<String, List<String>>();
- for (String source : getSources()) {
- String name;
- String subname;
-
- int pos = source.indexOf('/');
- if (pos > 0 && pos < source.length() - 1) {
- name = source.substring(0, pos);
- subname = source.substring(pos + 1);
-
- } else {
- name = source;
- subname = "";
- }
-
- List<String> list = map.get(name);
- if (list == null) {
- list = new ArrayList<String>();
- map.put(name, list);
- }
- list.add(subname);
- }
-
- return map;
+ @Deprecated
+ public Map<String, List<String>> getSourcesGrouped() throws IOException {
+ return getList().getSourcesGrouped();
}
/**
- * List all the known authors of stories.
- *
- * @return the authors
- *
- * @throws IOException
- * in case of IOException
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getAuthors()} instead.
*/
- public synchronized List<String> getAuthors() throws IOException {
- List<String> list = new ArrayList<String>();
- for (MetaData meta : getMetas(null)) {
- String storyAuthor = meta.getAuthor();
- if (!list.contains(storyAuthor)) {
- list.add(storyAuthor);
- }
- }
-
- Collections.sort(list);
- return list;
+ @Deprecated
+ public List<String> getAuthors() throws IOException {
+ return getList().getAuthors();
}
/**
- * Return the list of authors, grouped by starting letter(s) if needed.
- * <p>
- * If the number of author is not too high, only one group with an empty
- * name and all the authors will be returned.
- * <p>
- * If not, the authors will be separated into groups:
- * <ul>
- * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
- * </li>
- * <li><tt>0-9</tt>: any authors whose name starts with a number</li>
- * <li><tt>A-C</tt> (for instance): any author whose name starts with
- * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
- * </ul>
- * Note that the letters used in the groups can vary (except <tt>*</tt> and
- * <tt>0-9</tt>, which may only be present or not).
- *
- * @return the authors' names, grouped by letter(s)
- *
- * @throws IOException
- * in case of IOException
+ * @deprecated please use {@link BasicLibrary#getList()} and
+ * {@link MetaResultList#getAuthorsGrouped()} instead.
*/
public Map<String, List<String>> getAuthorsGrouped() throws IOException {
- int MAX = 20;
-
- Map<String, List<String>> groups = new TreeMap<String, List<String>>();
- List<String> authors = getAuthors();
-
- // If all authors fit the max, just report them as is
- if (authors.size() <= MAX) {
- groups.put("", authors);
- return groups;
- }
-
- // Create groups A to Z, which can be empty here
- for (char car = 'A'; car <= 'Z'; car++) {
- groups.put(Character.toString(car), getAuthorsGroup(authors, car));
- }
-
- // Collapse them
- List<String> keys = new ArrayList<String>(groups.keySet());
- for (int i = 0; i + 1 < keys.size(); i++) {
- String keyNow = keys.get(i);
- String keyNext = keys.get(i + 1);
-
- List<String> now = groups.get(keyNow);
- List<String> next = groups.get(keyNext);
-
- int currentTotal = now.size() + next.size();
- if (currentTotal <= MAX) {
- String key = keyNow.charAt(0) + "-"
- + keyNext.charAt(keyNext.length() - 1);
-
- List<String> all = new ArrayList<String>();
- all.addAll(now);
- all.addAll(next);
-
- groups.remove(keyNow);
- groups.remove(keyNext);
- groups.put(key, all);
-
- keys.set(i, key); // set the new key instead of key(i)
- keys.remove(i + 1); // remove the next, consumed key
- i--; // restart at key(i)
- }
- }
-
- // Add "special" groups
- groups.put("*", getAuthorsGroup(authors, '*'));
- groups.put("0-9", getAuthorsGroup(authors, '0'));
-
- // Prune empty groups
- keys = new ArrayList<String>(groups.keySet());
- for (String key : keys) {
- if (groups.get(key).isEmpty()) {
- groups.remove(key);
- }
- }
-
- return groups;
- }
-
- /**
- * Get all the authors that start with the given character:
- * <ul>
- * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
- * </li>
- * <li><tt>0</tt>: any authors whose name starts with a number</li>
- * <li><tt>A</tt> (any capital latin letter): any author whose name starts
- * with <tt>A</tt></li>
- * </ul>
- *
- * @param authors
- * the full list of authors
- * @param car
- * the starting character, <tt>*</tt>, <tt>0</tt> or a capital
- * letter
- *
- * @return the authors that fulfil the starting letter
- */
- private List<String> getAuthorsGroup(List<String> authors, char car) {
- List<String> accepted = new ArrayList<String>();
- for (String author : authors) {
- char first = '*';
- for (int i = 0; first == '*' && i < author.length(); i++) {
- String san = StringUtils.sanitize(author, true, true);
- char c = san.charAt(i);
- if (c >= '0' && c <= '9') {
- first = '0';
- } else if (c >= 'a' && c <= 'z') {
- first = (char) (c - 'a' + 'A');
- } else if (c >= 'A' && c <= 'Z') {
- first = c;
- }
- }
-
- if (first == car) {
- accepted.add(author);
- }
- }
-
- return accepted;
+ return getList().getAuthorsGrouped();
}
/**
* cover image <b>MAY</b> not be included.
*
* @param luid
- * the Library UID of the story
+ * the Library UID of the story, can be NULL
*
- * @return the corresponding {@link Story}
+ * @return the corresponding {@link Story} or NULL if not found
*
* @throws IOException
* in case of IOException
*/
- public synchronized MetaData getInfo(String luid) throws IOException {
+ public MetaData getInfo(String luid) throws IOException {
if (luid != null) {
for (MetaData meta : getMetas(null)) {
if (luid.equals(meta.getLuid())) {
* @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) {
* 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
* @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) {
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);
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();
*/
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 {
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;
}
* 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 + ")");
}
/**
public class CacheLibrary extends BasicLibrary {
private List<MetaData> metasReal;
private List<MetaData> metasMixed;
+ private Object metasLock = new Object();
+
private BasicLibrary lib;
private LocalLibrary cacheLib;
/**
* Create a cache library around the given one.
* <p>
- * It will return the same result, but those will be saved to disk at the same
- * time to be fetched quicker the next time.
+ * It will return the same result, but those will be saved to disk at the
+ * same time to be fetched quicker the next time.
*
- * @param cacheDir the cache directory where to save the files to disk
- * @param lib the original library to wrap
- * @param config the configuration used to know which kind of default
- * {@link OutputType} to use for images and non-images stories
+ * @param cacheDir
+ * the cache directory where to save the files to disk
+ * @param lib
+ * the original library to wrap
+ * @param config
+ * the configuration used to know which kind of default
+ * {@link OutputType} to use for images and non-images stories
*/
- public CacheLibrary(File cacheDir, BasicLibrary lib, UiConfigBundle config) {
+ public CacheLibrary(File cacheDir, BasicLibrary lib,
+ UiConfigBundle config) {
this.cacheLib = new LocalLibrary(cacheDir, //
config.getString(UiConfig.GUI_NON_IMAGES_DOCUMENT_TYPE),
config.getString(UiConfig.GUI_IMAGES_DOCUMENT_TYPE), true);
}
@Override
- protected synchronized List<MetaData> getMetas(Progress pg) throws IOException {
- // We make sure that cached metas have precedence
-
+ protected List<MetaData> getMetas(Progress pg) throws IOException {
if (pg == null) {
pg = new Progress();
}
- if (metasMixed == null) {
- if (metasReal == null) {
- metasReal = lib.getMetas(pg);
- }
-
- metasMixed = new ArrayList<MetaData>();
- TreeSet<String> cachedLuids = new TreeSet<String>();
- for (MetaData cachedMeta : cacheLib.getMetas(null)) {
- metasMixed.add(cachedMeta);
- cachedLuids.add(cachedMeta.getLuid());
- }
- for (MetaData realMeta : metasReal) {
- if (!cachedLuids.contains(realMeta.getLuid())) {
- metasMixed.add(realMeta);
+ List<MetaData> copy;
+ synchronized (metasLock) {
+ // We make sure that cached metas have precedence
+ if (metasMixed == null) {
+ if (metasReal == null) {
+ metasReal = lib.getMetas(pg);
+ }
+
+ metasMixed = new ArrayList<MetaData>();
+ TreeSet<String> cachedLuids = new TreeSet<String>();
+ for (MetaData cachedMeta : cacheLib.getMetas(null)) {
+ metasMixed.add(cachedMeta);
+ cachedLuids.add(cachedMeta.getLuid());
+ }
+ for (MetaData realMeta : metasReal) {
+ if (!cachedLuids.contains(realMeta.getLuid())) {
+ metasMixed.add(realMeta);
+ }
}
}
+
+ copy = new ArrayList<MetaData>(metasMixed);
}
pg.done();
- return new ArrayList<MetaData>(metasMixed);
+ return copy;
}
@Override
- public synchronized Story getStory(String luid, MetaData meta, Progress pg) throws IOException {
+ public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+ throws IOException {
if (pg == null) {
pg = new Progress();
}
}
@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();
}
* Invalidate the {@link Story} cache (when the content has changed, but we
* already have it) with the new given meta.
* <p>
- * <b>Make sure to always use {@link MetaData} from the cached library
- * in priority, here.</b>
+ * <b>Make sure to always use {@link MetaData} from the cached library in
+ * priority, here.</b>
*
* @param meta
* the {@link Story} to clear from the cache
throw new IOException(
"This method is not supported in a CacheLibrary, please use updateMetaCache");
}
-
+
// relplace the meta in Metas by Meta, add it if needed
// return TRUE = added
private boolean updateMetaCache(List<MetaData> metas, MetaData meta) {
if (meta != null && metas != null) {
- boolean changed = false;
- for (int i = 0; i < metas.size(); i++) {
- if (metas.get(i).getLuid().equals(meta.getLuid())) {
- metas.set(i, meta);
- changed = true;
+ synchronized (metasLock) {
+ boolean changed = false;
+ for (int i = 0; i < metas.size(); i++) {
+ if (metas.get(i).getLuid().equals(meta.getLuid())) {
+ metas.set(i, meta);
+ changed = true;
+ }
}
- }
- if (!changed) {
- metas.add(meta);
- return true;
+ if (!changed) {
+ metas.add(meta);
+ return true;
+ }
}
}
-
+
return false;
}
@Override
protected void invalidateInfo(String luid) {
if (luid == null) {
- metasReal = null;
- metasMixed = null;
+ synchronized (metasLock) {
+ metasReal = null;
+ metasMixed = null;
+ }
} else {
invalidateInfo(metasReal, luid);
invalidateInfo(metasMixed, luid);
// luid cannot be null
private void invalidateInfo(List<MetaData> metas, String luid) {
if (metas != null) {
- for (int i = 0; i < metas.size(); i++) {
- if (metas.get(i).getLuid().equals(luid)) {
- metas.remove(i--);
+ synchronized (metasLock) {
+ for (int i = 0; i < metas.size(); i++) {
+ if (metas.get(i).getLuid().equals(luid)) {
+ metas.remove(i--);
+ }
}
}
}
}
@Override
- public synchronized Story save(Story story, String luid, Progress pg) throws IOException {
+ public synchronized Story save(Story story, String luid, Progress pg)
+ throws IOException {
Progress pgLib = new Progress();
Progress pgCacheLib = new Progress();
story = lib.save(story, luid, pgLib);
updateMetaCache(metasReal, story.getMeta());
-
+
story = cacheLib.save(story, story.getMeta().getLuid(), pgCacheLib);
updateMetaCache(metasMixed, story.getMeta());
}
@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();
}
}
@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();
}
MetaData meta = lib.imprt(url, pgImprt);
updateMetaCache(metasReal, meta);
metasMixed = null;
+
clearFromCache(meta.getLuid());
pg.done();
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;
*/
public class LocalLibrary extends BasicLibrary {
private int lastId;
+ private Object lock = new Object();
private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
private Map<String, Image> sourceCovers;
private Map<String, Image> authorCovers;
/**
* 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);
}
/**
*/
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));
}
}
@Override
- protected synchronized List<MetaData> getMetas(Progress pg) {
+ protected List<MetaData> getMetas(Progress pg) {
return new ArrayList<MetaData>(getStories(pg).keySet());
}
@Override
public File getFile(String luid, Progress pg) throws IOException {
- Instance.getInstance().getTraceHandler().trace(this.getClass().getSimpleName() + ": get file for " + luid);
+ Instance.getInstance().getTraceHandler().trace(
+ this.getClass().getSimpleName() + ": get file for " + luid);
File file = null;
String mess = "no file found for ";
MetaData meta = getInfo(luid);
- File[] files = getStories(pg).get(meta);
- if (files != null) {
- mess = "file retrieved for ";
- file = files[1];
+ if (meta != null) {
+ File[] files = getStories(pg).get(meta);
+ if (files != null) {
+ mess = "file retrieved for ";
+ file = files[1];
+ }
}
Instance.getInstance().getTraceHandler()
- .trace(this.getClass().getSimpleName() + ": " + mess + luid + " (" + meta.getTitle() + ")");
+ .trace(this.getClass().getSimpleName() + ": " + mess + luid
+ + " (" + meta.getTitle() + ")");
return file;
}
}
@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
// 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();
}
@Override
- public synchronized Image getCustomSourceCover(String source) {
- if (sourceCovers == null) {
- sourceCovers = new HashMap<String, Image>();
+ public Image getCustomSourceCover(String source) {
+ synchronized (lock) {
+ if (sourceCovers == null) {
+ sourceCovers = new HashMap<String, Image>();
+ }
}
- Image img = sourceCovers.get(source);
- if (img != null) {
- return img;
+ synchronized (lock) {
+ Image img = sourceCovers.get(source);
+ if (img != null) {
+ return img;
+ }
}
File coverDir = getExpectedDir(source);
try {
in = new FileInputStream(cover);
try {
- sourceCovers.put(source, new Image(in));
+ synchronized (lock) {
+ sourceCovers.put(source, new Image(in));
+ }
} finally {
in.close();
}
e.printStackTrace();
} catch (IOException e) {
Instance.getInstance().getTraceHandler()
- .error(new IOException("Cannot load the existing custom source cover: " + cover, e));
+ .error(new IOException(
+ "Cannot load the existing custom source cover: "
+ + cover,
+ e));
}
}
}
- return sourceCovers.get(source);
+ synchronized (lock) {
+ return sourceCovers.get(source);
+ }
}
@Override
- public synchronized Image getCustomAuthorCover(String author) {
- if (authorCovers == null) {
- authorCovers = new HashMap<String, Image>();
+ public Image getCustomAuthorCover(String author) {
+ synchronized (lock) {
+ if (authorCovers == null) {
+ authorCovers = new HashMap<String, Image>();
+ }
}
- Image img = authorCovers.get(author);
- if (img != null) {
- return img;
+ synchronized (lock) {
+ Image img = authorCovers.get(author);
+ if (img != null) {
+ return img;
+ }
}
File cover = getAuthorCoverFile(author);
try {
in = new FileInputStream(cover);
try {
- authorCovers.put(author, new Image(in));
+ synchronized (lock) {
+ authorCovers.put(author, new Image(in));
+ }
} finally {
in.close();
}
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
* @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);
* @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);
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);
}
/**
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());
}
}
}
- 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()) {
* @return the list of stories (for each item, the first {@link File} is the
* info file, the second file is the target {@link File})
*/
- private synchronized Map<MetaData, File[]> getStories(Progress pg) {
+ private Map<MetaData, File[]> getStories(Progress pg) {
if (pg == null) {
pg = new Progress();
} else {
pg.setMinMax(0, 100);
}
+ Map<MetaData, File[]> stories = this.stories;
if (stories == null) {
- stories = new HashMap<MetaData, File[]>();
+ stories = getStoriesDo(pg);
+ synchronized (lock) {
+ if (this.stories == null)
+ this.stories = stories;
+ else
+ stories = this.stories;
+ }
+ }
+
+ pg.done();
+ return stories;
- lastId = 0;
+ }
- File[] dirs = baseDir.listFiles(new FileFilter() {
- @Override
- public boolean accept(File file) {
- return file != null && file.isDirectory();
- }
- });
+ /**
+ * Actually do the work of {@link LocalLibrary#getStories(Progress)} (i.e.,
+ * do not retrieve the cache).
+ *
+ * @param pg
+ * the optional {@link Progress}
+ *
+ * @return the list of stories (for each item, the first {@link File} is the
+ * info file, the second file is the target {@link File})
+ */
+ private synchronized Map<MetaData, File[]> getStoriesDo(Progress pg) {
+ if (pg == null) {
+ pg = new Progress();
+ } else {
+ pg.setMinMax(0, 100);
+ }
- if (dirs != null) {
- Progress pgDirs = new Progress(0, 100 * dirs.length);
- pg.addProgress(pgDirs, 100);
+ Map<MetaData, File[]> stories = new HashMap<MetaData, File[]>();
- for (File dir : dirs) {
- Progress pgFiles = new Progress();
- pgDirs.addProgress(pgFiles, 100);
- pgDirs.setName("Loading from: " + dir.getName());
+ File[] dirs = baseDir.listFiles(new FileFilter() {
+ @Override
+ public boolean accept(File file) {
+ return file != null && file.isDirectory();
+ }
+ });
- addToStories(pgFiles, dir);
+ if (dirs != null) {
+ Progress pgDirs = new Progress(0, 100 * dirs.length);
+ pg.addProgress(pgDirs, 100);
- pgFiles.setName(null);
- }
+ for (File dir : dirs) {
+ Progress pgFiles = new Progress();
+ pgDirs.addProgress(pgFiles, 100);
+ pgDirs.setName("Loading from: " + dir.getName());
+
+ addToStories(stories, pgFiles, dir);
- pgDirs.setName("Loading directories");
+ pgFiles.setName(null);
}
+
+ pgDirs.setName("Loading directories");
}
pg.done();
+
return stories;
}
- private void addToStories(Progress pgFiles, File dir) {
+ private void addToStories(Map<MetaData, File[]> stories, Progress pgFiles,
+ File dir) {
File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
@Override
public boolean accept(File file) {
}
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) {
} 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));
}
}
package be.nikiroo.fanfix.library;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Comparator;
import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.StringUtils;
public class MetaResultList {
+ /** Max number of items before splitting in [A-B] etc. for eligible items */
+ static private final int MAX = 20;
+
private List<MetaData> metas;
// Lazy lists:
if (!sources.contains(meta.getSource()))
sources.add(meta.getSource());
}
+ sort(sources);
}
return sources;
}
}
+ sort(linked);
return linked;
}
+ /**
+ * List all the known types (sources) of stories, grouped by directory
+ * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
+ * <p>
+ * Note that an empty item in the list means a non-grouped source (type) --
+ * e.g., you could have for Source_1:
+ * <ul>
+ * <li><tt></tt>: empty, so source is "Source_1"</li>
+ * <li><tt>a</tt>: empty, so source is "Source_1/a"</li>
+ * <li><tt>b</tt>: empty, so source is "Source_1/b"</li>
+ * </ul>
+ *
+ * @return the grouped list
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Map<String, List<String>> getSourcesGrouped() throws IOException {
+ Map<String, List<String>> map = new TreeMap<String, List<String>>();
+ for (String source : getSources()) {
+ String name;
+ String subname;
+
+ int pos = source.indexOf('/');
+ if (pos > 0 && pos < source.length() - 1) {
+ name = source.substring(0, pos);
+ subname = source.substring(pos + 1);
+
+ } else {
+ name = source;
+ subname = "";
+ }
+
+ List<String> list = map.get(name);
+ if (list == null) {
+ list = new ArrayList<String>();
+ map.put(name, list);
+ }
+ list.add(subname);
+ }
+
+ return map;
+ }
+
public List<String> getAuthors() {
if (authors == null) {
authors = new ArrayList<String>();
if (!authors.contains(meta.getAuthor()))
authors.add(meta.getAuthor());
}
+ sort(authors);
}
return authors;
}
+ /**
+ * Return the list of authors, grouped by starting letter(s) if needed.
+ * <p>
+ * If the number of authors is not too high, only one group with an empty
+ * name and all the authors will be returned.
+ * <p>
+ * If not, the authors will be separated into groups:
+ * <ul>
+ * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
+ * </li>
+ * <li><tt>0-9</tt>: any author whose name starts with a number</li>
+ * <li><tt>A-C</tt> (for instance): any author whose name starts with
+ * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+ * </ul>
+ * Note that the letters used in the groups can vary (except <tt>*</tt> and
+ * <tt>0-9</tt>, which may only be present or not).
+ *
+ * @return the authors' names, grouped by letter(s)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Map<String, List<String>> getAuthorsGrouped() throws IOException {
+ return group(getAuthors());
+ }
+
public List<String> getTags() {
if (tags == null) {
tags = new ArrayList<String>();
tags.add(tag);
}
}
+ sort(tags);
}
- return authors;
+ return tags;
+ }
+
+ /**
+ * Return the list of tags, grouped by starting letter(s) if needed.
+ * <p>
+ * If the number of tags is not too high, only one group with an empty name
+ * and all the tags will be returned.
+ * <p>
+ * If not, the tags will be separated into groups:
+ * <ul>
+ * <li><tt>*</tt>: any tag which name doesn't contain letters nor numbers
+ * </li>
+ * <li><tt>0-9</tt>: any tag which name starts with a number</li>
+ * <li><tt>A-C</tt> (for instance): any tag which name starts with
+ * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+ * </ul>
+ * Note that the letters used in the groups can vary (except <tt>*</tt> and
+ * <tt>0-9</tt>, which may only be present or not).
+ *
+ * @return the tags' names, grouped by letter(s)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ public Map<String, List<String>> getTagsGrouped() throws IOException {
+ return group(getTags());
}
// helper
}
// null or empty -> no check, rest = must be included
- // source: a source ending in "/" means "this or any source starting with this",
+ // source: a source ending in "/" means "this or any source starting with
+ // this",
// i;e., to enable source hierarchy
// + sorted
- public List<MetaData> filter(List<String> sources, List<String> authors, List<String> tags) {
+ public List<MetaData> filter(List<String> sources, List<String> authors,
+ List<String> tags) {
if (sources != null && sources.isEmpty())
sources = null;
if (authors != null && authors.isEmpty())
Collections.sort(result);
return result;
}
+
+ /**
+ * Return the list of values, grouped by starting letter(s) if needed.
+ * <p>
+ * If the number of values is not too high, only one group with an empty
+ * name and all the values will be returned (see
+ * {@link MetaResultList#MAX}).
+ * <p>
+ * If not, the values will be separated into groups:
+ * <ul>
+ * <li><tt>*</tt>: any value which name doesn't contain letters nor numbers
+ * </li>
+ * <li><tt>0-9</tt>: any value which name starts with a number</li>
+ * <li><tt>A-C</tt> (for instance): any value which name starts with
+ * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+ * </ul>
+ * Note that the letters used in the groups can vary (except <tt>*</tt> and
+ * <tt>0-9</tt>, which may only be present or not).
+ *
+ * @param values
+ * the values to group
+ *
+ * @return the values, grouped by letter(s)
+ *
+ * @throws IOException
+ * in case of IOException
+ */
+ private Map<String, List<String>> group(List<String> values)
+ throws IOException {
+ Map<String, List<String>> groups = new TreeMap<String, List<String>>();
+
+ // If all authors fit the max, just report them as is
+ if (values.size() <= MAX) {
+ groups.put("", values);
+ return groups;
+ }
+
+ // Create groups A to Z, which can be empty here
+ for (char car = 'A'; car <= 'Z'; car++) {
+ groups.put(Character.toString(car), find(values, car));
+ }
+
+ // Collapse them
+ List<String> keys = new ArrayList<String>(groups.keySet());
+ for (int i = 0; i + 1 < keys.size(); i++) {
+ String keyNow = keys.get(i);
+ String keyNext = keys.get(i + 1);
+
+ List<String> now = groups.get(keyNow);
+ List<String> next = groups.get(keyNext);
+
+ int currentTotal = now.size() + next.size();
+ if (currentTotal <= MAX) {
+ String key = keyNow.charAt(0) + "-"
+ + keyNext.charAt(keyNext.length() - 1);
+
+ List<String> all = new ArrayList<String>();
+ all.addAll(now);
+ all.addAll(next);
+
+ groups.remove(keyNow);
+ groups.remove(keyNext);
+ groups.put(key, all);
+
+ keys.set(i, key); // set the new key instead of key(i)
+ keys.remove(i + 1); // remove the next, consumed key
+ i--; // restart at key(i)
+ }
+ }
+
+ // Add "special" groups
+ groups.put("*", find(values, '*'));
+ groups.put("0-9", find(values, '0'));
+
+ // Prune empty groups
+ keys = new ArrayList<String>(groups.keySet());
+ for (String key : keys) {
+ if (groups.get(key).isEmpty()) {
+ groups.remove(key);
+ }
+ }
+
+ return groups;
+ }
+
+ /**
+ * Get all the authors that start with the given character:
+ * <ul>
+ * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
+ * </li>
+ * <li><tt>0</tt>: any authors whose name starts with a number</li>
+ * <li><tt>A</tt> (any capital latin letter): any author whose name starts
+ * with <tt>A</tt></li>
+ * </ul>
+ *
+ * @param values
+ * the full list of authors
+ * @param car
+ * the starting character, <tt>*</tt>, <tt>0</tt> or a capital
+ * letter
+ *
+ * @return the authors that fulfil the starting letter
+ */
+ private List<String> find(List<String> values, char car) {
+ List<String> accepted = new ArrayList<String>();
+ for (String value : values) {
+ char first = '*';
+ for (int i = 0; first == '*' && i < value.length(); i++) {
+ String san = StringUtils.sanitize(value, true, true);
+ char c = san.charAt(i);
+ if (c >= '0' && c <= '9') {
+ first = '0';
+ } else if (c >= 'a' && c <= 'z') {
+ first = (char) (c - 'a' + 'A');
+ } else if (c >= 'A' && c <= 'Z') {
+ first = c;
+ }
+ }
+
+ if (first == car) {
+ accepted.add(value);
+ }
+ }
+
+ return accepted;
+ }
+
+ /**
+ * Sort the given {@link String} values, ignoring case.
+ *
+ * @param values
+ * the values to sort
+ */
+ private void sort(List<String> values) {
+ Collections.sort(values, new Comparator<String>() {
+ @Override
+ public int compare(String o1, String o2) {
+ return ("" + o1).compareToIgnoreCase("" + o2);
+ }
+ });
+ }
}
}
@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");
}
}
@Override
- public synchronized MetaData getInfo(String luid) throws IOException {
+ public MetaData getInfo(String luid) throws IOException {
List<MetaData> metas = getMetasList(luid, null);
if (!metas.isEmpty()) {
return metas.get(0);
}
@Override
- protected synchronized List<MetaData> getMetas(Progress pg) throws IOException {
+ protected List<MetaData> getMetas(Progress pg) throws IOException {
return getMetasList("*", pg);
}
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);
* @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;
}
}
};
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);
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) {
}
}
}
+
+ 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;
+ }
}
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;
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);
return story;
}
+ /**
+ * Utility method to convert the given URL into a JSON object.
+ * <p>
+ * Note that this method expects small JSON files (everything is copied into
+ * memory at least twice).
+ *
+ * @param url
+ * the URL to parse
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the JSON object
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected JSONObject getJson(String url, boolean stable)
+ throws IOException {
+ try {
+ return getJson(new URL(url), stable);
+ } catch (MalformedURLException e) {
+ throw new IOException("Malformed URL: " + url, e);
+ }
+ }
+
+ /**
+ * Utility method to convert the given URL into a JSON object.
+ * <p>
+ * Note that this method expects small JSON files (everything is copied into
+ * memory at least twice).
+ *
+ * @param url
+ * the URL to parse
+ * @param stable
+ * TRUE for more stable resources, FALSE when they often change
+ *
+ * @return the JSON object
+ *
+ * @throws IOException
+ * in case of I/O error
+ */
+ protected JSONObject getJson(URL url, boolean stable) throws IOException {
+ InputStream in = Instance.getInstance().getCache().open(url, null,
+ stable);
+ try {
+ Scanner scan = new Scanner(in);
+ scan.useDelimiter("\0");
+ try {
+ return new JSONObject(scan.next());
+ } finally {
+ scan.close();
+ }
+ } finally {
+ in.close();
+ }
+ }
+
/**
* Process the given story resource into a fully filled {@link Story}
* object.
} 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);
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
return author;
}
+
+ /**
+ * Try to convert the date to a known, fixed format.
+ * <p>
+ * If it fails to do so, it will return the date as-is.
+ *
+ * @param date
+ * the date to convert
+ *
+ * @return the converted date, or the date as-is
+ */
+ public String formatDate(String date) {
+ long ms = 0;
+
+ if (date != null && !date.isEmpty()) {
+ // Default Fanfix format:
+ try {
+ ms = StringUtils.toTime(date);
+ } catch (ParseException e) {
+ }
+
+ // Second chance:
+ if (ms <= 0) {
+ SimpleDateFormat sdf = new SimpleDateFormat(
+ "yyyy-MM-dd'T'HH:mm:ssSSS");
+ try {
+ ms = sdf.parse(date).getTime();
+ } catch (ParseException e) {
+ }
+ }
+
+ // Last chance:
+ if (ms <= 0 && date.length() >= 10) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ try {
+ ms = sdf.parse(date.substring(0, 10)).getTime();
+ } catch (ParseException e) {
+ }
+ }
+
+ // If we found something, use THIS format:
+ if (ms > 0) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ return sdf.format(new Date(ms));
+ }
+ }
+
+ if (date == null) {
+ date = "";
+ }
+
+ // :(
+ return date;
+ }
}
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);
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();
} else {
pg.setMinMax(0, 100);
}
+
+ pg.setName("Initialising");
Progress pgMeta = new Progress();
pg.addProgress(pgMeta, 10);
MetaData meta = story.getMeta();
pgMeta.done(); // 10%
-
+
File tmpDir = Instance.getInstance().getTempFiles().createTempDir("info-text");
String basename = null;
}
}
- pg.setProgress(100);
+ pg.done();
return story;
}
package be.nikiroo.fanfix.supported;
import java.io.IOException;
-import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Map.Entry;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
import org.jsoup.helper.DataUtil;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
import be.nikiroo.fanfix.data.MetaData;
-import be.nikiroo.utils.IOUtils;
import be.nikiroo.utils.Image;
import be.nikiroo.utils.Progress;
import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
/**
* Support class for <a href="http://e621.net/">e621.net</a> and
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
meta.setTitle(getTitle());
meta.setAuthor(getAuthor());
- meta.setDate("");
+ meta.setDate(bsHelper.formatDate(getDate()));
meta.setTags(getTags());
meta.setSource(getType().getSourceName());
meta.setUrl(getSource().toString());
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);
}
@Override
- protected List<Entry<String, URL>> getChapters(Progress pg) throws IOException {
- if (isPool(getSource())) {
- String baseUrl = "https://e621.net/" + getSource().getPath() + "?page=";
- return getChapters(getSource(), pg, baseUrl, "");
- } else if (isSearchOrSet(getSource())) {
- String baseUrl = "https://e621.net/posts/?page=";
- String search = "&tags=" + getTagsFromUrl(getSource());
- return getChapters(getSource(), pg, baseUrl, search);
- }
-
- return new LinkedList<Entry<String, URL>>();
- }
-
- private List<Entry<String, URL>> getChapters(URL source, Progress pg, String baseUrl, String parameters)
+ protected List<Entry<String, URL>> getChapters(Progress pg)
throws IOException {
- List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
-
- if (source.getHost().contains("e926")) {
- baseUrl = baseUrl.replace("e621", "e926");
- }
+ int i = 1;
+ String jsonUrl = getJsonUrl();
+ if (jsonUrl != null) {
+ for (i = 1; true; i++) {
+ if (i > 1) {
+ try {
+ // The API does not accept more than 2 request per sec,
+ // and asks us to limit at one per sec when possible
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ }
+ }
- for (int i = 1; true; i++) {
- URL url = new URL(baseUrl + i + parameters);
- try {
- InputStream pageI = Instance.getInstance().getCache().open(url, this, false);
try {
- if (IOUtils.readSmallStream(pageI).contains("Nobody here but us chickens!")) {
+ JSONObject json = getJson(jsonUrl + "&page=" + i, false);
+ if (!json.has("posts"))
break;
- }
- urls.add(new AbstractMap.SimpleEntry<String, URL>("Page " + Integer.toString(i), url));
- } finally {
- pageI.close();
+ JSONArray posts = json.getJSONArray("posts");
+ if (posts.isEmpty())
+ break;
+ } catch (Exception e) {
+ e.printStackTrace();
}
- } catch (Exception e) {
- break;
}
+
+ // The last page was empty:
+ i--;
}
- // They are sorted in reverse order on the website
- Collections.reverse(urls);
- return urls;
+ // The pages and images are in reverse order on /posts/
+ List<Entry<String, URL>> chapters = new LinkedList<Entry<String, URL>>();
+ for (int page = i; page > 0; page--) {
+ chapters.add(new AbstractMap.SimpleEntry<String, URL>(
+ "Page " + Integer.toString(i - page + 1),
+ new URL(jsonUrl + "&page=" + page)));
+ }
+
+ return chapters;
}
@Override
- protected String getChapterContent(URL chapUrl, int number, Progress pg) throws IOException {
+ protected String getChapterContent(URL chapUrl, int number, Progress pg)
+ throws IOException {
StringBuilder builder = new StringBuilder();
- Document chapterNode = loadDocument(chapUrl);
- for (Element el : chapterNode.getElementsByTag("article")) {
- builder.append("[");
- builder.append(el.attr("data-file-url"));
- builder.append("]<br/>");
+
+ JSONObject json = getJson(chapUrl, false);
+ JSONArray postsArr = json.getJSONArray("posts");
+
+ // The pages and images are in reverse order on /posts/
+ List<JSONObject> posts = new ArrayList<JSONObject>(postsArr.length());
+ for (int i = postsArr.length() - 1; i >= 0; i--) {
+ Object o = postsArr.get(i);
+ if (o instanceof JSONObject)
+ posts.add((JSONObject) o);
+ }
+
+ for (JSONObject post : posts) {
+ if (!post.has("file"))
+ continue;
+ JSONObject file = post.getJSONObject("file");
+ if (!file.has("url"))
+ continue;
+
+ try {
+ String url = file.getString("url");
+ builder.append("[");
+ builder.append(url);
+ builder.append("]<br/>");
+ } catch (JSONException e) {
+ // Can be NULL if filtered
+ // When the value is NULL, we get an exception
+ // but the "has" method still returns true
+ Instance.getInstance().getTraceHandler()
+ .error("Cannot get image for chapter " + number + " of "
+ + getSource());
+ }
}
return builder.toString();
@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"));
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) {
}
}
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 = "";
title = el.text().trim();
}
- for (String s : new String[] { "e621", "-", "e621" }) {
+ for (String s : new String[] { "e621", "-", "e621", "Pool", "-" }) {
if (title.startsWith(s)) {
title = title.substring(s.length()).trim();
}
if (title.endsWith(s)) {
title = title.substring(0, title.length() - s.length()).trim();
}
-
}
if (isSearchOrSet(getSource())) {
title = title.isEmpty() ? "e621" : "[e621] " + title;
}
+
return title;
}
- private String getAuthor() throws IOException {
- StringBuilder builder = new StringBuilder();
-
- if (isSearchOrSet(getSource())) {
- for (Element el : getSourceNode().getElementsByClass("search-tag")) {
- if (el.attr("itemprop").equals("author")) {
- if (builder.length() > 0) {
- builder.append(", ");
+ private String getAuthor() {
+ List<String> list = new ArrayList<String>();
+ String jsonUrl = getJsonUrl();
+ if (jsonUrl != null) {
+ try {
+ JSONObject json = getJson(jsonUrl, false);
+ JSONArray posts = json.getJSONArray("posts");
+ for (Object obj : posts) {
+ if (!(obj instanceof JSONObject))
+ continue;
+
+ JSONObject post = (JSONObject) obj;
+ if (!post.has("tags"))
+ continue;
+
+ JSONObject tags = post.getJSONObject("tags");
+ if (!tags.has("artist"))
+ continue;
+
+ JSONArray artists = tags.getJSONArray("artist");
+ for (Object artist : artists) {
+ if (list.contains(artist.toString()))
+ continue;
+
+ list.add(artist.toString());
}
- builder.append(el.text().trim());
}
+ } catch (Exception e) {
+ e.printStackTrace();
}
}
- if (isPool(getSource())) {
- String desc = getDesc();
- String descL = desc.toLowerCase();
+ StringBuilder builder = new StringBuilder();
+ for (String artist : list) {
+ if (builder.length() > 0) {
+ builder.append(", ");
+ }
+ builder.append(artist);
+ }
- if (descL.startsWith("by:") || descL.startsWith("by ")) {
- desc = desc.substring(3).trim();
- desc = desc.split("\n")[0];
+ return builder.toString();
+ }
- String tab[] = desc.split(" ");
- for (int i = 0; i < Math.min(tab.length, 5); i++) {
- if (tab[i].startsWith("http"))
- break;
- builder.append(" ").append(tab[i]);
+ private String getDate() {
+ String jsonUrl = getJsonUrl();
+ if (jsonUrl != null) {
+ try {
+ JSONObject json = getJson(jsonUrl, false);
+ JSONArray posts = json.getJSONArray("posts");
+ for (Object obj : posts) {
+ if (!(obj instanceof JSONObject))
+ continue;
+
+ JSONObject post = (JSONObject) obj;
+ if (!post.has("created_at"))
+ continue;
+
+ return post.getString("created_at");
}
+ } catch (Exception e) {
+ e.printStackTrace();
}
}
- return builder.toString();
+ return "";
}
// no tags for pools
return tags;
}
+ // returns "xxx+ddd+ggg" if "tags=xxx+ddd+ggg" was present in the query
+ private String getTagsFromUrl(URL url) {
+ String tags = url == null ? "" : url.getQuery();
+ int pos = tags.indexOf("tags=");
+
+ if (pos >= 0) {
+ tags = tags.substring(pos).substring("tags=".length());
+ } else {
+ return "";
+ }
+
+ pos = tags.indexOf('&');
+ if (pos > 0) {
+ tags = tags.substring(0, pos);
+ }
+ pos = tags.indexOf('/');
+ if (pos > 0) {
+ tags = tags.substring(0, pos);
+ }
+
+ return tags;
+ }
+
private Image getCover() throws IOException {
Image image = null;
List<Entry<String, URL>> chapters = getChapters(null);
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
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;
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;
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");
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();
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");
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);
if (cover != null) {
meta.setCover(cover);
} else {
- meta.setCover(InfoReader
- .getCoverByName(getSourceFileOriginal().toURI()
- .toURL()));
+ meta.setCover(InfoReader.getCoverByName(
+ getSourceFileOriginal().toURI().toURL()));
}
}
} finally {
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());
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 {
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();
}
+ 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"));
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
} 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) {
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,
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;
}
}
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;
}
@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
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());
}
}
- 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;
}
meta.setTitle(getTitle());
meta.setAuthor(getAuthor());
- meta.setDate(getDate());
+ meta.setDate(bsHelper.formatDate(getDate()));
meta.setTags(new ArrayList<String>());
meta.setSource(getType().getSourceName());
meta.setUrl(getSourceFile().toURI().toURL().toString());
meta.setType(getType().toString());
meta.setImageDocument(false);
meta.setCover(getCover(getSourceFile()));
-
+
return meta;
}
return content;
}
- private Image getCover(File sourceFile) {
+ protected Image getCover(File sourceFile) {
String path = sourceFile.getName();
for (String ext : new String[] { ".txt", ".text", ".story" }) {
}
/**
- * Remove the ".txt" extension if it is present.
+ * Remove the ".txt" (or ".text") extension if it is present.
*
* @param file
* the file to process
* 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;
/**
* Progress reporting system, possibly nested.
+ * <p>
+ * A {@link Progress} can have a name, and that name will be reported through
+ * the event system (it will report the first non-null name in the stack from
+ * the {@link Progress} from which the event originated to the parent the event
+ * is listened on).
+ * <p>
+ * The {@link Progress} also has a table of keys/values shared amongst all the
+ * hierarchy (note that when adding a {@link Progress} to others, its values
+ * will be prioritized if some with the same keys were already present in the
+ * hierarchy).
*
* @author niki
*/
public void progress(Progress progress, String name);
}
+ private Map<Object, Object> map = new HashMap<Object, Object>();
private Progress parent = null;
private Object lock = new Object();
private String name;
};
synchronized (lock) {
+ // Should not happen but just in case
+ if (this.map != progress.map) {
+ this.map.putAll(progress.map);
+ }
+ progress.map = this.map;
progress.parent = this;
this.children.put(progress, weight);
progress.addProgressListener(progressListener);
}
}
+
+ /**
+ * Set the given value for the given key on this {@link Progress} and it's
+ * children.
+ *
+ * @param key
+ * the key
+ * @param value
+ * the value
+ */
+ public void put(Object key, Object value) {
+ map.put(key, value);
+ }
+
+ /**
+ * Return the value associated with this key as a {@link String} if any,
+ * NULL if not.
+ * <p>
+ * If the value is not NULL but not a {@link String}, it will be converted
+ * via {@link Object#toString()}.
+ *
+ * @param key
+ * the key to check
+ *
+ * @return the value or NULL
+ */
+ public String getString(Object key) {
+ Object value = map.get(key);
+ if (value == null) {
+ return null;
+ }
+
+ return value.toString();
+ }
+
+ /**
+ * Return the value associated with this key if any, NULL if not.
+ *
+ * @param key
+ * the key to check
+ *
+ * @return the value or NULL
+ */
+ public Object get(Object key) {
+ return map.get(key);
+ }
}
--- /dev/null
+package be.nikiroo.utils.compat;
+
+import javax.swing.DefaultListModel;
+import javax.swing.JList;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link DefaultListModel} that you can parametrise also
+ * in Java 1.6.
+ *
+ * @author niki
+ *
+ * @param <E>
+ * the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public class DefaultListModel6<E> extends DefaultListModel
+ implements ListModel6<E> {
+ private static final long serialVersionUID = 1L;
+}
--- /dev/null
+package be.nikiroo.utils.compat;
+
+import javax.swing.JList;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListModel;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link JList} that you can parametrise also in Java
+ * 1.6.
+ *
+ * @author niki
+ *
+ * @param <E>
+ * the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public class JList6<E> extends JList {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ @Deprecated
+ /**
+ * @deprecated please use {@link JList6#setCellRenderer(ListCellRenderer6)}
+ * instead
+ */
+ public void setCellRenderer(ListCellRenderer cellRenderer) {
+ super.setCellRenderer(cellRenderer);
+ }
+
+ /**
+ * Sets the delegate that is used to paint each cell in the list. The job of
+ * a cell renderer is discussed in detail in the <a href="#renderer">class
+ * level documentation</a>.
+ * <p>
+ * If the {@code prototypeCellValue} property is {@code non-null}, setting
+ * the cell renderer also causes the {@code fixedCellWidth} and
+ * {@code fixedCellHeight} properties to be re-calculated. Only one
+ * <code>PropertyChangeEvent</code> is generated however - for the
+ * <code>cellRenderer</code> property.
+ * <p>
+ * The default value of this property is provided by the {@code ListUI}
+ * delegate, i.e. by the look and feel implementation.
+ * <p>
+ * This is a JavaBeans bound property.
+ *
+ * @param cellRenderer
+ * the <code>ListCellRenderer</code> that paints list cells
+ * @see #getCellRenderer
+ * @beaninfo bound: true attribute: visualUpdate true description: The
+ * component used to draw the cells.
+ */
+ public void setCellRenderer(ListCellRenderer6<E> cellRenderer) {
+ super.setCellRenderer(cellRenderer);
+ }
+
+ @Override
+ @Deprecated
+ public void setModel(ListModel model) {
+ super.setModel(model);
+ }
+
+ /**
+ * Sets the model that represents the contents or "value" of the list,
+ * notifies property change listeners, and then clears the list's selection.
+ * <p>
+ * This is a JavaBeans bound property.
+ *
+ * @param model
+ * the <code>ListModel</code> that provides the list of items for
+ * display
+ * @exception IllegalArgumentException
+ * if <code>model</code> is <code>null</code>
+ * @see #getModel
+ * @see #clearSelection
+ * @beaninfo bound: true attribute: visualUpdate true description: The
+ * object that contains the data to be drawn by this JList.
+ */
+ public void setModel(ListModel6<E> model) {
+ super.setModel(model);
+ }
+}
--- /dev/null
+package be.nikiroo.utils.compat;
+
+import java.awt.Component;
+
+import javax.swing.JList;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListModel;
+import javax.swing.ListSelectionModel;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link ListCellRenderer} that you can parametrise also
+ * in Java 1.6.
+ *
+ * @author niki
+ *
+ * @param <E>
+ * the type to use
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6
+public abstract class ListCellRenderer6<E> implements ListCellRenderer {
+ @Override
+ @Deprecated
+ /**
+ * @deprecated please use@deprecated please use
+ * {@link ListCellRenderer6#getListCellRendererComponent(JList6, Object, int, boolean, boolean)}
+ * instead
+ * {@link ListCellRenderer6#getListCellRendererComponent(JList6, Object, int, boolean, boolean)}
+ * instead
+ */
+ public Component getListCellRendererComponent(JList list, Object value,
+ int index, boolean isSelected, boolean cellHasFocus) {
+ return getListCellRendererComponent((JList6<E>) list, (E) value, index,
+ isSelected, cellHasFocus);
+ }
+
+ /**
+ * Return a component that has been configured to display the specified
+ * value. That component's <code>paint</code> method is then called to
+ * "render" the cell. If it is necessary to compute the dimensions of a list
+ * because the list cells do not have a fixed size, this method is called to
+ * generate a component on which <code>getPreferredSize</code> can be
+ * invoked.
+ *
+ * @param list
+ * The JList we're painting.
+ * @param value
+ * The value returned by list.getModel().getElementAt(index).
+ * @param index
+ * The cells index.
+ * @param isSelected
+ * True if the specified cell was selected.
+ * @param cellHasFocus
+ * True if the specified cell has the focus.
+ * @return A component whose paint() method will render the specified value.
+ *
+ * @see JList
+ * @see ListSelectionModel
+ * @see ListModel
+ */
+ public abstract Component getListCellRendererComponent(JList6<E> list,
+ E value, int index, boolean isSelected, boolean cellHasFocus);
+}
--- /dev/null
+package be.nikiroo.utils.compat;
+
+import javax.swing.JList;
+
+/**
+ * Compatibility layer so I can at least get rid of the warnings of using
+ * {@link JList} without a parameter (and still staying Java 1.6 compatible).
+ * <p>
+ * This class is merely a {@link javax.swing.ListModel} that you can parametrise
+ * also in Java 1.6.
+ *
+ * @author niki
+ *
+ * @param <E>
+ * the type to use
+ */
+@SuppressWarnings("rawtypes") // not compatible Java 1.6
+public interface ListModel6<E> extends javax.swing.ListModel {
+}
* @return what it is
*/
String description() default "";
+
+ /**
+ * This item should be hidden from the user (she will still be able to
+ * modify it if she opens the file manually).
+ * <p>
+ * Defaults to FALSE (visible).
+ *
+ * @return TRUE if it should stay hidden
+ */
+ boolean hidden() default false;
/**
* This item is only used as a group, not as an option.
private List<Runnable> saveListeners = new ArrayList<Runnable>();
private String name;
+ private boolean hidden;
private String description;
private boolean dirty;
}
this.name = name;
+ this.hidden = meta.hidden();
this.description = description;
reload();
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
List<MetaInfo<E>> shadow = new ArrayList<MetaInfo<E>>();
for (E id : type.getEnumConstants()) {
MetaInfo<E> info = new MetaInfo<E>(type, bundle, id);
- list.add(info);
- shadow.add(info);
+ if (!info.hidden) {
+ list.add(info);
+ shadow.add(info);
+ }
}
for (int i = 0; i < list.size(); i++) {
--- /dev/null
+
+package be.nikiroo.utils.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+import javax.swing.BoxLayout;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JToggleButton;
+import javax.swing.SwingWorker;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+
+public class BreadCrumbsBar<T> extends ListenerPanel {
+ private class BreadCrumb extends JPanel {
+ private JToggleButton button;
+ private JToggleButton down;
+
+ public BreadCrumb(final DataNode<T> node) {
+ this.setLayout(new BorderLayout());
+
+ if (!node.isRoot()) {
+ button = new JToggleButton(node.toString());
+ button.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ button.setSelected(false);
+ if (!node.isRoot()) {
+ // TODO: allow clicking on root? option?
+ setSelectedNode(node);
+ }
+ }
+ });
+
+ this.add(button, BorderLayout.CENTER);
+ }
+
+ if (!node.getChildren().isEmpty()) {
+ // TODO (see things with icons included in viewer)
+ down = new JToggleButton(">");
+ final JPopupMenu popup = new JPopupMenu();
+
+ for (final DataNode<T> child : node.getChildren()) {
+ popup.add(new AbstractAction(child.toString()) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ setSelectedNode(child);
+ }
+ });
+ }
+
+ down.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ev) {
+ if (down.isSelected()) {
+ popup.show(down, 0, down.getBounds().height);
+ } else {
+ popup.setVisible(false);
+ }
+ }
+ });
+
+ popup.addPopupMenuListener(new PopupMenuListener() {
+ @Override
+ public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+ }
+
+ @Override
+ public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
+ down.setSelected(false);
+ }
+
+ @Override
+ public void popupMenuCanceled(PopupMenuEvent e) {
+ }
+ });
+
+ this.add(down, BorderLayout.EAST);
+ }
+ }
+ }
+
+ static public final String CHANGE_ACTION = "change";
+
+ private boolean vertical;
+ private DataNode<T> node;
+ private List<BreadCrumb> crumbs = new ArrayList<BreadCrumb>();
+
+ public BreadCrumbsBar(final DataTree<T> tree) {
+ vertical = true; // to force an update
+ setVertical(false);
+
+ addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent e) {
+ super.componentResized(e);
+ synchronized (crumbs) {
+ for (BreadCrumb crumb : crumbs) {
+ setCrumbSize(crumb);
+ }
+ }
+ }
+ });
+
+ new SwingWorker<DataNode<T>, Void>() {
+ @Override
+ protected DataNode<T> doInBackground() throws Exception {
+ tree.loadData();
+ return tree.getRoot();
+ }
+
+ @Override
+ protected void done() {
+ try {
+ node = get();
+ addCrumb(node);
+
+ // TODO: option?
+ if (node.size() > 0) {
+ setSelectedNode(node.getChildren().get(0));
+ } else {
+ revalidate();
+ repaint();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }.execute();
+ }
+
+ public void setVertical(boolean vertical) {
+ if (vertical != this.vertical) {
+ synchronized (crumbs) {
+ this.vertical = vertical;
+
+ for (BreadCrumb crumb : crumbs) {
+ this.remove(crumb);
+ }
+
+ if (vertical) {
+ this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+ } else {
+ this.setLayout(new WrapLayout(WrapLayout.LEADING));
+ }
+
+ for (BreadCrumb crumb : crumbs) {
+ this.add(crumb);
+ setCrumbSize(crumb);
+ }
+ }
+
+ this.revalidate();
+ this.repaint();
+ }
+ }
+
+ public DataNode<T> getSelectedNode() {
+ return node;
+ }
+
+ public void setSelectedNode(DataNode<T> node) {
+ if (this.node == node) {
+ return;
+ }
+
+ synchronized (crumbs) {
+ // clear until common ancestor (can clear all!)
+ while (this.node != null && !this.node.isParentOf(node)) {
+ this.node = this.node.getParent();
+ this.remove(crumbs.remove(crumbs.size() - 1));
+ }
+
+ // switch root if needed and possible
+ if (this.node == null && node != null) {
+ this.node = node.getRoot();
+ addCrumb(this.node);
+ }
+
+ // re-create until node
+ while (node != null && this.node != node) {
+ DataNode<T> ancestorOrNode = node;
+ for (DataNode<T> child : this.node.getChildren()) {
+ if (child.isParentOf(node))
+ ancestorOrNode = child;
+ }
+
+ this.node = ancestorOrNode;
+ addCrumb(this.node);
+ }
+ }
+
+ this.revalidate();
+ this.repaint();
+
+ fireActionPerformed(CHANGE_ACTION);
+ }
+
+ private void addCrumb(DataNode<T> node) {
+ BreadCrumb crumb = new BreadCrumb(node);
+ this.crumbs.add(crumb);
+ setCrumbSize(crumb);
+ this.add(crumb);
+ }
+
+ private void setCrumbSize(BreadCrumb crumb) {
+ if (vertical) {
+ crumb.setMaximumSize(new Dimension(this.getWidth(),
+ crumb.getMinimumSize().height));
+ } else {
+ crumb.setMaximumSize(null);
+ }
+ }
+}
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.swing.Icon;
+
+public class DataNode<T> {
+ private DataNode<T> parent;
+ private List<? extends DataNode<T>> children;
+ private T userData;
+
+ public DataNode(List<? extends DataNode<T>> children, T userData) {
+ if (children == null) {
+ children = new ArrayList<DataNode<T>>();
+ }
+
+ this.children = children;
+ this.userData = userData;
+
+ for (DataNode<T> child : children) {
+ child.parent = this;
+ }
+ }
+
+ public DataNode<T> getRoot() {
+ DataNode<T> root = this;
+ while (root.parent != null) {
+ root = root.parent;
+ }
+
+ return root;
+ }
+
+ public DataNode<T> getParent() {
+ return parent;
+ }
+
+ public List<? extends DataNode<T>> getChildren() {
+ return children;
+ }
+
+ public int size() {
+ return children.size();
+ }
+
+ public boolean isRoot() {
+ return this == getRoot();
+ }
+
+ public boolean isSiblingOf(DataNode<T> node) {
+ if (this == node) {
+ return true;
+ }
+
+ return node != null && parent != null && parent.children.contains(node);
+ }
+
+ public boolean isParentOf(DataNode<T> node) {
+ if (node == null || node.parent == null)
+ return false;
+
+ if (this == node.parent)
+ return true;
+
+ return isParentOf(node.parent);
+ }
+
+ public boolean isChildOf(DataNode<T> node) {
+ if (node == null || node.size() == 0)
+ return false;
+
+ return node.isParentOf(this);
+ }
+
+ public T getUserData() {
+ return userData;
+ }
+
+ /**
+ * The total number of nodes present in this {@link DataNode} (including
+ * itself and descendants).
+ *
+ * @return the number
+ */
+ public int count() {
+ int s = 1;
+ for (DataNode<T> child : children) {
+ s += child.count();
+ }
+
+ return s;
+ }
+
+ @Override
+ public String toString() {
+ if (userData == null) {
+ return "";
+ }
+
+ return userData.toString();
+ }
+}
\ No newline at end of file
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.MutableTreeNode;
+
+public abstract class DataTree<E> {
+ protected DataNode<E> data;
+
+ public DataNode<E> loadData() throws IOException {
+ return this.data = extractData();
+ }
+
+ public DataNode<E> getRoot() {
+ return getRoot(null);
+ }
+
+ public DataNode<E> getRoot(String filter) {
+ return filterNode(data, filter);
+ }
+
+ protected abstract DataNode<E> extractData() throws IOException;
+
+ // filter cannot be null nor empty
+ protected abstract boolean checkFilter(String filter, E userData);
+
+ protected boolean checkFilter(DataNode<E> node, String filter) {
+ if (filter == null || filter.isEmpty()) {
+ return true;
+ }
+
+ if (checkFilter(filter, node.getUserData()))
+ return true;
+
+ for (DataNode<E> child : node.getChildren()) {
+ if (checkFilter(child, filter))
+ return true;
+ }
+
+ return false;
+ }
+
+ protected void sort(List<String> values) {
+ Collections.sort(values, new Comparator<String>() {
+ @Override
+ public int compare(String o1, String o2) {
+ return ("" + o1).compareToIgnoreCase("" + o2);
+ }
+ });
+ }
+
+ // note: we always send TAHT node, but filter children
+ private DataNode<E> filterNode(DataNode<E> source, String filter) {
+ List<DataNode<E>> children = new ArrayList<DataNode<E>>();
+ for (DataNode<E> child : source.getChildren()) {
+ if (checkFilter(child, filter)) {
+ children.add(filterNode(child, filter));
+ }
+ }
+
+ return new DataNode<E>(children, source.getUserData());
+ }
+}
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeSet;
+
+import javax.swing.SwingWorker;
+
+/**
+ * This class helps you delay some graphical actions and execute the most recent
+ * ones when under contention.
+ * <p>
+ * How does it work?
+ * <ul>
+ * <li>it takes an ID and an associated {@link SwingWorker} and will call
+ * {@link SwingWorker#execute()} after a small delay (see
+ * {@link DelayWorker#DelayWorker(int)})</li>
+ * <li>if a second call to {@link DelayWorker#delay(String, SwingWorker)} comes
+ * with the same ID before the first one is done, it will be put on a waiting
+ * queue</li>
+ * <li>if a third call still with the same ID comes, its associated worker will
+ * <b>replace</b> the one in the queue (only one worker per ID in the queue,
+ * always the latest one)</li>
+ * <li>when the first worker is done, it will check the waiting queue and
+ * execute that latest worker if any</li>
+ * </ul>
+ *
+ * @author niki
+ *
+ */
+@SuppressWarnings("rawtypes")
+public class DelayWorker {
+ private Map<String, SwingWorker> lazyEnCours;
+ private Object lazyEnCoursLock;
+
+ private TreeSet<String> wip;
+
+ private Object waiter;
+
+ private boolean cont;
+ private boolean paused;
+ private Thread loop;
+
+ /**
+ * Create a new {@link DelayWorker} with the given delay (in milliseconds)
+ * before each drain of the queue.
+ *
+ * @param delayMs
+ * the delay in milliseconds (can be 0, cannot be negative)
+ */
+ public DelayWorker(final int delayMs) {
+ if (delayMs < 0) {
+ throw new IllegalArgumentException(
+ "A waiting delay cannot be negative");
+ }
+
+ lazyEnCours = new HashMap<String, SwingWorker>();
+ lazyEnCoursLock = new Object();
+ wip = new TreeSet<String>();
+ waiter = new Object();
+ cont = true;
+ paused = false;
+
+ loop = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ while (cont) {
+ try {
+ Thread.sleep(delayMs);
+ } catch (InterruptedException e) {
+ }
+
+ Map<String, SwingWorker> workers = new HashMap<String, SwingWorker>();
+ synchronized (lazyEnCoursLock) {
+ for (String key : new ArrayList<String>(
+ lazyEnCours.keySet())) {
+ if (!wip.contains(key)) {
+ workers.put(key, lazyEnCours.remove(key));
+ }
+ }
+ }
+
+ for (final String key : workers.keySet()) {
+ SwingWorker worker = workers.get(key);
+
+ synchronized (lazyEnCoursLock) {
+ wip.add(key);
+ }
+
+ worker.addPropertyChangeListener(
+ new PropertyChangeListener() {
+ @Override
+ public void propertyChange(
+ PropertyChangeEvent evt) {
+ synchronized (lazyEnCoursLock) {
+ wip.remove(key);
+ }
+ wakeup();
+ }
+ });
+
+ // Start it, at last
+ worker.execute();
+ }
+
+ synchronized (waiter) {
+ do {
+ try {
+ if (cont)
+ waiter.wait();
+ } catch (InterruptedException e) {
+ }
+ } while (cont && paused);
+ }
+ }
+ }
+ });
+
+ loop.setDaemon(true);
+ loop.setName("Loop for DelayWorker");
+ }
+
+ /**
+ * Start the internal loop that will drain the processing queue. <b>MUST
+ * NOT</b> be started twice (but see {@link DelayWorker#pause()} and
+ * {@link DelayWorker#resume()} instead).
+ */
+ public void start() {
+ loop.start();
+ }
+
+ /**
+ * Pause the system until {@link DelayWorker#resume()} is called -- note
+ * that it will still continue on the processes currently scheduled to run,
+ * but will pause after that.
+ * <p>
+ * Can be called even if already paused, will just do nothing in that
+ * context.
+ */
+ public void pause() {
+ paused = true;
+ }
+
+ /**
+ * Check if the {@link DelayWorker} is currently paused.
+ *
+ * @return TRUE if it is
+ */
+ public boolean isPaused() {
+ return paused;
+ }
+
+ /**
+ * Resume the system after a pause.
+ * <p>
+ * Can be called even if already running, will just do nothing in that
+ * context.
+ */
+ public void resume() {
+ synchronized (waiter) {
+ paused = false;
+ wakeup();
+ }
+ }
+
+ /**
+ * Stop the system.
+ * <p>
+ * Note: this is final, you <b>MUST NOT</b> call {@link DelayWorker#start()}
+ * a second time (but see {@link DelayWorker#pause()} and
+ * {@link DelayWorker#resume()} instead).
+ */
+ public void stop() {
+ synchronized (waiter) {
+ cont = false;
+ wakeup();
+ }
+ }
+
+ /**
+ * Clear all the processes that were put on the queue but not yet scheduled
+ * to be executed -- note that it will still continue on the processes
+ * currently scheduled to run.
+ */
+ public void clear() {
+ synchronized (lazyEnCoursLock) {
+ lazyEnCours.clear();
+ wip.clear();
+ }
+ }
+
+ /**
+ * Put a new process in the delay queue.
+ *
+ * @param id
+ * the ID of this process (if you want to skip workers when they
+ * are superseded by a new one, you need to use the same ID key)
+ * @param worker
+ * the process to delay
+ */
+ public void delay(String id, SwingWorker worker) {
+ synchronized (lazyEnCoursLock) {
+ lazyEnCours.put(id, worker);
+ }
+
+ wakeup();
+ }
+
+ /**
+ * Wake up the loop thread.
+ */
+ private void wakeup() {
+ synchronized (waiter) {
+ waiter.notifyAll();
+ }
+ }
+}
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.awt.Component;
+import java.awt.Point;
+import java.awt.Window;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.swing.JList;
+import javax.swing.JPopupMenu;
+import javax.swing.ListCellRenderer;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.utils.compat.DefaultListModel6;
+import be.nikiroo.utils.compat.JList6;
+import be.nikiroo.utils.compat.ListCellRenderer6;
+
+/**
+ * A {@link javax.swing.ListModel} that can maintain 2 lists; one with the
+ * actual data (the elements), and a second one with the items that are
+ * currently displayed (the items).
+ * <p>
+ * It also offers filter options, supports hovered changes and some more utility
+ * functions.
+ *
+ * @author niki
+ *
+ * @param <T>
+ * the type of elements and items (the same type)
+ */
+public class ListModel<T> extends DefaultListModel6<T> {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * A filter interface, to check for a condition (note that a Predicate class
+ * already exists in Java 1.8+, and is compatible with this one if you
+ * change the signatures -- but I support java 1.6+).
+ *
+ * @author niki
+ *
+ * @param <T>
+ * the type of elements and items (the same type)
+ */
+ public interface Predicate<T> {
+ /**
+ * Check if an item or an element pass a filter.
+ *
+ * @param item
+ * the item to test
+ *
+ * @return TRUE if the test passed, FALSE if not
+ */
+ public boolean test(T item);
+ }
+
+ /**
+ * A simple interface your elements must implement if you want to use
+ * {@link ListModel#generateRenderer(ListModel)}.
+ *
+ * @author niki
+ */
+ public interface Hoverable {
+ /**
+ * The element is currently selected.
+ *
+ * @param selected
+ * TRUE for selected, FALSE for unselected
+ */
+ public void setSelected(boolean selected);
+
+ /**
+ * The element is currently under the mouse cursor.
+ *
+ * @param hovered
+ * TRUE if it is, FALSE if not
+ */
+ public void setHovered(boolean hovered);
+ }
+
+ /**
+ * An interface required to support tooltips on this {@link ListModel}.
+ *
+ * @author niki
+ *
+ * @param <T>
+ * the type of elements and items (the same type)
+ */
+ public interface TooltipCreator<T> {
+ /**
+ * Generate a tooltip {@link Window} for this element.
+ * <p>
+ * Note that the tooltip can be of two modes: undecorated or standalone.
+ * An undecorated tooltip will be taken care of by this
+ * {@link ListModel}, but a standalone one is supposed to be its own
+ * Dialog or Frame (it won't be automatically closed).
+ *
+ * @param t
+ * the element to generate a tooltip for
+ * @param undecorated
+ * TRUE for undecorated tooltip, FALSE for standalone
+ * tooltips
+ *
+ * @return the generated tooltip or NULL for none
+ */
+ public Window generateTooltip(T t, boolean undecorated);
+ }
+
+ private int hoveredIndex;
+ private List<T> items = new ArrayList<T>();
+ private boolean keepSelection = true;
+
+ private TooltipCreator<T> tooltipCreator;
+ private Window tooltip;
+
+ @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+ private JList list;
+
+ /**
+ * Create a new {@link ListModel}.
+ *
+ * @param list
+ * the {@link JList6} we will handle the data of (cannot be NULL)
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+ public ListModel(JList6<T> list) {
+ this((JList) list);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ *
+ * @param list
+ * the {@link JList6} we will handle the data of (cannot be NULL)
+ * @param popup
+ * the popup to use and keep track of (can be NULL)
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+ public ListModel(JList6<T> list, JPopupMenu popup) {
+ this((JList) list, popup);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ *
+ * @param list
+ * the {@link JList6} we will handle the data of (cannot be NULL)
+ * @param tooltipCreator
+ * use this if you want the list to display tooltips on hover
+ * (can be NULL)
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+ public ListModel(JList6<T> list, TooltipCreator<T> tooltipCreator) {
+ this((JList) list, null, tooltipCreator);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ *
+ * @param list
+ * the {@link JList6} we will handle the data of (cannot be NULL)
+ * @param popup
+ * the popup to use and keep track of (can be NULL)
+ * @param tooltipCreator
+ * use this if you want the list to display tooltips on hover
+ * (can be NULL)
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+ public ListModel(JList6<T> list, JPopupMenu popup,
+ TooltipCreator<T> tooltipCreator) {
+ this((JList) list, popup, tooltipCreator);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ * <p>
+ * Note that you must take care of passing a {@link JList} that only handles
+ * elements of the type of this {@link ListModel} -- you can also use
+ * {@link ListModel#ListModel(JList6)} instead.
+ *
+ * @param list
+ * the {@link JList} we will handle the data of (cannot be NULL,
+ * must only contain elements of the type of this
+ * {@link ListModel})
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
+ public ListModel(JList list) {
+ this(list, null, null);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ * <p>
+ * Note that you must take care of passing a {@link JList} that only handles
+ * elements of the type of this {@link ListModel} -- you can also use
+ * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
+ *
+ * @param list
+ * the {@link JList} we will handle the data of (cannot be NULL,
+ * must only contain elements of the type of this
+ * {@link ListModel})
+ * @param popup
+ * the popup to use and keep track of (can be NULL)
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not in Java 1.6
+ public ListModel(JList list, JPopupMenu popup) {
+ this(list, popup, null);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ * <p>
+ * Note that you must take care of passing a {@link JList} that only handles
+ * elements of the type of this {@link ListModel} -- you can also use
+ * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
+ *
+ * @param list
+ * the {@link JList} we will handle the data of (cannot be NULL,
+ * must only contain elements of the type of this
+ * {@link ListModel})
+ * @param tooltipCreator
+ * use this if you want the list to display tooltips on hover
+ * (can be NULL)
+ */
+ @SuppressWarnings("rawtypes") // JList<?> not in Java 1.6
+ public ListModel(JList list, TooltipCreator<T> tooltipCreator) {
+ this(list, null, tooltipCreator);
+ }
+
+ /**
+ * Create a new {@link ListModel}.
+ * <p>
+ * Note that you must take care of passing a {@link JList} that only handles
+ * elements of the type of this {@link ListModel} -- you can also use
+ * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
+ *
+ * @param list
+ * the {@link JList} we will handle the data of (cannot be NULL,
+ * must only contain elements of the type of this
+ * {@link ListModel})
+ * @param popup
+ * the popup to use and keep track of (can be NULL)
+ * @param tooltipCreator
+ * use this if you want the list to display tooltips on hover
+ * (can be NULL)
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" }) // JList<?> not in Java 1.6
+ public ListModel(final JList list, final JPopupMenu popup,
+ TooltipCreator<T> tooltipCreator) {
+ this.list = list;
+ this.tooltipCreator = tooltipCreator;
+
+ list.setModel(this);
+
+ final DelayWorker tooltipWatcher = new DelayWorker(500);
+ if (tooltipCreator != null) {
+ tooltipWatcher.start();
+ }
+
+ list.addMouseMotionListener(new MouseAdapter() {
+ @Override
+ public void mouseMoved(final MouseEvent me) {
+ if (popup != null && popup.isShowing())
+ return;
+
+ Point p = new Point(me.getX(), me.getY());
+ final int index = list.locationToIndex(p);
+ if (index != hoveredIndex) {
+ int oldIndex = hoveredIndex;
+ hoveredIndex = index;
+ fireElementChanged(oldIndex);
+ fireElementChanged(index);
+
+ if (ListModel.this.tooltipCreator != null) {
+ tooltipWatcher.delay("tooltip",
+ new SwingWorker<Void, Void>() {
+ @Override
+ protected Void doInBackground()
+ throws Exception {
+ return null;
+ }
+
+ @Override
+ protected void done() {
+ Window oldTooltip = tooltip;
+ tooltip = null;
+ if (oldTooltip != null) {
+ oldTooltip.setVisible(false);
+ }
+
+ if (index < 0
+ || index != hoveredIndex) {
+ return;
+ }
+
+ tooltip = newTooltip(index, me);
+ }
+ });
+ }
+ }
+ }
+ });
+
+ list.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mousePressed(MouseEvent e) {
+ check(e);
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ check(e);
+ }
+
+ @Override
+ public void mouseExited(MouseEvent e) {
+ if (popup != null && popup.isShowing())
+ return;
+
+ if (hoveredIndex > -1) {
+ int oldIndex = hoveredIndex;
+ hoveredIndex = -1;
+ fireElementChanged(oldIndex);
+ }
+ }
+
+ private void check(MouseEvent e) {
+ if (popup == null) {
+ return;
+ }
+
+ if (e.isPopupTrigger()) {
+ if (list.getSelectedIndices().length <= 1) {
+ list.setSelectedIndex(
+ list.locationToIndex(e.getPoint()));
+ }
+
+ popup.show(list, e.getX(), e.getY());
+ }
+ }
+
+ });
+ }
+
+ /**
+ * (Try and) keep the elements that were selected when filtering.
+ * <p>
+ * This will use toString on the elements to identify them, and can be a bit
+ * resource intensive.
+ *
+ * @return TRUE if we do
+ */
+ public boolean isKeepSelection() {
+ return keepSelection;
+ }
+
+ /**
+ * (Try and) keep the elements that were selected when filtering.
+ * <p>
+ * This will use toString on the elements to identify them, and can be a bit
+ * resource intensive.
+ *
+ * @param keepSelection
+ * TRUE to try and keep them selected
+ */
+ public void setKeepSelection(boolean keepSelection) {
+ this.keepSelection = keepSelection;
+ }
+
+ /**
+ * Check if this element is currently under the mouse.
+ *
+ * @param element
+ * the element to check
+ *
+ * @return TRUE if it is
+ */
+ public boolean isHovered(T element) {
+ return indexOf(element) == hoveredIndex;
+ }
+
+ /**
+ * Check if this element is currently under the mouse.
+ *
+ * @param index
+ * the index of the element to check
+ *
+ * @return TRUE if it is
+ */
+ public boolean isHovered(int index) {
+ return index == hoveredIndex;
+ }
+
+ /**
+ * Add an item to the model.
+ *
+ * @param item
+ * the new item to add
+ */
+ public void addItem(T item) {
+ items.add(item);
+ }
+
+ /**
+ * Add items to the model.
+ *
+ * @param items
+ * the new items to add
+ */
+ public void addAllItems(Collection<T> items) {
+ this.items.addAll(items);
+ }
+
+ /**
+ * Removes the first occurrence of the specified element from this list, if
+ * it is present (optional operation).
+ *
+ * @param item
+ * the item to remove if possible (can be NULL)
+ *
+ * @return TRUE if one element was removed, FALSE if not found
+ */
+ public boolean removeItem(T item) {
+ return items.remove(item);
+ }
+
+ /**
+ * Remove the items that pass the given filter (or all items if the filter
+ * is NULL).
+ *
+ * @param filter
+ * the filter (if the filter returns TRUE, the item will be
+ * removed)
+ *
+ * @return TRUE if at least one item was removed
+ */
+ public boolean removeItemIf(Predicate<T> filter) {
+ boolean changed = false;
+ if (filter == null) {
+ changed = !items.isEmpty();
+ clearItems();
+ } else {
+ for (int i = 0; i < items.size(); i++) {
+ if (filter.test(items.get(i))) {
+ items.remove(i--);
+ changed = true;
+ }
+ }
+ }
+
+ return changed;
+ }
+
+ /**
+ * Removes all the items from this model.
+ */
+ public void clearItems() {
+ items.clear();
+ }
+
+ /**
+ * Filter the current elements.
+ * <p>
+ * This method will clear all the elements then look into all the items:
+ * those that pass the given filter will be copied as elements.
+ *
+ * @param filter
+ * the filter to select which elements to keep; an item that pass
+ * the filter will be copied as an element (can be NULL, in that
+ * case all items will be copied as elements)
+ */
+ @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
+ public void filter(Predicate<T> filter) {
+ ListSnapshot snapshot = null;
+
+ if (keepSelection)
+ snapshot = new ListSnapshot(list);
+
+ clear();
+ for (T item : items) {
+ if (filter == null || filter.test(item)) {
+ addElement(item);
+ }
+ }
+
+ if (keepSelection)
+ snapshot.apply();
+
+ list.repaint();
+ }
+
+ /**
+ * Return the currently selected elements.
+ *
+ * @return the selected elements
+ */
+ public List<T> getSelectedElements() {
+ List<T> selected = new ArrayList<T>();
+ for (int index : list.getSelectedIndices()) {
+ selected.add(get(index));
+ }
+
+ return selected;
+ }
+
+ /**
+ * Return the selected element if <b>one</b> and <b>only one</b> element is
+ * selected. I.E., if zero, two or more elements are selected, NULL will be
+ * returned.
+ *
+ * @return the element if it is the only selected element, NULL otherwise
+ */
+ public T getUniqueSelectedElement() {
+ List<T> selected = getSelectedElements();
+ if (selected.size() == 1) {
+ return selected.get(0);
+ }
+
+ return null;
+ }
+
+ /**
+ * Notify that this element has been changed.
+ *
+ * @param index
+ * the index of the element
+ */
+ public void fireElementChanged(int index) {
+ if (index >= 0) {
+ fireContentsChanged(this, index, index);
+ }
+ }
+
+ /**
+ * Notify that this element has been changed.
+ *
+ * @param element
+ * the element
+ */
+ public void fireElementChanged(T element) {
+ int index = indexOf(element);
+ if (index >= 0) {
+ fireContentsChanged(this, index, index);
+ }
+ }
+
+ @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
+ @Override
+ public T get(int index) {
+ return (T) super.get(index);
+ }
+
+ private Window newTooltip(final int index, final MouseEvent me) {
+ final T value = ListModel.this.get(index);
+
+ final Window newTooltip = tooltipCreator.generateTooltip(value, true);
+
+ if (newTooltip != null) {
+ newTooltip.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+
+ Window promotedTooltip = tooltipCreator
+ .generateTooltip(value, false);
+ promotedTooltip.setLocation(newTooltip.getLocation());
+ newTooltip.setVisible(false);
+ promotedTooltip.setVisible(true);
+ }
+ });
+ newTooltip.setLocation(me.getXOnScreen(), me.getYOnScreen());
+
+ newTooltip.setVisible(true);
+ }
+
+ return newTooltip;
+ }
+
+ /**
+ * Generate a {@link ListCellRenderer} that supports {@link Hoverable}
+ * elements.
+ *
+ * @param <T>
+ * the type of elements and items (the same type), which should
+ * implement {@link Hoverable} (it will not cause issues if not,
+ * but then, it will be a default renderer)
+ * @param model
+ * the model to use
+ *
+ * @return a suitable, {@link Hoverable} compatible renderer
+ */
+ static public <T extends Component> ListCellRenderer6<T> generateRenderer(
+ final ListModel<T> model) {
+ return new ListCellRenderer6<T>() {
+ @Override
+ public Component getListCellRendererComponent(JList6<T> list,
+ T item, int index, boolean isSelected,
+ boolean cellHasFocus) {
+ if (item instanceof Hoverable) {
+ Hoverable hoverable = (Hoverable) item;
+ hoverable.setSelected(isSelected);
+ hoverable.setHovered(model.isHovered(index));
+ }
+
+ return item;
+ }
+ };
+ }
+}
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JList;
+
+public class ListSnapshot {
+ private JList list;
+ private List<Object> elements = new ArrayList<Object>();
+
+ public ListSnapshot(JList list) {
+ this.list = list;
+
+ for (int index : list.getSelectedIndices()) {
+ elements.add(list.getModel().getElementAt(index));
+ }
+ }
+
+ public void apply() {
+ applyTo(list);
+ }
+
+ public void applyTo(JList list) {
+ List<Integer> indices = new ArrayList<Integer>();
+ for (int i = 0; i < list.getModel().getSize(); i++) {
+ Object newObject = list.getModel().getElementAt(i);
+ for (Object oldObject : elements) {
+ if (isSameElement(oldObject, newObject)) {
+ indices.add(i);
+ break;
+ }
+ }
+ }
+
+ int a[] = new int[indices.size()];
+ for (int i = 0; i < indices.size(); i++) {
+ a[i] = indices.get(i);
+ }
+ list.setSelectedIndices(a);
+ }
+
+ // You can override this
+ protected boolean isSameElement(Object oldElement, Object newElement) {
+ if (oldElement == null || newElement == null)
+ return oldElement == null && newElement == null;
+
+ return oldElement.toString().equals(newElement.toString());
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("List Snapshot of: ").append(list).append("\n");
+ builder.append("Selected elements:\n");
+ for (Object element : elements) {
+ builder.append("\t").append(element).append("\n");
+ }
+
+ return builder.toString();
+ }
+}
--- /dev/null
+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);
+}
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.LinkedList;
+import java.util.Queue;
+
+import javax.swing.JPanel;
+
+/**
+ * A {@link JPanel} with the default {@link ActionListener} add/remove/fire
+ * methods.
+ * <p>
+ * Note that it will queue all events until at least one listener comes (or
+ * comes back!); this first (or at least currently unique) listener will drain
+ * the queue.
+ *
+ * @author niki
+ */
+public class ListenerPanel extends JPanel implements ListenerItem {
+ private static final long serialVersionUID = 1L;
+
+ /** Waiting queue until at least one listener is here to get the events. */
+ private final Queue<ActionEvent> waitingQueue;
+
+ /**
+ * Create a new {@link ListenerPanel}.
+ */
+ public ListenerPanel() {
+ waitingQueue = new LinkedList<ActionEvent>();
+ }
+
+ @Override
+ public synchronized boolean hasListeners() {
+ return listenerList.getListenerList().length > 1;
+ }
+
+ @Override
+ public synchronized int getWaitingEventCount() {
+ return waitingQueue.size();
+ }
+
+ @Override
+ public synchronized void addActionListener(ActionListener listener) {
+ if (!hasListeners()) {
+ while (!waitingQueue.isEmpty()) {
+ listener.actionPerformed(waitingQueue.remove());
+ }
+ }
+
+ listenerList.add(ActionListener.class, listener);
+ }
+
+ @Override
+ public synchronized void removeActionListener(ActionListener listener) {
+ listenerList.remove(ActionListener.class, listener);
+ }
+
+ @Override
+ public synchronized void fireActionPerformed(String listenerCommand) {
+ ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED,
+ listenerCommand);
+
+ ActionListener[] listeners = getListeners(ActionListener.class);
+ if (listeners.length > 0) {
+ for (ActionListener action : listeners) {
+ action.actionPerformed(e);
+ }
+ } else {
+ waitingQueue.add(e);
+ }
+ }
+}
actionListeners = new ArrayList<ActionListener>();
updateListeners = new ArrayList<ActionListener>();
}
+
+ public ProgressBar(Progress pg) {
+ this();
+ setProgress(pg);
+ }
public void setProgress(final Progress pg) {
this.pg = pg;
--- /dev/null
+/*
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.utils.ui;
+
+import java.awt.*;
+import java.awt.event.*;
+
+import javax.swing.*;
+import javax.swing.tree.*;
+
+import java.util.*;
+
+public class TreeCellSpanner extends Container implements TreeCellRenderer, ComponentListener {
+
+ public TreeCellSpanner(JTree tree, TreeCellRenderer renderer) {
+ if (tree == null || renderer == null)
+ throw new NullPointerException();
+ this.tree = tree;
+ this.renderer = renderer;
+ treeParent = tree.getParent();
+ if (treeParent != null && treeParent instanceof JViewport) {
+ treeParent.addComponentListener(this);
+ } else {
+ treeParent = null;
+ tree.addComponentListener(this);
+ }
+ }
+
+ protected final JTree tree;
+
+ private TreeCellRenderer renderer;
+
+ private Component rendererComponent;
+
+ private Container treeParent;
+
+ private Map<TreePath,Integer> offsets = new HashMap<TreePath,Integer>();
+
+ private TreePath path;
+
+ public TreeCellRenderer getRenderer() {
+ return renderer;
+ }
+
+ @Override
+ public Component getTreeCellRendererComponent(JTree tree, Object value,
+ boolean selected, boolean expanded, boolean leaf, int row,
+ boolean hasFocus) {
+ path = tree.getPathForRow(row);
+ if (path != null && path.getLastPathComponent() != value)
+ path = null;
+ rendererComponent = renderer.getTreeCellRendererComponent(
+ tree, value, selected, expanded, leaf, row, hasFocus);
+ if (getComponentCount() < 1 || getComponent(0) != rendererComponent) {
+ removeAll();
+ add(rendererComponent);
+ }
+ return this;
+ }
+
+ @Override
+ public void doLayout() {
+ int x = getX();
+ if (x < 0)
+ return;
+ if (path != null) {
+ Integer offset = offsets.get(path);
+ if (offset == null || offset.intValue() != x) {
+ offsets.put(path, x);
+ fireTreePathChanged(path);
+ }
+ }
+ rendererComponent.setBounds(getX(), getY(), getWidth(), getHeight());
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ if (rendererComponent != null)
+ rendererComponent.paint(g);
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ Dimension s = rendererComponent.getPreferredSize();
+ // check if path count is greater than 1 to exclude the root
+ if (path != null && path.getPathCount() > 1) {
+ Integer offset = offsets.get(path);
+ if (offset != null) {
+ int width;
+ if (tree.getParent() == treeParent) {
+ width = treeParent.getWidth();
+ } else {
+ if (treeParent != null) {
+ treeParent.removeComponentListener(this);
+ tree.addComponentListener(this);
+ treeParent = null;
+ }
+ if (tree.getParent() instanceof JViewport) {
+ treeParent = tree.getParent();
+ tree.removeComponentListener(this);
+ treeParent.addComponentListener(this);
+ width = treeParent.getWidth();
+ } else {
+ width = tree.getWidth();
+ }
+ }
+ s.width = width - offset;
+ }
+ }
+ return s;
+ }
+
+
+ protected void fireTreePathChanged(TreePath path) {
+ if (path.getPathCount() > 1) {
+ // this cannot be used for the root node or else
+ // the entire tree will keep being revalidated ad infinitum
+ TreeModel model = tree.getModel();
+ Object node = path.getLastPathComponent();
+ if (node instanceof TreeNode && (model instanceof DefaultTreeModel
+ || (model instanceof TreeModelTransformer<?> &&
+ (model=((TreeModelTransformer<?>)model).getModel()) instanceof DefaultTreeModel))) {
+ ((DefaultTreeModel)model).nodeChanged((TreeNode)node);
+ } else {
+ model.valueForPathChanged(path, node.toString());
+ }
+ } else {
+ // root!
+
+ }
+ }
+
+
+ private int lastWidth;
+
+ @Override
+ public void componentHidden(ComponentEvent e) {}
+
+ @Override
+ public void componentMoved(ComponentEvent e) {}
+
+ @Override
+ public void componentResized(ComponentEvent e) {
+ if (e.getComponent().getWidth() != lastWidth) {
+ lastWidth = e.getComponent().getWidth();
+ for (int row=tree.getRowCount(); --row>=0;) {
+ fireTreePathChanged(tree.getPathForRow(row));
+ }
+ }
+ }
+
+ @Override
+ public void componentShown(ComponentEvent e) {}
+
+}
--- /dev/null
+/*
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.utils.ui;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.JTree;
+import javax.swing.SortOrder;
+import javax.swing.event.EventListenerList;
+import javax.swing.event.TreeExpansionEvent;
+import javax.swing.event.TreeExpansionListener;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+import javax.swing.event.TreeWillExpandListener;
+import javax.swing.tree.ExpandVetoException;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreePath;
+
+
+public class TreeModelTransformer<N> implements TreeModel {
+
+ public TreeModelTransformer(JTree tree, TreeModel model) {
+ if (tree == null)
+ throw new IllegalArgumentException();
+ if (model == null)
+ throw new IllegalArgumentException();
+ this.tree = tree;
+ this.model = model;
+ handler = createHandler();
+ addListeners();
+ }
+
+ private JTree tree;
+
+ private TreeModel model;
+
+ private Handler handler;
+
+ private Filter<N> filter;
+
+ private TreePath filterStartPath;
+
+ private int filterDepthLimit;
+
+ private SortOrder sortOrder = SortOrder.UNSORTED;
+
+ private Map<Object,Converter> converters;
+
+ protected EventListenerList listenerList = new EventListenerList();
+
+ protected Handler createHandler() {
+ return new Handler();
+ }
+
+ protected void addListeners() {
+ tree.addTreeExpansionListener(handler);
+ model.addTreeModelListener(handler);
+ }
+
+ protected void removeListeners() {
+ tree.removeTreeExpansionListener(handler);
+ model.removeTreeModelListener(handler);
+ }
+
+ public void dispose() {
+ removeListeners();
+ }
+
+ public TreeModel getModel() {
+ return model;
+ }
+
+ private Converter getConverter(Object node) {
+ return converters == null ? null : converters.get(node);
+ }
+
+ int convertRowIndexToView(Object parent, int index) {
+ Converter converter = getConverter(parent);
+ if (converter != null)
+ return converter.convertRowIndexToView(index);
+ return index;
+ }
+
+ int convertRowIndexToModel(Object parent, int index) {
+ Converter converter = getConverter(parent);
+ if (converter != null)
+ return converter.convertRowIndexToModel(index);
+ return index;
+ }
+
+ @Override
+ public Object getChild(Object parent, int index) {
+ return model.getChild(parent, convertRowIndexToModel(parent, index));
+ }
+
+ @Override
+ public int getChildCount(Object parent) {
+ Converter converter = getConverter(parent);
+ if (converter != null)
+ return converter.getChildCount();
+ return model.getChildCount(parent);
+ }
+
+ @Override
+ public int getIndexOfChild(Object parent, Object child) {
+ int index = model.getIndexOfChild(parent, child);
+ if (index < 0)
+ return -1;
+ return convertRowIndexToView(parent, index);
+ }
+
+ @Override
+ public Object getRoot() {
+ return model.getRoot();
+ }
+
+ @Override
+ public boolean isLeaf(Object node) {
+ return model.isLeaf(node);
+ }
+
+ @Override
+ public void valueForPathChanged(TreePath path, Object newValue) {
+ model.valueForPathChanged(path, newValue);
+ }
+
+ @Override
+ public void addTreeModelListener(TreeModelListener l) {
+ listenerList.add(TreeModelListener.class, l);
+ }
+
+ @Override
+ public void removeTreeModelListener(TreeModelListener l) {
+ listenerList.remove(TreeModelListener.class, l);
+ }
+
+ /**
+ * Set the comparator that compares nodes in sorting.
+ * @param comparator
+ * @see #getComparator()
+ */
+ public void setComparator(Comparator<N> comparator) {
+ handler.setComparator(comparator);
+ }
+
+ /**
+ * @return comparator that compares nodes
+ * @see #setComparator(Comparator)
+ */
+ public Comparator<N> getComparator() {
+ return handler.getComparator();
+ }
+
+ public void setSortOrder(SortOrder newOrder) {
+ SortOrder oldOrder = sortOrder;
+ if (oldOrder == newOrder)
+ return;
+ sortOrder = newOrder;
+ ArrayList<TreePath> paths = null;
+ switch (newOrder) {
+ case ASCENDING:
+ if (oldOrder == SortOrder.DESCENDING) {
+ flip();
+ } else {
+ paths = sort();
+ }
+ break;
+ case DESCENDING:
+ if (oldOrder == SortOrder.ASCENDING) {
+ flip();
+ } else {
+ paths = sort();
+ }
+ break;
+ case UNSORTED:
+ unsort();
+ break;
+ }
+ fireTreeStructureChangedAndExpand(new TreePath(getRoot()), paths, true);
+ }
+
+ public SortOrder getSortOrder() {
+ return sortOrder;
+ }
+
+ public void toggleSortOrder() {
+ setSortOrder(sortOrder == SortOrder.ASCENDING ?
+ SortOrder.DESCENDING : SortOrder.ASCENDING);
+ }
+
+
+ /**
+ * Flip all sorted paths.
+ */
+ private void flip() {
+ for (Converter c : converters.values()) {
+ flip(c.viewToModel);
+ }
+ }
+
+ /**
+ * Flip array.
+ * @param array
+ */
+ private static void flip(int[] array) {
+ for (int left=0, right=array.length-1;
+ left<right; left++, right--) {
+ int tmp = array[left];
+ array[left] = array[right];
+ array[right] = tmp;
+ }
+ }
+
+ private void unsort() {
+ if (filter == null) {
+ converters = null;
+ } else {
+ Iterator<Converter> cons = converters.values().iterator();
+ while (cons.hasNext()) {
+ Converter converter = cons.next();
+ if (!converter.isFiltered()) {
+ cons.remove();
+ } else {
+ Arrays.sort(converter.viewToModel);
+ }
+ }
+ }
+ }
+
+ /**
+ * Sort root and expanded descendants.
+ * @return list of paths that were sorted
+ */
+ private ArrayList<TreePath> sort() {
+ if (converters == null)
+ converters = createConvertersMap(); //new IdentityHashMap<Object,Converter>();
+ return sortHierarchy(new TreePath(model.getRoot()));
+ }
+
+ /**
+ * Sort path and expanded descendants.
+ * @param path
+ * @return list of paths that were sorted
+ */
+ private ArrayList<TreePath> sortHierarchy(TreePath path) {
+ ValueIndexPair<N>[] pairs = createValueIndexPairArray(20);
+ ArrayList<TreePath> list = new ArrayList<TreePath>();
+ pairs = sort(path.getLastPathComponent(), pairs);
+ list.add(path);
+ Enumeration<TreePath> paths = tree.getExpandedDescendants(path);
+ if (paths != null)
+ while (paths.hasMoreElements()) {
+ path = paths.nextElement();
+ pairs = sort(path.getLastPathComponent(), pairs);
+ list.add(path);
+ }
+ return list;
+ }
+
+ private ValueIndexPair<N>[] sort(Object node, ValueIndexPair<N>[] pairs) {
+ Converter converter = getConverter(node);
+ TreeModel mdl = model;
+ int[] vtm;
+ if (converter != null) {
+ vtm = converter.viewToModel;
+ if (pairs.length < vtm.length)
+ pairs = createValueIndexPairArray(vtm.length);
+ for (int i=vtm.length; --i>=0;) {
+ int idx = vtm[i];
+ pairs[i].index = idx;
+ pairs[i].value = (N)mdl.getChild(node, idx);
+ }
+ } else {
+ int count = mdl.getChildCount(node);
+ if (count <= 0)
+ return pairs;
+ if (pairs.length < count)
+ pairs = createValueIndexPairArray(count);
+ for (int i=count; --i>=0;) {
+ pairs[i].index = i;
+ pairs[i].value = (N)mdl.getChild(node, i);
+ }
+ vtm = new int[count];
+ }
+ Arrays.sort(pairs, 0, vtm.length, handler);
+ for (int i=vtm.length; --i>=0;)
+ vtm[i] = pairs[i].index;
+ if (converter == null) {
+ converters.put(node, new Converter(vtm, false));
+ }
+ if (sortOrder == SortOrder.DESCENDING)
+ flip(vtm);
+ return pairs;
+ }
+
+ private ValueIndexPair<N>[] createValueIndexPairArray(int len) {
+ ValueIndexPair<N>[] pairs = new ValueIndexPair[len];
+ for (int i=len; --i>=0;)
+ pairs[i] = new ValueIndexPair<N>();
+ return pairs;
+ }
+
+ public void setFilter(Filter<N> filter) {
+ setFilter(filter, null);
+ }
+
+ public void setFilter(Filter<N> filter, TreePath startingPath) {
+ setFilter(filter, null, -1);
+ }
+
+ public void setFilter(Filter<N> filter, TreePath startingPath, int depthLimit) {
+ if (filter == null && startingPath != null)
+ throw new IllegalArgumentException();
+ if (startingPath != null && startingPath.getPathCount() == 1)
+ startingPath = null;
+ Filter<N> oldFilter = this.filter;
+ TreePath oldStartPath = filterStartPath;
+ this.filter = filter;
+ filterStartPath = startingPath;
+ filterDepthLimit = depthLimit;
+ applyFilter(oldFilter, oldStartPath, null, true);
+ }
+
+ public Filter<N> getFilter() {
+ return filter;
+ }
+
+ public TreePath getFilterStartPath() {
+ return filterStartPath;
+ }
+
+ private void applyFilter(Filter<N> oldFilter, TreePath oldStartPath, Collection<TreePath> expanded, boolean sort) {
+ TreePath startingPath = filterStartPath;
+ ArrayList<TreePath> expand = null;
+ if (filter == null) {
+ converters = null;
+ } else {
+ if (converters == null || startingPath == null) {
+ converters = createConvertersMap();
+ } else if (oldFilter != null) {
+ // unfilter the oldStartPath if oldStartPath isn't descendant of startingPath
+ if (oldStartPath == null) {
+ converters = createConvertersMap();
+ fireTreeStructureChangedAndExpand(new TreePath(getRoot()), null, true);
+ } else if (!startingPath.isDescendant(oldStartPath)) {
+ Object node = oldStartPath.getLastPathComponent();
+ handler.removeConverter(getConverter(node), node);
+ fireTreeStructureChangedAndExpand(oldStartPath, null, true);
+ }
+ }
+ expand = new ArrayList<TreePath>();
+ TreePath path = startingPath != null ? startingPath : new TreePath(getRoot());
+ if (!applyFilter(filter, path, expand, filterDepthLimit)) {
+ converters.put(path.getLastPathComponent(), new Converter(Converter.EMPTY, true));
+ }
+ }
+ if (startingPath == null)
+ startingPath = new TreePath(getRoot());
+ fireTreeStructureChanged(startingPath);
+ if (expanded != null)
+ expand.retainAll(expanded);
+ expandPaths(expand);
+ if (sort && sortOrder != SortOrder.UNSORTED) {
+ if (filter == null)
+ converters = createConvertersMap();
+ if (startingPath.getPathCount() > 1 && oldFilter != null) {
+ // upgrade startingPath or sort oldStartPath
+ if (oldStartPath == null) {
+ startingPath = new TreePath(getRoot());
+ } else if (oldStartPath.isDescendant(startingPath)) {
+ startingPath = oldStartPath;
+ } else if (!startingPath.isDescendant(oldStartPath)) {
+ expand = sortHierarchy(oldStartPath);
+ fireTreeStructureChanged(oldStartPath);
+ expandPaths(expand);
+ }
+ }
+ expand = sortHierarchy(startingPath);
+ fireTreeStructureChanged(startingPath);
+ expandPaths(expand);
+ }
+
+ }
+
+ private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand) {
+ int depthLimit = filterDepthLimit;
+ if (depthLimit >= 0) {
+ depthLimit -= filterStartPath.getPathCount() - path.getPathCount();
+ if (depthLimit <= 0)
+ return false;
+ }
+ return applyFilter(filter, path, expand, depthLimit);
+ }
+
+ private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand, int depthLimit) {
+ Object node = path.getLastPathComponent();
+ int count = model.getChildCount(node);
+ int[] viewToModel = null;
+ int viewIndex = 0;
+ boolean needsExpand = false;
+ boolean isExpanded = false;
+ if (depthLimit > 0)
+ depthLimit--;
+ for (int i=0; i<count; i++) {
+ Object child = model.getChild(node, i);
+ boolean leaf = model.isLeaf(child);
+ if (filter.acceptNode(path, (N)child, leaf)) {
+ if (viewToModel == null)
+ viewToModel = new int[count-i];
+ viewToModel[viewIndex++] = i;
+ needsExpand = true;
+ } else if (depthLimit != 0 && !leaf) {
+ if (applyFilter(filter, path.pathByAddingChild(child), expand, depthLimit)) {
+ if (viewToModel == null)
+ viewToModel = new int[count-i];
+ viewToModel[viewIndex++] = i;
+ isExpanded = true;
+ }
+ }
+ }
+ if (needsExpand && expand != null && !isExpanded && path.getPathCount() > 1) {
+ expand.add(path);
+ }
+ if (viewToModel != null) {
+ if (viewIndex < viewToModel.length)
+ viewToModel = Arrays.copyOf(viewToModel, viewIndex);
+ // a node must have a converter to signify that tree modifications
+ // need to query the filter, so have to put in converter
+ // even if viewIndex == viewToModel.length
+ converters.put(node, new Converter(viewToModel, true));
+ return true;
+ }
+ return false;
+ }
+
+
+ private void expandPaths(ArrayList<TreePath> paths) {
+ if (paths == null || paths.isEmpty())
+ return;
+ JTree tre = tree;
+ for (TreePath path : paths)
+ tre.expandPath(path);
+ }
+
+
+ private void fireTreeStructureChangedAndExpand(TreePath path, ArrayList<TreePath> list, boolean retainSelection) {
+ Enumeration<TreePath> paths = list != null ?
+ Collections.enumeration(list) : tree.getExpandedDescendants(path);
+ TreePath[] sel = retainSelection ? tree.getSelectionPaths() : null;
+ fireTreeStructureChanged(path);
+ if (paths != null)
+ while (paths.hasMoreElements())
+ tree.expandPath(paths.nextElement());
+ if (sel != null)
+ tree.setSelectionPaths(sel);
+ }
+
+
+
+ protected void fireTreeStructureChanged(TreePath path) {
+ Object[] listeners = listenerList.getListenerList();
+ TreeModelEvent e = null;
+ for (int i = listeners.length-2; i>=0; i-=2) {
+ if (listeners[i]==TreeModelListener.class) {
+ if (e == null)
+ e = new TreeModelEvent(this, path, null, null);
+ ((TreeModelListener)listeners[i+1]).treeStructureChanged(e);
+ }
+ }
+ }
+
+ protected void fireTreeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+ Object[] listeners = listenerList.getListenerList();
+ TreeModelEvent e = null;
+ for (int i = listeners.length-2; i>=0; i-=2) {
+ if (listeners[i]==TreeModelListener.class) {
+ if (e == null)
+ e = new TreeModelEvent(this, path, childIndices, childNodes);
+ ((TreeModelListener)listeners[i+1]).treeNodesChanged(e);
+ }
+ }
+ }
+
+ protected void fireTreeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+ Object[] listeners = listenerList.getListenerList();
+ TreeModelEvent e = null;
+ for (int i = listeners.length-2; i>=0; i-=2) {
+ if (listeners[i]==TreeModelListener.class) {
+ if (e == null)
+ e = new TreeModelEvent(this, path, childIndices, childNodes);
+ ((TreeModelListener)listeners[i+1]).treeNodesInserted(e);
+ }
+ }
+ }
+
+ protected void fireTreeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+ Object[] listeners = listenerList.getListenerList();
+ TreeModelEvent e = null;
+ for (int i = listeners.length-2; i>=0; i-=2) {
+ if (listeners[i]==TreeModelListener.class) {
+ if (e == null)
+ e = new TreeModelEvent(this, path, childIndices, childNodes);
+ ((TreeModelListener)listeners[i+1]).treeNodesRemoved(e);
+ }
+ }
+ }
+
+
+ protected class Handler implements Comparator<ValueIndexPair<N>>,
+ TreeModelListener, TreeExpansionListener {
+
+ private Comparator<N> comparator;
+
+ private Collator collator = Collator.getInstance();
+
+ void setComparator(Comparator<N> cmp) {
+ comparator = cmp;
+ collator = cmp == null ? Collator.getInstance() : null;
+ }
+
+ Comparator<N> getComparator() {
+ return comparator;
+ }
+
+ // TODO, maybe switch to TreeWillExpandListener?
+ // TreeExpansionListener was used in case an expanded node
+ // had children that would also be expanded, but it is impossible
+ // for hidden nodes' expansion state to survive a SortOrder change
+ // since they are all erased when the tree structure change event
+ // is fired after changing the SortOrder.
+
+ @Override
+ public void treeCollapsed(TreeExpansionEvent e) {}
+
+ @Override
+ public void treeExpanded(TreeExpansionEvent e) {
+ if (sortOrder != SortOrder.UNSORTED) {
+ TreePath path = e.getPath();
+ Converter converter = getConverter(path.getLastPathComponent());
+ if (converter == null) {
+ ArrayList<TreePath> paths = sortHierarchy(path);
+ fireTreeStructureChangedAndExpand(path, paths, false);
+ }
+ }
+ }
+
+ private boolean isFiltered(Object node) {
+ Converter c = getConverter(node);
+ return c == null ? false : c.isFiltered();
+ }
+
+ private boolean acceptable(TreePath path, Object[] childNodes, int index, ArrayList<TreePath> expand) {
+ return acceptable(path, childNodes, index) ||
+ applyFilter(filter, path.pathByAddingChild(childNodes[index]), expand);
+ }
+
+ @Override
+ public void treeNodesChanged(TreeModelEvent e) {
+ treeNodesChanged(e.getTreePath(), e.getChildIndices(), e.getChildren());
+ }
+
+ protected void treeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+ if (childIndices == null) {
+ // path should be root path
+ // reapply filter
+ if (filter != null)
+ applyFilter(null, null, null, true);
+ return;
+ }
+ Converter converter = getConverter(path.getLastPathComponent());
+ ArrayList<TreePath> expand = null;
+ if (converter != null) {
+ expand = new ArrayList<TreePath>();
+ int childIndex = 0;
+ for (int i=0; i<childIndices.length; i++) {
+ int idx = converter.convertRowIndexToView(childIndices[i]);
+ if (idx >= 0) {
+ // see if the filter dislikes the nodes new state
+ if (converter.isFiltered() &&
+ !isFiltered(childNodes[i]) &&
+ !acceptable(path, childNodes, i)) {
+ // maybe it likes a child nodes state
+ if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+ remove(path, childNodes[i]);
+ continue;
+ }
+ childNodes[childIndex] = childNodes[i];
+ childIndices[childIndex++] = idx;
+ } else if (acceptable(path, childNodes, i, expand)) {
+ int viewIndex = insert(path.getLastPathComponent(),
+ childNodes[i], childIndices[i], converter);
+ fireTreeNodesInserted(path, indices(viewIndex), nodes(childNodes[i]));
+ }
+ }
+ if (childIndex == 0) {
+ maybeFireStructureChange(path, expand);
+ return;
+ }
+ if (sortOrder != SortOrder.UNSORTED && converter.getChildCount() > 1) {
+ sort(path.getLastPathComponent(), createValueIndexPairArray(converter.getChildCount()));
+ fireTreeStructureChangedAndExpand(path, null, true);
+ expandPaths(expand);
+ return;
+ }
+ if (childIndex != childIndices.length) {
+ childIndices = Arrays.copyOf(childIndices, childIndex);
+ childNodes = Arrays.copyOf(childNodes, childIndex);
+ }
+ } else if (filter != null && isFilteredOut(path)) {
+ // see if the filter likes the nodes new states
+ expand = new ArrayList<TreePath>();
+ int[] vtm = null;
+ int idx = 0;
+ for (int i=0; i<childIndices.length; i++) {
+ if (acceptable(path, childNodes, i, expand)) {
+ if (vtm == null)
+ vtm = new int[childIndices.length-i];
+ vtm[idx++] = childIndices[i];
+ }
+ }
+ // filter in path if appropriate
+ if (vtm != null)
+ filterIn(vtm, idx, path, expand);
+ return;
+ }
+ // must fire tree nodes changed even if a
+ // structure change will be fired because the
+ // expanded paths need to be updated first
+ fireTreeNodesChanged(path, childIndices, childNodes);
+ maybeFireStructureChange(path, expand);
+ }
+
+ /**
+ * Helper method for treeNodesChanged...
+ * @param path
+ * @param expand
+ */
+ private void maybeFireStructureChange(TreePath path, ArrayList<TreePath> expand) {
+ if (expand != null && !expand.isEmpty()) {
+ Enumeration<TreePath> expanded = tree.getExpandedDescendants(path);
+ fireTreeStructureChanged(path);
+ if (expanded != null)
+ while (expanded.hasMoreElements())
+ tree.expandPath(expanded.nextElement());
+ expandPaths(expand);
+ }
+ }
+
+ @Override
+ public void treeNodesInserted(TreeModelEvent e) {
+ treeNodesInserted(e.getTreePath(), e.getChildIndices(), e.getChildren());
+ }
+
+ protected void treeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+ Object parent = path.getLastPathComponent();
+ Converter converter = getConverter(parent);
+ ArrayList<TreePath> expand = null;
+ if (converter != null) {
+// if (childIndices.length > 3 && !converter.isFiltered()
+// && childIndices.length > converter.getChildCount()/10) {
+// TreePath expand = sortHierarchy(path);
+// fireTreeStructureChangedAndExpand(expand);
+// return;
+// }
+ int childIndex = 0;
+ for (int i=0; i<childIndices.length; i++) {
+ if (converter.isFiltered()) {
+ // path hasn't met the filter criteria, so childNodes must be filtered
+ if (expand == null)
+ expand = new ArrayList<TreePath>();
+ if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+ continue;
+ }
+ // shift the appropriate cached modelIndices
+ int[] vtm = converter.viewToModel;
+ int modelIndex = childIndices[i];
+ for (int j=vtm.length; --j>=0;) {
+ if (vtm[j] >= modelIndex)
+ vtm[j] += 1;
+ }
+ // insert modelIndex to converter
+ int viewIndex = insert(parent, childNodes[i], modelIndex, converter);
+ childNodes[childIndex] = childNodes[i];
+ childIndices[childIndex++] = viewIndex;
+ }
+ if (childIndex == 0)
+ return;
+ if (childIndex != childIndices.length) {
+ childIndices = Arrays.copyOf(childIndices, childIndex);
+ childNodes = Arrays.copyOf(childNodes, childIndex);
+ }
+ if (childIndex > 1 && sortOrder != SortOrder.UNSORTED) {
+ sort(childIndices, childNodes);
+ }
+ } else if (filter != null && isFilteredOut(path)) {
+ // apply filter to inserted nodes
+ int[] vtm = null;
+ int idx = 0;
+ expand = new ArrayList<TreePath>();
+ for (int i=0; i<childIndices.length; i++) {
+ if (acceptable(path, childNodes, i, expand)) {
+ if (vtm == null)
+ vtm = new int[childIndices.length-i];
+ vtm[idx++] = childIndices[i];
+ }
+ }
+ // filter in path if appropriate
+ if (vtm != null)
+ filterIn(vtm, idx, path, expand);
+ return;
+ }
+ fireTreeNodesInserted(path, childIndices, childNodes);
+ expandPaths(expand);
+ }
+
+ @Override
+ public void treeNodesRemoved(TreeModelEvent e) {
+ treeNodesRemoved(e.getTreePath(), e.getChildIndices(), e.getChildren());
+ }
+
+
+ private boolean isFilterStartPath(TreePath path) {
+ if (filterStartPath == null)
+ return path.getPathCount() == 1;
+ return filterStartPath.equals(path);
+ }
+
+ protected void treeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+ Object parent = path.getLastPathComponent();
+ Converter converter = getConverter(parent);
+ if (converter != null) {
+ int len = 0;
+ for (int i=0; i<childNodes.length; i++) {
+ removeConverter(childNodes[i]);
+ int viewIndex = converter.convertRowIndexToView(childIndices[i]);
+ if (viewIndex >= 0) {
+ childNodes[len] = childNodes[i];
+ childIndices[len++] = viewIndex;
+ }
+ }
+ if (len == 0)
+ return;
+ if (converter.isFiltered() && converter.getChildCount() == len) {
+ ArrayList<TreePath> expand = new ArrayList<TreePath>();
+ if (applyFilter(filter, path, expand)) {
+ expand.retainAll(getExpandedPaths(path));
+ if (sortOrder != SortOrder.UNSORTED)
+ sortHierarchy(path);
+ fireTreeStructureChangedAndExpand(path, expand, true);
+ } else if (isFilterStartPath(path)) {
+ converters.put(parent, new Converter(Converter.EMPTY, true));
+ fireTreeStructureChanged(path);
+ } else {
+ remove(path.getParentPath(), parent);
+ }
+ return;
+ }
+ if (len != childIndices.length) {
+ childIndices = Arrays.copyOf(childIndices, len);
+ childNodes = Arrays.copyOf(childNodes, len);
+ }
+ if (len > 1 && sortOrder != SortOrder.UNSORTED) {
+ sort(childIndices, childNodes);
+ }
+ if (childIndices.length == 1) {
+ converter.remove(converter.convertRowIndexToModel(childIndices[0]));
+ } else {
+ converter.remove(childIndices);
+ }
+ } else if (filter != null && isFilteredOut(path)) {
+ return;
+ }
+ fireTreeNodesRemoved(path, childIndices, childNodes);
+ }
+
+ private Collection<TreePath> getExpandedPaths(TreePath path) {
+ Enumeration<TreePath> en = tree.getExpandedDescendants(path);
+ if (en == null)
+ return Collections.emptySet();
+ HashSet<TreePath> expanded = new HashSet<TreePath>();
+ while (en.hasMoreElements())
+ expanded.add(en.nextElement());
+ return expanded;
+ }
+
+ @Override
+ public void treeStructureChanged(TreeModelEvent e) {
+ if (converters != null) {
+ // not enough information to properly clean up
+ // reapply filter/sort
+ converters = createConvertersMap();
+ TreePath[] sel = tree.getSelectionPaths();
+ if (filter != null) {
+ applyFilter(null, null, getExpandedPaths(new TreePath(getRoot())), false);
+ }
+ if (sortOrder != SortOrder.UNSORTED) {
+ TreePath path = new TreePath(getRoot());
+ ArrayList<TreePath> expand = sortHierarchy(path);
+ fireTreeStructureChangedAndExpand(path, expand, false);
+ }
+ if (sel != null) {
+ tree.clearSelection();
+ TreePath changedPath = e.getTreePath();
+ for (TreePath path : sel) {
+ if (!changedPath.isDescendant(path))
+ tree.addSelectionPath(path);
+ }
+ }
+ } else {
+ fireTreeStructureChanged(e.getTreePath());
+ }
+ }
+
+
+ @Override
+ public final int compare(ValueIndexPair<N> a, ValueIndexPair<N> b) {
+ return compareNodes(a.value, b.value);
+ }
+
+
+ protected int compareNodes(N a, N b) {
+ if (comparator != null)
+ return comparator.compare(a, b);
+ return collator.compare(a.toString(), b.toString());
+ }
+
+ private void removeConverter(Object node) {
+ Converter c = getConverter(node);
+ if (c != null)
+ removeConverter(c, node);
+ }
+
+ private void removeConverter(Converter converter, Object node) {
+ for (int i=converter.getChildCount(); --i>=0;) {
+ int index = converter.convertRowIndexToModel(i);
+ Object child = model.getChild(node, index);
+ Converter c = getConverter(child);
+ if (c != null)
+ removeConverter(c, child);
+ }
+ converters.remove(node);
+ }
+
+ private boolean isFilteredOut(TreePath path) {
+ if (filterStartPath != null && !filterStartPath.isDescendant(path))
+ return false;
+ TreePath parent = path.getParentPath();
+ // root should always have a converter if filter is non-null,
+ // so if parent is ever null, there is a bug somewhere else
+ Converter c = getConverter(parent.getLastPathComponent());
+ if (c != null) {
+ return getIndexOfChild(
+ parent.getLastPathComponent(),
+ path.getLastPathComponent()) < 0;
+ }
+ return isFilteredOut(parent);
+ }
+
+ private void filterIn(int[] vtm, int vtmLength, TreePath path, ArrayList<TreePath> expand) {
+ Object node = path.getLastPathComponent();
+ if (vtmLength != vtm.length)
+ vtm = Arrays.copyOf(vtm, vtmLength);
+ Converter converter = new Converter(vtm, true);
+ converters.put(node, converter);
+ insert(path.getParentPath(), node);
+ tree.expandPath(path);
+ expandPaths(expand);
+ }
+
+ private boolean acceptable(TreePath path, Object[] nodes, int index) {
+ Object node = nodes[index];
+ return filter.acceptNode(path, (N)node, model.isLeaf(node));
+ }
+
+ private int ascInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+ for (int i=vtm.length; --i>=0;) {
+ int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+ if (cmp > 0 || (cmp == 0 && idx > vtm[i])) {
+ return i+1;
+ }
+ }
+ return 0;
+ }
+
+
+ private int dscInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+ for (int i=vtm.length; --i>=0;) {
+ int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+ if (cmp < 0) {
+ return i+1;
+ } else if (cmp == 0 && idx < vtm[i]) {
+ return i;
+ }
+ }
+ return 0;
+ }
+
+
+ /**
+ * Inserts the specified path and node and any parent paths as necessary.
+ * <p>
+ * Fires appropriate event.
+ * @param path
+ * @param node
+ */
+ private void insert(TreePath path, Object node) {
+ Object parent = path.getLastPathComponent();
+ Converter converter = converters.get(parent);
+ int modelIndex = model.getIndexOfChild(parent, node);
+ if (converter == null) {
+ converter = new Converter(indices(modelIndex), true);
+ converters.put(parent, converter);
+ insert(path.getParentPath(), parent);
+ } else {
+ int viewIndex = insert(parent, node, modelIndex, converter);
+ fireTreeNodesInserted(path, indices(viewIndex), nodes(node));
+ }
+ }
+
+ /**
+ * Inserts node into parent in correct sort order.
+ * <p>
+ * Responsibility of caller to fire appropriate event with the returned viewIndex.
+ * @param path
+ * @param node
+ * @param modelIndex
+ * @param converter
+ * @return viewIndex
+ */
+ private int insert(Object parent, Object node, int modelIndex, Converter converter) {
+ int[] vtm = converter.viewToModel;
+ int viewIndex;
+ switch (sortOrder) {
+ case ASCENDING:
+ viewIndex = ascInsertionIndex(vtm, parent, (N)node, modelIndex);
+ break;
+ case DESCENDING:
+ viewIndex = dscInsertionIndex(vtm, parent, (N)node, modelIndex);
+ break;
+ default: case UNSORTED:
+ viewIndex = unsortedInsertionIndex(vtm, modelIndex);
+ break;
+ }
+ int[] a = new int[vtm.length+1];
+ System.arraycopy(vtm, 0, a, 0, viewIndex);
+ System.arraycopy(vtm, viewIndex, a, viewIndex+1, vtm.length-viewIndex);
+ a[viewIndex] = modelIndex;
+ converter.viewToModel = a;
+ return viewIndex;
+ }
+
+ private void remove(TreePath path, Object node) {
+ Object parent = path.getLastPathComponent();
+ if (path.getPathCount() == 1 || (filterStartPath != null && filterStartPath.equals(path))) {
+ removeConverter(node);
+ converters.put(parent, new Converter(Converter.EMPTY, true));
+ fireTreeNodesRemoved(path, indices(0), nodes(node));
+ return;
+ }
+ Converter converter = converters.get(parent);
+ int modelIndex = model.getIndexOfChild(parent, node);
+ int viewIndex = converter.remove(modelIndex);
+ switch (viewIndex) {
+ default:
+ removeConverter(node);
+ fireTreeNodesRemoved(path, indices(viewIndex), nodes(node));
+ break;
+ case Converter.ONLY_INDEX:
+// if (path.getParentPath() == null) {
+// // reached filter root
+// removeConverter(node);
+// converters.put(parent, new Converter(Converter.EMPTY, true));
+// fireTreeNodesRemoved(path, indices(0), nodes(node));
+// return;
+// }
+ remove(path.getParentPath(), parent);
+ break;
+ case Converter.INDEX_NOT_FOUND:
+ removeConverter(node);
+ }
+ }
+
+
+
+ }
+
+
+
+ private static int unsortedInsertionIndex(int[] vtm, int idx) {
+ for (int i=vtm.length; --i>=0;)
+ if (vtm[i] < idx)
+ return i+1;
+ return 0;
+ }
+
+ private static void sort(int[] childIndices, Object[] childNodes) {
+ int len = childIndices.length;
+ ValueIndexPair[] pairs = new ValueIndexPair[len];
+ for (int i=len; --i>=0;)
+ pairs[i] = new ValueIndexPair<Object>(childIndices[i], childNodes[i]);
+ Arrays.sort(pairs);
+ for (int i=len; --i>=0;) {
+ childIndices[i] = pairs[i].index;
+ childNodes[i] = pairs[i].value;
+ }
+ }
+
+ private static int[] indices(int...indices) {
+ return indices;
+ }
+
+ private static Object[] nodes(Object...nodes) {
+ return nodes;
+ }
+
+
+
+
+ /**
+ * This class has a dual purpose, both related to comparing/sorting.
+ * <p>
+ * The Handler class sorts an array of ValueIndexPair based on the value.
+ * Used for sorting the view.
+ * <p>
+ * ValueIndexPair sorts itself based on the index.
+ * Used for sorting childIndices for fire* methods.
+ */
+ private static class ValueIndexPair<N> implements Comparable<ValueIndexPair<N>> {
+ ValueIndexPair() {}
+
+ ValueIndexPair(int idx, N val) {
+ index = idx;
+ value = val;
+ }
+
+ N value;
+
+ int index;
+
+ public int compareTo(ValueIndexPair<N> o) {
+ return index - o.index;
+ }
+ }
+
+ private static class Converter {
+
+ static final int[] EMPTY = new int[0];
+
+ static final int ONLY_INDEX = -2;
+
+ static final int INDEX_NOT_FOUND = -1;
+
+ Converter(int[] vtm, boolean filtered) {
+ viewToModel = vtm;
+ isFiltered = filtered;
+ }
+
+ private int[] viewToModel;
+
+ private boolean isFiltered;
+
+// public boolean equals(Converter conv) {
+// if (conv == null)
+// return false;
+// if (isFiltered != conv.isFiltered)
+// return false;
+// return Arrays.equals(viewToModel, conv.viewToModel);
+// }
+
+ boolean isFiltered() {
+ return isFiltered;
+ }
+
+ void remove(int viewIndices[]) {
+ int len = viewToModel.length - viewIndices.length;
+ if (len == 0) {
+ viewToModel = EMPTY;
+ } else {
+ int[] oldVTM = viewToModel;
+ int[] newVTM = new int[len];
+ for (int oldIndex=0, newIndex=0, removeIndex=0;
+ newIndex<newVTM.length;
+ newIndex++, oldIndex++) {
+ while (removeIndex < viewIndices.length && oldIndex == viewIndices[removeIndex]) {
+ int idx = oldVTM[oldIndex];
+ removeIndex++;
+ oldIndex++;
+ for (int i=newIndex; --i>=0;)
+ if (newVTM[i] > idx)
+ newVTM[i]--;
+ for (int i=oldIndex; i<oldVTM.length; i++)
+ if (oldVTM[i] > idx)
+ oldVTM[i]--;
+ }
+ newVTM[newIndex] = oldVTM[oldIndex];
+ }
+ viewToModel = newVTM;
+ }
+ }
+
+ /**
+ * @param modelIndex
+ * @return viewIndex that was removed<br>
+ * or <code>ONLY_INDEX</code> if the modelIndex is the only one in the view<br>
+ * or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+ */
+ int remove(int modelIndex) {
+ int[] vtm = viewToModel;
+ for (int i=vtm.length; --i>=0;) {
+ if (vtm[i] > modelIndex) {
+ vtm[i] -= 1;
+ } else if (vtm[i] == modelIndex) {
+ if (vtm.length == 1) {
+ viewToModel = EMPTY;
+ return ONLY_INDEX;
+ }
+ int viewIndex = i;
+ while (--i>=0) {
+ if (vtm[i] > modelIndex)
+ vtm[i] -= 1;
+ }
+ int[] a = new int[vtm.length-1];
+ if (viewIndex > 0)
+ System.arraycopy(vtm, 0, a, 0, viewIndex);
+ int len = a.length-viewIndex;
+ if (len > 0)
+ System.arraycopy(vtm, viewIndex+1, a, viewIndex, len);
+ viewToModel = a;
+ return viewIndex;
+ }
+ }
+ return INDEX_NOT_FOUND;
+ }
+
+
+ int getChildCount() {
+ return viewToModel.length;
+ }
+
+ /**
+ * @param modelIndex
+ * @return viewIndex corresponding to modelIndex<br>
+ * or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+ */
+ int convertRowIndexToView(int modelIndex) {
+ int[] vtm = viewToModel;
+ for (int i=vtm.length; --i>=0;) {
+ if (vtm[i] == modelIndex)
+ return i;
+ }
+ return INDEX_NOT_FOUND;
+ }
+
+ int convertRowIndexToModel(int viewIndex) {
+ return viewToModel[viewIndex];
+ }
+ }
+
+ public interface Filter<N> {
+ boolean acceptNode(TreePath parent, N node, boolean leaf);
+ }
+
+ public static class RegexFilter<N> implements Filter<N> {
+
+ public RegexFilter(Pattern pattern, boolean leaf) {
+ matcher = pattern.matcher("");
+ leafOnly = leaf;
+ }
+
+ private Matcher matcher;
+
+ private boolean leafOnly;
+
+ public boolean acceptNode(TreePath parent, N node, boolean leaf) {
+ if (leafOnly && !leaf)
+ return false;
+ matcher.reset(getStringValue(node));
+ return matcher.find();
+ }
+
+ protected String getStringValue(N node) {
+ return node.toString();
+ }
+ }
+
+
+ private static Map<Object,Converter> createConvertersMap() {
+ return new HashMap<Object,Converter>();
+ }
+}
--- /dev/null
+package be.nikiroo.utils.ui;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import javax.swing.JTree;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreeNode;
+import javax.swing.tree.TreePath;
+
+public class TreeSnapshot {
+ private interface NodeAction {
+ public void run(TreeNode node);
+ }
+
+ private JTree tree;
+ private TreePath[] selectionPaths;
+ private List<TreePath> expanded;
+
+ public TreeSnapshot(JTree tree) {
+ this.tree = tree;
+
+ selectionPaths = tree.getSelectionPaths();
+ if (selectionPaths == null) {
+ selectionPaths = new TreePath[0];
+ }
+
+ expanded = new ArrayList<TreePath>();
+ forEach(tree, new NodeAction() {
+ @Override
+ public void run(TreeNode node) {
+ TreePath path = nodeToPath(node);
+ if (path != null) {
+ if (TreeSnapshot.this.tree.isExpanded(path)) {
+ expanded.add(path);
+ }
+ }
+ }
+ });
+ }
+
+ public void apply() {
+ applyTo(tree);
+ }
+
+ public void applyTo(JTree tree) {
+ final List<TreePath> newExpanded = new ArrayList<TreePath>();
+ final List<TreePath> newSlectionPaths = new ArrayList<TreePath>();
+
+ forEach(tree, new NodeAction() {
+ @Override
+ public void run(TreeNode newNode) {
+ TreePath newPath = nodeToPath(newNode);
+ if (newPath != null) {
+ for (TreePath path : selectionPaths) {
+ if (isSamePath(path, newPath)) {
+ newSlectionPaths.add(newPath);
+ if (expanded.contains(path)) {
+ newExpanded.add(newPath);
+ }
+ }
+ }
+ }
+ }
+ });
+
+ for (TreePath newPath : newExpanded) {
+ tree.expandPath(newPath);
+ }
+
+ tree.setSelectionPaths(newSlectionPaths.toArray(new TreePath[0]));
+ }
+
+ // You can override this
+ protected boolean isSamePath(TreePath oldPath, TreePath newPath) {
+ return newPath.toString().equals(oldPath.toString());
+ }
+
+ private void forEach(JTree tree, NodeAction action) {
+ forEach(tree.getModel(), tree.getModel().getRoot(), action);
+ }
+
+ private void forEach(TreeModel model, Object parent, NodeAction action) {
+ if (!(parent instanceof TreeNode))
+ return;
+
+ TreeNode node = (TreeNode) parent;
+
+ action.run(node);
+ int count = model.getChildCount(node);
+ for (int i = 0; i < count; i++) {
+ Object child = model.getChild(node, i);
+ forEach(model, child, action);
+ }
+ }
+
+ private static TreePath nodeToPath(TreeNode node) {
+ List<Object> nodes = new LinkedList<Object>();
+ if (node != null) {
+ nodes.add(node);
+ node = node.getParent();
+ while (node != null) {
+ nodes.add(0, node);
+ node = node.getParent();
+ }
+ }
+
+ return nodes.isEmpty() ? null : new TreePath(nodes.toArray());
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Tree Snapshot of: ").append(tree).append("\n");
+ builder.append("Selected paths:\n");
+ for (TreePath path : selectionPaths) {
+ builder.append("\t").append(path).append("\n");
+ }
+ builder.append("Expanded paths:\n");
+ for (TreePath epath : expanded) {
+ builder.append("\t").append(epath).append("\n");
+ }
+
+ return builder.toString();
+ }
+}
import java.awt.RadialGradientPaint;
import java.awt.RenderingHints;
+import javax.swing.JComponent;
+import javax.swing.JScrollPane;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
*/
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,
// 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(),
// 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,
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,
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;
+ }
}