zotero/chrome/content/zotero/xpcom/data/dataObjectUtilities.js
Dan Stillman 4600318ad7 Support 'successful' property in upload response
Save uploaded data to cache, and update local object if necessary (which
it mostly shouldn't be except for invalid characters and HTML filtering
in notes)

Also add some upload and JSON tests
2015-08-01 05:28:42 -04:00

655 lines
15 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
Zotero.DataObjectUtilities = {
/**
* Get all DataObject types
*
* @return {String[]} - An array of DataObject types
*/
getTypes: function () {
return ['collection', 'search', 'item'];
},
/**
* Get DataObject types that are valid for a given library
*
* @param {Integer} libraryID
* @return {String[]} - An array of DataObject types
*/
getTypesForLibrary: function (libraryID) {
switch (Zotero.Libraries.getType(libraryID)) {
case 'publications':
return ['item'];
default:
return this.getTypes();
}
},
"checkLibraryID": function (libraryID) {
if (!libraryID) {
throw new Error("libraryID not provided");
}
var intValue = parseInt(libraryID);
if (libraryID != intValue || intValue <= 0) {
throw new Error("libraryID must be a positive integer");
}
return intValue;
},
"checkDataID": function(dataID) {
var intValue = parseInt(dataID);
if (dataID != intValue || dataID <= 0)
throw new Error("id must be a positive integer");
return intValue;
},
generateKey: function () {
return Zotero.Utilities.generateObjectKey();
},
"checkKey": function(key) {
if (!key) return null;
if (!Zotero.Utilities.isValidObjectKey(key)) {
throw new Error("key is not valid");
}
return key;
},
getObjectTypeSingular: function (objectTypePlural) {
return objectTypePlural.replace(/(s|es)$/, '');
},
"getObjectTypePlural": function(objectType) {
return objectType == 'search' ? 'searches' : objectType + 's';
},
"getObjectsClassForObjectType": function(objectType) {
if (objectType == 'setting') objectType = 'syncedSetting';
var objectTypePlural = this.getObjectTypePlural(objectType);
var className = objectTypePlural[0].toUpperCase() + objectTypePlural.substr(1);
return Zotero[className]
},
patch: function (base, obj) {
var target = {};
Object.assign(target, obj);
for (let i in base) {
switch (i) {
case 'key':
case 'version':
case 'dateModified':
continue;
}
// If field from base exists in the new version, delete it if it's the same
if (i in target) {
if (!this._fieldChanged(i, base[i], target[i])) {
delete target[i];
}
}
// If field from base doesn't exist in new version, clear it
else {
switch (i) {
case 'deleted':
target[i] = false;
break;
default:
target[i] = '';
}
}
}
return target;
},
/**
* Determine whether two API JSON objects are equivalent
*
* @param {Object} data1 - API JSON of first object
* @param {Object} data2 - API JSON of second object
* @param {Array} [ignoreFields] - Fields to ignore
* @param {Boolean} - True if objects are the same, false if not
*/
equals: function (data1, data2, ignoreFields) {
var skipFields = {};
for (let field of ['key', 'version'].concat(ignoreFields || [])) {
skipFields[field] = true;
}
for (let field in data1) {
if (skipFields[field]) {
continue;
}
let val1 = data1[field];
let val2 = data2[field];
let val1HasValue = val1 || val1 === 0;
let val2HasValue = val2 || val2 === 0;
if (!val1HasValue && !val2HasValue) {
continue;
}
let changed = this._fieldChanged(field, val1, val2);
if (changed) {
return true;
}
skipFields[field] = true;
}
for (let field in data2) {
// Skip ignored fields and fields we've already compared
if (skipFields[field]) {
continue;
}
// All remaining fields don't exist in data1
if (data2[field] === false) {
continue;
}
return true;
}
return false;
},
_fieldChanged: function (fieldName, field1, field2) {
switch (fieldName) {
case 'collections':
case 'conditions':
case 'creators':
case 'tags':
case 'relations':
return this["_" + fieldName + "Changed"](field1, field2);
default:
return field1 !== field2;
}
},
_creatorsChanged: function (data1, data2) {
if (!data2 || data1.length != data2.length) return true;
for (let i = 0; i < data1.length; i++) {
if (!Zotero.Creators.equals(data1[i], data2[i])) {
return true;
}
}
return false;
},
_conditionsChanged: function (data1, data2) {
if (!data2) return true;
var pred1 = Object.keys(data1);
pred1.sort();
var pred2 = Object.keys(data2);
pred2.sort();
if (!Zotero.Utilities.arrayEquals(pred1, pred2)) return false;
for (let i in pred1) {
if (!Zotero.Utilities.arrayEquals(pred1[i], pred2[i])) {
return true;
}
}
return false;
},
_collectionsChanged: function (data1, data2) {
if (!data2 || data1.length != data2.length) return true;
let c1 = data1.concat();
let c2 = data2.concat();
c1.sort();
c2.sort();
return !Zotero.Utilities.arrayEquals(c1, c2);
},
_tagsChanged: function (data1, data2) {
if (!data2 || data1.length != data2.length) return true;
for (let i = 0; i < data1.length; i++) {
if (!Zotero.Tags.equals(data1[i], data2[i])) {
return true;
}
}
return false;
},
_relationsChanged: function (data1, data2) {
if (!data2) return true;
var pred1 = Object.keys(data1);
pred1.sort();
var pred2 = Object.keys(data2);
pred2.sort();
if (!Zotero.Utilities.arrayEquals(pred1, pred2)) return true;
for (let i in pred1) {
if (!Zotero.Utilities.arrayEquals(pred1[i], pred2[i])) {
return true;
}
}
return false;
},
/**
* Compare two API JSON objects and generate a changeset
*
* @param {Object} data1
* @param {Object} data2
* @param {String[]} [ignoreFields] - Fields to ignore
*/
diff: function (data1, data2, ignoreFields) {
var changeset = [];
var skipFields = {};
for (let field of ['key', 'version'].concat(ignoreFields || [])) {
skipFields[field] = true;
}
for (let field in data1) {
if (skipFields[field]) {
continue;
}
let val1 = data1[field];
let val2 = data2[field];
let val1HasValue = (val1 && val1 !== "") || val1 === 0;
let val2HasValue = (val2 && val2 !== "") || val2 === 0;
if (!val1HasValue && !val2HasValue) {
continue;
}
switch (field) {
case 'creators':
case 'collections':
case 'conditions':
case 'relations':
case 'tags':
let changes = this["_" + field + "Diff"](val1, val2);
if (changes.length) {
changeset = changeset.concat(changes);
}
break;
default:
var changed = val1 !== val2;
if (changed) {
if (val1HasValue && !val2HasValue) {
changeset.push({
field: field,
op: 'delete'
});
}
else if (!val1HasValue && val2HasValue) {
changeset.push({
field: field,
op: 'add',
value: val2
});
}
else {
changeset.push({
field: field,
op: 'modify',
value: val2
});
}
}
}
skipFields[field] = true;
}
for (let field in data2) {
// Skip ignored fields and fields we've already compared
if (skipFields[field]) {
continue;
}
// All remaining fields don't exist in data1
let val = data2[field];
if (val === false || val === "" || val === null
|| (typeof val == 'object' && Object.keys(val).length == 0)) {
continue;
}
changeset.push({
field: field,
op: "add",
value: data2[field]
});
}
return changeset;
},
/**
* For creators, just determine if changed, since ordering makes a full diff too complicated
*/
_creatorsDiff: function (data1, data2) {
if (!data2 || !data2.length) {
if (!data1.length) {
return [];
}
return [{
field: "creators",
op: "delete"
}];
}
if (this._creatorsChanged(data1, data2)) {
return [{
field: "creators",
op: "modify",
value: data2
}];
}
return [];
},
_collectionsDiff: function (data1, data2 = []) {
var changeset = [];
var removed = Zotero.Utilities.arrayDiff(data1, data2);
for (let i = 0; i < removed.length; i++) {
changeset.push({
field: "collections",
op: "member-remove",
value: removed[i]
});
}
let added = Zotero.Utilities.arrayDiff(data2, data1);
for (let i = 0; i < added.length; i++) {
changeset.push({
field: "collections",
op: "member-add",
value: added[i]
});
}
return changeset;
},
_conditionsDiff: function (data1, data2 = {}) {
var changeset = [];
outer:
for (let i = 0; i < data1.length; i++) {
for (let j = 0; j < data2.length; j++) {
if (Zotero.SearchConditions.equals(data1[i], data2[j])) {
continue outer;
}
}
changeset.push({
field: "conditions",
op: "member-remove",
value: data1[i]
});
}
outer:
for (let i = 0; i < data2.length; i++) {
for (let j = 0; j < data1.length; j++) {
if (Zotero.SearchConditions.equals(data2[i], data1[j])) {
continue outer;
}
}
changeset.push({
field: "conditions",
op: "member-add",
value: data2[i]
});
}
return changeset;
},
_tagsDiff: function (data1, data2 = []) {
var changeset = [];
outer:
for (let i = 0; i < data1.length; i++) {
for (let j = 0; j < data2.length; j++) {
if (Zotero.Tags.equals(data1[i], data2[j])) {
continue outer;
}
}
changeset.push({
field: "tags",
op: "member-remove",
value: data1[i]
});
}
outer:
for (let i = 0; i < data2.length; i++) {
for (let j = 0; j < data1.length; j++) {
if (Zotero.Tags.equals(data2[i], data1[j])) {
continue outer;
}
}
changeset.push({
field: "tags",
op: "member-add",
value: data2[i]
});
}
return changeset;
},
_relationsDiff: function (data1, data2 = {}) {
var changeset = [];
for (let pred in data1) {
let vals1 = typeof data1[pred] == 'string' ? [data1[pred]] : data1[pred];
let vals2 = (!data2[pred] || data2[pred] === '')
? []
: typeof data2[pred] == 'string' ? [data2[pred]] : data2[pred];
var removed = Zotero.Utilities.arrayDiff(vals1, vals2);
for (let i = 0; i < removed.length; i++) {
changeset.push({
field: "relations",
op: "property-member-remove",
value: {
key: pred,
value: removed[i]
}
});
}
let added = Zotero.Utilities.arrayDiff(vals2, vals1);
for (let i = 0; i < added.length; i++) {
changeset.push({
field: "relations",
op: "property-member-add",
value: {
key: pred,
value: added[i]
}
});
}
}
for (let pred in data2) {
// Property in first object have already been handled
if (data1[pred]) continue;
let vals = typeof data2[pred] == 'string' ? [data2[pred]] : data2[pred];
for (let i = 0; i < vals.length; i++) {
changeset.push({
field: "relations",
op: "property-member-add",
value: {
key: pred,
value: vals[i]
}
});
}
}
return changeset;
},
/**
* Apply a set of changes generated by Zotero.DataObjectUtilities.diff() to an API JSON object
*
* @param {Object} json - API JSON object to modify
* @param {Object[]} changeset - Change instructions, as generated by .diff()
*/
applyChanges: function (json, changeset) {
for (let i = 0; i < changeset.length; i++) {
let c = changeset[i];
if (c.op == 'delete') {
delete json[c.field];
}
else if (c.op == 'add' || c.op == 'modify') {
json[c.field] = c.value;
}
else if (c.op == 'member-add') {
switch (c.field) {
case 'collections':
if (json[c.field].indexOf(c.value) == -1) {
json[c.field].push(c.value);
}
break;
case 'creators':
throw new Error("Unimplemented");
break;
case 'conditions':
case 'tags':
let found = false;
let f = c.field == 'conditions' ? Zotero.SearchConditions : Zotero.Tags;
for (let i = 0; i < json[c.field].length; i++) {
if (f.equals(json[c.field][i], c.value)) {
found = true;
break;
}
}
if (!found) {
json[c.field].push(c.value);
}
break;
default:
throw new Error("Unexpected field '" + c.field + "'");
}
}
else if (c.op == 'member-remove') {
switch (c.field) {
case 'collections':
let pos = json[c.field].indexOf(c.value);
if (pos == -1) {
continue;
}
json[c.field].splice(pos, 1);
break;
case 'creators':
throw new Error("Unimplemented");
break;
case 'conditions':
case 'tags':
let f = c.field == 'conditions' ? Zotero.SearchConditions : Zotero.Tags;
for (let i = 0; i < json[c.field].length; i++) {
if (f.equals(json[c.field][i], c.value)) {
json[c.field].splice(i, 1);
break;
}
}
break;
default:
throw new Error("Unexpected field '" + c.field + "'");
}
}
else if (c.op == 'property-member-add') {
switch (c.field) {
case 'relations':
let obj = json[c.field];
let prop = c.value.key;
let val = c.value.value;
if (!obj) {
obj = json[c.field] = {};
}
if (!obj[prop]) {
obj[prop] = [];
}
// Convert string to array
if (typeof obj[prop] == 'string') {
obj[prop] = [obj[prop]];
}
if (obj[prop].indexOf(val) == -1) {
obj[prop].push(val);
}
break;
default:
throw new Error("Unexpected field '" + c.field + "'");
}
}
else if (c.op == 'property-member-remove') {
switch (c.field) {
case 'relations':
let obj = json[c.field];
let prop = c.value.key;
let val = c.value.value;
if (!obj || !obj[prop]) {
continue;
}
if (typeof obj[prop] == 'string') {
// If propetty was the specified string, remove property
if (obj[prop] === val) {
delete obj[prop];
}
continue;
}
let pos = obj[prop].indexOf(val);
if (pos == -1) {
continue;
}
obj[prop].splice(pos, 1);
// If no more members in property array, remove property
if (obj[prop].length == 0) {
delete obj[prop];
}
break;
default:
throw new Error("Unexpected field '" + c.field + "'");
}
}
else {
throw new Error("Unexpected change operation '" + c.op + "'");
}
}
}
};