/*
* @copyright 2012 Philip Warner
* @license GNU General Public License
*
* This file is part of Book Catalogue.
*
* Book Catalogue 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 3 of the License, or
* (at your option) any later version.
*
* Book Catalogue 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 Book Catalogue. If not, see <http://www.gnu.org/licenses/>.
*/
package com.eleybourn.bookcatalogue.goodreads;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.Date;
import java.util.Hashtable;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import oauth.signpost.OAuthProvider;
import oauth.signpost.commonshttp.CommonsHttpOAuthConsumer;
import oauth.signpost.commonshttp.CommonsHttpOAuthProvider;
import oauth.signpost.exception.OAuthCommunicationException;
import oauth.signpost.exception.OAuthExpectationFailedException;
import oauth.signpost.exception.OAuthMessageSignerException;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.os.Bundle;
import com.eleybourn.bookcatalogue.BookCatalogueApp;
import com.eleybourn.bookcatalogue.BookCataloguePreferences;
import com.eleybourn.bookcatalogue.BooksRowView;
import com.eleybourn.bookcatalogue.CatalogueDBAdapter;
import com.eleybourn.bookcatalogue.goodreads.GoodreadsManager.Exceptions.BookNotFoundException;
import com.eleybourn.bookcatalogue.goodreads.GoodreadsManager.Exceptions.NetworkException;
import com.eleybourn.bookcatalogue.goodreads.GoodreadsManager.Exceptions.NotAuthorizedException;
import com.eleybourn.bookcatalogue.goodreads.api.AuthUserApiHandler;
import com.eleybourn.bookcatalogue.goodreads.api.BookshelfListApiHandler;
import com.eleybourn.bookcatalogue.goodreads.api.BookshelfListApiHandler.BookshelfListFieldNames;
import com.eleybourn.bookcatalogue.goodreads.api.IsbnToId;
import com.eleybourn.bookcatalogue.goodreads.api.ReviewUpdateHandler;
import com.eleybourn.bookcatalogue.goodreads.api.SearchBooksApiHandler;
import com.eleybourn.bookcatalogue.goodreads.api.ShelfAddBookHandler;
import com.eleybourn.bookcatalogue.goodreads.api.ShowBookApiHandler.ShowBookFieldNames;
import com.eleybourn.bookcatalogue.goodreads.api.ShowBookByIdApiHandler;
import com.eleybourn.bookcatalogue.goodreads.api.ShowBookByIsbnApiHandler;
import com.eleybourn.bookcatalogue.utils.IsbnUtils;
import com.eleybourn.bookcatalogue.utils.Logger;
import com.eleybourn.bookcatalogue.utils.Utils;
/**
* Class to wrap all GoodReads API calls and manage an API connection.
*
* ENHANCE: Add 'send to goodreads'/'update from internet' option in book edit menu
* ENHANCE: Change 'update from internet' to allow source selection and single-book execution
* ENHANCE: Link an Event to a book, and display in book list with exclamation triangle overwriting cover.
* ENHANCE: MAYBE Replace Events with something similar in local DB?
*
* @author Philip Warner
*/
public class GoodreadsManager {
/** Enum to handle possible results of sending a book to goodreads */
public static enum ExportDisposition { error, sent, noIsbn, notFound, networkError };
private static final String LAST_SYNC_DATE = "GoodreadsManager.LastSyncDate";
/**
* Exceptions that may be thrown and used to wrap more varied inner exceptions
*/
public static class Exceptions {
public static class GeneralException extends Exception {
private static final long serialVersionUID = 5762518476144652354L;
Throwable m_inner;
public GeneralException(Throwable inner) { m_inner = inner; };
}
public static class NotAuthorizedException extends GeneralException {
private static final long serialVersionUID = 5589234170614368111L;
public NotAuthorizedException(Throwable inner) { super(inner); }
};
public static class BookNotFoundException extends GeneralException {
private static final long serialVersionUID = 872113355903361212L;
public BookNotFoundException(Throwable inner) { super(inner); }
};
public static class NetworkException extends GeneralException {
private static final long serialVersionUID = -4233137984910957925L;
public NetworkException(Throwable inner) { super(inner); }
};
}
// Set to true when the credentials have been successfully verified.
protected static boolean m_hasValidCredentials = false;
// Cached when credentials have been verified.
protected static String m_accessToken = null;
protected static String m_accessSecret = null;
// Local copies of user data retrieved when the credentials were verified
protected static String m_username = null;
protected static long m_userid = 0;
// Stores the last time an API request was made to avoid breaking API rules.
private static Long m_LastRequestTime = 0L;
private final static String DEV_KEY = GoodreadsApiKeys.GOODREADS_DEV_KEY;
private final static String DEV_SECRET = GoodreadsApiKeys.GOODREADS_DEV_SECRET;
// OAuth helpers
CommonsHttpOAuthConsumer m_consumer;
OAuthProvider m_provider;
/**
* Standard constructor; call common code.
*
* @author Philip Warner
*/
public GoodreadsManager() {
sharedInit();
}
/**
* Common constructor code.
*
* @author Philip Warner
*/
private void sharedInit() {
m_consumer = new CommonsHttpOAuthConsumer(DEV_KEY, DEV_SECRET);
m_provider = new CommonsHttpOAuthProvider(
"http://www.goodreads.com/oauth/request_token",
"http://www.goodreads.com/oauth/access_token",
"http://www.goodreads.com/oauth/authorize");
if (hasCredentials())
m_consumer.setTokenWithSecret(m_accessToken, m_accessSecret);
}
/**
* Clear the credentials from the preferences and local cache
*/
public static void forgetCredentials() {
m_accessToken = "";
m_accessSecret = "";
m_hasValidCredentials = false;
// Get the stored token values from prefs, and setup the consumer if present
BookCataloguePreferences prefs = BookCatalogueApp.getAppPreferences();
prefs.setString("GoodReads.AccessToken.Token", "");
prefs.setString("GoodReads.AccessToken.Secret", "");
}
/**
* Utility method to check if the access tokens are available (not if they are valid).
*
* @return
*/
public static boolean hasCredentials() {
if (m_accessToken != null && m_accessSecret != null &&
!m_accessToken.equals("") && !m_accessSecret.equals(""))
return true;
// Get the stored token values from prefs, and setup the consumer if present
BookCataloguePreferences prefs = BookCatalogueApp.getAppPreferences();
m_accessToken = prefs.getString("GoodReads.AccessToken.Token", "");
m_accessSecret = prefs.getString("GoodReads.AccessToken.Secret", "");
return m_accessToken != null && m_accessSecret != null &&
!m_accessToken.equals("") && !m_accessSecret.equals("");
}
/**
* Return the public developer key, used for GET queries.
*
* @author Philip Warner
*/
public String getDeveloperKey() {
return DEV_KEY;
}
/**
* Check if the current credentials (either cached or in prefs) are valid. If they
* have been previously checked and were valid, just use that result.
*
* @author Philip Warner
*/
public boolean hasValidCredentials() {
// If credentials have already been accepted, don't re-check.
if (m_hasValidCredentials)
return true;
return validateCredentials();
}
/**
* Check if the current credentials (either cached or in prefs) are valid, and
* cache the result.
*
* If cached credentials were used, call recursively after clearing the cached
* values.
*
* @author Philip Warner
*/
private boolean validateCredentials() {
// Get the stored token values from prefs, and setup the consumer
BookCataloguePreferences prefs = BookCatalogueApp.getAppPreferences();
m_accessToken = prefs.getString("GoodReads.AccessToken.Token", "");
m_accessSecret = prefs.getString("GoodReads.AccessToken.Secret", "");
m_consumer.setTokenWithSecret(m_accessToken, m_accessSecret);
try {
AuthUserApiHandler authUserApi = new AuthUserApiHandler(this);
if (authUserApi.getAuthUser() == 0)
return false;
// Save result...
m_username = authUserApi.getUsername();
m_userid = authUserApi.getUserid();
} catch (Exception e) {
// Something went wrong. Clear the access token, mark credentials as bad, and if we used
// cached values, retry by getting them from prefs.
m_hasValidCredentials = false;
m_accessToken = null;
return false;
}
// Cache the result to avoid web checks later
m_hasValidCredentials = true;
return true;
}
/**
* Request authorization from the current user by going to the OAuth web page.
*
* @author Philip Warner
* @throws NetworkException
*/
public void requestAuthorization(Context ctx) throws NetworkException {
String authUrl;
// Dont do this; this is just part of OAuth and not the API
//waitUntilRequestAllowed();
// Get the URL
try {
authUrl = m_provider.retrieveRequestToken(m_consumer, "com.eleybourn.bookcatalogue://goodreadsauth");
//authUrl = m_provider.retrieveRequestToken(m_consumer, "intent:#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=com.eleybourn.bookcatalogue/.goodreads.GoodReadsAuthorizationActivity;end");
} catch (OAuthCommunicationException e) {
throw new NetworkException(e);
} catch (Exception e) {
throw new RuntimeException(e);
}
// Make a valid URL for the parser (some come back without a schema)
if (!authUrl.startsWith("http://") && !authUrl.startsWith("https://"))
authUrl = "http://" + authUrl;
// Save the token; this object may well be destroyed before the web page has returned.
BookCataloguePreferences prefs = BookCatalogueApp.getAppPreferences();
SharedPreferences.Editor ed = prefs.edit();
ed.putString("GoodReads.RequestToken.Token", m_consumer.getToken());
ed.putString("GoodReads.RequestToken.Secret", m_consumer.getTokenSecret());
ed.commit();
// Open the web page
android.content.Intent browserIntent = new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(authUrl));
ctx.startActivity(browserIntent);
}
/**
* Called by the callback activity, GoodReadsAuthorizationActivity, when a request has been
* authorized by the user.
*
* @author Philip Warner
* @throws NotAuthorizedException
*/
public void handleAuthentication() throws NotAuthorizedException {
// Get the saved request tokens.
BookCataloguePreferences prefs = BookCatalogueApp.getAppPreferences();
String tokenString = prefs.getString("GoodReads.RequestToken.Token", "");
String secretString = prefs.getString("GoodReads.RequestToken.Secret", "");
if (tokenString.equals("") || secretString.equals(""))
throw new RuntimeException("Expected a request token to be stored in preferences; none found");
// Update the consumer.
m_consumer.setTokenWithSecret(tokenString, secretString);
// Get the access token
waitUntilRequestAllowed();
try {
m_provider.retrieveAccessToken(m_consumer, null ); //m_consumer.getToken());
} catch (oauth.signpost.exception.OAuthNotAuthorizedException e) {
throw new NotAuthorizedException(e);
} catch (Exception e) {
throw new RuntimeException(e);
}
// Cache and save the token
m_accessToken = m_consumer.getToken();
m_accessSecret = m_consumer.getTokenSecret();
SharedPreferences.Editor ed = prefs.edit();
ed.putString("GoodReads.AccessToken.Token", m_accessToken);
ed.putString("GoodReads.AccessToken.Secret", m_accessSecret);
ed.commit();
}
/**
* Create an HttpClient with specifically set buffer sizes to deal with
* potentially exorbitant settings on some HTC handsets.
*
* @return
*/
private HttpClient newHttpClient() {
HttpParams params = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(params, 30000);
HttpConnectionParams.setSocketBufferSize(params, 8192);
HttpConnectionParams.setLinger(params, 0);
HttpConnectionParams.setTcpNoDelay(params, false);
HttpClient httpClient = new DefaultHttpClient(params);
return httpClient;
}
/**
* Utility routine called to sign a request and submit it then pass it off to a parser.
*
* @author Philip Warner
* @throws NotAuthorizedException
* @throws BookNotFoundException
* @throws NetworkException
*/
public HttpResponse execute(HttpUriRequest request, DefaultHandler requestHandler, boolean requiresSignature) throws ClientProtocolException, IOException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, NetworkException {
// Get a new client
HttpClient httpClient = newHttpClient();
// Sign the request and wait until we can submit it legally.
if (requiresSignature) {
m_consumer.setTokenWithSecret(m_accessToken, m_accessSecret);
m_consumer.sign(request);
}
waitUntilRequestAllowed();
// Submit the request and process result.
HttpResponse response;
try {
response = httpClient.execute(request);
} catch (Exception e) {
throw new NetworkException(e);
}
int code = response.getStatusLine().getStatusCode();
if (code == 200 || code == 201)
parseResponse(response, requestHandler);
else if (code == 401) {
m_hasValidCredentials = false;
throw new NotAuthorizedException(null);
} else if (code == 404) {
throw new BookNotFoundException(null);
} else
throw new RuntimeException("Unexpected status code from API: " + response.getStatusLine().getStatusCode() + "/" + response.getStatusLine().getReasonPhrase());
return response;
}
/**
* Utility routine called to sign a request and submit it then return the raw text output.
*
* @author Philip Warner
* @throws OAuthCommunicationException
* @throws OAuthExpectationFailedException
* @throws OAuthMessageSignerException
* @throws IOException
* @throws ClientProtocolException
* @throws NotAuthorizedException
* @throws BookNotFoundException
* @throws NetworkException
*/
public String executeRaw(HttpUriRequest request) throws OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, ClientProtocolException, IOException, NotAuthorizedException, BookNotFoundException, NetworkException {
// Get a new client
HttpClient httpClient = newHttpClient();
// Sign the request and wait until we can submit it legally.
m_consumer.setTokenWithSecret(m_accessToken, m_accessSecret);
m_consumer.sign(request);
waitUntilRequestAllowed();
// Submit the request then process result.
HttpResponse response = null;
try {
response = httpClient.execute(request);
} catch (Exception e) {
throw new NetworkException(e);
}
int code = response.getStatusLine().getStatusCode();
StringBuilder html = new StringBuilder();
HttpEntity e = response.getEntity();
if (e != null) {
InputStream in = e.getContent();
if (in != null) {
while (true) {
int i = in.read();
if (i == -1) break;
html.append((char)(i));
}
}
}
if (code == 200 || code == 201) {
return html.toString();
} else if (code == 401) {
m_hasValidCredentials = false;
throw new NotAuthorizedException(null);
} else if (code == 404) {
throw new BookNotFoundException(null);
} else {
throw new RuntimeException("Unexpected status code from API: " + response.getStatusLine().getStatusCode() + "/" + response.getStatusLine().getReasonPhrase());
}
}
/**
* Utility routine called to pass a response off to a parser.
*
* @author Philip Warner
*/
private boolean parseResponse(HttpResponse response, DefaultHandler requestHandler) throws IllegalStateException, IOException {
boolean parseOk = false;
// Setup the parser
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser parser;
InputStream in = response.getEntity().getContent();
// Dont bother catching general exceptions, they will be caught by the caller.
try {
parser = factory.newSAXParser();
// Make sure we follow LibraryThing ToS (no more than 1 request/second).
parser.parse(in, requestHandler);
parseOk = true;
} catch (MalformedURLException e) {
String s = "unknown";
try { s = e.getMessage(); } catch (Exception e2) {};
Logger.logError(e, s);
} catch (ParserConfigurationException e) {
String s = "unknown";
try { s = e.getMessage(); } catch (Exception e2) {};
Logger.logError(e, s);
} catch (SAXException e) {
String s = e.getMessage(); // "unknown";
try { s = e.getMessage(); } catch (Exception e2) {};
Logger.logError(e, s);
} catch (java.io.IOException e) {
String s = "unknown";
try { s = e.getMessage(); } catch (Exception e2) {};
Logger.logError(e, s);
}
return parseOk;
}
/**
* Use mLastRequestTime to determine how long until the next request is allowed; and
* update mLastRequestTime this needs to be synchroized across threads.
*
* Note that as a result of this approach mLastRequestTime may in fact be
* in the future; callers to this routine effectively allocate time slots.
*
* This method will sleep() until it can make a request; if ten threads call this
* simultaneously, one will return immediately, one will return 1 second later, another
* two seconds etc.
*
*/
private static void waitUntilRequestAllowed() {
long now = System.currentTimeMillis();
long wait;
synchronized(m_LastRequestTime) {
wait = 1000 - (now - m_LastRequestTime);
//
// mLastRequestTime must be updated while synchronized. As soon as this
// block is left, another block may perform another update.
//
if (wait < 0)
wait = 0;
m_LastRequestTime = now + wait;
}
if (wait > 0) {
try {
Thread.sleep(wait);
} catch (InterruptedException e) {
}
}
}
public String getUsername() {
if (!m_hasValidCredentials)
throw new RuntimeException("GoodReads credentials need to be validated before accessing user data");
return m_username;
}
public long getUserid() {
if (!m_hasValidCredentials)
throw new RuntimeException("GoodReads credentials need to be validated before accessing user data");
return m_userid;
}
/** Local API object */
private IsbnToId m_isbnToId = null;
/**
* Wrapper to call ISBN->ID API
*/
public long isbnToId(String isbn) throws OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, NetworkException, IOException {
if (m_isbnToId == null)
m_isbnToId = new IsbnToId(this);
return m_isbnToId.isbnToId(isbn);
}
private GoodreadsBookshelves mBookshelfList = null;
private class GoodreadsBookshelf {
private Bundle mBundle;
public GoodreadsBookshelf(Bundle b) {
mBundle = b;
}
public String getName() {
return mBundle.getString(BookshelfListFieldNames.NAME);
}
public boolean isExclusive() {
return mBundle.getBoolean(BookshelfListFieldNames.EXCLUSIVE);
}
}
private class GoodreadsBookshelves {
private Hashtable<String, GoodreadsBookshelf> mBookshelfList = null;
public boolean isExclusive(String name) {
if (mBookshelfList.containsKey(name)) {
return mBookshelfList.get(name).isExclusive();
} else {
return false;
}
}
public GoodreadsBookshelves(Hashtable<String, GoodreadsBookshelf> list) {
mBookshelfList = list;
}
}
private GoodreadsBookshelves getShelves() throws ClientProtocolException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, NetworkException, IOException {
if (mBookshelfList == null) {
Hashtable<String, GoodreadsBookshelf> list = new Hashtable<String, GoodreadsBookshelf>();
BookshelfListApiHandler h = new BookshelfListApiHandler(this);
int page = 1;
while(true) {
Bundle result = h.run(page);
ArrayList<Bundle> shelves = result.getParcelableArrayList(BookshelfListFieldNames.SHELVES);
if (shelves.size() == 0)
break;
for(Bundle b: shelves) {
GoodreadsBookshelf shelf = new GoodreadsBookshelf(b);
list.put(shelf.getName(), shelf);
}
if (result.getLong(BookshelfListFieldNames.END) >= result.getLong(BookshelfListFieldNames.TOTAL))
break;
page++;
}
mBookshelfList = new GoodreadsBookshelves(list);
}
return mBookshelfList;
}
/** Local API object */
ShelfAddBookHandler m_addBookHandler = null;
/**
* Wrapper to call API to add book to shelf
*/
public long addBookToShelf(String shelfName,long grBookId) throws OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, NetworkException, IOException {
if (m_addBookHandler == null)
m_addBookHandler = new ShelfAddBookHandler(this);
return m_addBookHandler.add(shelfName, grBookId);
}
/**
* Wrapper to call API to remove a book from a shelf
*/
public void removeBookFromShelf(String shelfName,long grBookId) throws OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, NetworkException, IOException {
if (m_addBookHandler == null)
m_addBookHandler = new ShelfAddBookHandler(this);
m_addBookHandler.remove(shelfName, grBookId);
}
private ReviewUpdateHandler mReviewUpdater = null;
public void updateReview(long reviewId, boolean isRead, String readAt, String review, int rating) throws ClientProtocolException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, IOException, NetworkException {
if (mReviewUpdater == null) {
mReviewUpdater = new ReviewUpdateHandler(this);
}
mReviewUpdater.update(reviewId, isRead, readAt, review, rating);
}
/**
* Wrapper to send an entire book, including shelves, to Goodreads.
*
* @param dbHelper DB connection
* @param books Cursor pointing to single book to send
*
* @return Disposition of book
* @throws InterruptedException
* @throws IOException
* @throws NotAuthorizedException
* @throws OAuthCommunicationException
* @throws OAuthExpectationFailedException
* @throws OAuthMessageSignerException
* @throws NetworkException
* @throws BookNotFoundException
*/
public ExportDisposition sendOneBook(CatalogueDBAdapter dbHelper, BooksRowView books) throws InterruptedException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, IOException, NetworkException, BookNotFoundException {
long bookId = books.getId();
long grId;
long reviewId = 0;
Bundle grBookInfo = null;
boolean isNew;
// Get the list of shelves from goodreads. This is cached per instance of GoodreadsManager.
GoodreadsBookshelves grShelfList = getShelves();
// Get the book ISBN
String isbn = books.getIsbn();
// See if the book has a goodreads ID and if it is valid.
try {
grId = books.getGoodreadsBookId();
if (grId != 0) {
// Get the book details to make sure we have a valid book ID
grBookInfo = this.getBookById(grId);
if (grBookInfo == null)
grId = 0;
}
} catch (Exception e) {
grId = 0;
}
isNew = (grId == 0);
if (grId == 0 && !isbn.equals("")) {
if (!IsbnUtils.isValid(isbn))
return ExportDisposition.notFound;
try {
// Get the book details using ISBN
grBookInfo = this.getBookByIsbn(isbn);
if (grBookInfo != null && grBookInfo.containsKey(ShowBookFieldNames.BOOK_ID))
grId = grBookInfo.getLong(ShowBookFieldNames.BOOK_ID);
// If we got an ID, save it against the book
if (grId != 0) {
dbHelper.setGoodreadsBookId(bookId, grId);
}
} catch (BookNotFoundException e) {
return ExportDisposition.notFound;
} catch (NetworkException e) {
return ExportDisposition.networkError;
}
}
// If we found a goodreads book, update it
if (grId != 0) {
// Get the review ID if we have the book details. For new books, it will not be present.
if (!isNew && grBookInfo != null && grBookInfo.containsKey(ShowBookFieldNames.REVIEW_ID)) {
reviewId = grBookInfo.getLong(ShowBookFieldNames.REVIEW_ID);
}
// Lists of shelf names and our best guess at the goodreads canonical name
ArrayList<String> shelves = new ArrayList<String>();
ArrayList<String> canonicalShelves = new ArrayList<String>();
// Build the list of shelves that we have in the local database for the book
int exclusiveCount = 0;
Cursor shelfCsr = dbHelper.getAllBookBookshelvesForGoodreadsCursor(bookId);
try {
int shelfCol = shelfCsr.getColumnIndexOrThrow(CatalogueDBAdapter.KEY_BOOKSHELF);
// Collect all shelf names for this book
while (shelfCsr.moveToNext()) {
final String shelfName = shelfCsr.getString(shelfCol);
final String canonicalShelfName = canonicalizeBookshelfName(shelfName);
shelves.add(shelfName);
canonicalShelves.add(canonicalShelfName);
// Count how many of these shelves are exclusive in goodreads.
if (grShelfList.isExclusive(canonicalShelfName)) {
exclusiveCount++;
}
}
} finally {
shelfCsr.close();
}
// If no exclusive shelves are specified, then add pseudo-shelf to match goodreads because
// review.update does not seem to update them properly
if (exclusiveCount == 0) {
String pseudoShelf;
if (books.getRead() == 0) {
pseudoShelf = "To Read";
} else {
pseudoShelf = "Read";
}
if (!shelves.contains(pseudoShelf)) {
shelves.add(pseudoShelf);
canonicalShelves.add(canonicalizeBookshelfName(pseudoShelf));
}
}
// Get the names of the shelves the book is currently on AT GODREADS
ArrayList<String> grShelves;
if (!isNew && grBookInfo.containsKey(ShowBookFieldNames.SHELVES)) {
grShelves = grBookInfo.getStringArrayList(ShowBookFieldNames.SHELVES);
} else {
grShelves = new ArrayList<String>();
}
// Remove from any shelves from goodreads that are not in our local list
for(String grShelf: grShelves) {
if (!canonicalShelves.contains(grShelf)) {
try {
// Goodreads does not seem to like removing books from the special shelves.
if (! ( grShelfList.isExclusive(grShelf) ) )
this.removeBookFromShelf(grShelf, grId);
} catch (BookNotFoundException e) {
// Ignore for now; probably means book not on shelf anyway
} catch (Exception e) {
return ExportDisposition.error;
}
}
}
// Add shelves to goodreads if they are not currently there
for(String shelf: shelves) {
// Get the name the shelf will have at goodreads
final String canonicalShelfName = canonicalizeBookshelfName(shelf);
// Can only sent canonical shelf names if the book is on 0 or 1 of them.
boolean okToSend = (exclusiveCount < 2 || !grShelfList.isExclusive(canonicalShelfName));
if (okToSend && (grShelves == null || !grShelves.contains(canonicalShelfName)) ) {
try {
reviewId = this.addBookToShelf(shelf, grId);
} catch (Exception e) {
return ExportDisposition.error;
}
}
}
/* We should be safe always updating here because:
* - all books that are already added have a review ID, which we would have got from the bundle
* - all new books will be added to at least one shelf, which will have returned a review ID.
* But, just in case, we check the review ID, and if 0, we add the book to the 'Default' shelf.
*/
if (reviewId == 0) {
try {
reviewId = this.addBookToShelf("Default", grId);
} catch (Exception e) {
return ExportDisposition.error;
}
}
// Now update the remaining review details.
try {
// Do not sync Notes<->Review. We will add a 'Review' field later.
//this.updateReview(reviewId, books.getRead() != 0, books.getReadEnd(), books.getNotes(), ((int)books.getRating()) );
this.updateReview(reviewId, books.getRead() != 0, books.getReadEnd(), null, ((int)books.getRating()) );
} catch (BookNotFoundException e) {
return ExportDisposition.error;
}
return ExportDisposition.sent;
} else {
return ExportDisposition.noIsbn;
}
}
/**
* Create canonical representation based on the best guess as to the goodreads rules.
*/
public static String canonicalizeBookshelfName(String name) {
StringBuilder canonical = new StringBuilder();
name = name.toLowerCase();
for(int i = 0; i < name.length() ; i++) {
Character c = name.charAt(i);
if (Character.isLetterOrDigit(c)) {
canonical.append(c);
} else {
canonical.append('-');
}
}
return canonical.toString();
}
/**
* Wrapper to search for a book.
*
* @param query String to search for
*
* @return Array of GoodreadsWork objects
*
* @throws ClientProtocolException
* @throws OAuthMessageSignerException
* @throws OAuthExpectationFailedException
* @throws OAuthCommunicationException
* @throws NotAuthorizedException
* @throws BookNotFoundException
* @throws IOException
* @throws NetworkException
*/
public ArrayList<GoodreadsWork> search(String query) throws ClientProtocolException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, IOException, NetworkException {
if (!query.equals("")) {
SearchBooksApiHandler searcher = new SearchBooksApiHandler(this);
// Run the search
return searcher.search(query);
} else {
throw new RuntimeException("No search criteria specified");
}
}
/**
* Wrapper to search for a book.
*
* @param query String to search for
*
* @return Array of GoodreadsWork objects
*
* @throws ClientProtocolException
* @throws OAuthMessageSignerException
* @throws OAuthExpectationFailedException
* @throws OAuthCommunicationException
* @throws NotAuthorizedException
* @throws BookNotFoundException
* @throws IOException
* @throws NetworkException
*/
public Bundle getBookById(long bookId) throws ClientProtocolException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, IOException, NetworkException {
if (bookId != 0) {
ShowBookByIdApiHandler api = new ShowBookByIdApiHandler(this);
// Run the search
return api.get(bookId, true);
} else {
throw new RuntimeException("No work ID specified");
}
}
/**
* Wrapper to search for a book.
*
* @param query String to search for
*
* @return Array of GoodreadsWork objects
*
* @throws ClientProtocolException
* @throws OAuthMessageSignerException
* @throws OAuthExpectationFailedException
* @throws OAuthCommunicationException
* @throws NotAuthorizedException
* @throws BookNotFoundException
* @throws IOException
* @throws NetworkException
*/
public Bundle getBookByIsbn(String isbn) throws ClientProtocolException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NotAuthorizedException, BookNotFoundException, IOException, NetworkException {
if (isbn != null && isbn.length() > 0) {
ShowBookByIsbnApiHandler api = new ShowBookByIsbnApiHandler(this);
// Run the search
return api.get(isbn, true);
} else {
throw new RuntimeException("No work ID specified");
}
}
/**
* Construct a full or partial date string based on the y/m/d fields.
*
* @param yearField
* @param monthField
* @param dayField
* @param resultField
* @return
*/
public static String buildDate(Bundle data, String yearField, String monthField, String dayField, String resultField) {
String date = null;
if (data.containsKey(yearField)) {
date = String.format("%04d", data.getLong(yearField));
if (data.containsKey(monthField)) {
date += "-" + String.format("%02d", data.getLong(monthField));
if (data.containsKey(dayField)) {
date += "-" + String.format("%02d", data.getLong(dayField));
}
}
if (resultField != null && date != null && date.length() > 0)
data.putString(resultField, date);
}
return date;
}
/**
* Get the date at which the last goodreads synchronization was run
*
* @return Last date
*/
public static Date getLastSyncDate() {
String last = BookCatalogueApp.getAppPreferences().getString(LAST_SYNC_DATE,null);
if (last == null || last.equals("")) {
return null;
} else {
try {
Date d = Utils.parseDate(last);
return d;
} catch (Exception e) {
Logger.logError(e);
return null;
}
}
}
/**
* Set the date at which the last goodreads synchronization was run
*
* @param d Last date
*/
public static void setLastSyncDate(Date d) {
if (d == null) {
BookCatalogueApp.getAppPreferences().setString(LAST_SYNC_DATE,null);
} else {
BookCatalogueApp.getAppPreferences().setString(LAST_SYNC_DATE,Utils.toSqlDateTime(d));
}
}
}