dataObjectUtilities.js (16499B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 27 Zotero.DataObjectUtilities = { 28 /** 29 * Get all DataObject types 30 * 31 * @return {String[]} - An array of DataObject types 32 */ 33 getTypes: function () { 34 return ['collection', 'search', 'item']; 35 }, 36 37 /** 38 * Get DataObject types that are valid for a given library 39 * 40 * @param {Integer} libraryID 41 * @return {String[]} - An array of DataObject types 42 */ 43 getTypesForLibrary: function (libraryID) { 44 switch (Zotero.Libraries.get(libraryID).libraryType) { 45 case 'publications': 46 return ['item']; 47 48 default: 49 return this.getTypes(); 50 } 51 }, 52 53 "checkLibraryID": function (libraryID) { 54 if (!libraryID) { 55 throw new Error("libraryID not provided"); 56 } 57 var intValue = parseInt(libraryID); 58 if (libraryID != intValue || intValue <= 0) { 59 throw new Error("libraryID must be a positive integer"); 60 } 61 return intValue; 62 }, 63 64 "checkDataID": function(dataID) { 65 var intValue = parseInt(dataID); 66 if (dataID != intValue || dataID <= 0) 67 throw new Error("id must be a positive integer"); 68 return intValue; 69 }, 70 71 72 generateKey: function () { 73 return Zotero.Utilities.generateObjectKey(); 74 }, 75 76 77 "checkKey": function(key) { 78 if (!key && key !== 0) return null; 79 if (!Zotero.Utilities.isValidObjectKey(key)) { 80 throw new Error("key is not valid"); 81 } 82 return key; 83 }, 84 85 86 getObjectTypeSingular: function (objectTypePlural) { 87 return objectTypePlural.replace(/(s|es)$/, ''); 88 }, 89 90 91 "getObjectTypePlural": function(objectType) { 92 switch(objectType) { 93 case 'search': 94 return 'searches'; 95 break; 96 case 'library': 97 return 'libraries'; 98 break; 99 default: 100 return objectType + 's'; 101 } 102 }, 103 104 105 "getObjectsClassForObjectType": function(objectType) { 106 if (objectType == 'setting') objectType = 'syncedSetting'; 107 108 var objectTypePlural = this.getObjectTypePlural(objectType); 109 var className = objectTypePlural[0].toUpperCase() + objectTypePlural.substr(1); 110 return Zotero[className] 111 }, 112 113 114 patch: function (base, obj) { 115 var target = {}; 116 Object.assign(target, obj); 117 118 for (let i in base) { 119 switch (i) { 120 case 'key': 121 case 'version': 122 case 'dateModified': 123 continue; 124 } 125 126 // If field from base exists in the new version, delete it if it's the same 127 if (i in target) { 128 if (!this._fieldChanged(i, base[i], target[i])) { 129 delete target[i]; 130 } 131 } 132 // If field from base doesn't exist in new version, clear it 133 else { 134 switch (i) { 135 // When changing an item from top-level to child, the collections property is 136 // no valid, so it doesn't need to be cleared 137 case 'collections': 138 break; 139 140 case 'deleted': 141 case 'parentItem': 142 case 'inPublications': 143 target[i] = false; 144 break; 145 146 default: 147 target[i] = ''; 148 } 149 } 150 } 151 152 return target; 153 }, 154 155 156 /** 157 * Determine whether two API JSON objects are equivalent 158 * 159 * Note: Currently unused 160 * 161 * @param {Object} data1 - API JSON of first object 162 * @param {Object} data2 - API JSON of second object 163 * @param {Array} [ignoreFields] - Fields to ignore 164 * @return {Boolean} - True if objects are the same, false if not 165 */ 166 equals: function (data1, data2, ignoreFields) { 167 var skipFields = {}; 168 for (let field of ['key', 'version'].concat(ignoreFields || [])) { 169 skipFields[field] = true; 170 } 171 172 for (let field in data1) { 173 if (skipFields[field]) { 174 continue; 175 } 176 177 let val1 = data1[field]; 178 let val2 = data2[field]; 179 let val1HasValue = val1 || val1 === 0; 180 let val2HasValue = val2 || val2 === 0; 181 182 if (!val1HasValue && !val2HasValue) { 183 continue; 184 } 185 186 let changed = this._fieldChanged(field, val1, val2); 187 if (changed) { 188 return false; 189 } 190 191 skipFields[field] = true; 192 } 193 194 for (let field in data2) { 195 // Skip ignored fields and fields we've already compared 196 if (skipFields[field]) { 197 continue; 198 } 199 200 // All remaining fields don't exist in data1 201 202 if (data2[field] === false) { 203 continue; 204 } 205 206 return false; 207 } 208 209 return true; 210 }, 211 212 _fieldChanged: function (fieldName, field1, field2) { 213 switch (fieldName) { 214 case 'collections': 215 case 'conditions': 216 case 'creators': 217 case 'tags': 218 case 'relations': 219 return this["_" + fieldName + "Changed"](field1, field2); 220 221 default: 222 return field1 !== field2; 223 } 224 }, 225 226 _creatorsChanged: function (data1, data2) { 227 if (!data2 || data1.length != data2.length) return true; 228 for (let i = 0; i < data1.length; i++) { 229 if (!Zotero.Creators.equals(data1[i], data2[i])) { 230 return true; 231 } 232 } 233 return false; 234 }, 235 236 _conditionsChanged: function (data1, data2) { 237 if (!data2 || data1.length != data2.length) return true; 238 for (let i = 0; i < data1.length; i++) { 239 if (!Zotero.Searches.conditionEquals(data1[i], data2[i])) { 240 return true; 241 } 242 } 243 return false; 244 }, 245 246 _collectionsChanged: function (data1, data2) { 247 if (!data2 || data1.length != data2.length) return true; 248 let c1 = data1.concat(); 249 let c2 = data2.concat(); 250 c1.sort(); 251 c2.sort(); 252 return !Zotero.Utilities.arrayEquals(c1, c2); 253 }, 254 255 _tagsChanged: function (data1, data2) { 256 if (!data2 || data1.length != data2.length) return true; 257 for (let i = 0; i < data1.length; i++) { 258 if (!Zotero.Tags.equals(data1[i], data2[i])) { 259 return true; 260 } 261 } 262 return false; 263 }, 264 265 _relationsChanged: function (data1, data2) { 266 if (!data2) return true; 267 var pred1 = Object.keys(data1); 268 pred1.sort(); 269 var pred2 = Object.keys(data2); 270 pred2.sort(); 271 if (!Zotero.Utilities.arrayEquals(pred1, pred2)) return true; 272 for (let pred in pred1) { 273 let vals1 = typeof data1[pred] == 'string' ? [data1[pred]] : data1[pred]; 274 let vals2 = (!data2[pred] || data2[pred] === '') 275 ? [] 276 : typeof data2[pred] == 'string' ? [data2[pred]] : data2[pred]; 277 278 if (!Zotero.Utilities.arrayEquals(vals1, vals2)) { 279 return true; 280 } 281 } 282 return false; 283 }, 284 285 286 /** 287 * Compare two API JSON objects and generate a changeset 288 * 289 * @param {Object} data1 290 * @param {Object} data2 291 * @param {String[]} [ignoreFields] - Fields to ignore 292 */ 293 diff: function (data1, data2, ignoreFields) { 294 var changeset = []; 295 296 var skipFields = {}; 297 for (let field of ['key', 'version'].concat(ignoreFields || [])) { 298 skipFields[field] = true; 299 } 300 301 for (let field in data1) { 302 if (skipFields[field]) { 303 continue; 304 } 305 306 let val1 = data1[field]; 307 let val2 = data2[field]; 308 let val1HasValue = (val1 && val1 !== "") || val1 === 0; 309 let val2HasValue = (val2 && val2 !== "") || val2 === 0; 310 311 if (!val1HasValue && !val2HasValue) { 312 continue; 313 } 314 315 switch (field) { 316 case 'creators': 317 case 'collections': 318 case 'conditions': 319 case 'relations': 320 case 'tags': 321 let changes = this["_" + field + "Diff"](val1, val2); 322 if (changes.length) { 323 changeset = changeset.concat(changes); 324 } 325 break; 326 327 case 'note': 328 let change = this._htmlDiff(field, val1, val2); 329 if (change) { 330 changeset.push(change); 331 } 332 break; 333 334 default: 335 var changed = val1 !== val2; 336 if (changed) { 337 if (val1HasValue && !val2HasValue) { 338 changeset.push({ 339 field: field, 340 op: 'delete' 341 }); 342 } 343 else if (!val1HasValue && val2HasValue) { 344 changeset.push({ 345 field: field, 346 op: 'add', 347 value: val2 348 }); 349 } 350 else { 351 changeset.push({ 352 field: field, 353 op: 'modify', 354 value: val2 355 }); 356 } 357 } 358 } 359 360 skipFields[field] = true; 361 } 362 363 for (let field in data2) { 364 // Skip ignored fields and fields we've already compared 365 if (skipFields[field]) { 366 continue; 367 } 368 369 // All remaining fields don't exist in data1 370 371 let val = data2[field]; 372 if (val === false || val === "" || val === null 373 || (typeof val == 'object' && Object.keys(val).length == 0)) { 374 continue; 375 } 376 377 changeset.push({ 378 field: field, 379 op: "add", 380 value: data2[field] 381 }); 382 } 383 384 return changeset; 385 }, 386 387 /** 388 * For creators, just determine if changed, since ordering makes a full diff too complicated 389 */ 390 _creatorsDiff: function (data1, data2) { 391 if (!data2 || !data2.length) { 392 if (!data1.length) { 393 return []; 394 } 395 return [{ 396 field: "creators", 397 op: "delete" 398 }]; 399 } 400 if (this._creatorsChanged(data1, data2)) { 401 return [{ 402 field: "creators", 403 op: "modify", 404 value: data2 405 }]; 406 } 407 return []; 408 }, 409 410 _collectionsDiff: function (data1, data2 = []) { 411 var changeset = []; 412 var removed = Zotero.Utilities.arrayDiff(data1, data2); 413 for (let i = 0; i < removed.length; i++) { 414 changeset.push({ 415 field: "collections", 416 op: "member-remove", 417 value: removed[i] 418 }); 419 } 420 let added = Zotero.Utilities.arrayDiff(data2, data1); 421 for (let i = 0; i < added.length; i++) { 422 changeset.push({ 423 field: "collections", 424 op: "member-add", 425 value: added[i] 426 }); 427 } 428 return changeset; 429 }, 430 431 _conditionsDiff: function (data1, data2 = {}) { 432 var changeset = []; 433 outer: 434 for (let i = 0; i < data1.length; i++) { 435 for (let j = 0; j < data2.length; j++) { 436 if (Zotero.SearchConditions.equals(data1[i], data2[j])) { 437 continue outer; 438 } 439 } 440 changeset.push({ 441 field: "conditions", 442 op: "member-remove", 443 value: data1[i] 444 }); 445 } 446 outer: 447 for (let i = 0; i < data2.length; i++) { 448 for (let j = 0; j < data1.length; j++) { 449 if (Zotero.SearchConditions.equals(data2[i], data1[j])) { 450 continue outer; 451 } 452 } 453 changeset.push({ 454 field: "conditions", 455 op: "member-add", 456 value: data2[i] 457 }); 458 } 459 return changeset; 460 }, 461 462 _htmlDiff: function (field, html1, html2 = "") { 463 if (html1 == "" && html2 != "") { 464 return { 465 field, 466 op: "add", 467 value: html2 468 }; 469 } 470 if (html1 != "" && html2 == "") { 471 return { 472 field, 473 op: "delete" 474 }; 475 } 476 477 // Until we have a consistent way of sanitizing HTML on client and server, account for differences 478 var mods = [ 479 ['<p> </p>', '<p>\u00a0</p>'] 480 ]; 481 var a = html1; 482 var b = html2; 483 for (let mod of mods) { 484 a = a.replace(new RegExp(mod[0], 'g'), mod[1]); 485 b = b.replace(new RegExp(mod[0], 'g'), mod[1]); 486 } 487 if (a != b) { 488 Zotero.debug("HTML diff:"); 489 Zotero.debug(a); 490 Zotero.debug(b); 491 return { 492 field, 493 op: "modify", 494 value: html2 495 }; 496 } 497 498 return false; 499 }, 500 501 _tagsDiff: function (data1, data2 = []) { 502 var changeset = []; 503 outer: 504 for (let i = 0; i < data1.length; i++) { 505 for (let j = 0; j < data2.length; j++) { 506 if (Zotero.Tags.equals(data1[i], data2[j])) { 507 continue outer; 508 } 509 } 510 changeset.push({ 511 field: "tags", 512 op: "member-remove", 513 value: data1[i] 514 }); 515 } 516 outer: 517 for (let i = 0; i < data2.length; i++) { 518 for (let j = 0; j < data1.length; j++) { 519 if (Zotero.Tags.equals(data2[i], data1[j])) { 520 continue outer; 521 } 522 } 523 changeset.push({ 524 field: "tags", 525 op: "member-add", 526 value: data2[i] 527 }); 528 } 529 return changeset; 530 }, 531 532 _relationsDiff: function (data1, data2 = {}) { 533 var changeset = []; 534 for (let pred in data1) { 535 let vals1 = typeof data1[pred] == 'string' ? [data1[pred]] : data1[pred]; 536 let vals2 = (!data2[pred] || data2[pred] === '') 537 ? [] 538 : typeof data2[pred] == 'string' ? [data2[pred]] : data2[pred]; 539 540 var removed = Zotero.Utilities.arrayDiff(vals1, vals2); 541 for (let i = 0; i < removed.length; i++) { 542 changeset.push({ 543 field: "relations", 544 op: "property-member-remove", 545 value: { 546 key: pred, 547 value: removed[i] 548 } 549 }); 550 } 551 let added = Zotero.Utilities.arrayDiff(vals2, vals1); 552 for (let i = 0; i < added.length; i++) { 553 changeset.push({ 554 field: "relations", 555 op: "property-member-add", 556 value: { 557 key: pred, 558 value: added[i] 559 } 560 }); 561 } 562 } 563 for (let pred in data2) { 564 // Property in first object has already been handled 565 if (data1[pred]) continue; 566 567 let vals = typeof data2[pred] == 'string' ? [data2[pred]] : data2[pred]; 568 for (let i = 0; i < vals.length; i++) { 569 changeset.push({ 570 field: "relations", 571 op: "property-member-add", 572 value: { 573 key: pred, 574 value: vals[i] 575 } 576 }); 577 } 578 } 579 return changeset; 580 }, 581 582 583 /** 584 * Apply a set of changes generated by Zotero.DataObjectUtilities.diff() to an API JSON object 585 * 586 * @param {Object} json - API JSON object to modify 587 * @param {Object[]} changeset - Change instructions, as generated by .diff() 588 */ 589 applyChanges: function (json, changeset) { 590 for (let i = 0; i < changeset.length; i++) { 591 let c = changeset[i]; 592 if (c.op == 'delete') { 593 delete json[c.field]; 594 } 595 else if (c.op == 'add' || c.op == 'modify') { 596 json[c.field] = c.value; 597 } 598 else if (c.op == 'member-add') { 599 switch (c.field) { 600 case 'collections': 601 if (json[c.field].indexOf(c.value) == -1) { 602 json[c.field].push(c.value); 603 } 604 break; 605 606 case 'creators': 607 throw new Error("Unimplemented"); 608 break; 609 610 case 'conditions': 611 case 'tags': 612 let found = false; 613 let f = c.field == 'conditions' ? Zotero.SearchConditions : Zotero.Tags; 614 for (let i = 0; i < json[c.field].length; i++) { 615 if (f.equals(json[c.field][i], c.value)) { 616 found = true; 617 break; 618 } 619 } 620 if (!found) { 621 json[c.field].push(c.value); 622 } 623 break; 624 625 default: 626 throw new Error("Unexpected field '" + c.field + "'"); 627 } 628 } 629 else if (c.op == 'member-remove') { 630 switch (c.field) { 631 case 'collections': 632 let pos = json[c.field].indexOf(c.value); 633 if (pos == -1) { 634 continue; 635 } 636 json[c.field].splice(pos, 1); 637 break; 638 639 case 'creators': 640 throw new Error("Unimplemented"); 641 break; 642 643 case 'conditions': 644 case 'tags': 645 let f = c.field == 'conditions' ? Zotero.SearchConditions : Zotero.Tags; 646 for (let i = 0; i < json[c.field].length; i++) { 647 if (f.equals(json[c.field][i], c.value)) { 648 json[c.field].splice(i, 1); 649 break; 650 } 651 } 652 break; 653 654 default: 655 throw new Error("Unexpected field '" + c.field + "'"); 656 } 657 } 658 else if (c.op == 'property-member-add') { 659 switch (c.field) { 660 case 'relations': 661 let obj = json[c.field]; 662 let prop = c.value.key; 663 let val = c.value.value; 664 if (!obj) { 665 obj = json[c.field] = {}; 666 } 667 if (!obj[prop]) { 668 obj[prop] = []; 669 } 670 // Convert string to array 671 if (typeof obj[prop] == 'string') { 672 obj[prop] = [obj[prop]]; 673 } 674 if (obj[prop].indexOf(val) == -1) { 675 obj[prop].push(val); 676 } 677 break; 678 679 default: 680 throw new Error("Unexpected field '" + c.field + "'"); 681 } 682 } 683 else if (c.op == 'property-member-remove') { 684 switch (c.field) { 685 case 'relations': 686 let obj = json[c.field]; 687 let prop = c.value.key; 688 let val = c.value.value; 689 if (!obj || !obj[prop]) { 690 continue; 691 } 692 if (typeof obj[prop] == 'string') { 693 // If propetty was the specified string, remove property 694 if (obj[prop] === val) { 695 delete obj[prop]; 696 } 697 continue; 698 } 699 let pos = obj[prop].indexOf(val); 700 if (pos == -1) { 701 continue; 702 } 703 obj[prop].splice(pos, 1); 704 // If no more members in property array, remove property 705 if (obj[prop].length == 0) { 706 delete obj[prop]; 707 } 708 break; 709 710 default: 711 throw new Error("Unexpected field '" + c.field + "'"); 712 } 713 } 714 else { 715 throw new Error("Unexpected change operation '" + c.op + "'"); 716 } 717 } 718 } 719 };