package org.limewire.ui.swing.search.model;
import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import javax.swing.SwingUtilities;
import org.limewire.collection.glazedlists.GlazedListsFactory;
import org.limewire.core.api.URN;
import org.limewire.core.api.download.DownloadAction;
import org.limewire.core.api.download.DownloadException;
import org.limewire.core.api.download.DownloadItem;
import org.limewire.core.api.download.DownloadListManager;
import org.limewire.core.api.search.Search;
import org.limewire.core.api.search.SearchCategory;
import org.limewire.core.api.search.SearchListener;
import org.limewire.core.api.search.SearchResult;
import org.limewire.core.api.search.SearchDetails.SearchType;
import org.limewire.logging.Log;
import org.limewire.logging.LogFactory;
import org.limewire.ui.swing.components.DisposalListener;
import org.limewire.ui.swing.filter.FilterDebugger;
import org.limewire.ui.swing.search.SearchInfo;
import org.limewire.ui.swing.util.DownloadExceptionHandler;
import org.limewire.ui.swing.util.PropertiableHeadings;
import ca.odell.glazedlists.BasicEventList;
import ca.odell.glazedlists.EventList;
import ca.odell.glazedlists.FilterList;
import ca.odell.glazedlists.SortedList;
import ca.odell.glazedlists.TransactionList;
import ca.odell.glazedlists.matchers.AbstractMatcherEditor;
import ca.odell.glazedlists.matchers.Matcher;
import ca.odell.glazedlists.matchers.MatcherEditor;
import ca.odell.glazedlists.matchers.MatcherEditor.Event;
import ca.odell.glazedlists.util.concurrent.Lock;
import ca.odell.glazedlists.util.concurrent.ReadWriteLock;
import com.google.inject.Provider;
/**
* The default implementation of SearchResultsModel containing the results of
* a search. This assembles search results into grouped, filtered, and sorted
* lists, provides access to details about the search request, and handles
* requests to download a search result.
*/
class BasicSearchResultsModel implements SearchResultsModel, VisualSearchResultStatusListener {
private static final Log LOG = LogFactory.getLog(BasicSearchResultsModel.class);
/** Filter debugger associated with this model. */
private final FilterDebugger<VisualSearchResult> filterDebugger;
/** Descriptor containing search details. */
private final SearchInfo searchInfo;
/** Search request object. */
private final Search search;
/** Core download manager. */
private final DownloadListManager downloadListManager;
/** Download exception handler. */
private final Provider<DownloadExceptionHandler> downloadExceptionHandler;
/** Total number of search results. */
private int resultCount;
/** List of search results grouped by URN. */
private final EventList<VisualSearchResult> groupedUrnResults;
/** A TransactionList for upstream changes. */
private final TransactionList<VisualSearchResult> transactionList;
/** Filtered list of grouped search results. */
private final FilterList<VisualSearchResult> filteredResultList;
/** Listener to handle search request events. */
private SearchListener searchListener;
/** Current list of sorted and filtered results. */
private SortedList<VisualSearchResult> sortedResultList;
/** Current list of visible, sorted and filtered results. */
private FilterList<VisualSearchResult> visibleResultList;
/** Current selected search category. */
private SearchCategory selectedCategory;
/** Current sort option. */
private SortOption sortOption;
/** Current matcher editor for filtered search results. */
private MatcherEditor<VisualSearchResult> filterEditor;
/** Listener for filter editor. */
private MatcherEditor.Listener<VisualSearchResult> filterEditorListener;
/** Matcher editor for visible search results. */
private final VisibleMatcherEditor visibleEditor = new VisibleMatcherEditor();
private List<DisposalListener> disposalListeners = new ArrayList<DisposalListener>();
/** Headings to create search results with. */
private final Provider<PropertiableHeadings> propertiableHeadings;
/** A list of listeners for changes. */
private final List<VisualSearchResultStatusListener> changeListeners;
/** The object that queues up list changes & applies them in bulk. */
private final ListQueuer listQueuer = new ListQueuer();
/** Comparator that searches through the list of results & finds them based on URN. */
private final UrnResultFinder resultFinder = new UrnResultFinder();
//TODO Using this to fix a case where events are coming in for items no longer in the list after a clear.
//We should remove this after the release and fix the root cause, that DownloadListeners for visual search results
//are not being removed from the downloaders after search tabs are removed or refreshed.
private boolean cleared = false;
/**
* Constructs a BasicSearchResultsModel with the specified search details,
* search request object, and services.
*/
public BasicSearchResultsModel(SearchInfo searchInfo, Search search,
Provider<PropertiableHeadings> propertiableHeadings,
DownloadListManager downloadListManager,
Provider<DownloadExceptionHandler> downloadExceptionHandler) {
this.searchInfo = searchInfo;
this.search = search;
this.downloadListManager = downloadListManager;
this.downloadExceptionHandler = downloadExceptionHandler;
this.propertiableHeadings = propertiableHeadings;
this.changeListeners = new ArrayList<VisualSearchResultStatusListener>(3);
// Create filter debugger.
filterDebugger = new FilterDebugger<VisualSearchResult>();
// Underlying list, with no locks -- always accessed on EDT.
groupedUrnResults = new BasicEventList<VisualSearchResult>(new ReadWriteLock() {
private Lock noopLock = new Lock() {
@Override public void lock() {}
@Override public boolean tryLock() { return true; }
@Override public void unlock() {}
};
@Override
public Lock readLock() {
return noopLock;
}
@Override
public Lock writeLock() {
return noopLock;
}
});
transactionList = GlazedListsFactory.transactionList(groupedUrnResults);
// Create filtered list.
filteredResultList = GlazedListsFactory.filterList(transactionList);
// Initialize display category and sorted list.
setSelectedCategory(searchInfo.getSearchCategory());
}
/**
* Installs the specified search listener and starts the search. The
* search listener should handle search results by calling the
* <code>addSearchResult(SearchResult)</code> method.
*/
@Override
public void start(SearchListener searchListener) {
if (searchListener == null) {
throw new IllegalArgumentException("Search listener cannot be null");
}
// Install search listener.
this.searchListener = searchListener;
search.addSearchListener(searchListener);
// Start search.
search.start();
}
/**
* Stops the search and removes the current search listener.
*/
@Override
public void dispose() {
// Stop search.
search.stop();
// Remove search listener.
if (searchListener != null) {
search.removeSearchListener(searchListener);
searchListener = null;
}
groupedUrnResults.dispose();
notifyDisposalListeners();
}
@Override
public SearchCategory getFilterCategory() {
return searchInfo.getSearchCategory();
}
@Override
public FilterDebugger<VisualSearchResult> getFilterDebugger() {
return filterDebugger;
}
@Override
public EventList<VisualSearchResult> getUnfilteredList() {
return transactionList;
}
@Override
public EventList<VisualSearchResult> getFilteredList() {
return filteredResultList;
}
@Override
public SearchCategory getSearchCategory() {
return searchInfo.getSearchCategory();
}
@Override
public String getSearchQuery() {
return searchInfo.getSearchQuery();
}
@Override
public String getSearchTitle() {
return searchInfo.getTitle();
}
@Override
public int getResultCount() {
return resultCount;
}
@Override
public SearchType getSearchType() {
return searchInfo.getSearchType();
}
/**
* Returns a list of filtered results.
*/
@Override
public EventList<VisualSearchResult> getFilteredSearchResults() {
return filteredResultList;
}
/**
* Returns a list of sorted and filtered results for the selected search
* category and sort option. Only visible results are included in the list.
*/
@Override
public EventList<VisualSearchResult> getSortedSearchResults() {
return visibleResultList;
}
/**
* Returns the selected search category.
*/
@Override
public SearchCategory getSelectedCategory() {
return selectedCategory;
}
/**
* Selects the specified search category. If the selected category is
* changed, this method updates the sorted list.
*/
@Override
public void setSelectedCategory(SearchCategory selectedCategory) {
if (this.selectedCategory != selectedCategory) {
this.selectedCategory = selectedCategory;
updateSortedList();
}
}
/**
* Updates sorted list of visible results. This method is called when the
* selected category is changed.
*/
private void updateSortedList() {
// Create visible and sorted lists if necessary.
if (visibleResultList == null) {
sortedResultList = GlazedListsFactory.sortedList(filteredResultList, sortOption != null ? SortFactory.getSortComparator(sortOption) : null);
visibleResultList = GlazedListsFactory.filterList(sortedResultList, visibleEditor);
}
}
/**
* Sets the sort option. This method updates the sorted list by changing
* the sort comparator.
*/
@Override
public void setSortOption(SortOption sortOption) {
this.sortOption = sortOption;
sortedResultList.setComparator((sortOption != null) ? SortFactory.getSortComparator(sortOption) : null);
}
@Override
public void setFilterEditor(MatcherEditor<VisualSearchResult> editor) {
// Remove listener from existing filter editor.
if ((filterEditor != null) && (filterEditorListener != null)) {
filterEditor.removeMatcherEditorListener(filterEditorListener);
}
// Create listener to handle changes to the filter.
if (filterEditorListener == null) {
filterEditorListener = new MatcherEditor.Listener<VisualSearchResult>() {
@Override
public void changedMatcher(Event<VisualSearchResult> matcherEvent) {
// Post Runnable on event queue to update filtered list for
// visible items. This allows us to display child results
// whose parents become hidden due to filtering.
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
visibleEditor.update();
}
});
}
};
}
// Set filter editor and add listener.
filterEditor = editor;
filterEditor.addMatcherEditorListener(filterEditorListener);
// Apply filter editor.
filteredResultList.setMatcherEditor(filterEditor);
}
public void addResultListener(VisualSearchResultStatusListener vsrChangeListener) {
changeListeners.add(vsrChangeListener);
}
@Override
public void resultCreated(VisualSearchResult vsr) {
}
@Override
public void resultsCleared() {
}
@Override
public void resultChanged(VisualSearchResult vsr, String propertyName, Object oldValue, Object newValue) {
// Scan through the list & find the item.
URN urn = vsr.getUrn();
int idx = Collections.binarySearch(groupedUrnResults, urn, resultFinder);
//TODO clean up, see comment about cleared variable, and why it should be removed.
assert cleared || idx >= 0;
if(idx >=0) {
VisualSearchResult existing = groupedUrnResults.get(idx);
VisualSearchResult replaced = groupedUrnResults.set(idx, existing);
assert cleared || replaced == vsr;
if(replaced == vsr) {
for(VisualSearchResultStatusListener listener : changeListeners) {
listener.resultChanged(vsr, propertyName, oldValue, newValue);
}
}
}
}
@Override
public void addSearchResult(SearchResult result) {
if(result.getUrn() == null) {
// Some results can be missing a URN, specifically
// secure results. For now, we drop these.
// We should figure out a way to show them later on.
return;
}
// LOG.debugf("Adding result urn: {0} EDT: {1}", result.getUrn(), SwingUtilities.isEventDispatchThread());
try {
listQueuer.add(result);
} catch (Throwable th) {
// Throw wrapper exception with detailed message.
throw new RuntimeException(createMessageDetail("Problem adding result", result), th);
}
}
@Override
public void addSearchResults(Collection<? extends SearchResult> results) {
boolean ok = true;
// make certain there's nothing w/o a URN in here
for(SearchResult result : results) {
if(result.getUrn() == null) {
// crap, we need to really work on this list & remove bad items.
ok = false;
break;
}
}
// filter out any items w/o a URN
if(!ok) {
ArrayList<SearchResult> cleanup = new ArrayList<SearchResult>(results);
for(int i = cleanup.size() - 1; i >= 0; i--) {
if(cleanup.get(i).getUrn() == null) {
cleanup.remove(i);
}
}
results = cleanup;
}
listQueuer.addAll(results);
}
/**
* Removes all results from the model
*/
public void clear(){
listQueuer.clear();
}
/**
* Initiates a download of the specified visual search result.
*/
@Override
public void download(VisualSearchResult vsr) {
download(vsr, null);
}
/**
* Initiates a download of the specified visual search result to the
* specified save file.
*/
@Override
public void download(final VisualSearchResult vsr, File saveFile) {
try {
// Add download to manager. If save file is specified, then set
// overwrite to true because the user has already confirmed it.
DownloadItem di = (saveFile == null) ?
downloadListManager.addDownload(search, vsr.getCoreSearchResults()) :
downloadListManager.addDownload(search, vsr.getCoreSearchResults(), saveFile, true);
// Add listener, and initialize download state.
di.addPropertyChangeListener(new DownloadItemPropertyListener(vsr));
BasicDownloadState state = BasicDownloadState.fromState(di.getState());
if(state != null) {
vsr.setDownloadState(state);
}
} catch (final DownloadException e) {
if (e.getErrorCode() == DownloadException.ErrorCode.FILE_ALREADY_DOWNLOADING) {
DownloadItem downloadItem = downloadListManager.getDownloadItem(vsr.getUrn());
if (downloadItem != null) {
downloadItem.addPropertyChangeListener(new DownloadItemPropertyListener(vsr));
BasicDownloadState state = BasicDownloadState.fromState(downloadItem.getState());
if(state != null) {
vsr.setDownloadState(state);
}
if (saveFile != null) {
try {
// Update save file in DownloadItem.
downloadItem.setSaveFile(saveFile, true);
} catch (DownloadException ex) {
LOG.infof(ex, "Unable to relocate downloading file {0}", ex.getMessage());
}
}
}
} else {
downloadExceptionHandler.get().handleDownloadException(new DownloadAction() {
@Override
public void download(File saveFile, boolean overwrite)
throws DownloadException {
DownloadItem di = downloadListManager.addDownload(search, vsr.getCoreSearchResults(), saveFile, overwrite);
di.addPropertyChangeListener(new DownloadItemPropertyListener(vsr));
BasicDownloadState state = BasicDownloadState.fromState(di.getState());
if(state != null) {
vsr.setDownloadState(state);
}
}
@Override
public void downloadCanceled(DownloadException ignored) {
//nothing to do
}
}, e, true);
}
}
}
/**
* Returns a detailed message including the specified prefix and search
* result.
*/
private String createMessageDetail(String prefix, SearchResult result) {
StringBuilder buf = new StringBuilder(prefix);
buf.append(", searchCategory=").append(searchInfo.getSearchCategory());
buf.append(", resultCategory=").append(result.getCategory());
buf.append(", spam=").append(result.isSpam());
buf.append(", unfilteredSize=").append(getUnfilteredList().size());
buf.append(", filteredSize=").append(getFilteredList().size());
buf.append(", EDT=").append(SwingUtilities.isEventDispatchThread());
buf.append(", filters=").append(filterDebugger.getFilterString());
return buf.toString();
}
/**
* Returns true if the specified search result is allowed by the current
* filter editor. This means the result is a member of the filtered list.
*/
private boolean isFilterMatch(VisualSearchResult vsr) {
if (filterEditor != null) {
return filterEditor.getMatcher().matches(vsr);
}
return true;
}
/**
* A matcher editor used to filter visible search results.
*/
private class VisibleMatcherEditor extends AbstractMatcherEditor<VisualSearchResult> {
public VisibleMatcherEditor() {
currentMatcher = new Matcher<VisualSearchResult>() {
@Override
public boolean matches(VisualSearchResult item) {
// Determine whether item has parent, and parent is hidden
// due to filtering. If so, we treat the item as visible.
VisualSearchResult parent = item.getSimilarityParent();
boolean parentHidden = (parent != null) && !isFilterMatch(parent);
return item.isVisible() || parentHidden;
}
};
}
/**
* Updates the list by firing a matcher change event to registered
* listeners.
*/
public void update() {
fireChangedMatcher(new MatcherEditor.Event<VisualSearchResult>(
this, Event.CHANGED, currentMatcher));
}
}
private void addResultsInternal(Collection<? extends SearchResult> results) {
transactionList.beginEvent(true);
try {
for(SearchResult result : results) {
URN urn = result.getUrn();
if(urn != null) {
int idx = Collections.binarySearch(groupedUrnResults, urn, resultFinder);
if(idx >= 0) {
SearchResultAdapter vsr = (SearchResultAdapter)groupedUrnResults.get(idx);
vsr.addNewSource(result);
groupedUrnResults.set(idx, vsr);
for(VisualSearchResultStatusListener listener : changeListeners) {
listener.resultChanged(vsr, "new-sources", null, null);
}
} else {
idx = -(idx + 1);
SearchResultAdapter vsr = new SearchResultAdapter(result, propertiableHeadings, this);
groupedUrnResults.add(idx, vsr);
for(VisualSearchResultStatusListener listener : changeListeners) {
listener.resultCreated(vsr);
}
}
resultCount += result.getSources().size();
}
}
} finally {
transactionList.commitEvent();
}
}
@Override
public void addDisposalListener(DisposalListener listener) {
disposalListeners.add(listener);
}
@Override
public void removeDisposalListener(DisposalListener listener) {
disposalListeners.remove(listener);
}
private void notifyDisposalListeners(){
for (DisposalListener listener : disposalListeners){
listener.objectDisposed(this);
}
}
private class ListQueuer implements Runnable {
private final Object LOCK = new Object();
private final List<SearchResult> queue = new ArrayList<SearchResult>();
private final ArrayList<SearchResult> transferQ = new ArrayList<SearchResult>();
private boolean scheduled = false;
void add(SearchResult result) {
synchronized(LOCK) {
queue.add(result);
schedule();
}
}
void addAll(Collection<? extends SearchResult> results) {
synchronized(LOCK) {
queue.addAll(results);
schedule();
}
}
void clear() {
synchronized(LOCK) {
queue.clear();
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
cleared = true;
groupedUrnResults.clear();
for(VisualSearchResultStatusListener listener : changeListeners) {
listener.resultsCleared();
}
}
});
}
}
private void schedule() {
if(!scheduled) {
scheduled = true;
// purposely SwingUtilities & not SwingUtils so
// that we force it to the back of the stack
SwingUtilities.invokeLater(this);
}
}
@Override
public void run() {
// move to transferQ inside lock
transferQ.clear();
synchronized(LOCK) {
transferQ.addAll(queue);
queue.clear();
}
// move to searchResults outside lock,
// so we don't hold a lock while allSearchResults
// triggers events.
addResultsInternal(transferQ);
transferQ.clear();
synchronized(LOCK) {
scheduled = false;
// If the queue wasn't empty, we need to reschedule
// ourselves, because something got added to the queue
// without scheduling itself, since we had scheduled=true
if(!queue.isEmpty()) {
schedule();
}
}
}
}
private static class UrnResultFinder implements Comparator<Object> {
@Override
public int compare(Object o1, Object o2) {
return ((VisualSearchResult)o1).getUrn().compareTo(((URN)o2));
}
}
}