/* * Copyright (C) 2007-2013 Geometer Plus * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * 02110-1301, USA. */ package org.geometerplus.fbreader.library; import java.io.File; import java.util.*; import org.geometerplus.zlibrary.core.filesystem.*; import org.geometerplus.zlibrary.core.resources.ZLResource; import org.geometerplus.fbreader.Paths; import org.geometerplus.fbreader.book.*; import org.geometerplus.fbreader.bookmodel.BookReadingException; import org.geometerplus.fbreader.tree.FBTree; public final class Library { public static ZLResource resource() { return ZLResource.resource("library"); } public static final String ROOT_FOUND = "found"; public static final String ROOT_FAVORITES = "favorites"; public static final String ROOT_RECENT = "recent"; public static final String ROOT_BY_AUTHOR = "byAuthor"; public static final String ROOT_BY_TITLE = "byTitle"; public static final String ROOT_BY_SERIES = "bySeries"; public static final String ROOT_BY_TAG = "byTag"; public static final String ROOT_FILE_TREE = "fileTree"; public static final int REMOVE_DONT_REMOVE = 0x00; public static final int REMOVE_FROM_LIBRARY = 0x01; public static final int REMOVE_FROM_DISK = 0x02; public static final int REMOVE_FROM_LIBRARY_AND_DISK = REMOVE_FROM_LIBRARY | REMOVE_FROM_DISK; private static Library ourInstance; public static Library Instance() { if (ourInstance == null) { ourInstance = new Library(BooksDatabase.Instance()); } return ourInstance; } private final List myListeners = Collections.synchronizedList(new LinkedList()); public interface ChangeListener { public enum Code { BookAdded, BookRemoved, StatusChanged, Found, NotFound } void onLibraryChanged(Code code); } public void addChangeListener(ChangeListener listener) { myListeners.add(listener); } public void removeChangeListener(ChangeListener listener) { myListeners.remove(listener); } protected void fireModelChangedEvent(ChangeListener.Code code) { synchronized (myListeners) { for (ChangeListener l : myListeners) { l.onLibraryChanged(code); } } } public final IBookCollection Collection; private final BooksDatabase myDatabase; private final Map myBooks = Collections.synchronizedMap(new HashMap()); private final RootTree myRootTree = new RootTree(); private boolean myDoGroupTitlesByFirstLetter; private final static int STATUS_LOADING = 1; private final static int STATUS_SEARCHING = 2; private volatile int myStatusMask = 0; private final Object myStatusLock = new Object(); private void setStatus(int status) { synchronized (myStatusLock) { if (myStatusMask != status) { myStatusMask = status; fireModelChangedEvent(ChangeListener.Code.StatusChanged); } } } public Library(BooksDatabase db) { Collection = null; myDatabase = db; new FavoritesTree(myRootTree, ROOT_FAVORITES); new FirstLevelTree(myRootTree, ROOT_RECENT); new FirstLevelTree(myRootTree, ROOT_BY_AUTHOR); new FirstLevelTree(myRootTree, ROOT_BY_TITLE); new FirstLevelTree(myRootTree, ROOT_BY_TAG); new FileFirstLevelTree(Collection, myRootTree, ROOT_FILE_TREE); } public LibraryTree getRootTree() { return myRootTree; } private FirstLevelTree getFirstLevelTree(String key) { return (FirstLevelTree)myRootTree.getSubTree(key); } public LibraryTree getLibraryTree(LibraryTree.Key key) { if (key == null) { return null; } if (key.Parent == null) { return key.Id.equals(myRootTree.getUniqueKey().Id) ? myRootTree : null; } final LibraryTree parentTree = getLibraryTree(key.Parent); return parentTree != null ? (LibraryTree)parentTree.getSubTree(key.Id) : null; } public static ZLResourceFile getHelpFile() { final Locale locale = Locale.getDefault(); ZLResourceFile file = ZLResourceFile.createResourceFile( "data/help/MiniHelp." + locale.getLanguage() + "_" + locale.getCountry() + ".fb2" ); if (file.exists()) { return file; } file = ZLResourceFile.createResourceFile( "data/help/MiniHelp." + locale.getLanguage() + ".fb2" ); if (file.exists()) { return file; } return ZLResourceFile.createResourceFile("data/help/MiniHelp.en.fb2"); } private void collectBooks( ZLFile file, FileInfoSet fileInfos, Map savedBooksByFileId, Map orphanedBooksByFileId, Set newBooks, boolean doReadMetaInfo ) { final long fileId = fileInfos.getId(file); if (savedBooksByFileId.get(fileId) != null) { return; } try { final Book book = orphanedBooksByFileId.get(fileId); if (book != null) { if (doReadMetaInfo) { book.readMetaInfo(); } addBookToLibrary(book); fireModelChangedEvent(ChangeListener.Code.BookAdded); newBooks.add(book); return; } } catch (BookReadingException e) { // ignore } try { final Book book = new Book(file); addBookToLibrary(book); fireModelChangedEvent(ChangeListener.Code.BookAdded); newBooks.add(book); return; } catch (BookReadingException e) { // ignore } if (file.isArchive()) { for (ZLFile entry : fileInfos.archiveEntries(file)) { collectBooks( entry, fileInfos, savedBooksByFileId, orphanedBooksByFileId, newBooks, doReadMetaInfo ); } } } private List collectPhysicalFiles() { final Queue dirQueue = new LinkedList(); final HashSet dirSet = new HashSet(); final LinkedList fileList = new LinkedList(); dirQueue.offer(new ZLPhysicalFile(new File(Paths.BooksDirectoryOption().getValue()))); while (!dirQueue.isEmpty()) { for (ZLFile file : dirQueue.poll().children()) { if (file.isDirectory()) { if (!dirSet.contains(file)) { dirQueue.add(file); dirSet.add(file); } } else { file.setCached(true); fileList.add((ZLPhysicalFile)file); } } } return fileList; } private final List myNullList = Collections.singletonList(null); private LibraryTree getTagTree(Tag tag) { if (tag == null || tag.Parent == null) { return getFirstLevelTree(ROOT_BY_TAG).getTagSubTree(tag); } else { return getTagTree(tag.Parent).getTagSubTree(tag); } } private synchronized void addBookToLibrary(Book book) { if (myBooks.containsKey(book.File)) { return; } myBooks.put(book.File, book); List authors = book.authors(); if (authors.isEmpty()) { authors = (List)myNullList; } final SeriesInfo seriesInfo = book.getSeriesInfo(); for (Author a : authors) { final AuthorTree authorTree = getFirstLevelTree(ROOT_BY_AUTHOR).getAuthorSubTree(a); if (seriesInfo == null) { authorTree.getBookSubTree(book, false); } else { authorTree.getSeriesSubTree(seriesInfo.Title).getBookInSeriesSubTree(book); } } if (seriesInfo != null) { FirstLevelTree seriesRoot = getFirstLevelTree(ROOT_BY_SERIES); if (seriesRoot == null) { seriesRoot = new FirstLevelTree( myRootTree, myRootTree.indexOf(getFirstLevelTree(ROOT_BY_TITLE)) + 1, ROOT_BY_SERIES ); } seriesRoot.getSeriesSubTree(seriesInfo.Title).getBookInSeriesSubTree(book); } if (myDoGroupTitlesByFirstLetter) { final String letter = TitleTree.firstTitleLetter(book); if (letter != null) { final TitleTree tree = getFirstLevelTree(ROOT_BY_TITLE).getTitleSubTree(letter); tree.getBookSubTree(book, true); } } else { getFirstLevelTree(ROOT_BY_TITLE).getBookSubTree(book, true); } List tags = book.tags(); if (tags.isEmpty()) { tags = (List)myNullList; } for (Tag t : tags) { getTagTree(t).getBookSubTree(book, true); } final SearchResultsTree found = (SearchResultsTree)getFirstLevelTree(ROOT_FOUND); if (found != null && book.matches(found.getPattern())) { found.getBookSubTree(book, true); } } private void removeFromTree(String rootId, Book book) { final FirstLevelTree tree = getFirstLevelTree(rootId); if (tree != null) { tree.removeBook(book, false); } } private void refreshInTree(String rootId, Book book) { final FirstLevelTree tree = getFirstLevelTree(rootId); if (tree != null) { int index = tree.indexOf(new BookTree(book, true)); if (index >= 0) { tree.removeBook(book, false); new BookTree(tree, book, true, index); } } } public synchronized void refreshBookInfo(Book book) { if (book == null) { return; } myBooks.remove(book.File); refreshInTree(ROOT_FAVORITES, book); refreshInTree(ROOT_RECENT, book); removeFromTree(ROOT_FOUND, book); removeFromTree(ROOT_BY_TITLE, book); removeFromTree(ROOT_BY_SERIES, book); removeFromTree(ROOT_BY_AUTHOR, book); removeFromTree(ROOT_BY_TAG, book); addBookToLibrary(book); fireModelChangedEvent(ChangeListener.Code.BookAdded); } private void build() { // Step 0: get database books marked as "existing" final FileInfoSet fileInfos = new FileInfoSet(myDatabase); final Map savedBooksByFileId = myDatabase.loadBooks(fileInfos, true); final Map savedBooksByBookId = new HashMap(); for (Book b : savedBooksByFileId.values()) { savedBooksByBookId.put(b.getId(), b); } // Step 1: set myDoGroupTitlesByFirstLetter value, // add "existing" books into recent and favorites lists if (savedBooksByFileId.size() > 10) { final HashSet letterSet = new HashSet(); for (Book book : savedBooksByFileId.values()) { final String letter = TitleTree.firstTitleLetter(book); if (letter != null) { letterSet.add(letter); } } myDoGroupTitlesByFirstLetter = savedBooksByFileId.values().size() > letterSet.size() * 5 / 4; } for (long id : myDatabase.loadRecentBookIds()) { Book book = savedBooksByBookId.get(id); if (book == null) { book = Book.getById(id); if (book != null && !book.File.exists()) { book = null; } } if (book != null) { new BookTree(getFirstLevelTree(ROOT_RECENT), book, true); } } for (long id : myDatabase.loadFavoriteIds()) { Book book = savedBooksByBookId.get(id); if (book == null) { book = Book.getById(id); if (book != null && !book.File.exists()) { book = null; } } if (book != null) { getFirstLevelTree(ROOT_FAVORITES).getBookSubTree(book, true); } } fireModelChangedEvent(ChangeListener.Code.BookAdded); // Step 2: check if files corresponding to "existing" books really exists; // add books to library if yes (and reload book info if needed); // remove from recent/favorites list if no; // collect newly "orphaned" books final Set orphanedBooks = new HashSet(); final Set physicalFiles = new HashSet(); int count = 0; for (Book book : savedBooksByFileId.values()) { synchronized (this) { final ZLPhysicalFile file = book.File.getPhysicalFile(); if (file != null) { physicalFiles.add(file); } if (file != book.File && file != null && file.getPath().endsWith(".epub")) { continue; } if (book.File.exists()) { boolean doAdd = true; if (file == null) { continue; } if (!fileInfos.check(file, true)) { try { book.readMetaInfo(); book.save(); } catch (BookReadingException e) { doAdd = false; } file.setCached(false); } if (doAdd) { addBookToLibrary(book); if (++count % 16 == 0) { fireModelChangedEvent(ChangeListener.Code.BookAdded); } } } else { myRootTree.removeBook(book, true); fireModelChangedEvent(ChangeListener.Code.BookRemoved); orphanedBooks.add(book); } } } fireModelChangedEvent(ChangeListener.Code.BookAdded); myDatabase.setExistingFlag(orphanedBooks, false); // Step 3: collect books from physical files; add new, update already added, // unmark orphaned as existing again, collect newly added final Map orphanedBooksByFileId = myDatabase.loadBooks(fileInfos, false); final Set newBooks = new HashSet(); final List physicalFilesList = collectPhysicalFiles(); for (ZLPhysicalFile file : physicalFilesList) { if (physicalFiles.contains(file)) { continue; } collectBooks( file, fileInfos, savedBooksByFileId, orphanedBooksByFileId, newBooks, !fileInfos.check(file, true) ); file.setCached(false); } // Step 4: add help file try { final ZLFile helpFile = getHelpFile(); Book helpBook = savedBooksByFileId.get(fileInfos.getId(helpFile)); if (helpBook == null) { helpBook = new Book(helpFile); } addBookToLibrary(helpBook); fireModelChangedEvent(ChangeListener.Code.BookAdded); } catch (BookReadingException e) { // that's impossible e.printStackTrace(); } // Step 5: save changes into database fileInfos.save(); myDatabase.executeAsATransaction(new Runnable() { public void run() { for (Book book : newBooks) { book.save(); } } }); myDatabase.setExistingFlag(newBooks, true); } private volatile boolean myBuildStarted = false; public synchronized void startBuild() { if (myBuildStarted) { fireModelChangedEvent(ChangeListener.Code.StatusChanged); return; } myBuildStarted = true; setStatus(myStatusMask | STATUS_LOADING); final Thread builder = new Thread("Library.build") { public void run() { try { build(); } finally { setStatus(myStatusMask & ~STATUS_LOADING); } } }; builder.setPriority((Thread.MIN_PRIORITY + Thread.NORM_PRIORITY) / 2); builder.start(); } public boolean isUpToDate() { return myStatusMask == 0; } public Book getRecentBook() { List recentIds = myDatabase.loadRecentBookIds(); return recentIds.size() > 0 ? Book.getById(recentIds.get(0)) : null; } public Book getPreviousBook() { List recentIds = myDatabase.loadRecentBookIds(); return recentIds.size() > 1 ? Book.getById(recentIds.get(1)) : null; } public void startBookSearch(final String pattern) { setStatus(myStatusMask | STATUS_SEARCHING); final Thread searcher = new Thread("Library.searchBooks") { public void run() { try { searchBooks(pattern); } finally { setStatus(myStatusMask & ~STATUS_SEARCHING); } } }; searcher.setPriority((Thread.MIN_PRIORITY + Thread.NORM_PRIORITY) / 2); searcher.start(); } private void searchBooks(String pattern) { if (pattern == null) { fireModelChangedEvent(ChangeListener.Code.NotFound); return; } pattern = pattern.toLowerCase(); final SearchResultsTree oldSearchResults = (SearchResultsTree)getFirstLevelTree(ROOT_FOUND); if (oldSearchResults != null && pattern.equals(oldSearchResults.getPattern())) { fireModelChangedEvent(ChangeListener.Code.Found); return; } FirstLevelTree newSearchResults = null; final List booksCopy; synchronized (myBooks) { booksCopy = new ArrayList(myBooks.values()); } for (Book book : booksCopy) { if (book.matches(pattern)) { synchronized (this) { if (newSearchResults == null) { if (oldSearchResults != null) { oldSearchResults.removeSelf(); } newSearchResults = new SearchResultsTree(myRootTree, ROOT_FOUND, pattern); fireModelChangedEvent(ChangeListener.Code.Found); } newSearchResults.getBookSubTree(book, true); fireModelChangedEvent(ChangeListener.Code.BookAdded); } } } if (newSearchResults == null) { fireModelChangedEvent(ChangeListener.Code.NotFound); } } public void addBookToRecentList(Book book) { final List ids = myDatabase.loadRecentBookIds(); final Long bookId = book.getId(); ids.remove(bookId); ids.add(0, bookId); if (ids.size() > 12) { ids.remove(12); } myDatabase.saveRecentBookIds(ids); } public boolean isBookInFavorites(Book book) { if (book == null) { return false; } final LibraryTree rootFavorites = getFirstLevelTree(ROOT_FAVORITES); for (FBTree tree : rootFavorites.subTrees()) { if (tree instanceof BookTree && book.equals(((BookTree)tree).Book)) { return true; } } return false; } public void addBookToFavorites(Book book) { if (isBookInFavorites(book)) { return; } final LibraryTree rootFavorites = getFirstLevelTree(ROOT_FAVORITES); rootFavorites.getBookSubTree(book, true); myDatabase.addToFavorites(book.getId()); } public void removeBookFromFavorites(Book book) { if (getFirstLevelTree(ROOT_FAVORITES).removeBook(book, false)) { myDatabase.removeFromFavorites(book.getId()); fireModelChangedEvent(ChangeListener.Code.BookRemoved); } } public boolean canRemoveBookFile(Book book) { ZLFile file = book.File; if (file.getPhysicalFile() == null) { return false; } while (file instanceof ZLArchiveEntryFile) { file = file.getParent(); if (file.children().size() != 1) { return false; } } return true; } public void removeBook(Book book, int removeMode) { if (removeMode == REMOVE_DONT_REMOVE) { return; } myBooks.remove(book.File); if (getFirstLevelTree(ROOT_RECENT).removeBook(book, false)) { final List ids = myDatabase.loadRecentBookIds(); ids.remove(book.getId()); myDatabase.saveRecentBookIds(ids); } getFirstLevelTree(ROOT_FAVORITES).removeBook(book, false); myRootTree.removeBook(book, true); if ((removeMode & REMOVE_FROM_DISK) != 0) { book.File.getPhysicalFile().delete(); } } public List allBookmarks() { return BooksDatabase.Instance().loadAllVisibleBookmarks(); } public List invisibleBookmarks(Book book) { final List list = BooksDatabase.Instance().loadBookmarks(book.getId(), false); Collections.sort(list, new Bookmark.ByTimeComparator()); return list; } }