storageLocal.js (31995B)
1 Zotero.Sync.Storage.Local = { 2 // 3 // Constants 4 // 5 SYNC_STATE_TO_UPLOAD: 0, 6 SYNC_STATE_TO_DOWNLOAD: 1, 7 SYNC_STATE_IN_SYNC: 2, 8 SYNC_STATE_FORCE_UPLOAD: 3, 9 SYNC_STATE_FORCE_DOWNLOAD: 4, 10 SYNC_STATE_IN_CONFLICT: 5, 11 12 lastFullFileCheck: {}, 13 uploadCheckFiles: [], 14 15 getEnabledForLibrary: function (libraryID) { 16 var libraryType = Zotero.Libraries.get(libraryID).libraryType; 17 switch (libraryType) { 18 case 'user': 19 return Zotero.Prefs.get("sync.storage.enabled"); 20 21 // TEMP: Always sync publications files, at least until we have a better interface for 22 // setting library-specific settings 23 case 'publications': 24 return true; 25 26 case 'group': 27 return Zotero.Prefs.get("sync.storage.groups.enabled"); 28 29 case 'feed': 30 return false; 31 32 default: 33 throw new Error(`Unexpected library type '${libraryType}'`); 34 } 35 }, 36 37 getClassForLibrary: function (libraryID) { 38 return Zotero.Sync.Storage.Utilities.getClassForMode(this.getModeForLibrary(libraryID)); 39 }, 40 41 getModeForLibrary: function (libraryID) { 42 var libraryType = Zotero.Libraries.get(libraryID).libraryType; 43 switch (libraryType) { 44 case 'user': 45 return Zotero.Prefs.get("sync.storage.protocol") == 'webdav' ? 'webdav' : 'zfs'; 46 47 case 'publications': 48 case 'group': 49 // TODO: Remove after making sure this is never called for feed libraries 50 case 'feed': 51 return 'zfs'; 52 53 default: 54 throw new Error(`Unexpected library type '${libraryType}'`); 55 } 56 }, 57 58 setModeForLibrary: function (libraryID, mode) { 59 var libraryType = Zotero.Libraries.get(libraryID).libraryType; 60 61 if (libraryType != 'user') { 62 throw new Error(`Cannot set storage mode for ${libraryType} library`); 63 } 64 65 switch (mode) { 66 case 'webdav': 67 case 'zfs': 68 Zotero.Prefs.set("sync.storage.protocol", mode); 69 break; 70 71 default: 72 throw new Error(`Unexpected storage mode '${mode}'`); 73 } 74 }, 75 76 /** 77 * Check or enable download-as-needed mode 78 * 79 * @param {Integer} [libraryID] 80 * @param {Boolean} [enable] - If true, enable download-as-needed mode for the given library 81 * @return {Boolean|undefined} - If 'enable' isn't set to true, return true if 82 * download-as-needed mode enabled and false if not 83 */ 84 downloadAsNeeded: function (libraryID, enable) { 85 var pref = this._getDownloadPrefFromLibrary(libraryID); 86 var val = 'on-demand'; 87 if (enable) { 88 Zotero.Prefs.set(pref, val); 89 return; 90 } 91 return Zotero.Prefs.get(pref) == val; 92 }, 93 94 /** 95 * Check or enable download-on-sync mode 96 * 97 * @param {Integer} [libraryID] 98 * @param {Boolean} [enable] - If true, enable download-on-demand mode for the given library 99 * @return {Boolean|undefined} - If 'enable' isn't set to true, return true if 100 * download-as-needed mode enabled and false if not 101 */ 102 downloadOnSync: function (libraryID, enable) { 103 var pref = this._getDownloadPrefFromLibrary(libraryID); 104 var val = 'on-sync'; 105 if (enable) { 106 Zotero.Prefs.set(pref, val); 107 return; 108 } 109 return Zotero.Prefs.get(pref) == val; 110 }, 111 112 _getDownloadPrefFromLibrary: function (libraryID) { 113 if (libraryID == Zotero.Libraries.userLibraryID) { 114 return 'sync.storage.downloadMode.personal'; 115 } 116 // TODO: Library-specific settings 117 118 // Group library 119 return 'sync.storage.downloadMode.groups'; 120 }, 121 122 /** 123 * Get files to check for local modifications for uploading 124 * 125 * This includes files previously modified or opened externally via Zotero within maxCheckAge 126 */ 127 getFilesToCheck: Zotero.Promise.coroutine(function* (libraryID, maxCheckAge) { 128 var minTime = new Date().getTime() - (maxCheckAge * 1000); 129 130 // Get files modified and synced since maxCheckAge 131 var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " 132 + "WHERE libraryID=? AND linkMode IN (?,?) AND syncState IN (?) AND " 133 + "storageModTime>=?"; 134 var params = [ 135 libraryID, 136 Zotero.Attachments.LINK_MODE_IMPORTED_FILE, 137 Zotero.Attachments.LINK_MODE_IMPORTED_URL, 138 this.SYNC_STATE_IN_SYNC, 139 minTime 140 ]; 141 var itemIDs = yield Zotero.DB.columnQueryAsync(sql, params); 142 143 // Get files opened since maxCheckAge 144 itemIDs = itemIDs.concat( 145 this.uploadCheckFiles.filter(x => x.timestamp >= minTime).map(x => x.itemID) 146 ); 147 148 return Zotero.Utilities.arrayUnique(itemIDs); 149 }), 150 151 152 /** 153 * Scans local files and marks any that have changed for uploading 154 * and any that are missing for downloading 155 * 156 * @param {Integer} libraryID 157 * @param {Integer[]} [itemIDs] 158 * @param {Object} [itemModTimes] Item mod times indexed by item ids; 159 * items with stored mod times 160 * that differ from the provided 161 * time but file mod times 162 * matching the stored time will 163 * be marked for download 164 * @return {Promise} Promise resolving to TRUE if any items changed state, 165 * FALSE otherwise 166 */ 167 checkForUpdatedFiles: Zotero.Promise.coroutine(function* (libraryID, itemIDs, itemModTimes) { 168 var libraryName = Zotero.Libraries.getName(libraryID); 169 var msg = "Checking for locally changed attachment files in " + libraryName; 170 171 var memmgr = Components.classes["@mozilla.org/memory-reporter-manager;1"] 172 .getService(Components.interfaces.nsIMemoryReporterManager); 173 memmgr.init(); 174 //Zotero.debug("Memory usage: " + memmgr.resident); 175 176 if (itemIDs) { 177 if (!itemIDs.length) { 178 Zotero.debug("No files to check for local changes"); 179 return false; 180 } 181 } 182 if (itemModTimes) { 183 if (!Object.keys(itemModTimes).length) { 184 return false; 185 } 186 msg += " in download-marking mode"; 187 } 188 189 Zotero.debug(msg); 190 191 var changed = false; 192 193 if (!itemIDs) { 194 itemIDs = Object.keys(itemModTimes ? itemModTimes : {}); 195 } 196 197 // Can only handle a certain number of bound parameters at a time 198 var numIDs = itemIDs.length; 199 var maxIDs = Zotero.DB.MAX_BOUND_PARAMETERS - 10; 200 var done = 0; 201 var rows = []; 202 203 do { 204 let chunk = itemIDs.splice(0, maxIDs); 205 let sql = "SELECT itemID, linkMode, path, storageModTime, storageHash, syncState " 206 + "FROM itemAttachments JOIN items USING (itemID) " 207 + "WHERE linkMode IN (?,?) AND syncState IN (?,?)"; 208 let params = [ 209 Zotero.Attachments.LINK_MODE_IMPORTED_FILE, 210 Zotero.Attachments.LINK_MODE_IMPORTED_URL, 211 this.SYNC_STATE_TO_UPLOAD, 212 this.SYNC_STATE_IN_SYNC 213 ]; 214 if (libraryID !== false) { 215 sql += " AND libraryID=?"; 216 params.push(libraryID); 217 } 218 if (chunk.length) { 219 sql += " AND itemID IN (" + chunk.map(() => '?').join() + ")"; 220 params = params.concat(chunk); 221 } 222 let chunkRows = yield Zotero.DB.queryAsync(sql, params); 223 if (chunkRows) { 224 rows = rows.concat(chunkRows); 225 } 226 done += chunk.length; 227 } 228 while (done < numIDs); 229 230 // If no files, or everything is already marked for download, 231 // we don't need to do anything 232 if (!rows.length) { 233 Zotero.debug("No in-sync or to-upload files found in " + libraryName); 234 return false; 235 } 236 237 // Index attachment data by item id 238 itemIDs = []; 239 var attachmentData = {}; 240 for (let row of rows) { 241 var id = row.itemID; 242 itemIDs.push(id); 243 attachmentData[id] = { 244 linkMode: row.linkMode, 245 path: row.path, 246 mtime: row.storageModTime, 247 hash: row.storageHash, 248 state: row.syncState 249 }; 250 } 251 rows = null; 252 253 var t = new Date(); 254 var items = yield Zotero.Items.getAsync(itemIDs, { noCache: true }); 255 var numItems = items.length; 256 var updatedStates = {}; 257 258 //Zotero.debug("Memory usage: " + memmgr.resident); 259 260 var changed = false; 261 var statesToSet = {}; 262 for (let item of items) { 263 // TODO: Catch error? 264 let state = yield this._checkForUpdatedFile(item, attachmentData[item.id]); 265 if (state !== false) { 266 if (!statesToSet[state]) { 267 statesToSet[state] = []; 268 } 269 statesToSet[state].push(item); 270 changed = true; 271 } 272 } 273 // Update sync states in bulk 274 if (changed) { 275 yield Zotero.DB.executeTransaction(function* () { 276 for (let state in statesToSet) { 277 yield this.updateSyncStates(statesToSet[state], parseInt(state)); 278 } 279 }.bind(this)); 280 } 281 282 if (!items.length) { 283 Zotero.debug("No synced files have changed locally"); 284 } 285 286 Zotero.debug(`Checked ${numItems} files in ${libraryName} in ` + (new Date() - t) + " ms"); 287 288 return changed; 289 }), 290 291 292 _checkForUpdatedFile: Zotero.Promise.coroutine(function* (item, attachmentData, remoteModTime) { 293 var lk = item.libraryKey; 294 Zotero.debug("Checking attachment file for item " + lk, 4); 295 296 var path = item.getFilePath(); 297 if (!path) { 298 Zotero.debug("Marking pathless attachment " + lk + " as in-sync"); 299 return this.SYNC_STATE_IN_SYNC; 300 } 301 var fileName = OS.Path.basename(path); 302 var file; 303 304 try { 305 file = yield OS.File.open(path); 306 let info = yield file.stat(); 307 //Zotero.debug("Memory usage: " + memmgr.resident); 308 309 let fmtime = info.lastModificationDate.getTime(); 310 //Zotero.debug("File modification time for item " + lk + " is " + fmtime); 311 312 if (fmtime < 0) { 313 Zotero.debug("File mod time " + fmtime + " is less than 0 -- interpreting as 0", 2); 314 fmtime = 0; 315 } 316 317 // If file is already marked for upload, skip check. Even if the file was changed 318 // both locally and remotely, conflicts are checked at upload time, so we don't need 319 // to worry about it here. 320 if (item.attachmentSyncState == this.SYNC_STATE_TO_UPLOAD) { 321 Zotero.debug("File is already marked for upload"); 322 return false; 323 } 324 325 //Zotero.debug("Stored mtime is " + attachmentData.mtime); 326 //Zotero.debug("File mtime is " + fmtime); 327 328 //BAIL AFTER DOWNLOAD MARKING MODE, OR CHECK LOCAL? 329 let mtime = attachmentData ? attachmentData.mtime : false; 330 331 // Download-marking mode 332 if (remoteModTime) { 333 Zotero.debug(`Remote mod time for item ${lk} is ${remoteModTime}`); 334 335 // Ignore attachments whose stored mod times haven't changed 336 mtime = mtime !== false ? mtime : item.attachmentSyncedModificationTime; 337 if (mtime == remoteModTime) { 338 Zotero.debug(`Synced mod time (${mtime}) hasn't changed for item ${lk}`); 339 return false; 340 } 341 342 Zotero.debug(`Marking attachment ${lk} for download (stored mtime: ${mtime})`); 343 // DEBUG: Always set here, or allow further steps? 344 return this.SYNC_STATE_FORCE_DOWNLOAD; 345 } 346 347 var same = !this.checkFileModTime(item, fmtime, mtime); 348 if (same) { 349 Zotero.debug("File has not changed"); 350 return false; 351 } 352 353 // If file hash matches stored hash, only the mod time changed, so skip 354 let fileHash = yield Zotero.Utilities.Internal.md5Async(file); 355 356 var hash = attachmentData ? attachmentData.hash : (yield this.getSyncedHash(item.id)); 357 if (hash && hash == fileHash) { 358 // We have to close the file before modifying it from the main 359 // thread (at least on Windows, where assigning lastModifiedTime 360 // throws an NS_ERROR_FILE_IS_LOCKED otherwise) 361 yield file.close(); 362 363 Zotero.debug("Mod time didn't match (" + fmtime + " != " + mtime + ") " 364 + "but hash did for " + fileName + " for item " + lk 365 + " -- updating file mod time"); 366 try { 367 yield OS.File.setDates(path, null, mtime); 368 } 369 catch (e) { 370 Zotero.File.checkFileAccessError(e, path, 'update'); 371 } 372 return false; 373 } 374 375 // Mark file for upload 376 Zotero.debug("Marking attachment " + lk + " as changed " 377 + "(" + mtime + " != " + fmtime + ")"); 378 return this.SYNC_STATE_TO_UPLOAD; 379 } 380 catch (e) { 381 if (e instanceof OS.File.Error) { 382 let missing = e.becauseNoSuchFile 383 // ERROR_PATH_NOT_FOUND: This can happen if a path is too long on Windows, e.g. a 384 // file is being accessed on a VM through a share (and probably in other cases) 385 || e.winLastError == 3 386 // ERROR_INVALID_NAME: This can happen if there's a colon in the name from before 387 // we were filtering 388 || e.winLastError == 123 389 // ERROR_BAD_PATHNAME 390 || e.winLastError == 161; 391 if (!missing) { 392 Components.classes["@mozilla.org/net/osfileconstantsservice;1"] 393 .getService(Components.interfaces.nsIOSFileConstantsService) 394 .init(); 395 missing = e.unixErrno == OS.Constants.libc.ENOTDIR 396 // Handle long filenames on OS X/Linux 397 || e.unixErrno == OS.Constants.libc.ENAMETOOLONG; 398 } 399 if (missing) { 400 if (!e.becauseNoSuchFile) { 401 Zotero.debug(e, 1); 402 } 403 Zotero.debug("Marking attachment " + lk + " as missing"); 404 return this.SYNC_STATE_TO_DOWNLOAD; 405 } 406 if (e.becauseClosed) { 407 Zotero.debug("File was closed", 2); 408 } 409 Zotero.debug(e, 1); 410 Zotero.debug(e.unixErrno, 1); 411 Zotero.debug(e.winLastError, 1); 412 throw new Error(`Error for operation '${e.operation}' for ${path}: ${e}`); 413 } 414 throw e; 415 } 416 finally { 417 if (file) { 418 //Zotero.debug("Closing file for item " + lk); 419 file.close(); 420 } 421 } 422 }), 423 424 /** 425 * 426 * @param {Zotero.Item} item 427 * @param {Integer} fmtime - File modification time in milliseconds 428 * @param {Integer} mtime - Remote modification time in milliseconds 429 * @return {Boolean} - True if file modification time differs from remote mod time, 430 * false otherwise 431 */ 432 checkFileModTime: function (item, fmtime, mtime) { 433 var libraryKey = item.libraryKey; 434 435 if (fmtime == mtime) { 436 Zotero.debug(`Mod time for ${libraryKey} matches remote file -- skipping`); 437 } 438 // Compare floored timestamps for filesystems that don't support millisecond 439 // precision (e.g., HFS+) 440 else if (Math.floor(mtime / 1000) == Math.floor(fmtime / 1000)) { 441 Zotero.debug(`File mod times for ${libraryKey} are within one-second precision ` 442 + "(" + fmtime + " \u2248 " + mtime + ") -- skipping"); 443 } 444 // Allow timestamp to be exactly one hour off to get around time zone issues 445 // -- there may be a proper way to fix this 446 else if (Math.abs(Math.floor(fmtime / 1000) - Math.floor(mtime / 1000)) == 3600) { 447 Zotero.debug(`File mod time (${fmtime}) for {$libraryKey} is exactly one hour off ` 448 + `remote file (${mtime}) -- assuming time zone issue and skipping`); 449 } 450 else { 451 return true; 452 } 453 454 return false; 455 }, 456 457 checkForForcedDownloads: Zotero.Promise.coroutine(function* (libraryID) { 458 // Forced downloads happen even in on-demand mode 459 var sql = "SELECT COUNT(*) FROM items JOIN itemAttachments USING (itemID) " 460 + "WHERE libraryID=? AND syncState=?"; 461 return !!(yield Zotero.DB.valueQueryAsync( 462 sql, [libraryID, this.SYNC_STATE_FORCE_DOWNLOAD] 463 )); 464 }), 465 466 467 /** 468 * Get files marked as ready to download 469 * 470 * @param {Integer} libraryID 471 * @return {Promise<Number[]>} - Promise for an array of attachment itemIDs 472 */ 473 getFilesToDownload: function (libraryID, forcedOnly) { 474 var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " 475 + "WHERE libraryID=? AND syncState IN (?"; 476 var params = [libraryID, this.SYNC_STATE_FORCE_DOWNLOAD]; 477 if (!forcedOnly) { 478 sql += ",?"; 479 params.push(this.SYNC_STATE_TO_DOWNLOAD); 480 } 481 sql += ") " 482 // Skip attachments with empty path, which can't be saved, and files with .zotero* 483 // paths, which have somehow ended up in some users' libraries 484 + "AND path!='' AND path NOT LIKE ?"; 485 params.push('storage:.zotero%'); 486 return Zotero.DB.columnQueryAsync(sql, params); 487 }, 488 489 490 /** 491 * Get files marked as ready to upload 492 * 493 * @param {Integer} libraryID 494 * @return {Promise<Number[]>} - Promise for an array of attachment itemIDs 495 */ 496 getFilesToUpload: function (libraryID) { 497 var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " 498 + "WHERE libraryID=? AND syncState IN (?,?) AND linkMode IN (?,?)"; 499 var params = [ 500 libraryID, 501 this.SYNC_STATE_TO_UPLOAD, 502 this.SYNC_STATE_FORCE_UPLOAD, 503 Zotero.Attachments.LINK_MODE_IMPORTED_FILE, 504 Zotero.Attachments.LINK_MODE_IMPORTED_URL 505 ]; 506 return Zotero.DB.columnQueryAsync(sql, params); 507 }, 508 509 510 /** 511 * @param {Integer} libraryID 512 * @return {Promise<String[]>} - Promise for an array of item keys 513 */ 514 getDeletedFiles: function (libraryID) { 515 var sql = "SELECT key FROM storageDeleteLog WHERE libraryID=?"; 516 return Zotero.DB.columnQueryAsync(sql, libraryID); 517 }, 518 519 520 /** 521 * @param {Zotero.Item[]} items 522 * @param {String|Integer} syncState 523 * @return {Promise} 524 */ 525 updateSyncStates: function (items, syncState) { 526 if (syncState === undefined) { 527 throw new Error("Sync state not specified"); 528 } 529 if (typeof syncState == 'string') { 530 syncState = this["SYNC_STATE_" + syncState.toUpperCase()]; 531 } 532 return Zotero.Utilities.Internal.forEachChunkAsync( 533 items, 534 1000, 535 async function (chunk) { 536 chunk.forEach((item) => { 537 item._attachmentSyncState = syncState; 538 }); 539 return Zotero.DB.queryAsync( 540 "UPDATE itemAttachments SET syncState=? WHERE itemID IN " 541 + "(" + chunk.map(item => item.id).join(', ') + ")", 542 syncState 543 ); 544 } 545 ); 546 }, 547 548 549 /** 550 * Mark all stored files for upload checking 551 * 552 * This is used when switching between storage modes in the preferences so that all existing files 553 * are uploaded via the new mode if necessary. 554 */ 555 resetAllSyncStates: async function (libraryID) { 556 if (!libraryID) { 557 throw new Error("libraryID not provided"); 558 } 559 560 return Zotero.DB.executeTransaction(async function () { 561 var sql = "SELECT itemID FROM items JOIN itemAttachments USING (itemID) " 562 + "WHERE libraryID=? AND itemTypeID=? AND linkMode IN (?, ?)"; 563 var params = [ 564 libraryID, 565 Zotero.ItemTypes.getID('attachment'), 566 Zotero.Attachments.LINK_MODE_IMPORTED_FILE, 567 Zotero.Attachments.LINK_MODE_IMPORTED_URL, 568 ]; 569 var itemIDs = await Zotero.DB.columnQueryAsync(sql, params); 570 for (let itemID of itemIDs) { 571 let item = Zotero.Items.get(itemID); 572 item._attachmentSyncState = this.SYNC_STATE_TO_UPLOAD; 573 } 574 sql = "UPDATE itemAttachments SET syncState=? WHERE itemID IN (" + sql + ")"; 575 await Zotero.DB.queryAsync(sql, [this.SYNC_STATE_TO_UPLOAD].concat(params)); 576 }.bind(this)); 577 }, 578 579 580 /** 581 * Extract a downloaded file and update the database metadata 582 * 583 * @param {Zotero.Item} data.item 584 * @param {Integer} data.mtime 585 * @param {String} data.md5 586 * @param {Boolean} data.compressed 587 * @return {Promise} 588 */ 589 processDownload: Zotero.Promise.coroutine(function* (data) { 590 if (!data) { 591 throw new Error("'data' not set"); 592 } 593 if (!data.item) { 594 throw new Error("'data.item' not set"); 595 } 596 if (!data.mtime) { 597 throw new Error("'data.mtime' not set"); 598 } 599 if (data.mtime != parseInt(data.mtime)) { 600 throw new Error("Invalid mod time '" + data.mtime + "'"); 601 } 602 if (!data.compressed && !data.md5) { 603 throw new Error("'data.md5' is required if 'data.compressed'"); 604 } 605 606 var item = data.item; 607 var mtime = parseInt(data.mtime); 608 var md5 = data.md5; 609 610 // TODO: Test file hash 611 612 if (data.compressed) { 613 var newPath = yield this._processZipDownload(item); 614 } 615 else { 616 var newPath = yield this._processSingleFileDownload(item); 617 } 618 619 // If newPath is set, the file was renamed, so set item filename to that 620 // and mark for updated 621 var path = yield item.getFilePathAsync(); 622 if (newPath && path != newPath) { 623 // If library isn't editable but filename was changed, update 624 // database without updating the item's mod time, which would result 625 // in a library access error 626 try { 627 if (!Zotero.Items.isEditable(item)) { 628 Zotero.debug("File renamed without library access -- " 629 + "updating itemAttachments path", 3); 630 yield item.relinkAttachmentFile(newPath, true); 631 } 632 else { 633 yield item.relinkAttachmentFile(newPath); 634 } 635 } 636 catch (e) { 637 Zotero.File.checkFileAccessError(e, path, 'update'); 638 } 639 640 path = newPath; 641 } 642 643 if (!path) { 644 // This can happen if an HTML snapshot filename was changed and synced 645 // elsewhere but the renamed file wasn't synced, so the ZIP doesn't 646 // contain a file with the known name 647 Components.utils.reportError("File '" + item.attachmentFilename 648 + "' not found after processing download " + item.libraryKey); 649 return new Zotero.Sync.Storage.Result({ 650 localChanges: false 651 }); 652 } 653 654 try { 655 // If hash not provided (e.g., WebDAV), calculate it now 656 if (!md5) { 657 md5 = yield item.attachmentHash; 658 } 659 660 // Set the file mtime to the time from the server 661 yield OS.File.setDates(path, null, new Date(parseInt(mtime))); 662 } 663 catch (e) { 664 Zotero.File.checkFileAccessError(e, path, 'update'); 665 } 666 667 item.attachmentSyncedModificationTime = mtime; 668 item.attachmentSyncedHash = md5; 669 item.attachmentSyncState = "in_sync"; 670 yield item.saveTx({ skipAll: true }); 671 672 return new Zotero.Sync.Storage.Result({ 673 localChanges: true 674 }); 675 }), 676 677 678 _processSingleFileDownload: Zotero.Promise.coroutine(function* (item) { 679 var tempFilePath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp'); 680 681 if (!(yield OS.File.exists(tempFilePath))) { 682 Zotero.debug(tempFilePath, 1); 683 throw new Error("Downloaded file not found"); 684 } 685 686 yield Zotero.Attachments.createDirectoryForItem(item); 687 688 var filename = item.attachmentFilename; 689 if (!filename) { 690 Zotero.debug("Empty filename for item " + item.key, 2); 691 } 692 // Don't save Windows aliases 693 if (filename.endsWith('.lnk')) { 694 return false; 695 } 696 697 var attachmentDir = Zotero.Attachments.getStorageDirectory(item).path; 698 var renamed = false; 699 700 // Make sure the new filename is valid, in case an invalid character made it over 701 // (e.g., from before we checked for them) 702 var filteredFilename = Zotero.File.getValidFileName(filename); 703 if (filteredFilename != filename) { 704 Zotero.debug("Filtering filename '" + filename + "' to '" + filteredFilename + "'"); 705 filename = filteredFilename; 706 renamed = true; 707 } 708 var path = OS.Path.join(attachmentDir, filename); 709 710 Zotero.debug("Moving download file " + OS.Path.basename(tempFilePath) 711 + ` into attachment directory as '${filename}'`); 712 try { 713 var finalFilename = Zotero.File.createShortened( 714 path, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644 715 ); 716 } 717 catch (e) { 718 Zotero.File.checkFileAccessError(e, path, 'create'); 719 } 720 721 if (finalFilename != filename) { 722 Zotero.debug("Changed filename '" + filename + "' to '" + finalFilename + "'"); 723 724 filename = finalFilename; 725 path = OS.Path.join(attachmentDir, filename); 726 727 // Abort if Windows path limitation would cause filenames to be overly truncated 728 if (Zotero.isWin && filename.length < 40) { 729 try { 730 yield OS.File.remove(path); 731 } 732 catch (e) {} 733 // TODO: localize 734 var msg = "Due to a Windows path length limitation, your Zotero data directory " 735 + "is too deep in the filesystem for syncing to work reliably. " 736 + "Please relocate your Zotero data to a higher directory."; 737 Zotero.debug(msg, 1); 738 throw new Error(msg); 739 } 740 741 renamed = true; 742 } 743 744 try { 745 yield OS.File.move(tempFilePath, path); 746 } 747 catch (e) { 748 try { 749 yield OS.File.remove(tempFilePath); 750 } 751 catch (e) {} 752 753 Zotero.File.checkFileAccessError(e, path, 'create'); 754 } 755 756 // processDownload() needs to know that we're renaming the file 757 return renamed ? path : null; 758 }), 759 760 761 _processZipDownload: Zotero.Promise.coroutine(function* (item) { 762 var zipFile = Zotero.getTempDirectory(); 763 zipFile.append(item.key + '.tmp'); 764 765 if (!zipFile.exists()) { 766 Zotero.debug(zipFile.path); 767 throw new Error(`Downloaded ZIP file not found for item ${item.libraryKey}`); 768 } 769 770 var zipReader = Components.classes["@mozilla.org/libjar/zip-reader;1"]. 771 createInstance(Components.interfaces.nsIZipReader); 772 try { 773 zipReader.open(zipFile); 774 zipReader.test(null); 775 776 Zotero.debug("ZIP file is OK"); 777 } 778 catch (e) { 779 Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2); 780 zipReader.close(); 781 782 try { 783 zipFile.remove(false); 784 } 785 catch (e) { 786 Zotero.File.checkFileAccessError(e, zipFile, 'delete'); 787 } 788 789 // TODO: Remove prop file to trigger reuploading, in case it was an upload error? 790 791 return false; 792 } 793 794 var parentDir = Zotero.Attachments.getStorageDirectory(item).path; 795 try { 796 yield Zotero.Attachments.createDirectoryForItem(item); 797 } 798 catch (e) { 799 zipReader.close(); 800 throw e; 801 } 802 803 var returnFile = null; 804 var count = 0; 805 806 var itemFileName = item.attachmentFilename; 807 808 var entries = zipReader.findEntries(null); 809 while (entries.hasMore()) { 810 var entryName = entries.getNext(); 811 var entry = zipReader.getEntry(entryName); 812 var b64re = /%ZB64$/; 813 if (entryName.match(b64re)) { 814 var filePath = Zotero.Utilities.Internal.Base64.decode( 815 entryName.replace(b64re, '') 816 ); 817 } 818 else { 819 var filePath = entryName; 820 } 821 822 if (filePath.startsWith('.zotero')) { 823 Zotero.debug("Skipping " + filePath); 824 continue; 825 } 826 827 if (entry.isDirectory) { 828 Zotero.debug("Skipping directory " + filePath); 829 continue; 830 } 831 count++; 832 833 Zotero.debug("Extracting " + filePath); 834 835 var primaryFile = itemFileName == filePath; 836 var filtered = false; 837 var renamed = false; 838 839 // Make sure all components of the path are valid, in case an invalid character somehow made 840 // it into the ZIP (e.g., from before we checked for them) 841 var filteredPath = filePath.split('/').map(part => Zotero.File.getValidFileName(part)).join('/'); 842 if (filteredPath != filePath) { 843 Zotero.debug("Filtering filename '" + filePath + "' to '" + filteredPath + "'"); 844 filePath = filteredPath; 845 filtered = true; 846 } 847 848 var destPath = OS.Path.join(parentDir, ...filePath.split('/')); 849 850 // If only one file in zip and it doesn't match the known filename, 851 // take our chances and use that name 852 if (count == 1 && !entries.hasMore() && itemFileName) { 853 // May not be necessary, but let's be safe 854 itemFileName = Zotero.File.getValidFileName(itemFileName); 855 if (itemFileName != filePath) { 856 let msg = "Renaming single file '" + filePath + "' in ZIP to known filename '" + itemFileName + "'"; 857 Zotero.debug(msg, 2); 858 Components.utils.reportError(msg); 859 filePath = itemFileName; 860 destPath = OS.Path.join(OS.Path.dirname(destPath), itemFileName); 861 renamed = true; 862 primaryFile = true; 863 } 864 } 865 866 if (primaryFile && filtered) { 867 renamed = true; 868 } 869 870 if (yield OS.File.exists(destPath)) { 871 var msg = "ZIP entry '" + filePath + "' already exists"; 872 Zotero.logError(msg); 873 Zotero.debug(destPath); 874 continue; 875 } 876 877 let shortened; 878 try { 879 shortened = Zotero.File.createShortened( 880 destPath, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644 881 ); 882 } 883 catch (e) { 884 Zotero.logError(e); 885 886 zipReader.close(); 887 888 Zotero.File.checkFileAccessError(e, destPath, 'create'); 889 } 890 891 if (OS.Path.basename(destPath) != shortened) { 892 Zotero.debug(`Changed filename '${OS.Path.basename(destPath)}' to '${shortened}'`); 893 894 // Abort if Windows path limitation would cause filenames to be overly truncated 895 if (Zotero.isWin && shortened < 40) { 896 try { 897 yield OS.File.remove(destPath); 898 } 899 catch (e) {} 900 zipReader.close(); 901 // TODO: localize 902 var msg = "Due to a Windows path length limitation, your Zotero data directory " 903 + "is too deep in the filesystem for syncing to work reliably. " 904 + "Please relocate your Zotero data to a higher directory."; 905 Zotero.debug(msg, 1); 906 throw new Error(msg); 907 } 908 909 destPath = OS.Path.join(OS.Path.dirname(destPath), shortened); 910 911 if (primaryFile) { 912 renamed = true; 913 } 914 } 915 916 try { 917 zipReader.extract(entryName, Zotero.File.pathToFile(destPath)); 918 } 919 catch (e) { 920 try { 921 yield OS.File.remove(destPath); 922 } 923 catch (e) {} 924 925 // For advertising junk files, ignore a bug on Windows where 926 // destFile.create() works but zipReader.extract() doesn't 927 // when the path length is close to 255. 928 if (OS.Path.basename(destPath).match(/[a-zA-Z0-9+=]{130,}/)) { 929 var msg = "Ignoring error extracting '" + destPath + "'"; 930 Zotero.debug(msg, 2); 931 Zotero.debug(e, 2); 932 Components.utils.reportError(msg + " in " + funcName); 933 continue; 934 } 935 936 zipReader.close(); 937 938 Zotero.File.checkFileAccessError(e, destPath, 'create'); 939 } 940 941 yield Zotero.File.setNormalFilePermissions(destPath); 942 943 // If we're renaming the main file, processDownload() needs to know 944 if (renamed) { 945 returnFile = destPath; 946 } 947 } 948 zipReader.close(); 949 zipFile.remove(false); 950 951 return returnFile; 952 }), 953 954 955 /** 956 * @return {Promise<Object[]>} - A promise for an array of conflict objects 957 */ 958 getConflicts: Zotero.Promise.coroutine(function* (libraryID) { 959 var sql = "SELECT itemID, version FROM items JOIN itemAttachments USING (itemID) " 960 + "WHERE libraryID=? AND syncState=?"; 961 var rows = yield Zotero.DB.queryAsync( 962 sql, 963 [ 964 { int: libraryID }, 965 this.SYNC_STATE_IN_CONFLICT 966 ] 967 ); 968 var keyVersionPairs = rows.map(function (row) { 969 var { libraryID, key } = Zotero.Items.getLibraryAndKeyFromID(row.itemID); 970 return [key, row.version]; 971 }); 972 var cacheObjects = yield Zotero.Sync.Data.Local.getCacheObjects( 973 'item', libraryID, keyVersionPairs 974 ); 975 if (!cacheObjects.length) return []; 976 977 var cacheObjectsByKey = {}; 978 cacheObjects.forEach(obj => cacheObjectsByKey[obj.key] = obj); 979 980 var items = []; 981 var localItems = yield Zotero.Items.getAsync(rows.map(row => row.itemID)); 982 for (let localItem of localItems) { 983 // Use the mtime for the dateModified field, since that's all that's shown in the 984 // CR window at the moment 985 let localItemJSON = localItem.toJSON(); 986 localItemJSON.dateModified = Zotero.Date.dateToISO( 987 new Date(yield localItem.attachmentModificationTime) 988 ); 989 990 let remoteItemJSON = cacheObjectsByKey[localItem.key]; 991 if (!remoteItemJSON) { 992 Zotero.logError("Cached object not found for item " + localItem.libraryKey); 993 continue; 994 } 995 remoteItemJSON = remoteItemJSON.data; 996 if (remoteItemJSON.mtime) { 997 remoteItemJSON.dateModified = Zotero.Date.dateToISO(new Date(remoteItemJSON.mtime)); 998 } 999 items.push({ 1000 libraryID, 1001 left: localItemJSON, 1002 right: remoteItemJSON, 1003 changes: [], 1004 conflicts: [] 1005 }) 1006 } 1007 return items; 1008 }), 1009 1010 1011 resolveConflicts: Zotero.Promise.coroutine(function* (libraryID) { 1012 var conflicts = yield this.getConflicts(libraryID); 1013 if (!conflicts.length) return false; 1014 1015 Zotero.debug("Reconciling conflicts for " + Zotero.Libraries.get(libraryID).name); 1016 1017 var io = { 1018 dataIn: { 1019 type: 'file', 1020 captions: [ 1021 Zotero.getString('sync.storage.localFile'), 1022 Zotero.getString('sync.storage.remoteFile'), 1023 Zotero.getString('sync.storage.savedFile') 1024 ], 1025 conflicts 1026 } 1027 }; 1028 1029 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1030 .getService(Components.interfaces.nsIWindowMediator); 1031 var lastWin = wm.getMostRecentWindow("navigator:browser"); 1032 lastWin.openDialog('chrome://zotero/content/merge.xul', '', 'chrome,modal,centerscreen', io); 1033 1034 if (!io.dataOut) { 1035 return false; 1036 } 1037 1038 yield Zotero.DB.executeTransaction(function* () { 1039 for (let i = 0; i < conflicts.length; i++) { 1040 let conflict = conflicts[i]; 1041 let item = Zotero.Items.getByLibraryAndKey(libraryID, conflict.left.key); 1042 let mtime = io.dataOut[i].data.dateModified; 1043 // Local 1044 if (mtime == conflict.left.dateModified) { 1045 syncState = this.SYNC_STATE_FORCE_UPLOAD; 1046 // When local version is chosen, update stored hash (and mtime) to remote values so 1047 // that upload goes through without 412 1048 item.attachmentSyncedModificationTime = conflict.right.mtime; 1049 item.attachmentSyncedHash = conflict.right.md5; 1050 } 1051 // Remote 1052 else { 1053 syncState = this.SYNC_STATE_FORCE_DOWNLOAD; 1054 } 1055 item.attachmentSyncState = syncState; 1056 yield item.save({ skipAll: true }); 1057 } 1058 }.bind(this)); 1059 return true; 1060 }) 1061 }