file.js (37234B)
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 * Functions for reading files 28 * @namespace 29 */ 30 Zotero.File = new function(){ 31 Components.utils.import("resource://gre/modules/NetUtil.jsm"); 32 Components.utils.import("resource://gre/modules/FileUtils.jsm"); 33 34 this.getExtension = getExtension; 35 this.getContentsFromURL = getContentsFromURL; 36 this.putContents = putContents; 37 this.getValidFileName = getValidFileName; 38 this.truncateFileName = truncateFileName; 39 this.getCharsetFromFile = getCharsetFromFile; 40 this.addCharsetListener = addCharsetListener; 41 42 43 this.pathToFile = function (pathOrFile) { 44 try { 45 if (typeof pathOrFile == 'string') { 46 return new FileUtils.File(pathOrFile); 47 } 48 else if (pathOrFile instanceof Ci.nsIFile) { 49 return pathOrFile; 50 } 51 } 52 catch (e) { 53 Zotero.logError(e); 54 } 55 throw new Error("Unexpected value '" + pathOrFile + "'"); 56 } 57 58 59 this.pathToFileURI = function (path) { 60 var file = new FileUtils.File(path); 61 var ios = Components.classes["@mozilla.org/network/io-service;1"] 62 .getService(Components.interfaces.nsIIOService); 63 return ios.newFileURI(file).spec; 64 } 65 66 67 /** 68 * Encode special characters in file paths that might cause problems, 69 * like # (but preserve slashes or colons) 70 * 71 * @param {String} path File path 72 * @return {String} Encoded file path 73 */ 74 this.encodeFilePath = function(path) { 75 var parts = path.split(/([\\\/:]+)/); 76 // Every other item is the separator 77 for (var i=0, n=parts.length; i<n; i+=2) { 78 parts[i] = encodeURIComponent(parts[i]); 79 } 80 return parts.join(''); 81 } 82 83 function getExtension(file){ 84 file = this.pathToFile(file); 85 var pos = file.leafName.lastIndexOf('.'); 86 return pos==-1 ? '' : file.leafName.substr(pos+1); 87 } 88 89 90 /** 91 * Traverses up the filesystem from a file until it finds an existing 92 * directory, or false if it hits the root 93 */ 94 this.getClosestDirectory = async function (file) { 95 try { 96 let stat = await OS.File.stat(file); 97 // If file is an existing directory, return it 98 if (stat.isDir) { 99 return file; 100 } 101 } 102 catch (e) { 103 if (e.becauseNoSuchFile) {} 104 else { 105 throw e; 106 } 107 } 108 109 var dir = OS.Path.dirname(file); 110 while (dir && !await OS.File.exists(dir)) { 111 dir = OS.Path.dirname(dir); 112 } 113 114 return dir || false; 115 } 116 117 118 /** 119 * Get the first 200 bytes of a source as a string (multibyte-safe) 120 * 121 * @param {nsIURI|nsIFile|string spec|nsIChannel|nsIInputStream} source - The source to read 122 * @return {Promise} 123 */ 124 this.getSample = function (file) { 125 var bytes = 200; 126 return this.getContentsAsync(file, null, bytes); 127 } 128 129 130 /** 131 * Get contents of a binary file 132 */ 133 this.getBinaryContents = function(file) { 134 var iStream = Components.classes["@mozilla.org/network/file-input-stream;1"] 135 .createInstance(Components.interfaces.nsIFileInputStream); 136 iStream.init(file, 0x01, 0o664, 0); 137 var bStream = Components.classes["@mozilla.org/binaryinputstream;1"] 138 .createInstance(Components.interfaces.nsIBinaryInputStream); 139 bStream.setInputStream(iStream); 140 var string = bStream.readBytes(file.fileSize); 141 iStream.close(); 142 return string; 143 } 144 145 146 /** 147 * Get the contents of a file or input stream 148 * @param {nsIFile|nsIInputStream|string path} file The file to read 149 * @param {String} [charset] The character set; defaults to UTF-8 150 * @param {Integer} [maxLength] The maximum number of bytes to read 151 * @return {String} The contents of the file 152 * @deprecated Use {@link Zotero.File.getContentsAsync} when possible 153 */ 154 this.getContents = function (file, charset, maxLength){ 155 var fis; 156 157 if (typeof file == 'string') { 158 file = new FileUtils.File(file); 159 } 160 161 if(file instanceof Components.interfaces.nsIInputStream) { 162 fis = file; 163 } else if(file instanceof Components.interfaces.nsIFile) { 164 fis = Components.classes["@mozilla.org/network/file-input-stream;1"]. 165 createInstance(Components.interfaces.nsIFileInputStream); 166 fis.init(file, 0x01, 0o664, 0); 167 } else { 168 throw new Error("File is not an nsIInputStream or nsIFile"); 169 } 170 171 if (charset) { 172 charset = Zotero.CharacterSets.toLabel(charset, true) 173 } 174 charset = charset || "UTF-8"; 175 176 var blockSize = maxLength ? Math.min(maxLength, 524288) : 524288; 177 178 const replacementChar 179 = Components.interfaces.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER; 180 var is = Components.classes["@mozilla.org/intl/converter-input-stream;1"] 181 .createInstance(Components.interfaces.nsIConverterInputStream); 182 is.init(fis, charset, blockSize, replacementChar); 183 var chars = 0; 184 185 var contents = "", str = {}; 186 while (is.readString(blockSize, str) !== 0) { 187 if (maxLength) { 188 var strLen = str.value.length; 189 if ((chars + strLen) > maxLength) { 190 var remainder = maxLength - chars; 191 contents += str.value.slice(0, remainder); 192 break; 193 } 194 chars += strLen; 195 } 196 197 contents += str.value; 198 } 199 200 is.close(); 201 202 return contents; 203 }; 204 205 206 /** 207 * Get the contents of a text source asynchronously 208 * 209 * @param {string path|nsIFile|file URI|nsIChannel|nsIInputStream} source The source to read 210 * @param {String} [charset] The character set; defaults to UTF-8 211 * @param {Integer} [maxLength] Maximum length to fetch, in bytes 212 * @return {Promise} A promise that is resolved with the contents of the file 213 */ 214 this.getContentsAsync = Zotero.Promise.coroutine(function* (source, charset, maxLength) { 215 Zotero.debug("Getting contents of " 216 + (source instanceof Components.interfaces.nsIFile 217 ? source.path 218 : (source instanceof Components.interfaces.nsIInputStream ? "input stream" : source))); 219 220 // Send URIs to Zotero.HTTP.request() 221 if (source instanceof Components.interfaces.nsIURI 222 || typeof source == 'string' && !source.startsWith('file:') && source.match(/^[a-z]{3,}:/)) { 223 Zotero.logError("Passing a URI to Zotero.File.getContentsAsync() is deprecated " 224 + "-- use Zotero.HTTP.request() instead"); 225 return Zotero.HTTP.request("GET", source); 226 } 227 228 // Use NetUtil.asyncFetch() for input streams and channels 229 if (source instanceof Components.interfaces.nsIInputStream 230 || source instanceof Components.interfaces.nsIChannel) { 231 var deferred = Zotero.Promise.defer(); 232 try { 233 NetUtil.asyncFetch(source, function(inputStream, status) { 234 if (!Components.isSuccessCode(status)) { 235 deferred.reject(new Components.Exception("File read operation failed", status)); 236 return; 237 } 238 239 try { 240 try { 241 var bytesToFetch = inputStream.available(); 242 } 243 catch (e) { 244 // The stream is closed automatically when end-of-file is reached, 245 // so this throws for empty files 246 if (e.name == "NS_BASE_STREAM_CLOSED") { 247 Zotero.debug("RESOLVING2"); 248 deferred.resolve(""); 249 } 250 deferred.reject(e); 251 } 252 253 if (maxLength && maxLength < bytesToFetch) { 254 bytesToFetch = maxLength; 255 } 256 257 if (bytesToFetch == 0) { 258 deferred.resolve(""); 259 return; 260 } 261 262 deferred.resolve(NetUtil.readInputStreamToString( 263 inputStream, 264 bytesToFetch, 265 options 266 )); 267 } 268 catch (e) { 269 deferred.reject(e); 270 } 271 }); 272 } 273 catch(e) { 274 // Make sure this get logged correctly 275 Zotero.logError(e); 276 throw e; 277 } 278 return deferred.promise; 279 } 280 281 // Use OS.File for files 282 if (source instanceof Components.interfaces.nsIFile) { 283 source = source.path; 284 } 285 else if (typeof source == 'string') { 286 if (source.startsWith('file:')) { 287 source = OS.Path.fromFileURI(source); 288 } 289 } 290 else { 291 throw new Error(`Unsupported type '${typeof source}' for source`); 292 } 293 var options = { 294 encoding: charset ? charset : "utf-8" 295 }; 296 if (maxLength) { 297 options.bytes = maxLength; 298 } 299 return OS.File.read(source, options); 300 }); 301 302 303 /** 304 * Get the contents of a binary source asynchronously 305 * 306 * This is quite slow and should only be used in tests. 307 * 308 * @param {string path|nsIFile|file URI} source The source to read 309 * @param {Integer} [maxLength] Maximum length to fetch, in bytes 310 * @return {Promise<String>} A promise for the contents of the source as a binary string 311 */ 312 this.getBinaryContentsAsync = Zotero.Promise.coroutine(function* (source, maxLength) { 313 // Use OS.File for files 314 if (source instanceof Components.interfaces.nsIFile) { 315 source = source.path; 316 } 317 else if (source.startsWith('^file:')) { 318 source = OS.Path.fromFileURI(source); 319 } 320 var options = {}; 321 if (maxLength) { 322 options.bytes = maxLength; 323 } 324 var buf = yield OS.File.read(source, options); 325 return [...buf].map(x => String.fromCharCode(x)).join(""); 326 }); 327 328 329 /* 330 * Return the contents of a URL as a string 331 * 332 * Runs synchronously, so should only be run on local (e.g. chrome) URLs 333 */ 334 function getContentsFromURL(url) { 335 var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] 336 .createInstance(); 337 xmlhttp.open('GET', url, false); 338 xmlhttp.overrideMimeType("text/plain"); 339 xmlhttp.send(null); 340 return xmlhttp.responseText; 341 } 342 343 344 /* 345 * Return a promise for the contents of a URL as a string 346 */ 347 this.getContentsFromURLAsync = function (url, options={}) { 348 return Zotero.HTTP.request("GET", url, Object.assign(options, { responseType: "text" })) 349 .then(function (xmlhttp) { 350 return xmlhttp.response; 351 }); 352 } 353 354 355 /* 356 * Write string to a file, overwriting existing file if necessary 357 */ 358 function putContents(file, str) { 359 if (file.exists()) { 360 file.remove(null); 361 } 362 var fos = Components.classes["@mozilla.org/network/file-output-stream;1"]. 363 createInstance(Components.interfaces.nsIFileOutputStream); 364 fos.init(file, 0x02 | 0x08 | 0x20, 0o664, 0); // write, create, truncate 365 366 var os = Components.classes["@mozilla.org/intl/converter-output-stream;1"] 367 .createInstance(Components.interfaces.nsIConverterOutputStream); 368 os.init(fos, "UTF-8", 4096, "?".charCodeAt(0)); 369 os.writeString(str); 370 os.close(); 371 372 fos.close(); 373 } 374 375 /** 376 * Write data to a file asynchronously 377 * 378 * @param {String|nsIFile} - String path or nsIFile to write to 379 * @param {String|nsIInputStream} data - The string or nsIInputStream to write to the file 380 * @param {String} [charset] - The character set; defaults to UTF-8 381 * @return {Promise} - A promise that is resolved when the file has been written 382 */ 383 this.putContentsAsync = function (path, data, charset) { 384 if (path instanceof Ci.nsIFile) { 385 path = path.path; 386 } 387 388 if (typeof data == 'string') { 389 return Zotero.Promise.resolve(OS.File.writeAtomic( 390 path, 391 data, 392 { 393 tmpPath: path + ".tmp", 394 encoding: charset ? charset.toLowerCase() : 'utf-8' 395 } 396 )); 397 } 398 399 var deferred = Zotero.Promise.defer(); 400 var os = FileUtils.openSafeFileOutputStream(new FileUtils.File(path)); 401 NetUtil.asyncCopy(data, os, function(inputStream, status) { 402 if (!Components.isSuccessCode(status)) { 403 deferred.reject(new Components.Exception("File write operation failed", status)); 404 return; 405 } 406 deferred.resolve(); 407 }); 408 return deferred.promise; 409 }; 410 411 412 this.download = Zotero.Promise.coroutine(function* (uri, path) { 413 Zotero.debug("Saving " + (uri.spec ? uri.spec : uri) 414 + " to " + (path.path ? path.path : path)); 415 416 var deferred = Zotero.Promise.defer(); 417 NetUtil.asyncFetch(uri, function (is, status, request) { 418 if (!Components.isSuccessCode(status)) { 419 Zotero.logError(status); 420 deferred.reject(new Error("Download failed with status " + status)); 421 return; 422 } 423 deferred.resolve(is); 424 }); 425 var is = yield deferred.promise; 426 yield Zotero.File.putContentsAsync(path, is); 427 }); 428 429 430 /** 431 * Rename file within its parent directory 432 * 433 * @param {String} file - File path 434 * @param {String} newName 435 * @param {Object} [options] 436 * @param {Boolean} [options.overwrite=false] - Overwrite file if one exists 437 * @param {Boolean} [options.unique=false] - Add suffix to create unique filename if necessary 438 * @return {String|false} - New filename, or false if destination file exists and `overwrite` not set 439 */ 440 this.rename = async function (file, newName, options = {}) { 441 var overwrite = options.overwrite || false; 442 var unique = options.unique || false; 443 444 var origPath = file; 445 var origName = OS.Path.basename(origPath); 446 newName = Zotero.File.getValidFileName(newName); 447 448 // Ignore if no change 449 if (origName === newName) { 450 Zotero.debug("Filename has not changed"); 451 return origName; 452 } 453 454 var parentDir = OS.Path.dirname(origPath); 455 var destPath = OS.Path.join(parentDir, newName); 456 var destName = OS.Path.basename(destPath); 457 // Get root + extension, if there is one 458 var pos = destName.lastIndexOf('.'); 459 if (pos > 0) { 460 var root = destName.substr(0, pos); 461 var ext = destName.substr(pos + 1); 462 } 463 else { 464 var root = destName; 465 } 466 467 var incr = 0; 468 while (true) { 469 // If filename already exists, add a numeric suffix to the end of the root, before 470 // the extension if there is one 471 if (incr) { 472 if (ext) { 473 destName = root + ' ' + (incr + 1) + '.' + ext; 474 } 475 else { 476 destName = root + ' ' + (incr + 1); 477 } 478 destPath = OS.Path.join(parentDir, destName); 479 } 480 481 try { 482 Zotero.debug(`Renaming ${origPath} to ${OS.Path.basename(destPath)}`); 483 Zotero.debug(destPath); 484 await OS.File.move(origPath, destPath, { noOverwrite: !overwrite }) 485 } 486 catch (e) { 487 if (e instanceof OS.File.Error) { 488 if (e.becauseExists) { 489 // Increment number to create unique suffix 490 if (unique) { 491 incr++; 492 continue; 493 } 494 // No overwriting or making unique and file exists 495 return false; 496 } 497 } 498 throw e; 499 } 500 break; 501 } 502 return destName; 503 }; 504 505 506 /** 507 * Delete a file if it exists, asynchronously 508 * 509 * @return {Promise<Boolean>} A promise for TRUE if file was deleted, FALSE if missing 510 */ 511 this.removeIfExists = function (path) { 512 return Zotero.Promise.resolve(OS.File.remove(path)) 513 .return(true) 514 .catch(function (e) { 515 if (e instanceof OS.File.Error && e.becauseNoSuchFile) { 516 return false; 517 } 518 Zotero.debug(path, 1); 519 throw e; 520 }); 521 } 522 523 524 /** 525 * @return {Promise<Boolean>} 526 */ 527 this.directoryIsEmpty = Zotero.Promise.coroutine(function* (path) { 528 var it = new OS.File.DirectoryIterator(path); 529 try { 530 let entry = yield it.next(); 531 return false; 532 } 533 catch (e) { 534 if (e != StopIteration) { 535 throw e; 536 } 537 } 538 finally { 539 it.close(); 540 } 541 return true; 542 }); 543 544 545 /** 546 * Run a generator with an OS.File.DirectoryIterator, closing the 547 * iterator when done 548 * 549 * The DirectoryIterator is passed as the first parameter to the generator. 550 * 551 * Zotero.File.iterateDirectory(path, function* (iterator) { 552 * while (true) { 553 * var entry = yield iterator.next(); 554 * [...] 555 * } 556 * }) 557 * 558 * @return {Promise} 559 */ 560 this.iterateDirectory = function (path, generator) { 561 var iterator = new OS.File.DirectoryIterator(path); 562 return Zotero.Promise.coroutine(generator)(iterator) 563 .catch(function (e) { 564 if (e != StopIteration) { 565 throw e; 566 } 567 }) 568 .finally(function () { 569 iterator.close(); 570 }); 571 } 572 573 574 /** 575 * If directories can be moved at once, instead of recursively creating directories and moving files 576 * 577 * Currently this means using /bin/mv, which only works on macOS and Linux 578 */ 579 this.canMoveDirectoryWithCommand = Zotero.lazy(function () { 580 var cmd = "/bin/mv"; 581 return !Zotero.isWin && this.pathToFile(cmd).exists(); 582 }); 583 584 /** 585 * For tests 586 */ 587 this.canMoveDirectoryWithFunction = Zotero.lazy(function () { 588 return true; 589 }); 590 591 /** 592 * Move directory (using mv on macOS/Linux, recursively on Windows) 593 * 594 * @param {Boolean} [options.allowExistingTarget=false] - If true, merge files into an existing 595 * target directory if one exists rather than throwing an error 596 * @param {Function} options.noOverwrite - Function that returns true if the file at the given 597 * path should throw an error rather than overwrite an existing file in the target 598 */ 599 this.moveDirectory = Zotero.Promise.coroutine(function* (oldDir, newDir, options = {}) { 600 var maxDepth = options.maxDepth || 10; 601 var cmd = "/bin/mv"; 602 var useCmd = this.canMoveDirectoryWithCommand(); 603 var useFunction = this.canMoveDirectoryWithFunction(); 604 605 if (!options.allowExistingTarget && (yield OS.File.exists(newDir))) { 606 throw new Error(newDir + " exists"); 607 } 608 609 var errors = []; 610 611 // Throw certain known errors (no more disk space) to interrupt the operation 612 function checkError(e) { 613 if (!(e instanceof OS.File.Error)) { 614 return; 615 } 616 Components.classes["@mozilla.org/net/osfileconstantsservice;1"] 617 .getService(Components.interfaces.nsIOSFileConstantsService) 618 .init(); 619 if ((e.unixErrno !== undefined && e.unixErrno == OS.Constants.libc.ENOSPC) 620 || (e.winLastError !== undefined && e.winLastError == OS.Constants.libc.ENOSPC)) { 621 throw e; 622 } 623 } 624 625 function addError(e) { 626 errors.push(e); 627 Zotero.logError(e); 628 } 629 630 var rootDir = oldDir; 631 var moveSubdirs = Zotero.Promise.coroutine(function* (oldDir, depth) { 632 if (!depth) return; 633 634 // Create target directory 635 try { 636 yield Zotero.File.createDirectoryIfMissingAsync(newDir + oldDir.substr(rootDir.length)); 637 } 638 catch (e) { 639 addError(e); 640 return; 641 } 642 643 Zotero.debug("Moving files in " + oldDir); 644 645 yield Zotero.File.iterateDirectory(oldDir, function* (iterator) { 646 while (true) { 647 let entry = yield iterator.next(); 648 let dest = newDir + entry.path.substr(rootDir.length); 649 650 // entry.isDir can be false for some reason on Travis, causing spurious test failures 651 if (Zotero.automatedTest && !entry.isDir && (yield OS.File.stat(entry.path)).isDir) { 652 Zotero.debug("Overriding isDir for " + entry.path); 653 entry.isDir = true; 654 } 655 656 // Move files in directory 657 if (!entry.isDir) { 658 try { 659 yield OS.File.move( 660 entry.path, 661 dest, 662 { 663 noOverwrite: options 664 && options.noOverwrite 665 && options.noOverwrite(entry.path) 666 } 667 ); 668 } 669 catch (e) { 670 checkError(e); 671 Zotero.debug("Error moving " + entry.path); 672 addError(e); 673 } 674 } 675 else { 676 // Move directory with external command if possible and the directory doesn't 677 // already exist in target 678 let moved = false; 679 680 if (useCmd && !(yield OS.File.exists(dest))) { 681 Zotero.debug(`Moving ${entry.path} with ${cmd}`); 682 let args = [entry.path, dest]; 683 try { 684 yield Zotero.Utilities.Internal.exec(cmd, args); 685 moved = true; 686 } 687 catch (e) { 688 checkError(e); 689 Zotero.debug(e, 1); 690 } 691 } 692 693 694 // If can't use command, try moving with OS.File.move(). Technically this is 695 // unsupported for directories, but it works on all platforms as long as noCopy 696 // is set (and on some platforms regardless) 697 if (!moved && useFunction) { 698 Zotero.debug(`Moving ${entry.path} with OS.File`); 699 try { 700 yield OS.File.move( 701 entry.path, 702 dest, 703 { 704 noCopy: true 705 } 706 ); 707 moved = true; 708 } 709 catch (e) { 710 checkError(e); 711 Zotero.debug(e, 1); 712 } 713 } 714 715 // Otherwise, recurse into subdirectories to copy files individually 716 if (!moved) { 717 try { 718 yield moveSubdirs(entry.path, depth - 1); 719 } 720 catch (e) { 721 checkError(e); 722 addError(e); 723 } 724 } 725 } 726 } 727 }); 728 729 // Remove directory after moving everything within 730 // 731 // Don't try to remove root directory if there've been errors, since it won't work. 732 // (Deeper directories might fail too, but we don't worry about those.) 733 if (!errors.length || oldDir != rootDir) { 734 Zotero.debug("Removing " + oldDir); 735 try { 736 yield OS.File.removeEmptyDir(oldDir); 737 } 738 catch (e) { 739 addError(e); 740 } 741 } 742 }); 743 744 yield moveSubdirs(oldDir, maxDepth); 745 return errors; 746 }); 747 748 749 /** 750 * Generate a data: URI from an nsIFile 751 * 752 * From https://developer.mozilla.org/en-US/docs/data_URIs 753 */ 754 this.generateDataURI = function (file) { 755 var contentType = Components.classes["@mozilla.org/mime;1"] 756 .getService(Components.interfaces.nsIMIMEService) 757 .getTypeFromFile(file); 758 var inputStream = Components.classes["@mozilla.org/network/file-input-stream;1"] 759 .createInstance(Components.interfaces.nsIFileInputStream); 760 inputStream.init(file, 0x01, 0o600, 0); 761 var stream = Components.classes["@mozilla.org/binaryinputstream;1"] 762 .createInstance(Components.interfaces.nsIBinaryInputStream); 763 stream.setInputStream(inputStream); 764 var encoded = btoa(stream.readBytes(stream.available())); 765 return "data:" + contentType + ";base64," + encoded; 766 } 767 768 769 this.setNormalFilePermissions = function (file) { 770 return OS.File.setPermissions( 771 file, 772 { 773 unixMode: 0o644, 774 winAttributes: { 775 readOnly: false, 776 hidden: false, 777 system: false 778 } 779 } 780 ); 781 } 782 783 784 this.createShortened = function (file, type, mode, maxBytes) { 785 file = this.pathToFile(file); 786 787 if (!maxBytes) { 788 maxBytes = 255; 789 } 790 791 // Limit should be 255, but leave room for unique numbering if necessary 792 var padding = 3; 793 794 while (true) { 795 var newLength = maxBytes - padding; 796 797 try { 798 file.create(type, mode); 799 } 800 catch (e) { 801 let pathError = false; 802 803 let pathByteLength = Zotero.Utilities.Internal.byteLength(file.path); 804 let fileNameByteLength = Zotero.Utilities.Internal.byteLength(file.leafName); 805 806 // Windows API only allows paths of 260 characters 807 // 808 // I think this should be >260 but we had a report of an error with exactly 809 // 260 chars: https://forums.zotero.org/discussion/41410 810 if (e.name == "NS_ERROR_FILE_NOT_FOUND" && pathByteLength >= 260) { 811 Zotero.debug("Path is " + file.path); 812 pathError = true; 813 } 814 // ext3/ext4/HFS+ have a filename length limit of ~254 bytes 815 else if ((e.name == "NS_ERROR_FAILURE" || e.name == "NS_ERROR_FILE_NAME_TOO_LONG") 816 && (fileNameByteLength >= 254 || (Zotero.isLinux && fileNameByteLength > 143))) { 817 Zotero.debug("Filename is '" + file.leafName + "'"); 818 } 819 else { 820 Zotero.debug("Path is " + file.path); 821 throw e; 822 } 823 824 // Preserve extension 825 var matches = file.leafName.match(/.+(\.[a-z0-9]{0,20})$/i); 826 var ext = matches ? matches[1] : ""; 827 828 if (pathError) { 829 let pathLength = pathByteLength - fileNameByteLength; 830 newLength -= pathLength; 831 832 // Make sure there's a least 1 character of the basename left over 833 if (newLength - ext.length < 1) { 834 throw new Error("Path is too long"); 835 } 836 } 837 838 // Shorten the filename 839 // 840 // Shortened file could already exist if there was another file with a 841 // similar name that was also longer than the limit, so we do this in a 842 // loop, adding numbers if necessary 843 var uniqueFile = file.clone(); 844 var step = 0; 845 while (step < 100) { 846 let newBaseName = uniqueFile.leafName.substr(0, newLength - ext.length); 847 if (step == 0) { 848 var newName = newBaseName + ext; 849 } 850 else { 851 var newName = newBaseName + "-" + step + ext; 852 } 853 854 // Check actual byte length, and shorten more if necessary 855 if (Zotero.Utilities.Internal.byteLength(newName) > maxBytes) { 856 step = 0; 857 newLength--; 858 continue; 859 } 860 861 uniqueFile.leafName = newName; 862 if (!uniqueFile.exists()) { 863 break; 864 } 865 866 step++; 867 } 868 869 var msg = "Shortening filename to '" + newName + "'"; 870 Zotero.debug(msg, 2); 871 Zotero.log(msg, 'warning'); 872 873 try { 874 uniqueFile.create(Components.interfaces.nsIFile.type, mode); 875 } 876 catch (e) { 877 // On Linux, try 143, which is the max filename length with eCryptfs 878 if (e.name == "NS_ERROR_FILE_NAME_TOO_LONG" && Zotero.isLinux && uniqueFile.leafName.length > 143) { 879 Zotero.debug("Trying shorter filename in case of filesystem encryption", 2); 880 maxBytes = 143; 881 continue; 882 } 883 else { 884 throw e; 885 } 886 } 887 888 file.leafName = uniqueFile.leafName; 889 } 890 break; 891 } 892 893 return file.leafName; 894 } 895 896 897 this.copyToUnique = function (file, newFile) { 898 file = this.pathToFile(file); 899 newFile = this.pathToFile(newFile); 900 901 newFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644); 902 var newName = newFile.leafName; 903 newFile.remove(null); 904 905 // Copy file to unique name 906 file.copyToFollowingLinks(newFile.parent, newName); 907 return newFile; 908 } 909 910 911 /** 912 * Copies all files from dir into newDir 913 * 914 * @param {String|nsIFile} source - Source directory 915 * @param {String|nsIFile} target - Target directory 916 */ 917 this.copyDirectory = Zotero.Promise.coroutine(function* (source, target) { 918 if (source instanceof Ci.nsIFile) source = source.path; 919 if (target instanceof Ci.nsIFile) target = target.path; 920 921 yield OS.File.makeDir(target, { 922 ignoreExisting: true, 923 unixMode: 0o755 924 }); 925 926 return this.iterateDirectory(source, function* (iterator) { 927 while (true) { 928 let entry = yield iterator.next(); 929 yield OS.File.copy(entry.path, OS.Path.join(target, entry.name)); 930 } 931 }) 932 }); 933 934 935 this.createDirectoryIfMissing = function (dir) { 936 if (!dir.exists() || !dir.isDirectory()) { 937 if (dir.exists() && !dir.isDirectory()) { 938 dir.remove(null); 939 } 940 dir.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, 0o755); 941 } 942 } 943 944 945 this.createDirectoryIfMissingAsync = function (path) { 946 return Zotero.Promise.resolve( 947 OS.File.makeDir( 948 path, 949 { 950 ignoreExisting: true, 951 unixMode: 0o755 952 } 953 ) 954 ); 955 } 956 957 958 /** 959 * Check whether a directory is an ancestor directory of another directory/file 960 */ 961 this.directoryContains = function (dir, file) { 962 if (typeof dir != 'string') throw new Error("dir must be a string"); 963 if (typeof file != 'string') throw new Error("file must be a string"); 964 965 dir = OS.Path.normalize(dir); 966 file = OS.Path.normalize(file); 967 968 return file.startsWith(dir); 969 }; 970 971 972 /** 973 * @param {String} dirPath - Directory containing files to add to ZIP 974 * @param {String} zipPath - ZIP file to create 975 * @param {nsIRequestObserver} [observer] 976 * @return {Promise} 977 */ 978 this.zipDirectory = Zotero.Promise.coroutine(function* (dirPath, zipPath, observer) { 979 var zw = Components.classes["@mozilla.org/zipwriter;1"] 980 .createInstance(Components.interfaces.nsIZipWriter); 981 zw.open(this.pathToFile(zipPath), 0x04 | 0x08 | 0x20); // open rw, create, truncate 982 var entries = yield _addZipEntries(dirPath, dirPath, zw); 983 if (entries.length == 0) { 984 Zotero.debug('No files to add -- removing ZIP file'); 985 zw.close(); 986 yield OS.File.remove(zipPath); 987 return false; 988 } 989 990 Zotero.debug(`Creating ${OS.Path.basename(zipPath)} with ${entries.length} file(s)`); 991 992 var context = { 993 zipWriter: zw, 994 entries 995 }; 996 997 var deferred = Zotero.Promise.defer(); 998 zw.processQueue( 999 { 1000 onStartRequest: function (request, ctx) { 1001 try { 1002 if (observer && observer.onStartRequest) { 1003 observer.onStartRequest(request, context); 1004 } 1005 } 1006 catch (e) { 1007 deferred.reject(e); 1008 } 1009 }, 1010 onStopRequest: function (request, ctx, status) { 1011 try { 1012 if (observer && observer.onStopRequest) { 1013 observer.onStopRequest(request, context, status); 1014 } 1015 } 1016 catch (e) { 1017 deferred.reject(e); 1018 return; 1019 } 1020 finally { 1021 zw.close(); 1022 } 1023 deferred.resolve(true); 1024 } 1025 }, 1026 {} 1027 ); 1028 return deferred.promise; 1029 }); 1030 1031 1032 var _addZipEntries = Zotero.Promise.coroutine(function* (rootPath, path, zipWriter) { 1033 var entries = []; 1034 let iterator; 1035 try { 1036 iterator = new OS.File.DirectoryIterator(path); 1037 yield iterator.forEach(Zotero.Promise.coroutine(function* (entry) { 1038 // entry.isDir can be false for some reason on Travis, causing spurious test failures 1039 if (Zotero.automatedTest && !entry.isDir && (yield OS.File.stat(entry.path)).isDir) { 1040 Zotero.debug("Overriding isDir for " + entry.path); 1041 entry.isDir = true; 1042 } 1043 1044 if (entry.isSymLink) { 1045 Zotero.debug("Skipping symlink " + entry.name); 1046 return; 1047 } 1048 if (entry.isDir) { 1049 entries.concat(yield _addZipEntries(rootPath, entry.path, zipWriter)); 1050 return; 1051 } 1052 if (entry.name.startsWith('.')) { 1053 Zotero.debug('Skipping file ' + entry.name); 1054 return; 1055 } 1056 1057 Zotero.debug("Adding ZIP entry " + entry.path); 1058 zipWriter.addEntryFile( 1059 // Add relative path 1060 entry.path.substr(rootPath.length + 1), 1061 Components.interfaces.nsIZipWriter.COMPRESSION_DEFAULT, 1062 Zotero.File.pathToFile(entry.path), 1063 true 1064 ); 1065 entries.push({ 1066 name: entry.name, 1067 path: entry.path 1068 }); 1069 })); 1070 } 1071 finally { 1072 iterator.close(); 1073 } 1074 return entries; 1075 }); 1076 1077 1078 /** 1079 * Strip potentially invalid characters 1080 * 1081 * See http://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words 1082 * 1083 * @param {String} fileName 1084 * @param {Boolean} [skipXML=false] Don't strip characters invalid in XML 1085 */ 1086 function getValidFileName(fileName, skipXML) { 1087 // TODO: use space instead, and figure out what's doing extra 1088 // URL encode when saving attachments that trigger this 1089 fileName = fileName.replace(/[\/\\\?\*:|"<>]/g, ''); 1090 // Replace newlines and tabs (which shouldn't be in the string in the first place) with spaces 1091 fileName = fileName.replace(/[\r\n\t]+/g, ' '); 1092 // Replace various thin spaces 1093 fileName = fileName.replace(/[\u2000-\u200A]/g, ' '); 1094 // Replace zero-width spaces 1095 fileName = fileName.replace(/[\u200B-\u200E]/g, ''); 1096 if (!skipXML) { 1097 // Strip characters not valid in XML, since they won't sync and they're probably unwanted 1098 fileName = fileName.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff\ufffe\uffff]/g, ''); 1099 1100 // Normalize to NFC 1101 fileName = fileName.normalize(); 1102 } 1103 // Don't allow hidden files 1104 fileName = fileName.replace(/^\./, ''); 1105 // Don't allow blank or illegal filenames 1106 if (!fileName || fileName == '.' || fileName == '..') { 1107 fileName = '_'; 1108 } 1109 return fileName; 1110 } 1111 1112 /** 1113 * Truncate a filename (excluding the extension) to the given total length 1114 * If the "extension" is longer than 20 characters, 1115 * it is treated as part of the file name 1116 */ 1117 function truncateFileName(fileName, maxLength) { 1118 if(!fileName || (fileName + '').length <= maxLength) return fileName; 1119 1120 var parts = (fileName + '').split(/\.(?=[^\.]+$)/); 1121 var fn = parts[0]; 1122 var ext = parts[1]; 1123 //if the file starts with a period , use the whole file 1124 //the whole file name might also just be a period 1125 if(!fn) { 1126 fn = '.' + (ext || ''); 1127 } 1128 1129 //treat long extensions as part of the file name 1130 if(ext && ext.length > 20) { 1131 fn += '.' + ext; 1132 ext = undefined; 1133 } 1134 1135 if(ext === undefined) { //there was no period in the whole file name 1136 ext = ''; 1137 } else { 1138 ext = '.' + ext; 1139 } 1140 1141 return fn.substr(0,maxLength-ext.length) + ext; 1142 } 1143 1144 /* 1145 * Not implemented, but it'd sure be great if it were 1146 */ 1147 function getCharsetFromByteArray(arr) { 1148 1149 } 1150 1151 1152 /* 1153 * An extraordinarily inelegant way of getting the character set of a 1154 * text file using a hidden browser 1155 * 1156 * I'm quite sure there's a better way 1157 * 1158 * Note: This is for text files -- don't run on other files 1159 * 1160 * 'callback' is the function to pass the charset (and, if provided, 'args') 1161 * to after detection is complete 1162 */ 1163 function getCharsetFromFile(file, mimeType, callback, args){ 1164 if (!file || !file.exists()){ 1165 callback(false, args); 1166 return; 1167 } 1168 1169 if (mimeType.substr(0, 5) != 'text/' || 1170 !Zotero.MIME.hasInternalHandler(mimeType, this.getExtension(file))) { 1171 callback(false, args); 1172 return; 1173 } 1174 1175 var browser = Zotero.Browser.createHiddenBrowser(); 1176 1177 var url = Components.classes["@mozilla.org/network/protocol;1?name=file"] 1178 .getService(Components.interfaces.nsIFileProtocolHandler) 1179 .getURLSpecFromFile(file); 1180 1181 this.addCharsetListener(browser, function (charset, args) { 1182 callback(charset, args); 1183 Zotero.Browser.deleteHiddenBrowser(browser); 1184 }, args); 1185 1186 browser.loadURI(url); 1187 } 1188 1189 1190 /* 1191 * Attach a load listener to a browser object to perform charset detection 1192 * 1193 * We make sure the universal character set detector is set to the 1194 * universal_charset_detector (temporarily changing it if not--shhhh) 1195 * 1196 * 'callback' is the function to pass the charset (and, if provided, 'args') 1197 * to after detection is complete 1198 */ 1199 function addCharsetListener(browser, callback, args){ 1200 var prefService = Components.classes["@mozilla.org/preferences-service;1"] 1201 .getService(Components.interfaces.nsIPrefBranch); 1202 var oldPref = prefService.getCharPref('intl.charset.detector'); 1203 var newPref = 'universal_charset_detector'; 1204 //Zotero.debug("Default character detector is " + (oldPref ? oldPref : '(none)')); 1205 1206 if (oldPref != newPref){ 1207 //Zotero.debug('Setting character detector to universal_charset_detector'); 1208 prefService.setCharPref('intl.charset.detector', 'universal_charset_detector'); 1209 } 1210 1211 var onpageshow = function(){ 1212 // ignore spurious about:blank loads 1213 if(browser.contentDocument.location.href == "about:blank") return; 1214 1215 browser.removeEventListener("pageshow", onpageshow, false); 1216 1217 var charset = browser.contentDocument.characterSet; 1218 Zotero.debug("Detected character set '" + charset + "'"); 1219 1220 //Zotero.debug('Resetting character detector to ' + (oldPref ? oldPref : '(none)')); 1221 prefService.setCharPref('intl.charset.detector', oldPref); 1222 1223 callback(charset, args); 1224 }; 1225 1226 browser.addEventListener("pageshow", onpageshow, false); 1227 } 1228 1229 1230 this.checkFileAccessError = function (e, file, operation) { 1231 file = this.pathToFile(file); 1232 1233 var str = 'file.accessError.'; 1234 if (file) { 1235 str += 'theFile' 1236 } 1237 else { 1238 str += 'aFile' 1239 } 1240 str += 'CannotBe'; 1241 1242 switch (operation) { 1243 case 'create': 1244 str += 'Created'; 1245 break; 1246 1247 case 'delete': 1248 str += 'Deleted'; 1249 break; 1250 1251 default: 1252 str += 'Updated'; 1253 } 1254 str = Zotero.getString(str, file.path ? file.path : undefined); 1255 1256 Zotero.debug(file.path); 1257 Zotero.debug(e, 1); 1258 Components.utils.reportError(e); 1259 1260 if (e.name == 'NS_ERROR_FILE_ACCESS_DENIED' || e.name == 'NS_ERROR_FILE_IS_LOCKED' 1261 // These show up on some Windows systems 1262 || e.name == 'NS_ERROR_FAILURE' || e.name == 'NS_ERROR_FILE_NOT_FOUND' 1263 // OS.File.Error 1264 || e.becauseAccessDenied || e.becauseNoSuchFile) { 1265 let checkFileWindows = Zotero.getString('file.accessError.message.windows'); 1266 let checkFileOther = Zotero.getString('file.accessError.message.other'); 1267 let msg = str + "\n\n" 1268 + (Zotero.isWin ? checkFileWindows : checkFileOther) 1269 + "\n\n" 1270 + Zotero.getString('file.accessError.restart'); 1271 1272 e = new Zotero.Error( 1273 msg, 1274 0, 1275 { 1276 dialogButtonText: Zotero.getString('file.accessError.showParentDir'), 1277 dialogButtonCallback: function () { 1278 try { 1279 file.parent.QueryInterface(Components.interfaces.nsILocalFile); 1280 file.parent.reveal(); 1281 } 1282 // Unsupported on some platforms 1283 catch (e) { 1284 Zotero.launchFile(file.parent); 1285 } 1286 } 1287 } 1288 ); 1289 } 1290 1291 throw e; 1292 } 1293 1294 1295 this.isDropboxDirectory = function(path) { 1296 return path.toLowerCase().indexOf('dropbox') != -1; 1297 } 1298 1299 1300 this.reveal = Zotero.Promise.coroutine(function* (file) { 1301 if (!(yield OS.File.exists(file))) { 1302 throw new Error(file + " does not exist"); 1303 } 1304 1305 Zotero.debug("Revealing " + file); 1306 1307 var nsIFile = this.pathToFile(file); 1308 nsIFile.QueryInterface(Components.interfaces.nsILocalFile); 1309 try { 1310 nsIFile.reveal(); 1311 } 1312 catch (e) { 1313 Zotero.logError(e); 1314 // On platforms that don't support nsILocalFile.reveal() (e.g. Linux), 1315 // launch the directory 1316 let zp = Zotero.getActiveZoteroPane(); 1317 if (zp) { 1318 try { 1319 let info = yield OS.File.stat(file); 1320 // Launch parent directory for files 1321 if (!info.isDir) { 1322 file = OS.Path.dirname(file); 1323 } 1324 Zotero.launchFile(file); 1325 } 1326 catch (e) { 1327 Zotero.logError(e); 1328 return; 1329 } 1330 } 1331 else { 1332 Zotero.logError(e); 1333 } 1334 } 1335 }); 1336 }