// Copyright 2013 Michel Kraemer
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package de.undercouch.citeproc.zotero;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.commons.lang3.StringUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import de.undercouch.citeproc.csl.CSLItemData;
import de.undercouch.citeproc.helper.json.JsonLexer;
import de.undercouch.citeproc.helper.json.JsonParser;
import de.undercouch.citeproc.helper.oauth.OAuth;
import de.undercouch.citeproc.helper.oauth.Response;
import de.undercouch.citeproc.helper.oauth.UnauthorizedException;
import de.undercouch.citeproc.helper.oauth.OAuth.Method;
import de.undercouch.citeproc.remote.AbstractRemoteConnector;
/**
* Connects to the Zotero API v2. Needs an OAuth API key and secret in order
* to authenticate. Users of this class should
* <a href="http://www.zotero.org/oauth/apps">register their app</a> to
* receive such a key and secret.
* @author Michel Kraemer
*/
public class ZoteroConnector extends AbstractRemoteConnector {
private static final String OAUTH_ACCESS_TOKEN_URL =
"https://www.zotero.org/oauth/access";
private static final String OAUTH_REQUEST_TOKEN_URL =
"https://www.zotero.org/oauth/request";
private static final String OAUTH_AUTHORIZATION_URL =
"https://www.zotero.org/oauth/authorize?" +
"name=citeproc-java&library_access=1&oauth_token=";
private static final String ENDPOINT_USERS = "https://api.zotero.org/users/";
private static final String CSLJSON = "csljson";
private static final Map<String, String> REQUEST_HEADERS = new HashMap<>();
static {
REQUEST_HEADERS.put("Zotero-API-Version", "2");
}
/**
* Constructs a new connector
* @param consumerKey the app's consumer key
* @param consumerSecret the app's consumer secret
*/
public ZoteroConnector(String consumerKey, String consumerSecret) {
super(consumerKey, consumerSecret, null);
}
@Override
protected String getOAuthRequestTokenURL() {
return OAUTH_REQUEST_TOKEN_URL;
}
@Override
protected String getOAuthAuthorizationURL() {
return OAUTH_AUTHORIZATION_URL;
}
@Override
protected String getOAuthAccessTokenURL() {
return OAUTH_ACCESS_TOKEN_URL;
}
@Override
protected Method getOAuthAccessTokenMethod() {
return Method.GET;
}
@Override
protected OAuth createOAuth(String consumerKey, String consumerSecret,
String redirectUri) {
return new ZoteroOAuth(consumerKey, consumerSecret);
}
@Override
public List<String> getItemIDs() throws IOException {
if (accessToken == null) {
throw new UnauthorizedException("Access token has not yet been requested");
}
//since Zotero uses a single API key we store the user ID in the token
//see ZoteroOAuth#responseToToken(Map<String, String>)
String userId = accessToken.getToken();
String key = accessToken.getSecret();
Map<String, Object> res = performRequestObject(ENDPOINT_USERS +
userId + "/items?key=" + key + "&newer=0&format=versions"
+ "&itemType=-attachment", REQUEST_HEADERS);
return new ArrayList<>(res.keySet());
}
@Override
public CSLItemData getItem(String itemId) throws IOException {
Map<String, CSLItemData> r = getItems(Arrays.asList(itemId));
return r.get(itemId);
}
@Override
protected Map<String, Object> parseResponseObject(Response response) throws IOException {
String contentType = response.getHeader("Content-Type");
InputStream is = response.getInputStream();
Reader r = new BufferedReader(new InputStreamReader(is));
if (contentType != null && contentType.equals("application/atom+xml")) {
//response is an Atom. parse it...
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
Document doc;
try {
DocumentBuilder builder = factory.newDocumentBuilder();
InputSource source = new InputSource(r);
doc = builder.parse(source);
} catch (SAXException e) {
throw new IOException("Could not parse server response", e);
} catch (ParserConfigurationException e) {
throw new IOException("Could not create XML parser", e);
}
Map<String, Object> result = new HashMap<>();
//extract content in 'csljson' format
Element feed = doc.getDocumentElement();
NodeList entries = feed.getElementsByTagName("entry");
for (int i = 0; i < entries.getLength(); ++i) {
String key = null;
String csljson = null;
Node entry = entries.item(i);
if (entry instanceof Element) {
Element entryElem = (Element)entry;
NodeList keys = entryElem.getElementsByTagNameNS(
"http://zotero.org/ns/api", "key");
if (keys.getLength() > 0) {
key = keys.item(0).getTextContent().trim();
NodeList contents = entryElem.getElementsByTagName("content");
if (contents.getLength() > 0) {
Node content = contents.item(0);
Node type = content.getAttributes().getNamedItemNS(
"http://zotero.org/ns/api", "type");
if (type != null && type.getTextContent().equals(CSLJSON)) {
csljson = content.getTextContent().trim();
}
}
}
}
if (csljson == null || csljson.isEmpty()) {
throw new IOException("Could not extract CSL json content from entry");
}
Map<String, Object> item = new JsonParser(
new JsonLexer(new StringReader(csljson))).parseObject();
result.put(key, item);
}
return result;
} else {
return super.parseResponseObject(response);
}
}
@Override
public Map<String, CSLItemData> getItems(List<String> itemIds) throws IOException {
if (accessToken == null) {
throw new UnauthorizedException("Access token has not yet been requested");
}
//since Zotero uses a single API key we store the user ID in the token
//see ZoteroOAuth#responseToToken(Map<String, String>)
String userId = accessToken.getToken();
String key = accessToken.getSecret();
Map<String, CSLItemData> result = new LinkedHashMap<>(itemIds.size());
int s = 0;
while (s < itemIds.size()) {
int n = Math.min(getMaxBulkItems(), itemIds.size() - s);
List<String> itemsToRequest = itemIds.subList(s, s + n);
String istr = StringUtils.join(itemsToRequest, ',');
Map<String, Object> res = performRequestObject(ENDPOINT_USERS +
userId + "/items?key=" + key +
"&content=" + CSLJSON +
"&itemKey=" + istr, REQUEST_HEADERS);
for (Map.Entry<String, Object> e : res.entrySet()) {
String id = e.getKey();
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>)e.getValue();
CSLItemData item = CSLItemData.fromJson(m);
result.put(id, item);
}
s += n;
}
return result;
}
@Override
public int getMaxBulkItems() {
return 50;
}
}