zotero/chrome/content/zotero/xpcom/sync.js
Dan Stillman 96e88bda1e - Change local key if remote item has different id but different key (which should mostly be with the Quick Start Guide)
- Moved common singleton data logic (for now, just getByKey()) into Zotero.DataObjects, and use that as template for other data objects
2008-08-13 06:38:47 +00:00

2368 lines
60 KiB
JavaScript

Zotero.Sync = new function() {
this.init = init;
this.getObjectTypeID = getObjectTypeID;
this.getObjectTypeName = getObjectTypeName;
this.buildUploadIDs = buildUploadIDs;
this.getUpdatedObjects = getUpdatedObjects;
this.addToUpdated = addToUpdated;
this.getDeletedObjects = getDeletedObjects;
this.purgeDeletedObjects = purgeDeletedObjects;
this.removeFromDeleted = removeFromDeleted;
// Keep in sync with syncObjectTypes table
this.__defineGetter__('syncObjects', function () {
return {
creator: {
singular: 'Creator',
plural: 'Creators'
},
item: {
singular: 'Item',
plural: 'Items'
},
collection: {
singular: 'Collection',
plural: 'Collections'
},
search: {
singular: 'Search',
plural: 'Searches'
},
tag: {
singular: 'Tag',
plural: 'Tags'
}
};
});
default xml namespace = '';
var _typesLoaded = false;
var _objectTypeIDs = {};
var _objectTypeNames = {};
var _deleteLogDays = 30;
function init() {
var sql = "SELECT version FROM version WHERE schema='syncdeletelog'";
if (!Zotero.DB.valueQuery(sql)) {
sql = "SELECT COUNT(*) FROM syncDeleteLog";
if (Zotero.DB.valueQuery(sql)) {
throw ('syncDeleteLog not empty and no timestamp in Zotero.Sync.delete()');
}
sql = "INSERT INTO version VALUES ('syncdeletelog', ?)";
Zotero.DB.query(sql, Zotero.Date.getUnixTimestamp());
}
this.EventListener.init();
}
function getObjectTypeID(type) {
if (!_typesLoaded) {
_loadObjectTypes();
}
var id = _objectTypeIDs[type];
return id ? id : false;
}
function getObjectTypeName(typeID, plural) {
if (!_typesLoaded) {
_loadObjectTypes();
}
var name = _objectTypeNames[typeID];
return name ? name : false;
}
function buildUploadIDs() {
var uploadIDs = {};
uploadIDs.updated = {};
uploadIDs.changed = {};
uploadIDs.deleted = {};
for each(var syncObject in Zotero.Sync.syncObjects) {
var types = syncObject.plural.toLowerCase(); // 'items'
uploadIDs.updated[types] = [];
uploadIDs.changed[types] = {};
uploadIDs.deleted[types] = [];
}
return uploadIDs;
}
/**
* @param object lastSyncDate JS Date object
* @return object { items: [123, 234, ...], creators: [321, 432, ...], ... }
*/
function getUpdatedObjects(lastSyncDate) {
if (lastSyncDate && lastSyncDate.constructor.name != 'Date') {
throw ('lastSyncDate must be a Date or FALSE in '
+ 'Zotero.Sync.getDeletedObjects()')
}
var updatedIDs = {};
for each(var syncObject in this.syncObjects) {
var Types = syncObject.plural; // 'Items'
var types = syncObject.plural.toLowerCase(); // 'items'
Zotero.debug("Getting updated local " + types);
updatedIDs[types] = Zotero[Types].getUpdated(lastSyncDate);
if (!updatedIDs[types]) {
updatedIDs[types] = [];
}
}
return updatedIDs;
}
function addToUpdated(updated, ids) {
ids = Zotero.flattenArguments(ids);
for each(var id in ids) {
if (updated.indexOf(id) == -1) {
updated.push(id);
}
}
}
/**
* @param object lastSyncDate JS Date object
* @return mixed Returns object with deleted ids
* {
* items: [ { id: 123, key: ABCD1234 }, ... ]
* creators: [ { id: 123, key: ABCD1234 }, ... ],
* ...
* }
* or FALSE if none or -1 if last sync time is before start of log
*/
function getDeletedObjects(lastSyncDate) {
if (lastSyncDate && lastSyncDate.constructor.name != 'Date') {
throw ('lastSyncDate must be a Date or FALSE in '
+ 'Zotero.Sync.getDeletedObjects()')
}
var sql = "SELECT version FROM version WHERE schema='syncdeletelog'";
var syncLogStart = Zotero.DB.valueQuery(sql);
if (!syncLogStart) {
throw ('syncLogStart not found in Zotero.Sync.getDeletedObjects()');
}
// Last sync time is before start of log
if (lastSyncDate && new Date(syncLogStart * 1000) > lastSyncDate) {
return -1;
}
var param = false;
var sql = "SELECT syncObjectTypeID, objectID, key FROM syncDeleteLog";
if (lastSyncDate) {
param = Zotero.Date.toUnixTimestamp(lastSyncDate);
sql += " WHERE timestamp>?";
}
sql += " ORDER BY timestamp";
var rows = Zotero.DB.query(sql, param);
if (!rows) {
return false;
}
var deletedIDs = {};
for each(var syncObject in this.syncObjects) {
deletedIDs[syncObject.plural.toLowerCase()] = [];
}
for each(var row in rows) {
var type = this.getObjectTypeName(row.syncObjectTypeID);
type = this.syncObjects[type].plural.toLowerCase()
deletedIDs[type].push({
id: row.objectID,
key: row.key
});
}
return deletedIDs;
}
/**
* @param int deleteOlderThan Unix timestamp
*/
function purgeDeletedObjects(deleteOlderThan) {
if (isNaN(parseInt(deleteOlderThan))) {
throw ("Invalid timestamp '" + deleteOlderThan
+ "' in Zotero.Sync.purgeDeletedObjects");
}
var sql = "DELETE FROM syncDeleteLog WHERE timestamp<?";
Zotero.DB.query(sql, { int: deleteOlderThan });
}
function removeFromDeleted(deleted, id, key) {
for (var i=0; i<deleted.length; i++) {
if (deleted[i].id == id && deleted[i].key == key) {
deleted.splice(i, 1);
i--;
}
}
}
function _loadObjectTypes() {
var sql = "SELECT * FROM syncObjectTypes";
var types = Zotero.DB.query(sql);
for each(var type in types) {
_objectTypeNames[type.syncObjectTypeID] = type.name;
_objectTypeIDs[type.name] = type.syncObjectTypeID;
}
_typesLoaded = true;
}
}
/**
* Notifier observer to add deleted objects to syncDeleteLog
* plus related methods
*/
Zotero.Sync.EventListener = new function () {
default xml namespace = '';
this.init = init;
this.ignoreDeletions = ignoreDeletions;
this.unignoreDeletions = unignoreDeletions;
this.notify = notify;
var _notifierObserver = false;
var _shutdown = false;
var _deleteBlacklist = {};
function init() {
// Initialize delete log listener
_notifierObserver = Zotero.Notifier.registerObserver(this);
// Register shutdown handler
var observerService = Components.classes["@mozilla.org/observer-service;1"]
.getService(Components.interfaces.nsIObserverService);
observerService.addObserver(this, "xpcom-shutdown", false);
observerService = null;
}
/**
* Blacklist objects from going into the sync delete log
*/
function ignoreDeletions(type, ids) {
if (!Zotero.Sync.syncObjects[type]) {
throw ("Invalid type '" + type +
"' in Zotero.Sync.EventListener.ignoreDeletions()");
}
if (!_deleteBlacklist[type]) {
_deleteBlacklist[type] = {};
}
ids = Zotero.flattenArguments(ids);
for each(var id in ids) {
_deleteBlacklist[type][id] = true;
}
}
/**
* Remove objects blacklisted from the sync delete log
*/
function unignoreDeletions(type, ids) {
if (!Zotero.Sync.syncObjects[type]) {
throw ("Invalid type '" + type +
"' in Zotero.Sync.EventListener.ignoreDeletions()");
}
ids = Zotero.flattenArguments(ids);
for each(var id in ids) {
if (_deleteBlacklist[type][id]) {
delete _deleteBlacklist[type][id];
}
}
}
function notify(event, type, ids, extraData) {
var objectTypeID = Zotero.Sync.getObjectTypeID(type);
if (!objectTypeID) {
return;
}
var ZU = new Zotero.Utilities;
Zotero.DB.beginTransaction();
if (event == 'delete') {
var sql = "INSERT INTO syncDeleteLog VALUES (?, ?, ?, ?)";
var statement = Zotero.DB.getStatement(sql);
var ts = Zotero.Date.getUnixTimestamp();
for(var i=0, len=ids.length; i<len; i++) {
if (_deleteBlacklist[ids[i]]) {
Zotero.debug("Not logging blacklisted '"
+ type + "' id " + ids[i]
+ " in Zotero.Sync.EventListener.notify()", 4);
continue;
}
var key = extraData[ids[i]].old.primary.key;
statement.bindInt32Parameter(0, objectTypeID);
statement.bindInt32Parameter(1, ids[i]);
statement.bindStringParameter(2, key);
statement.bindInt32Parameter(3, ts);
try {
statement.execute();
}
catch(e) {
statement.reset();
Zotero.DB.rollbackTransaction();
throw(Zotero.DB.getLastErrorString());
}
}
statement.reset();
}
Zotero.DB.commitTransaction();
}
/*
* Shutdown observer -- implements nsIObserver
*/
function observe(subject, topic, data) {
switch (topic) {
case 'xpcom-shutdown':
if (_shutdown) {
Zotero.debug('returning');
return;
}
Zotero.debug('Shutting down sync system');
Zotero.Notifier.unregisterObserver(_notifierObserver);
_shutdown = true;
break;
}
}
}
/**
* Methods for syncing with the Zotero Server
*/
Zotero.Sync.Server = new function () {
this.init = init;
this.login = login;
this.sync = sync;
this.lock = lock;
this.unlock = unlock;
this.clear = clear;
this.resetServer = resetServer;
this.resetClient = resetClient;
this.logout = logout;
this.setSyncTimeout = setSyncTimeout;
this.clearSyncTimeout = clearSyncTimeout;
this.setSyncIcon = setSyncIcon;
this.__defineGetter__('enabled', function () {
// Set auto-sync expiry
var expiry = new Date("September 1, 2008 00:00:00");
if (new Date() > expiry) {
return false;
}
return this.username && this.password;
});
this.__defineGetter__('username', function () {
return Zotero.Prefs.get('sync.server.username');
});
this.__defineGetter__('password', function () {
var username = this.username;
if (!username) {
Zotero.debug('Username not set before setting Zotero.Sync.Server.password');
return '';
}
if (_cachedCredentials[username]) {
return _cachedCredentials[username];
}
Zotero.debug('Getting Zotero sync password');
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
.getService(Components.interfaces.nsILoginManager);
var logins = loginManager.findLogins({}, _loginManagerHost, _loginManagerURL, null);
// Find user from returned array of nsILoginInfo objects
for (var i = 0; i < logins.length; i++) {
if (logins[i].username == username) {
_cachedCredentials[username] = logins[i].password;
return logins[i].password;
}
}
return '';
});
this.__defineSetter__('password', function (password) {
_sessionID = null;
var username = this.username;
if (!username) {
Zotero.debug('Username not set before setting Zotero.Sync.Server.password');
return;
}
delete _cachedCredentials[username];
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
.getService(Components.interfaces.nsILoginManager);
var logins = loginManager.findLogins({}, _loginManagerHost, _loginManagerURL, null);
for (var i = 0; i < logins.length; i++) {
Zotero.debug('Clearing Zotero sync passwords');
loginManager.removeLogin(logins[i]);
break;
}
if (password) {
var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
Components.interfaces.nsILoginInfo, "init");
Zotero.debug('Setting Zotero sync password');
var loginInfo = new nsLoginInfo(_loginManagerHost, _loginManagerURL,
null, username, password, "", "");
loginManager.addLogin(loginInfo);
_cachedCredentials[username] = password;
}
});
this.__defineGetter__("sessionIDComponent", function () {
return 'sessionid=' + _sessionID;
});
this.__defineGetter__("lastRemoteSyncTime", function () {
return Zotero.DB.valueQuery("SELECT version FROM version WHERE schema='lastremotesync'");
});
this.__defineSetter__("lastRemoteSyncTime", function (val) {
Zotero.DB.query("REPLACE INTO version VALUES ('lastremotesync', ?)", { int: val });
});
this.__defineGetter__("lastLocalSyncTime", function () {
return Zotero.DB.valueQuery("SELECT version FROM version WHERE schema='lastlocalsync'");
});
this.__defineSetter__("lastLocalSyncTime", function (val) {
Zotero.DB.query("REPLACE INTO version VALUES ('lastlocalsync', ?)", { int: val });
});
this.__defineGetter__("lastSyncError", function () {
return _lastSyncError;
});
this.__defineSetter__("lastSyncError", function (val) {
_lastSyncError = val ? val : '';
});
this.nextLocalSyncDate = false;
this.apiVersion = 2;
default xml namespace = '';
var _loginManagerHost = 'chrome://zotero';
var _loginManagerURL = 'Zotero Sync Server';
var _serverURL = ZOTERO_CONFIG.SYNC_URL;
var _apiVersionComponent = "version=" + this.apiVersion;
var _maxAttempts = 3;
var _attempts = _maxAttempts;
var _cachedCredentials = {};
var _syncInProgress;
var _sessionID;
var _sessionLock;
var _lastSyncError;
var _autoSyncTimer;
function init() {
this.EventListener.init();
}
function login(callback) {
var url = _serverURL + "login";
var username = Zotero.Sync.Server.username;
if (!username) {
_error("Username not set in Zotero.Sync.Server.login()");
}
else if (!username.match(/^[\w\d ]+$/)) {
_error("Invalid username '" + username + "' in Zotero.Sync.Server.login()");
}
username = encodeURIComponent(Zotero.Sync.Server.username);
var password = encodeURIComponent(Zotero.Sync.Server.password);
var body = _apiVersionComponent
+ "&username=" + username
+ "&password=" + password;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
_checkResponse(xmlhttp);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
if (response.firstChild.getAttribute('type') == 'forbidden'
&& response.firstChild.getAttribute('code') == 'INVALID_LOGIN') {
_error('Invalid login/pass');
}
_error(response.firstChild.firstChild.nodeValue);
}
if (_sessionID) {
_error("Session ID already set in Zotero.Sync.Server.login()")
}
// <response><sessionID>[abcdefg0-9]{32}</sessionID></response>
_sessionID = response.firstChild.firstChild.nodeValue;
var re = /^[abcdefg0-9]{32}$/;
if (!re.test(_sessionID)) {
_sessionID = null;
_error('Invalid session ID received from server');
}
Zotero.debug('Got session ID ' + _sessionID + ' from server');
if (callback) {
callback();
}
});
}
function sync() {
Zotero.Sync.Server.clearSyncTimeout();
Zotero.Sync.Server.setSyncIcon('animate');
if (_attempts < 0) {
_error('Too many attempts in Zotero.Sync.Server.sync()');
}
if (!_sessionID) {
Zotero.debug("Session ID not available -- logging in");
Zotero.Sync.Server.login(Zotero.Sync.Server.sync);
return;
}
if (!_sessionLock) {
Zotero.Sync.Server.lock(Zotero.Sync.Server.sync);
return;
}
if (_syncInProgress) {
_error("Sync operation already in progress");
}
_syncInProgress = true;
// Get updated data
var url = _serverURL + 'updated';
var lastsync = Zotero.Sync.Server.lastRemoteSyncTime;
// TODO: use full sync instead? or make this full sync?
if (!lastsync) {
lastsync = 1;
}
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent
+ '&lastsync=' + lastsync;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
Zotero.debug(xmlhttp.responseText);
_checkResponse(xmlhttp);
if (_invalidSession(xmlhttp)) {
Zotero.debug("Invalid session ID -- logging in");
_sessionID = false;
_syncInProgress = false;
Zotero.Sync.Server.login(Zotero.Sync.Server.sync);
return;
}
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
// handle error
Zotero.debug(xmlhttp.responseText);
_error(response.firstChild.firstChild.nodeValue);
}
// Strip XML declaration and convert to E4X
var xml = new XML(xmlhttp.responseText.replace(/<\?xml.*\?>/, ''));
Zotero.DB.beginTransaction();
try {
Zotero.UnresponsiveScriptIndicator.disable();
var lastLocalSyncTime = Zotero.Sync.Server.lastLocalSyncTime;
var lastLocalSyncDate = lastLocalSyncTime ?
new Date(lastLocalSyncTime * 1000) : false;
var uploadIDs = Zotero.Sync.buildUploadIDs();
uploadIDs.updated = Zotero.Sync.getUpdatedObjects(lastLocalSyncDate);
var deleted = Zotero.Sync.getDeletedObjects(lastLocalSyncDate);
if (deleted == -1) {
_error('Sync delete log starts after last sync date in Zotero.Sync.Server.sync()');
}
if (deleted) {
uploadIDs.deleted = deleted;
}
var nextLocalSyncDate = Zotero.DB.transactionDate;
var nextLocalSyncTime = Zotero.Date.toUnixTimestamp(nextLocalSyncDate);
Zotero.Sync.Server.nextLocalSyncDate = nextLocalSyncDate;
// Reconcile and save updated data from server and
// prepare local data to upload
var xmlstr = Zotero.Sync.Server.Data.processUpdatedXML(
xml.updated, lastLocalSyncDate, uploadIDs
);
if (xmlstr === false) {
Zotero.debug("Sync cancelled");
Zotero.DB.rollbackTransaction();
Zotero.Sync.Server.unlock();
Zotero.reloadDataObjects();
_syncInProgress = false;
return;
}
if (xmlstr) {
Zotero.debug(xmlstr);
}
//throw('break1');
Zotero.Sync.Server.lastRemoteSyncTime = response.getAttribute('timestamp');
if (!xmlstr) {
Zotero.debug("Nothing to upload to server");
Zotero.Sync.Server.lastLocalSyncTime = nextLocalSyncTime;
Zotero.Sync.Server.nextLocalSyncDate = false;
Zotero.DB.commitTransaction();
Zotero.Sync.Server.unlock();
_syncInProgress = false;
return;
}
Zotero.DB.commitTransaction();
var url = _serverURL + 'upload';
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent
+ '&data=' + encodeURIComponent(xmlstr);
//var file = Zotero.getZoteroDirectory();
//file.append('lastupload.txt');
//Zotero.File.putContents(file, body);
var uploadCallback = function (xmlhttp) {
_checkResponse(xmlhttp);
//var ZU = new Zotero.Utilities;
//Zotero.debug(ZU.unescapeHTML(xmlhttp.responseText));
Zotero.debug(xmlhttp.responseText);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
// handle error
Zotero.debug(xmlhttp.responseText);
_error(response.firstChild.firstChild.nodeValue);
}
Zotero.DB.beginTransaction();
Zotero.Sync.purgeDeletedObjects(nextLocalSyncTime);
Zotero.Sync.Server.lastLocalSyncTime = nextLocalSyncTime;
Zotero.Sync.Server.nextLocalSyncDate = false;
Zotero.Sync.Server.lastRemoteSyncTime = response.getAttribute('timestamp');
//throw('break2');
Zotero.DB.commitTransaction();
Zotero.Sync.Server.unlock();
_syncInProgress = false;
}
var compress = Zotero.Prefs.get('sync.server.compressData');
// Compress upload data
if (compress) {
// Callback when compressed data is available
var bufferUploader = function (data) {
var gzurl = url + '?gzip=1';
var oldLen = body.length;
var newLen = data.length;
var savings = Math.round(((oldLen - newLen) / oldLen) * 100)
Zotero.debug("HTTP POST " + newLen + " bytes to " + gzurl
+ " (gzipped from " + oldLen + " bytes; "
+ savings + "% savings)");
if (Zotero.Utilities.HTTP.browserIsOffline()) {
Zotero.debug('Browser is offline');
return false;
}
var req =
Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"].
createInstance();
req.open('POST', gzurl, true);
req.setRequestHeader('Content-Type', "application/octet-stream");
req.setRequestHeader('Content-Encoding', 'gzip');
req.onreadystatechange = function () {
if (req.readyState == 4) {
uploadCallback(req);
}
};
try {
req.sendAsBinary(data);
}
catch (e) {
_error(e);
}
}
// Get input stream from POST data
var unicodeConverter =
Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]
.createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
unicodeConverter.charset = "UTF-8";
var bodyStream = unicodeConverter.convertToInputStream(body);
// Get listener for when compression is done
var listener = new Zotero.BufferedInputListener(bufferUploader);
// Initialize stream converter
var converter =
Components.classes["@mozilla.org/streamconv;1?from=uncompressed&to=gzip"]
.createInstance(Components.interfaces.nsIStreamConverter);
converter.asyncConvertData("uncompressed", "gzip", listener, null);
// Send input stream to stream converter
var pump = Components.classes["@mozilla.org/network/input-stream-pump;1"].
createInstance(Components.interfaces.nsIInputStreamPump);
pump.init(bodyStream, -1, -1, 0, 0, true);
pump.asyncRead(converter, null);
}
// Don't compress upload data
else {
Zotero.Utilities.HTTP.doPost(url, body, uploadCallback);
}
}
catch (e) {
_error(e);
}
finally {
Zotero.UnresponsiveScriptIndicator.enable();
}
_resetAttempts();
});
return;
}
function lock(callback) {
Zotero.debug("Getting session lock");
if (_attempts < 0) {
_error('Too many attempts in Zotero.Sync.Server.lock()', 2);
}
if (!_sessionID) {
_error('No session available in Zotero.Sync.Server.lock()', 2);
}
if (_sessionLock) {
_error('Session already locked in Zotero.Sync.Server.lock()', 2);
}
var url = _serverURL + "lock";
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
if (_invalidSession(xmlhttp)) {
Zotero.debug("Invalid session ID -- logging in");
_sessionID = false;
Zotero.Sync.Server.login(callback);
return;
}
_checkResponse(xmlhttp);
Zotero.debug(xmlhttp.responseText);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
_error(response.firstChild.firstChild.nodeValue);
}
if (response.firstChild.tagName != 'locked') {
_error('Invalid response from server');
}
_sessionLock = true;
if (callback) {
callback();
}
});
}
function unlock(callback) {
Zotero.debug("Releasing session lock");
if (_attempts < 0) {
_error('Too many attempts in Zotero.Sync.Server.unlock()');
}
if (!_sessionID) {
_error('No session available in Zotero.Sync.Server.unlock()');
}
var syncInProgress = _syncInProgress;
var url = _serverURL + "unlock";
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
_checkResponse(xmlhttp);
Zotero.debug(xmlhttp.responseText);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
_error(response.firstChild.firstChild.nodeValue);
}
if (response.firstChild.tagName != 'unlocked') {
_error('Invalid response from server');
}
_sessionLock = null;
if (callback) {
callback();
}
// Reset sync icon and last error
if (syncInProgress) {
Zotero.Sync.Server.lastSyncError = '';
Zotero.Sync.Server.setSyncIcon();
}
});
}
function clear() {
if (_attempts < 0) {
_error('Too many attempts in Zotero.Sync.Server.clear()');
}
if (!_sessionID) {
Zotero.debug("Session ID not available -- logging in");
Zotero.Sync.Server.login(Zotero.Sync.Server.clear);
return;
}
var url = _serverURL + "clear";
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
if (_invalidSession(xmlhttp)) {
Zotero.debug("Invalid session ID -- logging in");
_sessionID = false;
Zotero.Sync.Server.login(Zotero.Sync.Server.clear);
return;
}
_checkResponse(xmlhttp);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
_error(response.firstChild.firstChild.nodeValue);
}
if (response.firstChild.tagName != 'cleared') {
_error('Invalid response from server');
}
Zotero.Sync.Server.resetClient();
});
_resetAttempts();
}
/**
* Clear session lock on server
*/
function resetServer() {
if (_attempts < 0) {
_error('Too many attempts in Zotero.Sync.Server.resetServer()');
}
if (!_sessionID) {
Zotero.debug("Session ID not available -- logging in");
Zotero.Sync.Server.login(Zotero.Sync.Server.resetServer);
return;
}
var url = _serverURL + "reset";
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
if (_invalidSession(xmlhttp)) {
Zotero.debug("Invalid session ID -- logging in");
_sessionID = false;
Zotero.Sync.Server.login(Zotero.Sync.Server.reset);
return;
}
_checkResponse(xmlhttp);
Zotero.debug(xmlhttp.responseText);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
_error(response.firstChild.firstChild.nodeValue);
}
if (response.firstChild.tagName != 'reset') {
_error('Invalid response from server');
}
_syncInProgress = false;
});
_resetAttempts();
}
function resetClient() {
Zotero.DB.beginTransaction();
var sql = "DELETE FROM version WHERE schema IN "
+ "('lastlocalsync', 'lastremotesync', 'syncdeletelog')";
Zotero.DB.query(sql);
Zotero.DB.query("DELETE FROM syncDeleteLog");
sql = "INSERT INTO version VALUES ('syncdeletelog', ?)";
Zotero.DB.query(sql, Zotero.Date.getUnixTimestamp());
Zotero.DB.commitTransaction();
}
function logout(callback) {
var url = _serverURL + "logout";
var body = _apiVersionComponent
+ '&' + Zotero.Sync.Server.sessionIDComponent;
_sessionID = null;
Zotero.Utilities.HTTP.doPost(url, body, function (xmlhttp) {
_checkResponse(xmlhttp);
Zotero.debug(xmlhttp.responseText);
var response = xmlhttp.responseXML.childNodes[0];
if (response.firstChild.tagName == 'error') {
_error(response.firstChild.firstChild.nodeValue);
}
if (response.firstChild.tagName != 'loggedout') {
_error('Invalid response from server');
}
if (callback) {
callback();
}
});
}
function setSyncTimeout() {
// check if server/auto-sync are enabled
var autoSyncTimeout = 15;
Zotero.debug('Setting auto-sync timeout to ' + autoSyncTimeout + ' seconds');
if (_autoSyncTimer) {
_autoSyncTimer.cancel();
}
else {
_autoSyncTimer = Components.classes["@mozilla.org/timer;1"].
createInstance(Components.interfaces.nsITimer);
}
// {} implements nsITimerCallback
_autoSyncTimer.initWithCallback({ notify: function (event, type, ids) {
if (event == 'refresh') {
return;
}
if (_syncInProgress) {
Zotero.debug('Sync already in progress -- skipping auto-sync');
return;
}
Zotero.Sync.Server.sync();
}}, autoSyncTimeout * 1000, Components.interfaces.nsITimer.TYPE_ONE_SHOT);
}
function clearSyncTimeout() {
if (_autoSyncTimer) {
_autoSyncTimer.cancel();
}
}
function setSyncIcon(status) {
status = status ? status : '';
switch (status) {
case '':
case 'animate':
case 'error':
break;
default:
throw ("Invalid sync icon status '" + status + "' in Zotero.Sync.Server.setSyncIcon()");
}
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
var win = wm.getMostRecentWindow('navigator:browser');
win.document.getElementById('zotero-tb-sync').setAttribute('status', status);
}
function _checkResponse(xmlhttp) {
if (!xmlhttp.responseXML ||
!xmlhttp.responseXML.childNodes[0] ||
xmlhttp.responseXML.childNodes[0].tagName != 'response') {
Zotero.debug(xmlhttp.responseText);
_error('Invalid response from server');
}
if (!xmlhttp.responseXML.childNodes[0].firstChild) {
_error('Empty response from server');
}
}
function _invalidSession(xmlhttp) {
if (xmlhttp.responseXML.childNodes[0].firstChild.tagName != 'error') {
return false;
}
var code = xmlhttp.responseXML.childNodes[0].firstChild.getAttribute('code');
return (code == 'INVALID_SESSION_ID') || (code == 'SESSION_TIMED_OUT');
}
function _resetAttempts() {
_attempts = _maxAttempts;
}
function _error(e) {
_syncInProgress = false;
_resetAttempts();
Zotero.DB.rollbackAllTransactions();
if (_sessionID && _sessionLock) {
Zotero.Sync.Server.unlock()
}
Zotero.Sync.Server.setSyncIcon('error');
if (e.name) {
Zotero.Sync.Server.lastSyncError = e.name;
}
else {
Zotero.Sync.Server.lastSyncError = e;
}
throw(e);
}
}
Zotero.BufferedInputListener = function (callback) {
this._callback = callback;
}
Zotero.BufferedInputListener.prototype = {
binaryInputStream: null,
size: 0,
data: '',
onStartRequest: function(request, context) {},
onStopRequest: function(request, context, status) {
this.binaryInputStream.close();
delete this.binaryInputStream;
this._callback(this.data);
},
onDataAvailable: function(request, context, inputStream, offset, count) {
this.size += count;
this.binaryInputStream = Components.classes["@mozilla.org/binaryinputstream;1"]
.createInstance(Components.interfaces.nsIBinaryInputStream)
this.binaryInputStream.setInputStream(inputStream);
this.data += this.binaryInputStream.readBytes(this.binaryInputStream.available());
},
QueryInterface: function (iid) {
if (iid.equals(Components.interfaces.nsISupports)
|| iid.equals(Components.interfaces.nsIStreamListener)) {
return this;
}
throw Components.results.NS_ERROR_NO_INTERFACE;
}
}
// TODO: use prototype
Zotero.Sync.Server.EventListener = {
init: function () {
Zotero.Notifier.registerObserver(this);
},
notify: function (event, type, ids, extraData) {
// TODO: skip others
if (type == 'refresh') {
return;
}
if (Zotero.Prefs.get('sync.server.autoSync') && Zotero.Sync.Server.enabled) {
Zotero.Sync.Server.setSyncTimeout();
}
}
}
Zotero.Sync.Server.Data = new function() {
this.processUpdatedXML = processUpdatedXML;
this.buildUploadXML = buildUploadXML;
this.itemToXML = itemToXML;
this.xmlToItem = xmlToItem;
this.removeMissingRelatedItems = removeMissingRelatedItems;
this.collectionToXML = collectionToXML;
this.xmlToCollection = xmlToCollection;
this.creatorToXML = creatorToXML;
this.xmlToCreator = xmlToCreator;
this.searchToXML = searchToXML;
this.xmlToSearch = xmlToSearch;
this.tagToXML = tagToXML;
this.xmlToTag = xmlToTag;
var _noMergeTypes = ['search'];
default xml namespace = '';
/**
* Reorder XML nodes for parent/child relationships, etc.
*
* @param {E4X} xml
*/
function _preprocessUpdatedXML(xml) {
if (xml.collections.length()) {
var collections = xml.collections.children();
var orderedCollections = <collections/>;
var collectionIDHash = {};
for (var i=0; i<collections.length(); i++) {
// Build a hash of all collection ids
collectionIDHash[collections[i].@id.toString()] = true;
// Pull out top-level collections
if (!collections[i].@parent.toString()) {
orderedCollections.collection += collections[i];
delete collections[i];
i--;
}
}
// Pull out all collections pointing to parents that
// aren't present, which we assume already exist
for (var i=0; i<collections.length(); i++) {
if (!collectionIDHash[collections[i].@parent]) {
orderedCollections.collection += collections[i]
delete collections[i];
i--;
}
}
// Insert children directly under parents
for (var i=0; i<orderedCollections.children().length(); i++) {
for (var j=0; j<collections.length(); j++) {
if (collections[j].@parent.toString() ==
orderedCollections.children()[i].@id.toString()) {
// Make a clone of object, since otherwise
// delete below erases inserted item as well
// (which only seems to happen with
// insertChildBefore(), not += above)
var newChild = new XML(collections[j].toXMLString())
// If last top-level, just append
if (i == orderedCollections.children().length() - 1) {
orderedCollections.appendChild(newChild);
}
else {
orderedCollections.insertChildBefore(
orderedCollections.children()[i+1],
newChild
);
}
delete collections[j];
j--;
}
}
}
xml.collections = orderedCollections;
}
return xml;
}
function processUpdatedXML(xml, lastLocalSyncDate, uploadIDs) {
if (xml.children().length() == 0) {
Zotero.debug('No changes received from server');
return Zotero.Sync.Server.Data.buildUploadXML(uploadIDs);
}
xml = _preprocessUpdatedXML(xml);
var remoteCreatorStore = {};
var relatedItemsStore = {};
Zotero.DB.beginTransaction();
for each(var syncObject in Zotero.Sync.syncObjects) {
var Type = syncObject.singular; // 'Item'
var Types = syncObject.plural; // 'Items'
var type = Type.toLowerCase(); // 'item'
var types = Types.toLowerCase(); // 'items'
if (!xml[types]) {
continue;
}
var toSaveParents = [];
var toSaveChildren = [];
var toDeleteParents = [];
var toDeleteChildren = [];
var toReconcile = [];
//
// Handle modified objects
//
Zotero.debug("Processing remotely changed " + types);
typeloop:
for each(var xmlNode in xml[types][type]) {
var localDelete = false;
// Get local object with same id
var obj = Zotero[Types].get(parseInt(xmlNode.@id));
if (obj) {
// Key match -- same item
if (obj.key == xmlNode.@key.toString()) {
var objDate = Zotero.Date.sqlToDate(obj.dateModified, true);
// Local object has been modified since last sync
if ((objDate > lastLocalSyncDate &&
objDate < Zotero.Sync.Server.nextLocalSyncDate)
// Check for object in updated array, since it might
// have been modified during sync process, making its
// date equal to Zotero.Sync.Server.nextLocalSyncDate
// and therefore excluded above (example: an item
// linked to a creator whose id changed)
|| uploadIDs.updated[types].indexOf(obj.id) != -1) {
// Merge and store related items, since CR doesn't
// affect related items
if (type == 'item') {
// Remote
var related = xmlNode.related.toString();
related = related ? related.split(' ') : [];
// Local
for each(var relID in obj.relatedItems) {
if (related.indexOf(relID) == -1) {
related.push(relID);
}
}
if (related.length) {
relatedItemsStore[obj.id] = related;
}
Zotero.Sync.Server.Data.removeMissingRelatedItems(xmlNode);
}
var remoteObj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode);
// Some types we don't bother to reconcile
if (_noMergeTypes.indexOf(type) != -1) {
if (obj.dateModified > remoteObj.dateModified) {
Zotero.Sync.addToUpdated(uploadIDs.updated.items, obj.id);
continue;
}
// Overwrite local below
}
// Mark other types for conflict resolution
else {
// Skip item if dateModified is the only modified
// field (and no linked creators changed)
if (type == 'item') {
var diff = obj.diff(remoteObj, false, true);
if (!diff) {
// Check if creators changed
var creatorsChanged = false;
var creators = obj.getCreators();
creators = creators.concat(remoteObj.getCreators());
for each(var creator in creators) {
if (remoteCreatorStore[obj.id]) {
creatorsChanged = true;
break;
}
}
if (!creatorsChanged) {
continue;
}
}
}
// Will be handled by item CR for now
if (type == 'creator') {
remoteCreatorStore[remoteObj.id] = remoteObj;
continue;
}
if (type != 'item') {
alert('Reconciliation unimplemented for ' + types);
throw ('Reconciliation unimplemented for ' + types);
}
if (obj.isAttachment()) {
var msg = "Reconciliation unimplemented for attachment items";
alert(msg);
throw(msg);
}
// TODO: order reconcile by parent/child?
toReconcile.push([
obj,
remoteObj
]);
continue;
}
}
// Overwrite local below
}
// Key mismatch -- different objects with same id,
// so change id of local object
else {
var oldID = parseInt(xmlNode.@id);
var newID = Zotero.ID.get(types, true);
Zotero.debug("Changing " + type + " " + oldID + " id to " + newID);
// Save changed object now to update other linked objects
obj[type + 'ID'] = newID;
obj.save();
// Update id in local updates array
//
// Object might not appear in local update array if server
// data was cleared and synched from another client
var index = uploadIDs.updated[types].indexOf(oldID);
if (index != -1) {
uploadIDs.updated[types][index] = newID;
}
// Update id in local deletions array
for (var i in uploadIDs.deleted[types]) {
if (uploadIDs.deleted[types][i].id == oldID) {
uploadIDs.deleted[types][i] = newID;
}
}
// Add items linked to creators to updated array,
// since their timestamps will be set to the
// transaction timestamp
//
// Note: Don't need to change collection children or
// related items, since they're stored as objects
if (type == 'creator') {
var linkedItems = obj.getLinkedItems();
if (linkedItems) {
Zotero.Sync.addToUpdated(uploadIDs.updated.items, linkedItems);
}
}
uploadIDs.changed[types][oldID] = {
oldID: oldID,
newID: newID
};
obj = null;
}
}
// Object doesn't exist locally
else {
// Check if object has been deleted locally
for each(var pair in uploadIDs.deleted[types]) {
if (pair.id != parseInt(xmlNode.@id) ||
pair.key != xmlNode.@key.toString()) {
continue;
}
// TODO: non-merged items
if (type != 'item') {
alert('Delete reconciliation unimplemented for ' + types);
throw ('Delete reconciliation unimplemented for ' + types);
}
localDelete = true;
}
// If key already exists on a different item, change local key
var oldKey = xmlNode.@key.toString();
var keyObj = Zotero[Types].getByKey(oldKey);
if (keyObj) {
var newKey = Zotero.ID.getKey();
Zotero.debug("Changing key of local " + type + " " + keyObj.id
+ " from '" + oldKey + "' to '" + newKey + "'", 2);
keyObj.key = newKey;
keyObj.save();
Zotero.Sync.addToUpdated(uploadIDs.updated[types], keyObj.id);
}
}
// Temporarily remove and store related items that don't yet exist
if (type == 'item') {
var missing = Zotero.Sync.Server.Data.removeMissingRelatedItems(xmlNode);
if (missing.length) {
relatedItemsStore[xmlNode.@id] = missing;
}
}
// Create or overwrite locally
obj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode, obj);
if (localDelete) {
// TODO: order reconcile by parent/child?
toReconcile.push([
'deleted',
obj
]);
}
// Child items have to be saved after parent items
else if (type == 'item' && obj.getSource()) {
toSaveChildren.push(obj);
}
else {
toSaveParents.push(obj);
}
// Don't use assigned-but-unsaved ids for new ids
Zotero.ID.skip(types, obj.id);
}
//
// Handle deleted objects
//
if (xml.deleted && xml.deleted[types]) {
Zotero.debug("Processing remotely deleted " + types);
for each(var xmlNode in xml.deleted[types][type]) {
var id = parseInt(xmlNode.@id);
var obj = Zotero[Types].get(id);
// Object can't be found
if (!obj || obj.key != xmlNode.@key) {
continue;
}
// Local object has been modified since last sync -- reconcile
var now = Zotero.Date.sqlToDate(obj.dateModified, true);
if (now >= lastLocalSyncDate) {
// TODO: order reconcile by parent/child
toReconcile.push([obj, 'deleted']);
}
// Local object hasn't been modified -- delete
else {
if (type == 'item' && obj.getSource()) {
toDeleteChildren.push(id);
}
else {
toDeleteParents.push(id);
}
}
}
}
//
// Reconcile objects that have changed locally and remotely
//
if (toReconcile.length) {
var io = {
dataIn: {
captions: [
// TODO: localize
'Local Item',
'Remote Item',
'Merged Item'
],
objects: toReconcile
}
};
if (type == 'item') {
io.dataIn.changedCreators = remoteCreatorStore;
}
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
var lastWin = wm.getMostRecentWindow("navigator:browser");
lastWin.openDialog('chrome://zotero/content/merge.xul', '', 'chrome,modal,centerscreen', io);
if (io.dataOut) {
for each(var obj in io.dataOut) {
// TODO: do we need to make sure item isn't already being saved?
// Handle items deleted during merge
if (obj.ref == 'deleted') {
// Deleted item was remote
if (obj.left != 'deleted') {
if (type == 'item' && obj.left.getSource()) {
toDeleteParents.push(obj.id);
}
else {
toDeleteChildren.push(obj.id);
}
if (relatedItemsStore[obj.id]) {
delete relatedItemsStore[obj.id];
}
uploadIDs.deleted[types].push({
id: obj.id,
key: obj.left.key
});
}
continue;
}
if (type == 'item' && obj.ref.getSource()) {
toSaveParents.push(obj.ref);
}
else {
toSaveChildren.push(obj.ref);
}
// Don't use assigned-but-unsaved ids for new ids
Zotero.ID.skip(types, obj.id);
// Item had been deleted locally, so remove from
// deleted array
if (obj.left == 'deleted') {
Zotero.Sync.removeFromDeleted(uploadIDs.deleted[types], obj.id, obj.ref.key);
}
// TODO: only upload if the local item was chosen
// or remote item was changed
Zotero.Sync.addToUpdated(uploadIDs.updated[types], obj.id);
}
}
else {
Zotero.DB.rollbackTransaction();
return false;
}
}
/*
if (type == 'collection') {
// Temporarily remove and store subcollections before saving
// since referenced collections may not exist yet
var collections = [];
for each(var obj in toSaveParents) {
var colIDs = obj.getChildCollections(true);
// TODO: use exist(), like related items above
obj.childCollections = [];
collections.push({
obj: obj,
childCollections: colIDs
});
}
}
*/
// Save objects
Zotero.debug('Saving merged ' + types);
for each(var obj in toSaveParents) {
obj.save();
}
for each(var obj in toSaveChildren) {
obj.save();
}
// Add back related items (which now exist)
if (type == 'item') {
for (var itemID in relatedItemsStore) {
item = Zotero.Items.get(itemID);
for each(var id in relatedItemsStore[itemID]) {
item.addRelatedItem(id);
}
item.save();
}
}
/*
// Add back subcollections
else if (type == 'collection') {
for each(var collection in collections) {
if (collection.childCollections) {
collection.obj.childCollections = collection.childCollections;
collection.obj.save();
}
}
}
*/
// Delete
Zotero.debug('Deleting merged ' + types);
if (toDeleteChildren.length) {
Zotero.Sync.EventListener.ignoreDeletions(type, toDeleteChildren);
Zotero[Types].erase(toDeleteChildren);
Zotero.Sync.EventListener.unignoreDeletions(type, toDeleteChildren);
}
if (toDeleteParents.length) {
Zotero.Sync.EventListener.ignoreDeletions(type, toDeleteParents);
Zotero[Types].erase(toDeleteParents);
Zotero.Sync.EventListener.unignoreDeletions(type, toDeleteParents);
}
}
var xmlstr = Zotero.Sync.Server.Data.buildUploadXML(uploadIDs);
Zotero.DB.commitTransaction();
return xmlstr;
}
/**
* ids = {
* updated: {
* items: [123, 234, 345, 456],
* creators: [321, 432, 543, 654]
* },
* changed: {
* items: {
* oldID: { oldID: 1234, newID: 5678 }, ...
* },
* creators: {
* oldID: { oldID: 1234, newID: 5678 }, ...
* }
* },
* deleted: {
* items: [
* { id: 1234, key: ABCDEFGHIJKMNPQRSTUVWXYZ23456789 }, ...
* ],
* creators: [
* { id: 1234, key: ABCDEFGHIJKMNPQRSTUVWXYZ23456789 }, ...
* ]
* }
* };
*/
function buildUploadXML(ids) {
var xml = <data/>
// Add API version attribute
xml.@version = Zotero.Sync.Server.apiVersion;
// Updates
for each(var syncObject in Zotero.Sync.syncObjects) {
var Type = syncObject.singular; // 'Item'
var Types = syncObject.plural; // 'Items'
var type = Type.toLowerCase(); // 'item'
var types = Types.toLowerCase(); // 'items'
if (!ids.updated[types]) {
continue;
}
Zotero.debug("Processing locally changed " + types);
switch (type) {
// Items.get() can take multiple ids,
// so we handle them differently
case 'item':
var objs = Zotero[Types].get(ids.updated[types]);
for each(var obj in objs) {
xml[types][type] += this[type + 'ToXML'](obj);
}
break;
default:
for each(var id in ids.updated[types]) {
var obj = Zotero[Types].get(id);
xml[types][type] += this[type + 'ToXML'](obj);
}
}
}
// TODO: handle changed ids
// Deletions
for each(var syncObject in Zotero.Sync.syncObjects) {
var Type = syncObject.singular; // 'Item'
var Types = syncObject.plural; // 'Items'
var type = Type.toLowerCase(); // 'item'
var types = Types.toLowerCase(); // 'items'
if (!ids.deleted[types]) {
continue;
}
Zotero.debug('Processing locally deleted ' + types);
for each(var obj in ids.deleted[types]) {
var deletexml = new XML('<' + type + '/>');
deletexml.@id = obj.id;
deletexml.@key = obj.key;
xml.deleted[types][type] += deletexml;
}
}
var xmlstr = xml.toXMLString();
if (xmlstr.match('<data version="[0-9]+"/>')) {
return '';
}
return xmlstr;
}
/**
* Converts a Zotero.Item object to an E4X <item> object
*/
function itemToXML(item) {
var xml = <item/>;
var item = item.serialize();
// Primary fields
for (var field in item.primary) {
switch (field) {
case 'itemID':
var attr = 'id';
break;
default:
var attr = field;
}
xml['@' + attr] = item.primary[field];
}
// Item data
for (var field in item.fields) {
if (!item.fields[field]) {
continue;
}
var newField = <field>{_xmlize(item.fields[field])}</field>;
newField.@name = field;
xml.field += newField;
}
if (item.primary.itemType == 'note' || item.primary.itemType == 'attachment') {
if (item.sourceItemID) {
xml.@sourceItemID = item.sourceItemID;
}
}
// Note
if (item.primary.itemType == 'note') {
var note = <note>{_xmlize(item.note)}</note>;
xml.note += note;
}
// Attachment
if (item.primary.itemType == 'attachment') {
xml.@linkMode = item.attachment.linkMode;
xml.@mimeType = item.attachment.mimeType;
xml.@charset = item.attachment.charset;
// Don't include paths for links
if (item.attachment.linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
var path = <path>{item.attachment.path}</path>;
xml.path += path;
}
if (item.note) {
var note = <note>{_xmlize(item.note)}</note>;
xml.note += note;
}
}
// Creators
for (var index in item.creators) {
var newCreator = <creator/>;
newCreator.@id = item.creators[index].creatorID;
newCreator.@creatorType = item.creators[index].creatorType;
newCreator.@index = index;
xml.creator += newCreator;
}
// Related items
if (item.related.length) {
xml.related = item.related.join(' ');
}
return xml;
}
/**
* Convert E4X <item> object into an unsaved Zotero.Item
*
* @param object xmlItem E4X XML node with item data
* @param object item (Optional) Existing Zotero.Item to update
* @param bool skipPrimary (Optional) Ignore passed primary fields (except itemTypeID)
*/
function xmlToItem(xmlItem, item, skipPrimary) {
if (!item) {
if (skipPrimary) {
item = new Zotero.Item;
}
else {
item = new Zotero.Item(parseInt(xmlItem.@id));
/*
if (item.exists()) {
_error("Item specified in XML node already exists "
+ "in Zotero.Sync.Server.Data.xmlToItem()");
}
*/
}
}
else if (skipPrimary) {
throw ("Cannot use skipPrimary with existing item in "
+ "Zotero.Sync.Server.Data.xmlToItem()");
}
// TODO: add custom item types
var data = {
itemTypeID: Zotero.ItemTypes.getID(xmlItem.@itemType.toString())
};
if (!skipPrimary) {
data.dateAdded = xmlItem.@dateAdded.toString();
data.dateModified = xmlItem.@dateModified.toString();
data.key = xmlItem.@key.toString();
}
var changedFields = {};
// Primary data
for (var field in data) {
item.setField(field, data[field]);
changedFields[field] = true;
}
// Item data
for each(var field in xmlItem.field) {
var fieldName = field.@name.toString();
item.setField(fieldName, field.toString());
changedFields[fieldName] = true;
}
var previousFields = item.getUsedFields(true);
for each(var field in previousFields) {
if (!changedFields[field] &&
// If not valid, it'll already have been cleared by the
// type change
Zotero.ItemFields.isValidForType(
Zotero.ItemFields.getID(field), data.itemTypeID
)) {
item.setField(field, false);
}
}
// Item creators
var i = 0;
for each(var creator in xmlItem.creator) {
var pos = parseInt(creator.@index);
if (pos != i) {
throw ('No creator in position ' + i);
}
item.setCreator(
pos,
Zotero.Creators.get(parseInt(creator.@id)),
creator.@creatorType.toString()
);
i++;
}
// Remove item's remaining creators not in XML
var numCreators = item.numCreators();
var rem = numCreators - i;
for (var j=0; j<rem; j++) {
// Keep removing last creator
item.removeCreator(i);
}
// Both notes and attachments might have parents and notes
if (item.isNote() || item.isAttachment()) {
var sourceItemID = parseInt(xmlItem.@sourceItemID);
item.setSource(sourceItemID ? sourceItemID : false);
item.setNote(xmlItem.note.toString());
}
// Attachment metadata
if (item.isAttachment()) {
item.attachmentLinkMode = parseInt(xmlItem.@linkMode);
item.attachmentMIMEType = xmlItem.@mimeType;
item.attachmentCharset = parseInt(xmlItem.@charsetID);
if (item.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
item.attachmentPath = xmlItem.path.toString();
}
}
// Related items
var related = xmlItem.related.toString();
item.relatedItems = related ? related.split(' ') : [];
return item;
}
function removeMissingRelatedItems(xmlNode) {
var missing = [];
var related = xmlNode.related.toString();
var relIDs = related ? related.split(' ') : [];
if (relIDs.length) {
var exist = Zotero.Items.exist(relIDs);
for each(var id in relIDs) {
if (exist.indexOf(id) == -1) {
missing.push(id);
}
}
xmlNode.related = exist.join(' ');
}
return missing;
}
function collectionToXML(collection) {
var xml = <collection/>;
xml.@id = collection.id;
xml.@name = _xmlize(collection.name);
xml.@dateModified = collection.dateModified;
xml.@key = collection.key;
if (collection.parent) {
xml.@parent = collection.parent;
}
var children = collection.getChildren();
if (children) {
//xml.collections = '';
xml.items = '';
for each(var child in children) {
/*
if (child.type == 'collection') {
xml.collections = xml.collections ?
xml.collections + ' ' + child.id : child.id;
}
else */if (child.type == 'item') {
xml.items = xml.items ?
xml.items + ' ' + child.id : child.id;
}
}
/*
if (xml.collections == '') {
delete xml.collections;
}
*/
if (xml.items == '') {
delete xml.items;
}
}
return xml;
}
/**
* Convert E4X <collection> object into an unsaved Zotero.Collection
*
* @param object xmlCollection E4X XML node with collection data
* @param object item (Optional) Existing Zotero.Collection to update
* @param bool skipPrimary (Optional) Ignore passed primary fields (except itemTypeID)
*/
function xmlToCollection(xmlCollection, collection, skipPrimary) {
if (!collection) {
if (skipPrimary) {
collection = new Zotero.Collection(null);
}
else {
collection = new Zotero.Collection(parseInt(xmlCollection.@id));
/*
if (collection.exists()) {
throw ("Collection specified in XML node already exists "
+ "in Zotero.Sync.Server.Data.xmlToCollection()");
}
*/
}
}
else if (skipPrimary) {
throw ("Cannot use skipPrimary with existing collection in "
+ "Zotero.Sync.Server.Data.xmlToCollection()");
}
collection.name = xmlCollection.@name.toString();
if (!skipPrimary) {
collection.parent = xmlCollection.@parent.toString() ?
parseInt(xmlCollection.@parent) : false;
collection.dateAdded = xmlCollection.@dateAdded.toString();
collection.dateModified = xmlCollection.@dateModified.toString();
collection.key = xmlCollection.@key.toString();
}
/*
// Subcollections
var str = xmlCollection.collections.toString();
collection.childCollections = str == '' ? [] : str.split(' ');
*/
// Child items
var str = xmlCollection.items.toString();
collection.childItems = str == '' ? [] : str.split(' ');
return collection;
}
/**
* Converts a Zotero.Creator object to an E4X <creator> object
*/
function creatorToXML(creator) {
var xml = <creator/>;
var creator = creator.serialize();
for (var field in creator.primary) {
switch (field) {
case 'creatorID':
var attr = 'id';
break;
default:
var attr = field;
}
xml['@' + attr] = creator.primary[field];
}
var allowEmpty = ['firstName', 'lastName', 'name'];
for (var field in creator.fields) {
if (!creator.fields[field] && allowEmpty.indexOf(field) == -1) {
continue;
}
switch (field) {
case 'firstName':
case 'lastName':
case 'name':
xml[field] = _xmlize(creator.fields[field]);
break;
default:
xml[field] = creator.fields[field];
}
}
return xml;
}
/**
* Convert E4X <creator> object into an unsaved Zotero.Creator
*
* @param object xmlCreator E4X XML node with creator data
* @param object item (Optional) Existing Zotero.Creator to update
* @param bool skipPrimary (Optional) Ignore passed primary fields (except itemTypeID)
*/
function xmlToCreator(xmlCreator, creator, skipPrimary) {
if (!creator) {
if (skipPrimary) {
creator = new Zotero.Creator(null);
}
else {
creator = new Zotero.Creator(parseInt(xmlCreator.@id));
/*
if (creator.exists()) {
throw ("Creator specified in XML node already exists "
+ "in Zotero.Sync.Server.Data.xmlToCreator()");
}
*/
}
}
else if (skipPrimary) {
throw ("Cannot use skipPrimary with existing creator in "
+ "Zotero.Sync.Server.Data.xmlToCreator()");
}
var data = {
birthYear: xmlCreator.birthYear.toString()
};
if (!skipPrimary) {
data.dateAdded = xmlCreator.@dateAdded.toString();
data.dateModified = xmlCreator.@dateModified.toString();
data.key = xmlCreator.@key.toString();
}
if (xmlCreator.fieldMode == 1) {
data.firstName = '';
data.lastName = xmlCreator.name.toString();
data.fieldMode = 1;
}
else {
data.firstName = xmlCreator.firstName.toString();
data.lastName = xmlCreator.lastName.toString();
data.fieldMode = 0;
}
creator.setFields(data);
return creator;
}
function searchToXML(search) {
var xml = <search/>;
xml.@id = search.id;
xml.@name = _xmlize(search.name);
xml.@dateModified = search.dateModified;
xml.@key = search.key;
var conditions = search.getSearchConditions();
if (conditions) {
for each(var condition in conditions) {
var conditionXML = <condition/>
conditionXML.@id = condition.id;
conditionXML.@condition = condition.condition;
if (condition.mode) {
conditionXML.@mode = condition.mode;
}
conditionXML.@operator = condition.operator;
conditionXML.@value =
_xmlize(condition.value ? condition.value : '');
if (condition.required) {
conditionXML.@required = 1;
}
xml.condition += conditionXML;
}
}
return xml;
}
/**
* Convert E4X <search> object into an unsaved Zotero.Search
*
* @param object xmlSearch E4X XML node with search data
* @param object item (Optional) Existing Zotero.Search to update
* @param bool skipPrimary (Optional) Ignore passed primary fields (except itemTypeID)
*/
function xmlToSearch(xmlSearch, search, skipPrimary) {
if (!search) {
if (skipPrimary) {
search = new Zotero.Search(null);
}
else {
search = new Zotero.Search(parseInt(xmlSearch.@id));
/*
if (search.exists()) {
throw ("Search specified in XML node already exists "
+ "in Zotero.Sync.Server.Data.xmlToSearch()");
}
*/
}
}
else if (skipPrimary) {
throw ("Cannot use new id with existing search in "
+ "Zotero.Sync.Server.Data.xmlToSearch()");
}
search.name = xmlSearch.@name.toString();
if (!skipPrimary) {
search.dateAdded = xmlSearch.@dateAdded.toString();
search.dateModified = xmlSearch.@dateModified.toString();
search.key = xmlSearch.@key.toString();
}
var conditionID = -1;
// Search conditions
for each(var condition in xmlSearch.condition) {
conditionID = parseInt(condition.@id);
var name = condition.@condition.toString();
var mode = condition.@mode.toString();
if (mode) {
name = name + '/' + mode;
}
if (search.getSearchCondition(conditionID)) {
search.updateCondition(
conditionID,
name,
condition.@operator.toString(),
condition.@value.toString(),
!!condition.@required.toString()
);
}
else {
var newID = search.addCondition(
name,
condition.@operator.toString(),
condition.@value.toString(),
!!condition.@required.toString()
);
if (newID != conditionID) {
throw ("Search condition ids not contiguous in Zotero.Sync.Server.xmlToSearch()");
}
}
}
conditionID++;
while (search.getSearchCondition(conditionID)) {
search.removeCondition(conditionID);
}
return search;
}
function tagToXML(tag) {
var xml = <tag/>;
xml.@id = tag.id;
xml.@name = _xmlize(tag.name);
if (tag.type) {
xml.@type = tag.type;
}
xml.@dateModified = tag.dateModified;
xml.@key = tag.key;
var linkedItems = tag.getLinkedItems(true);
if (linkedItems) {
xml.items = linkedItems.join(' ');
}
return xml;
}
/**
* Convert E4X <tag> object into an unsaved Zotero.Tag
*
* @param object xmlTag E4X XML node with tag data
* @param object tag (Optional) Existing Zotero.Tag to update
* @param bool skipPrimary (Optional) Ignore passed primary fields
*/
function xmlToTag(xmlTag, tag, skipPrimary) {
if (!tag) {
if (skipPrimary) {
tag = new Zotero.Tag;
}
else {
tag = new Zotero.Tag(parseInt(xmlTag.@id));
/*
if (tag.exists()) {
throw ("Tag specified in XML node already exists "
+ "in Zotero.Sync.Server.Data.xmlToTag()");
}
*/
}
}
else if (skipPrimary) {
throw ("Cannot use new id with existing tag in "
+ "Zotero.Sync.Server.Data.xmlToTag()");
}
tag.name = xmlTag.@name.toString();
tag.type = parseInt(xmlTag.@type);
if (!skipPrimary) {
tag.dateModified = xmlTag.@dateModified.toString();
tag.key = xmlTag.@key.toString();
}
var str = xmlTag.items ? xmlTag.items.toString() : false;
tag.linkedItems = str ? str.split(' ') : [];
return tag;
}
function _xmlize(str) {
return str.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff\ufffe\uffff]/g, '\u2B1A');
}
}