www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

attachments.js (43890B)


      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 Zotero.Attachments = new function(){
     27 	// Keep in sync with Zotero.Schema.integrityCheck()
     28 	this.LINK_MODE_IMPORTED_FILE = 0;
     29 	this.LINK_MODE_IMPORTED_URL = 1;
     30 	this.LINK_MODE_LINKED_FILE = 2;
     31 	this.LINK_MODE_LINKED_URL = 3;
     32 	this.BASE_PATH_PLACEHOLDER = 'attachments:';
     33 	
     34 	var self = this;
     35 	
     36 	
     37 	/**
     38 	 * @param {Object} options
     39 	 * @param {nsIFile|String} [options.file] - File to add
     40 	 * @param {Integer} [options.libraryID]
     41 	 * @param {Integer[]|String[]} [options.parentItemID] - Parent item to add item to
     42 	 * @param {Integer[]} [options.collections] - Collection keys or ids to add new item to
     43 	 * @param {String} [options.fileBaseName]
     44 	 * @param {String} [options.contentType]
     45 	 * @param {String} [options.charset]
     46 	 * @param {Object} [options.saveOptions] - Options to pass to Zotero.Item::save()
     47 	 * @return {Promise<Zotero.Item>}
     48 	 */
     49 	this.importFromFile = Zotero.Promise.coroutine(function* (options) {
     50 		Zotero.debug('Importing attachment from file');
     51 		
     52 		var libraryID = options.libraryID;
     53 		var file = Zotero.File.pathToFile(options.file);
     54 		var path = file.path;
     55 		var leafName = file.leafName;
     56 		var parentItemID = options.parentItemID;
     57 		var collections = options.collections;
     58 		var fileBaseName = options.fileBaseName;
     59 		var contentType = options.contentType;
     60 		var charset = options.charset;
     61 		var saveOptions = options.saveOptions;
     62 		
     63 		if (fileBaseName) {
     64 			let ext = Zotero.File.getExtension(path);
     65 			var newName = fileBaseName + (ext != '' ? '.' + ext : '');
     66 		}
     67 		else {
     68 			var newName = Zotero.File.getValidFileName(OS.Path.basename(leafName));
     69 		}
     70 		
     71 		if (leafName.endsWith(".lnk")) {
     72 			throw new Error("Cannot add Windows shortcut");
     73 		}
     74 		if (parentItemID && collections) {
     75 			throw new Error("parentItemID and collections cannot both be provided");
     76 		}
     77 		
     78 		var attachmentItem, itemID, newFile, contentType, destDir;
     79 		try {
     80 			yield Zotero.DB.executeTransaction(function* () {
     81 				// Create a new attachment
     82 				attachmentItem = new Zotero.Item('attachment');
     83 				if (parentItemID) {
     84 					let {libraryID: parentLibraryID, key: parentKey} =
     85 						Zotero.Items.getLibraryAndKeyFromID(parentItemID);
     86 					attachmentItem.libraryID = parentLibraryID;
     87 				}
     88 				else if (libraryID) {
     89 					attachmentItem.libraryID = libraryID;
     90 				}
     91 				attachmentItem.setField('title', newName);
     92 				attachmentItem.parentID = parentItemID;
     93 				attachmentItem.attachmentLinkMode = this.LINK_MODE_IMPORTED_FILE;
     94 				if (collections) {
     95 					attachmentItem.setCollections(collections);
     96 				}
     97 				yield attachmentItem.save(saveOptions);
     98 				
     99 				// Create directory for attachment files within storage directory
    100 				destDir = yield this.createDirectoryForItem(attachmentItem);
    101 				
    102 				// Point to copied file
    103 				newFile = OS.Path.join(destDir, newName);
    104 				
    105 				// Copy file to unique filename, which automatically shortens long filenames
    106 				newFile = Zotero.File.copyToUnique(file, newFile);
    107 				
    108 				yield Zotero.File.setNormalFilePermissions(newFile.path);
    109 				
    110 				if (!contentType) {
    111 					contentType = yield Zotero.MIME.getMIMETypeFromFile(newFile);
    112 				}
    113 				attachmentItem.attachmentContentType = contentType;
    114 				if (charset) {
    115 					attachmentItem.attachmentCharset = charset;
    116 				}
    117 				attachmentItem.attachmentPath = newFile.path;
    118 				yield attachmentItem.save(saveOptions);
    119 			}.bind(this));
    120 			yield _postProcessFile(attachmentItem, newFile, contentType);
    121 		}
    122 		catch (e) {
    123 			Zotero.logError(e);
    124 			Zotero.logError("Failed importing file " + file.path);
    125 			
    126 			// Clean up
    127 			try {
    128 				if (destDir && (yield OS.File.exists(destDir))) {
    129 					yield OS.File.removeDir(destDir);
    130 				}
    131 			}
    132 			catch (e) {
    133 				Zotero.logError(e);
    134 			}
    135 			
    136 			throw e;
    137 		}
    138 		
    139 		return attachmentItem;
    140 	});
    141 	
    142 	
    143 	/**
    144 	 * @param {nsIFile|String} [options.file] - File to add
    145 	 * @param {Integer[]|String[]} [options.parentItemID] - Parent item to add item to
    146 	 * @param {Integer[]} [options.collections] - Collection keys or ids to add new item to
    147 	 * @param {Object} [options.saveOptions] - Options to pass to Zotero.Item::save()
    148 	 * @return {Promise<Zotero.Item>}
    149 	 */
    150 	this.linkFromFile = Zotero.Promise.coroutine(function* (options) {
    151 		Zotero.debug('Linking attachment from file');
    152 		
    153 		var file = Zotero.File.pathToFile(options.file);
    154 		var parentItemID = options.parentItemID;
    155 		var collections = options.collections;
    156 		var saveOptions = options.saveOptions;
    157 		
    158 		if (parentItemID && collections) {
    159 			throw new Error("parentItemID and collections cannot both be provided");
    160 		}
    161 		
    162 		var title = file.leafName;
    163 		var contentType = yield Zotero.MIME.getMIMETypeFromFile(file);
    164 		var item = yield _addToDB({
    165 			file,
    166 			title,
    167 			linkMode: this.LINK_MODE_LINKED_FILE,
    168 			contentType,
    169 			parentItemID,
    170 			collections,
    171 			saveOptions
    172 		});
    173 		yield _postProcessFile(item, file, contentType);
    174 		return item;
    175 	});
    176 	
    177 	
    178 	/**
    179 	 * @param {Object} options - 'file', 'url', 'title', 'contentType', 'charset', 'parentItemID', 'singleFile'
    180 	 * @return {Promise<Zotero.Item>}
    181 	 */
    182 	this.importSnapshotFromFile = Zotero.Promise.coroutine(function* (options) {
    183 		Zotero.debug('Importing snapshot from file');
    184 		
    185 		var file = Zotero.File.pathToFile(options.file);
    186 		// TODO: Fix main filename when copying directory, though in that case it's probably
    187 		// from our own export and already clean
    188 		var fileName = options.singleFile
    189 			? Zotero.File.getValidFileName(file.leafName)
    190 			: file.leafName;
    191 		var url = options.url;
    192 		var title = options.title;
    193 		var contentType = options.contentType;
    194 		var charset = options.charset;
    195 		var parentItemID = options.parentItemID;
    196 		
    197 		if (!parentItemID) {
    198 			throw new Error("parentItemID not provided");
    199 		}
    200 		
    201 		var attachmentItem, itemID, destDir, newPath;
    202 		try {
    203 			yield Zotero.DB.executeTransaction(function* () {
    204 				// Create a new attachment
    205 				attachmentItem = new Zotero.Item('attachment');
    206 				let {libraryID, key: parentKey} = Zotero.Items.getLibraryAndKeyFromID(parentItemID);
    207 				attachmentItem.libraryID = libraryID;
    208 				attachmentItem.setField('title', title);
    209 				attachmentItem.setField('url', url);
    210 				attachmentItem.parentID = parentItemID;
    211 				attachmentItem.attachmentLinkMode = this.LINK_MODE_IMPORTED_URL;
    212 				attachmentItem.attachmentContentType = contentType;
    213 				attachmentItem.attachmentCharset = charset;
    214 				attachmentItem.attachmentPath = 'storage:' + fileName;
    215 				
    216 				// DEBUG: this should probably insert access date too so as to
    217 				// create a proper item, but at the moment this is only called by
    218 				// translate.js, which sets the metadata fields itself
    219 				itemID = yield attachmentItem.save();
    220 				
    221 				var storageDir = Zotero.getStorageDirectory();
    222 				destDir = this.getStorageDirectory(attachmentItem);
    223 				yield OS.File.removeDir(destDir.path);
    224 				newPath = OS.Path.join(destDir.path, fileName);
    225 				// Copy single file to new directory
    226 				if (options.singleFile) {
    227 					yield this.createDirectoryForItem(attachmentItem);
    228 					yield OS.File.copy(file.path, newPath);
    229 				}
    230 				// Copy entire parent directory (for HTML snapshots)
    231 				else {
    232 					file.parent.copyTo(storageDir, destDir.leafName);
    233 				}
    234 			}.bind(this));
    235 			yield _postProcessFile(
    236 				attachmentItem,
    237 				Zotero.File.pathToFile(newPath),
    238 				contentType,
    239 				charset
    240 			);
    241 		}
    242 		catch (e) {
    243 			Zotero.logError(e);
    244 			
    245 			// Clean up
    246 			try {
    247 				if (destDir && destDir.exists()) {
    248 					destDir.remove(true);
    249 				}
    250 			}
    251 			catch (e) {
    252 				Zotero.logError(e, 1);
    253 			}
    254 			
    255 			throw e;
    256 		}
    257 		return attachmentItem;
    258 	});
    259 	
    260 	
    261 	/**
    262 	 * @param {Object} options
    263 	 * @param {Integer} options.libraryID
    264 	 * @param {String} options.url
    265 	 * @param {Integer} [options.parentItemID]
    266 	 * @param {Integer[]} [options.collections]
    267 	 * @param {String} [options.title]
    268 	 * @param {String} [options.fileBaseName]
    269 	 * @param {Boolean} [options.renameIfAllowedType=false]
    270 	 * @param {String} [options.contentType]
    271 	 * @param {String} [options.referrer]
    272 	 * @param {CookieSandbox} [options.cookieSandbox]
    273 	 * @param {Object} [options.saveOptions]
    274 	 * @return {Promise<Zotero.Item>} - A promise for the created attachment item
    275 	 */
    276 	this.importFromURL = Zotero.Promise.coroutine(function* (options) {
    277 		var libraryID = options.libraryID;
    278 		var url = options.url;
    279 		var parentItemID = options.parentItemID;
    280 		var collections = options.collections;
    281 		var title = options.title;
    282 		var fileBaseName = options.fileBaseName;
    283 		var renameIfAllowedType = options.renameIfAllowedType;
    284 		var contentType = options.contentType;
    285 		var referrer = options.referrer;
    286 		var cookieSandbox = options.cookieSandbox;
    287 		var saveOptions = options.saveOptions;
    288 		
    289 		Zotero.debug('Importing attachment from URL ' + url);
    290 		
    291 		if (parentItemID && collections) {
    292 			throw new Error("parentItemID and collections cannot both be provided");
    293 		}
    294 		
    295 		// Throw error on invalid URLs
    296 		//
    297 		// TODO: allow other schemes
    298 		var urlRe = /^https?:\/\/[^\s]*$/;
    299 		var matches = urlRe.exec(url);
    300 		if (!matches) {
    301 			throw new Error("Invalid URL '" + url + "'");
    302 		}
    303 		
    304 		// Save using a hidden browser
    305 		var nativeHandlerImport = function () {
    306 			return new Zotero.Promise(function (resolve, reject) {
    307 				var browser = Zotero.HTTP.loadDocuments(
    308 					url,
    309 					Zotero.Promise.coroutine(function* () {
    310 						let channel = browser.docShell.currentDocumentChannel;
    311 						if (channel && (channel instanceof Components.interfaces.nsIHttpChannel)) {
    312 							if (channel.responseStatus < 200 || channel.responseStatus >= 400) {
    313 								reject(new Error("Invalid response " + channel.responseStatus + " "
    314 									+ channel.responseStatusText + " for '" + url + "'"));
    315 								return false;
    316 							}
    317 						}
    318 						try {
    319 							let attachmentItem = yield Zotero.Attachments.importFromDocument({
    320 								libraryID,
    321 								document: browser.contentDocument,
    322 								parentItemID,
    323 								title,
    324 								collections,
    325 								saveOptions
    326 							});
    327 							resolve(attachmentItem);
    328 						}
    329 						catch (e) {
    330 							Zotero.logError(e);
    331 							reject(e);
    332 						}
    333 						finally {
    334 							Zotero.Browser.deleteHiddenBrowser(browser);
    335 						}
    336 					}),
    337 					undefined,
    338 					undefined,
    339 					true,
    340 					cookieSandbox
    341 				);
    342 			});
    343 		};
    344 		
    345 		// Save using remote web browser persist
    346 		var externalHandlerImport = Zotero.Promise.coroutine(function* (contentType) {
    347 			// Rename attachment
    348 			if (renameIfAllowedType && !fileBaseName && this.getRenamedFileTypes().includes(contentType)) {
    349 				let parentItem = Zotero.Items.get(parentItemID);
    350 				fileBaseName = this.getFileBaseNameFromItem(parentItem);
    351 			}
    352 			if (fileBaseName) {
    353 				let ext = _getExtensionFromURL(url, contentType);
    354 				var fileName = fileBaseName + (ext != '' ? '.' + ext : '');
    355 			}
    356 			else {
    357 				var fileName = _getFileNameFromURL(url, contentType);
    358 			}
    359 			
    360 			const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
    361 			var wbp = Components.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
    362 				.createInstance(nsIWBP);
    363 			if(cookieSandbox) cookieSandbox.attachToInterfaceRequestor(wbp);
    364 			var encodingFlags = false;
    365 			
    366 			// Create a temporary directory to save to within the storage directory.
    367 			// We don't use the normal temp directory because people might have 'storage'
    368 			// symlinked to another volume, which makes moving complicated.
    369 			var tmpDir = (yield this.createTemporaryStorageDirectory()).path;
    370 			var tmpFile = OS.Path.join(tmpDir, fileName);
    371 			
    372 			// Save to temp dir
    373 			var deferred = Zotero.Promise.defer();
    374 			wbp.progressListener = new Zotero.WebProgressFinishListener(function() {
    375 				deferred.resolve();
    376 			});
    377 				
    378 			var nsIURL = Components.classes["@mozilla.org/network/standard-url;1"]
    379 				.createInstance(Components.interfaces.nsIURL);
    380 			nsIURL.spec = url;
    381 			var headers = {};
    382 			if (referrer) {
    383 				headers.Referer = referrer;
    384 			}
    385 			Zotero.Utilities.Internal.saveURI(wbp, nsIURL, tmpFile, headers);
    386 
    387 
    388 			yield deferred.promise;
    389 			let sample = yield Zotero.File.getContentsAsync(tmpFile, null, 1000);
    390 			try {
    391 				if (contentType == 'application/pdf' &&
    392 					Zotero.MIME.sniffForMIMEType(sample) != 'application/pdf') {
    393 					let errString = "Downloaded PDF did not have MIME type "
    394 						+ "'application/pdf' in Attachments.importFromURL()";
    395 					Zotero.debug(errString, 2);
    396 					Zotero.debug(sample, 3);
    397 					throw(new Error(errString));
    398 				}
    399 
    400 				// Create DB item
    401 				var attachmentItem;
    402 				var destDir;
    403 				yield Zotero.DB.executeTransaction(function*() {
    404 					// Create a new attachment
    405 					attachmentItem = new Zotero.Item('attachment');
    406 					if (libraryID) {
    407 						attachmentItem.libraryID = libraryID;
    408 					}
    409 					else if (parentItemID) {
    410 						let {libraryID: parentLibraryID, key: parentKey} =
    411 							Zotero.Items.getLibraryAndKeyFromID(parentItemID);
    412 						attachmentItem.libraryID = parentLibraryID;
    413 					}
    414 					attachmentItem.setField('title', title ? title : fileName);
    415 					attachmentItem.setField('url', url);
    416 					attachmentItem.setField('accessDate', "CURRENT_TIMESTAMP");
    417 					attachmentItem.parentID = parentItemID;
    418 					attachmentItem.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_URL;
    419 					attachmentItem.attachmentContentType = contentType;
    420 					if (collections) {
    421 						attachmentItem.setCollections(collections);
    422 					}
    423 					attachmentItem.attachmentPath = 'storage:' + fileName;
    424 					var itemID = yield attachmentItem.save(saveOptions);
    425 					
    426 					Zotero.Fulltext.queueItem(attachmentItem);
    427 					
    428 					// DEBUG: Does this fail if 'storage' is symlinked to another drive?
    429 					destDir = this.getStorageDirectory(attachmentItem).path;
    430 					yield OS.File.move(tmpDir, destDir);
    431 				}.bind(this));
    432 			} catch (e) {
    433 				try {
    434 					if (tmpDir) {
    435 						yield OS.File.removeDir(tmpDir, { ignoreAbsent: true });
    436 					}
    437 					if (destDir) {
    438 						yield OS.File.removeDir(destDir, { ignoreAbsent: true });
    439 					}
    440 				}
    441 				catch (e) {
    442 					Zotero.debug(e, 1);
    443 				}
    444 				throw e;
    445 			}
    446 			
    447 			return attachmentItem;
    448 		}.bind(this));
    449 		
    450 		var process = function (contentType, hasNativeHandler) {
    451 			// If we can load this natively, use a hidden browser
    452 			// (so we can get the charset and title and index the document)
    453 			if (hasNativeHandler) {
    454 				return nativeHandlerImport();
    455 			}
    456 			
    457 			// Otherwise use a remote web page persist
    458 			return externalHandlerImport(contentType);
    459 		}
    460 		
    461 		if (contentType) {
    462 			return process(contentType, Zotero.MIME.hasNativeHandler(contentType));
    463 		}
    464 		
    465 		return Zotero.MIME.getMIMETypeFromURL(url, cookieSandbox).spread(process);
    466 	});
    467 	
    468 	
    469 	/**
    470 	 * Create a link attachment from a URL
    471 	 *
    472 	 * @param {Object} options - 'url', 'parentItemID', 'contentType', 'title', 'collections'
    473 	 * @return {Promise<Zotero.Item>} - A promise for the created attachment item
    474 	 */
    475 	this.linkFromURL = Zotero.Promise.coroutine(function* (options) {
    476 		Zotero.debug('Linking attachment from URL');
    477 	 
    478 		var url = options.url;
    479 		var parentItemID = options.parentItemID;
    480 		var contentType = options.contentType;
    481 		var title = options.title;
    482 		var collections = options.collections;
    483 		
    484 		/* Throw error on invalid URLs
    485 		 We currently accept the following protocols:
    486 		 PersonalBrain (brain://)
    487 		 DevonThink (x-devonthink-item://)
    488 		 Notational Velocity (nv://)
    489 		 MyLife Organized (mlo://)
    490 		 Evernote (evernote://)
    491 		 OneNote (onenote://)
    492 		 Kindle (kindle://) 
    493 		 Logos (logosres:) 
    494 		 Zotero (zotero://) */
    495 
    496 		var urlRe = /^((https?|zotero|evernote|onenote|brain|nv|mlo|kindle|x-devonthink-item|ftp):\/\/|logosres:)[^\s]*$/;
    497 		var matches = urlRe.exec(url);
    498 		if (!matches) {
    499 			throw ("Invalid URL '" + url + "' in Zotero.Attachments.linkFromURL()");
    500 		}
    501 		
    502 		// If no title provided, figure it out from the URL
    503 		// Web addresses with paths will be whittled to the last element
    504 		// excluding references and queries. All others are the full string
    505 		if (!title) {
    506 			var ioService = Components.classes["@mozilla.org/network/io-service;1"]
    507 				.getService(Components.interfaces.nsIIOService);
    508 			var titleURL = ioService.newURI(url, null, null);
    509 
    510 			if (titleURL.scheme == 'http' || titleURL.scheme == 'https') {
    511 				titleURL = titleURL.QueryInterface(Components.interfaces.nsIURL);
    512 				if (titleURL.path == '/') {
    513 					title = titleURL.host;
    514 				}
    515 				else if (titleURL.fileName) {
    516 					title = titleURL.fileName;
    517 				}
    518 				else {
    519 					var dir = titleURL.directory.split('/');
    520 					title = dir[dir.length - 2];
    521 				}
    522 			}
    523 			
    524 			if (!title) {
    525 				title = url;
    526 			}
    527 		}
    528 		
    529 		// Override MIME type to application/pdf if extension is .pdf --
    530 		// workaround for sites that respond to the HEAD request with an
    531 		// invalid MIME type (https://www.zotero.org/trac/ticket/460)
    532 		var ext = _getExtensionFromURL(url);
    533 		if (ext == 'pdf') {
    534 			contentType = 'application/pdf';
    535 		}
    536 		
    537 		return _addToDB({
    538 			url,
    539 			title,
    540 			linkMode: this.LINK_MODE_LINKED_URL,
    541 			contentType,
    542 			parentItemID,
    543 			collections
    544 		});
    545 	});
    546 	
    547 	
    548 	/**
    549 	 * TODO: what if called on file:// document?
    550 	 *
    551 	 * @param {Object} options - 'document', 'parentItemID', 'collections'
    552 	 * @return {Promise<Zotero.Item>}
    553 	 */
    554 	this.linkFromDocument = Zotero.Promise.coroutine(function* (options) {
    555 		Zotero.debug('Linking attachment from document');
    556 		
    557 		var document = options.document;
    558 		var parentItemID = options.parentItemID;
    559 		var collections = options.collections;
    560 		
    561 		if (parentItemID && collections) {
    562 			throw new Error("parentItemID and collections cannot both be provided");
    563 		}
    564 		
    565 		var url = document.location.href;
    566 		var title = document.title; // TODO: don't use Mozilla-generated title for images, etc.
    567 		var contentType = document.contentType;
    568 		
    569 		var item = yield _addToDB({
    570 			url,
    571 			title,
    572 			linkMode: this.LINK_MODE_LINKED_URL,
    573 			contentType,
    574 			charset: document.characterSet,
    575 			parentItemID,
    576 			collections
    577 		});
    578 		
    579 		if (Zotero.Fulltext.isCachedMIMEType(contentType)) {
    580 			// No file, so no point running the PDF indexer
    581 			//Zotero.Fulltext.indexItems([itemID]);
    582 		}
    583 		else if (Zotero.MIME.isTextType(document.contentType)) {
    584 			yield Zotero.Fulltext.indexDocument(document, item.id);
    585 		}
    586 		
    587 		return item;
    588 	});
    589 	
    590 	
    591 	/**
    592 	 * Save a snapshot from a Document
    593 	 *
    594 	 * @param {Object} options - 'libraryID', 'document', 'parentItemID', 'forceTitle', 'collections'
    595 	 * @return {Promise<Zotero.Item>} - A promise for the created attachment item
    596 	 */
    597 	this.importFromDocument = Zotero.Promise.coroutine(function* (options) {
    598 		Zotero.debug('Importing attachment from document');
    599 		
    600 		var libraryID = options.libraryID;
    601 		var document = options.document;
    602 		var parentItemID = options.parentItemID;
    603 		var title = options.title;
    604 		var collections = options.collections;
    605 		
    606 		if (parentItemID && collections) {
    607 			throw new Error("parentItemID and parentCollectionIDs cannot both be provided");
    608 		}
    609 		
    610 		var url = document.location.href;
    611 		title = title ? title : document.title;
    612 		var contentType = document.contentType;
    613 		if (Zotero.Attachments.isPDFJS(document)) {
    614 			contentType = "application/pdf";
    615 		}
    616 		
    617 		var tmpDir = (yield this.createTemporaryStorageDirectory()).path;
    618 		try {
    619 			var fileName = Zotero.File.truncateFileName(_getFileNameFromURL(url, contentType), 100);
    620 			var tmpFile = OS.Path.join(tmpDir, fileName);
    621 			
    622 			// If we're using the title from the document, make some adjustments
    623 			if (!options.title) {
    624 				// Remove e.g. " - Scaled (-17%)" from end of images saved from links,
    625 				// though I'm not sure why it's getting added to begin with
    626 				if (contentType.indexOf('image/') === 0) {
    627 					title = title.replace(/(.+ \([^,]+, [0-9]+x[0-9]+[^\)]+\)) - .+/, "$1" );
    628 				}
    629 				// If not native type, strip mime type data in parens
    630 				else if (!Zotero.MIME.hasNativeHandler(contentType, _getExtensionFromURL(url))) {
    631 					title = title.replace(/(.+) \([a-z]+\/[^\)]+\)/, "$1" );
    632 				}
    633 			}
    634 			
    635 			if ((contentType === 'text/html' || contentType === 'application/xhtml+xml')
    636 					// Documents from XHR don't work here
    637 					&& document instanceof Ci.nsIDOMDocument) {
    638 				Zotero.debug('Saving document with saveDocument()');
    639 				yield Zotero.Utilities.Internal.saveDocument(document, tmpFile);
    640 			}
    641 			else {
    642 				Zotero.debug("Saving file with saveURI()");
    643 				const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
    644 				var wbp = Components.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
    645 					.createInstance(nsIWBP);
    646 				wbp.persistFlags = nsIWBP.PERSIST_FLAGS_FROM_CACHE;
    647 				var ioService = Components.classes["@mozilla.org/network/io-service;1"]
    648 					.getService(Components.interfaces.nsIIOService);
    649 				var nsIURL = ioService.newURI(url, null, null);
    650 				var deferred = Zotero.Promise.defer();
    651 				wbp.progressListener = new Zotero.WebProgressFinishListener(function () {
    652 					deferred.resolve();
    653 				});
    654 				Zotero.Utilities.Internal.saveURI(wbp, nsIURL, tmpFile);
    655 				yield deferred.promise;
    656 			}
    657 			
    658 			var attachmentItem;
    659 			var destDir;
    660 			yield Zotero.DB.executeTransaction(function* () {
    661 				// Create a new attachment
    662 				attachmentItem = new Zotero.Item('attachment');
    663 				if (libraryID) {
    664 					attachmentItem.libraryID = libraryID;
    665 				}
    666 				else if (parentItemID) {
    667 					let {libraryID: parentLibraryID, key: parentKey} =
    668 						Zotero.Items.getLibraryAndKeyFromID(parentItemID);
    669 					attachmentItem.libraryID = parentLibraryID;
    670 				}
    671 				attachmentItem.setField('title', title);
    672 				attachmentItem.setField('url', url);
    673 				attachmentItem.setField('accessDate', "CURRENT_TIMESTAMP");
    674 				attachmentItem.parentID = parentItemID;
    675 				attachmentItem.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_URL;
    676 				attachmentItem.attachmentCharset = 'utf-8'; // WPD will output UTF-8
    677 				attachmentItem.attachmentContentType = contentType;
    678 				if (collections && collections.length) {
    679 					attachmentItem.setCollections(collections);
    680 				}
    681 				attachmentItem.attachmentPath = 'storage:' + fileName;
    682 				var itemID = yield attachmentItem.save();
    683 				
    684 				Zotero.Fulltext.queueItem(attachmentItem);
    685 				
    686 				// DEBUG: Does this fail if 'storage' is symlinked to another drive?
    687 				destDir = this.getStorageDirectory(attachmentItem).path;
    688 				yield OS.File.move(tmpDir, destDir);
    689 			}.bind(this));
    690 		}
    691 		catch (e) {
    692 			Zotero.debug(e, 1);
    693 			
    694 			// Clean up
    695 			try {
    696 				if (tmpDir) {
    697 					yield OS.File.removeDir(tmpDir, { ignoreAbsent: true });
    698 				}
    699 				if (destDir) {
    700 					yield OS.File.removeDir(destDir, { ignoreAbsent: true });
    701 				}
    702 			}
    703 			catch (e) {
    704 				Zotero.debug(e, 1);
    705 			}
    706 			
    707 			throw e;
    708 		}
    709 		
    710 		return attachmentItem;
    711 	});
    712 
    713 
    714 	/**
    715 	 * @deprecated Use Zotero.Utilities.cleanURL instead
    716 	 */
    717 	this.cleanAttachmentURI = function (uri, tryHttp) {
    718 		Zotero.debug("Zotero.Attachments.cleanAttachmentURI() is deprecated -- use Zotero.Utilities.cleanURL");
    719 		return Zotero.Utilities.cleanURL(uri, tryHttp);
    720 	}
    721 	
    722 	
    723 	/*
    724 	 * Returns a formatted string to use as the basename of an attachment
    725 	 * based on the metadata of the specified item and a format string
    726 	 *
    727 	 * (Optional) |formatString| specifies the format string -- otherwise
    728 	 * the 'attachmentRenameFormatString' pref is used
    729 	 *
    730 	 * Valid substitution markers:
    731 	 *
    732 	 * %c -- firstCreator
    733 	 * %y -- year (extracted from Date field)
    734 	 * %t -- title
    735 	 *
    736 	 * Fields can be truncated to a certain length by appending an integer
    737 	 * within curly brackets -- e.g. %t{50} truncates the title to 50 characters
    738 	 *
    739 	 * @param {Zotero.Item} item
    740 	 * @param {String} formatString
    741 	 */
    742 	this.getFileBaseNameFromItem = function (item, formatString) {
    743 		if (!(item instanceof Zotero.Item)) {
    744 			throw new Error("'item' must be a Zotero.Item");
    745 		}
    746 		
    747 		if (!formatString) {
    748 			formatString = Zotero.Prefs.get('attachmentRenameFormatString');
    749 		}
    750 		
    751 		// Replaces the substitution marker with the field value,
    752 		// truncating based on the {[0-9]+} modifier if applicable
    753 		function rpl(field, str) {
    754 			if (!str) {
    755 				str = formatString;
    756 			}
    757 			
    758 			switch (field) {
    759 				case 'creator':
    760 					field = 'firstCreator';
    761 					var rpl = '%c';
    762 					break;
    763 					
    764 				case 'year':
    765 					var rpl = '%y';
    766 					break;
    767 					
    768 				case 'title':
    769 					var rpl = '%t';
    770 					break;
    771 			}
    772 			
    773 			switch (field) {
    774 				case 'year':
    775 					var value = item.getField('date', true, true);
    776 					if (value) {
    777 						value = Zotero.Date.multipartToSQL(value).substr(0, 4);
    778 						if (value == '0000') {
    779 							value = '';
    780 						}
    781 					}
    782 				break;
    783 				
    784 				default:
    785 					var value = '' + item.getField(field, false, true);
    786 			}
    787 			
    788 			var re = new RegExp("\{?([^%\{\}]*)" + rpl + "(\{[0-9]+\})?" + "([^%\{\}]*)\}?");
    789 			
    790 			// If no value for this field, strip entire conditional block
    791 			// (within curly braces)
    792 			if (!value) {
    793 				if (str.match(re)) {
    794 					return str.replace(re, '')
    795 				}
    796 			}
    797 			
    798 			var f = function(match, p1, p2, p3) {
    799 				var maxChars = p2 ? p2.replace(/[^0-9]+/g, '') : false;
    800 				return p1 + (maxChars ? value.substr(0, maxChars) : value) + p3;
    801 			}
    802 			
    803 			return str.replace(re, f);
    804 		}
    805 		
    806 		formatString = rpl('creator');
    807 		formatString = rpl('year');
    808 		formatString = rpl('title');
    809 		
    810 		formatString = Zotero.File.getValidFileName(formatString);
    811 		return formatString;
    812 	}
    813 	
    814 	
    815 	this.getRenamedFileTypes = function () {
    816 		try {
    817 			var types = Zotero.Prefs.get('autoRenameFiles.fileTypes');
    818 			return types ? types.split(',') : [];
    819 		}
    820 		catch (e) {
    821 			return [];
    822 		}
    823 	};
    824 	
    825 	
    826 	this.getRenamedFileBaseNameIfAllowedType = async function (parentItem, file) {
    827 		var types = this.getRenamedFileTypes();
    828 		var contentType = file.endsWith('.pdf')
    829 			// Don't bother reading file if there's a .pdf extension
    830 			? 'application/pdf'
    831 			: await Zotero.MIME.getMIMETypeFromFile(file);
    832 		if (!types.includes(contentType)) {
    833 			return false;
    834 		}
    835 		return this.getFileBaseNameFromItem(parentItem);
    836 	}
    837 	
    838 	
    839 	/**
    840 	 * Create directory for attachment files within storage directory
    841 	 *
    842 	 * If a directory exists, delete and recreate
    843 	 *
    844 	 * @param {Number} itemID - Item id
    845 	 * @return {Promise<String>} - Path of new directory
    846 	 */
    847 	this.createDirectoryForItem = Zotero.Promise.coroutine(function* (item) {
    848 		if (!(item instanceof Zotero.Item)) {
    849 			throw new Error("'item' must be a Zotero.Item");
    850 		}
    851 		var dir = this.getStorageDirectory(item).path;
    852 		// Testing for directories in OS.File, used by removeDir(), is broken on Travis, so use nsIFile
    853 		if (Zotero.automatedTest) {
    854 			let nsIFile = Zotero.File.pathToFile(dir);
    855 			if (nsIFile.exists()) {
    856 				nsIFile.remove(true);
    857 			}
    858 		}
    859 		else {
    860 			yield OS.File.removeDir(dir, { ignoreAbsent: true });
    861 		}
    862 		yield Zotero.File.createDirectoryIfMissingAsync(dir);
    863 		return dir;
    864 	});
    865 	
    866 	
    867 	this.getStorageDirectory = function (item) {
    868 		if (!(item instanceof Zotero.Item)) {
    869 			throw new Error("'item' must be a Zotero.Item");
    870 		}
    871 		return this.getStorageDirectoryByLibraryAndKey(item.libraryID, item.key);
    872 	}
    873 	
    874 	
    875 	this.getStorageDirectoryByID = function (itemID) {
    876 		if (!itemID) {
    877 			throw new Error("itemID not provided");
    878 		}
    879 		var {libraryID, key} = Zotero.Items.getLibraryAndKeyFromID(itemID);
    880 		if (!key) {
    881 			throw new Error("Item " + itemID + " not found");
    882 		}
    883 		var dir = Zotero.getStorageDirectory();
    884 		dir.append(key);
    885 		return dir;
    886 	}
    887 	
    888 	
    889 	this.getStorageDirectoryByLibraryAndKey = function (libraryID, key) {
    890 		if (typeof key != 'string' || !key.match(/^[A-Z0-9]{8}$/)) {
    891 			throw ('key must be an 8-character string in '
    892 				+ 'Zotero.Attachments.getStorageDirectoryByLibraryAndKey()')
    893 		}
    894 		var dir = Zotero.getStorageDirectory();
    895 		dir.append(key);
    896 		return dir;
    897 	}
    898 	
    899 	
    900 	this.createTemporaryStorageDirectory = Zotero.Promise.coroutine(function* () {
    901 		var tmpDir = Zotero.getStorageDirectory();
    902 		tmpDir.append("tmp-" + Zotero.Utilities.randomString(6));
    903 		yield OS.File.makeDir(tmpDir.path, {
    904 			unixMode: 0o755
    905 		});
    906 		return tmpDir;
    907 	});
    908 	
    909 	
    910 	/**
    911 	 * If path is within the attachment base directory, return a relative
    912 	 * path prefixed by BASE_PATH_PLACEHOLDER. Otherwise, return unchanged.
    913 	 */
    914 	this.getBaseDirectoryRelativePath = function (path) {
    915 		if (!path || path.startsWith(this.BASE_PATH_PLACEHOLDER)) {
    916 			return path;
    917 		}
    918 		
    919 		var basePath = Zotero.Prefs.get('baseAttachmentPath');
    920 		if (!basePath) {
    921 			return path;
    922 		}
    923 		
    924 		if (Zotero.File.directoryContains(basePath, path)) {
    925 			// Since stored paths can be synced to other platforms, use forward slashes for consistency.
    926 			// resolveRelativePath() will convert to the appropriate platform-specific slash on use.
    927 			basePath = OS.Path.normalize(basePath).replace(/\\/g, "/");
    928 			path = OS.Path.normalize(path).replace(/\\/g, "/");
    929 			// Normalize D:\ vs. D:\foo
    930 			if (!basePath.endsWith('/')) {
    931 				basePath += '/';
    932 			}
    933 			path = this.BASE_PATH_PLACEHOLDER + path.substr(basePath.length)
    934 		}
    935 		
    936 		return path;
    937 	};
    938 	
    939 	
    940 	/**
    941 	 * Get an absolute path from this base-dir relative path, if we can
    942 	 *
    943 	 * @param {String} path - Absolute path or relative path prefixed by BASE_PATH_PLACEHOLDER
    944 	 * @return {String|false} - Absolute path, or FALSE if no path
    945 	 */
    946 	this.resolveRelativePath = function (path) {
    947 		if (!path.startsWith(Zotero.Attachments.BASE_PATH_PLACEHOLDER)) {
    948 			return false;
    949 		}
    950 		
    951 		var basePath = Zotero.Prefs.get('baseAttachmentPath');
    952 		if (!basePath) {
    953 			Zotero.debug("No base attachment path set -- can't resolve '" + path + "'", 2);
    954 			return false;
    955 		}
    956 		
    957 		return this.fixPathSlashes(OS.Path.join(
    958 			OS.Path.normalize(basePath),
    959 			path.substr(Zotero.Attachments.BASE_PATH_PLACEHOLDER.length)
    960 		));
    961 	}
    962 	
    963 	
    964 	this.fixPathSlashes = function (path) {
    965 		return path.replace(Zotero.isWin ? /\//g : /\\/g, Zotero.isWin ? "\\" : "/");
    966 	}
    967 	
    968 	
    969 	this.hasMultipleFiles = Zotero.Promise.coroutine(function* (item) {
    970 		if (!item.isAttachment()) {
    971 			throw new Error("Item is not an attachment");
    972 		}
    973 		
    974 		var linkMode = item.attachmentLinkMode;
    975 		switch (linkMode) {
    976 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
    977 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
    978 				break;
    979 			
    980 			default:
    981 				throw new Error("Invalid attachment link mode");
    982 		}
    983 		
    984 		if (item.attachmentContentType != 'text/html') {
    985 			return false;
    986 		}
    987 		
    988 		var path = yield item.getFilePathAsync();
    989 		if (!path) {
    990 			throw new Error("File not found");
    991 		}
    992 		
    993 		var numFiles = 0;
    994 		var parent = OS.Path.dirname(path);
    995 		var iterator = new OS.File.DirectoryIterator(parent);
    996 		try {
    997 			while (true) {
    998 				let entry = yield iterator.next();
    999 				if (entry.name.startsWith('.')) {
   1000 					continue;
   1001 				}
   1002 				numFiles++;
   1003 				if (numFiles > 1) {
   1004 					break;
   1005 				}
   1006 			}
   1007 		}
   1008 		catch (e) {
   1009 			if (e != StopIteration) {
   1010 				throw e;
   1011 			}
   1012 		}
   1013 		finally {
   1014 			iterator.close();
   1015 		}
   1016 		return numFiles > 1;
   1017 	});
   1018 	
   1019 	
   1020 	/**
   1021 	 * Returns the number of files in the attachment directory
   1022 	 *
   1023 	 * Only counts if MIME type is text/html
   1024 	 *
   1025 	 * @param	{Zotero.Item}	item	Attachment item
   1026 	 */
   1027 	this.getNumFiles = Zotero.Promise.coroutine(function* (item) {
   1028 		if (!item.isAttachment()) {
   1029 			throw new Error("Item is not an attachment");
   1030 		}
   1031 		
   1032 		var linkMode = item.attachmentLinkMode;
   1033 		switch (linkMode) {
   1034 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
   1035 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
   1036 				break;
   1037 			
   1038 			default:
   1039 				throw new Error("Invalid attachment link mode");
   1040 		}
   1041 		
   1042 		if (item.attachmentContentType != 'text/html') {
   1043 			return 1;
   1044 		}
   1045 		
   1046 		var path = yield item.getFilePathAsync();
   1047 		if (!path) {
   1048 			throw new Error("File not found");
   1049 		}
   1050 		
   1051 		var numFiles = 0;
   1052 		var parent = OS.Path.dirname(path);
   1053 		var iterator = new OS.File.DirectoryIterator(parent);
   1054 		try {
   1055 			yield iterator.forEach(function (entry) {
   1056 				if (entry.name.startsWith('.')) {
   1057 					return;
   1058 				}
   1059 				numFiles++;
   1060 			})
   1061 		}
   1062 		finally {
   1063 			iterator.close();
   1064 		}
   1065 		return numFiles;
   1066 	});
   1067 	
   1068 	
   1069 	/**
   1070 	 * @param {Zotero.Item} item
   1071 	 * @param {Boolean} [skipHidden=true] - Don't count hidden files
   1072 	 * @return {Promise<Integer>} - Promise for the total file size in bytes
   1073 	 */
   1074 	this.getTotalFileSize = Zotero.Promise.coroutine(function* (item, skipHidden = true) {
   1075 		if (!item.isAttachment()) {
   1076 			throw new Error("Item is not an attachment");
   1077 		}
   1078 		
   1079 		var linkMode = item.attachmentLinkMode;
   1080 		switch (linkMode) {
   1081 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
   1082 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
   1083 			case Zotero.Attachments.LINK_MODE_LINKED_FILE:
   1084 				break;
   1085 			
   1086 			default:
   1087 				throw new Error("Invalid attachment link mode");
   1088 		}
   1089 		
   1090 		var path = yield item.getFilePathAsync();
   1091 		if (!path) {
   1092 			throw new Error("File not found");
   1093 		}
   1094 		
   1095 		if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   1096 			return (yield OS.File.stat(path)).size;
   1097 		}
   1098 		
   1099 		var size = 0;
   1100 		var parent = OS.Path.dirname(path);
   1101 		let iterator = new OS.File.DirectoryIterator(parent);
   1102 		try {
   1103 			yield iterator.forEach(function (entry) {
   1104 				if (skipHidden && entry.name.startsWith('.')) {
   1105 					return;
   1106 				}
   1107 				return OS.File.stat(entry.path)
   1108 				.then(
   1109 					function (info) {
   1110 						size += info.size;
   1111 					},
   1112 					function (e) {
   1113 						// Can happen if there's a symlink to a missing file
   1114 						if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
   1115 							return;
   1116 						}
   1117 						else {
   1118 							throw e;
   1119 						}
   1120 					}
   1121 				);
   1122 			})
   1123 		}
   1124 		finally {
   1125 			iterator.close();
   1126 		}
   1127 		return size;
   1128 	});
   1129 	
   1130 	
   1131 	/**
   1132 	 * Move attachment item, including file, to another library
   1133 	 */
   1134 	this.moveAttachmentToLibrary = async function (attachment, libraryID, parentItemID) {
   1135 		if (attachment.libraryID == libraryID) {
   1136 			throw new Error("Attachment is already in library " + libraryID);
   1137 		}
   1138 		
   1139 		Zotero.DB.requireTransaction();
   1140 		
   1141 		var newAttachment = attachment.clone(libraryID);
   1142 		if (attachment.isImportedAttachment()) {
   1143 			// Attachment path isn't copied over by clone() if libraryID is different
   1144 			newAttachment.attachmentPath = attachment.attachmentPath;
   1145 		}
   1146 		if (parentItemID) {
   1147 			newAttachment.parentID = parentItemID;
   1148 		}
   1149 		await newAttachment.save();
   1150 		
   1151 		// Move files over if they exist
   1152 		var oldDir;
   1153 		var newDir;
   1154 		if (newAttachment.isImportedAttachment()) {
   1155 			oldDir = this.getStorageDirectory(attachment).path;
   1156 			if (await OS.File.exists(oldDir)) {
   1157 				newDir = this.getStorageDirectory(newAttachment).path;
   1158 				// Target directory shouldn't exist, but remove it if it does
   1159 				//
   1160 				// Testing for directories in OS.File, used by removeDir(), is broken on Travis,
   1161 				// so use nsIFile
   1162 				if (Zotero.automatedTest) {
   1163 					let nsIFile = Zotero.File.pathToFile(newDir);
   1164 					if (nsIFile.exists()) {
   1165 						nsIFile.remove(true);
   1166 					}
   1167 				}
   1168 				else {
   1169 					await OS.File.removeDir(newDir, { ignoreAbsent: true });
   1170 				}
   1171 				await OS.File.move(oldDir, newDir);
   1172 			}
   1173 		}
   1174 		
   1175 		try {
   1176 			await attachment.erase();
   1177 		}
   1178 		catch (e) {
   1179 			// Move files back if old item can't be deleted
   1180 			if (newAttachment.isImportedAttachment()) {
   1181 				try {
   1182 					await OS.File.move(newDir, oldDir);
   1183 				}
   1184 				catch (e) {
   1185 					Zotero.logError(e);
   1186 				}
   1187 			}
   1188 			throw e;
   1189 		}
   1190 		
   1191 		return newAttachment.id;
   1192 	};
   1193 	
   1194 	
   1195 	/**
   1196 	 * Copy attachment item, including file, to another library
   1197 	 */
   1198 	this.copyAttachmentToLibrary = Zotero.Promise.coroutine(function* (attachment, libraryID, parentItemID) {
   1199 		if (attachment.libraryID == libraryID) {
   1200 			throw new Error("Attachment is already in library " + libraryID);
   1201 		}
   1202 		
   1203 		Zotero.DB.requireTransaction();
   1204 		
   1205 		var newAttachment = attachment.clone(libraryID);
   1206 		if (attachment.isImportedAttachment()) {
   1207 			// Attachment path isn't copied over by clone() if libraryID is different
   1208 			newAttachment.attachmentPath = attachment.attachmentPath;
   1209 		}
   1210 		if (parentItemID) {
   1211 			newAttachment.parentID = parentItemID;
   1212 		}
   1213 		yield newAttachment.save();
   1214 		
   1215 		// Copy over files if they exist
   1216 		if (newAttachment.isImportedAttachment() && (yield attachment.fileExists())) {
   1217 			let dir = Zotero.Attachments.getStorageDirectory(attachment);
   1218 			let newDir = yield Zotero.Attachments.createDirectoryForItem(newAttachment);
   1219 			yield Zotero.File.copyDirectory(dir, newDir);
   1220 		}
   1221 		
   1222 		yield newAttachment.addLinkedItem(attachment);
   1223 		return newAttachment.id;
   1224 	});
   1225 	
   1226 	
   1227 	function _getFileNameFromURL(url, contentType){
   1228 		var nsIURL = Components.classes["@mozilla.org/network/standard-url;1"]
   1229 					.createInstance(Components.interfaces.nsIURL);
   1230 		nsIURL.spec = url;
   1231 		
   1232 		var ext = Zotero.MIME.getPrimaryExtension(contentType, nsIURL.fileExtension);
   1233 		
   1234 		if (!nsIURL.fileName) {
   1235 			var matches = nsIURL.directory.match(/\/([^\/]+)\/$/);
   1236 			// If no filename, use the last part of the path if there is one
   1237 			if (matches) {
   1238 				nsIURL.fileName = matches[1];
   1239 			}
   1240 			// Or just use the host
   1241 			else {
   1242 				nsIURL.fileName = nsIURL.host;
   1243 				var tld = nsIURL.fileExtension;
   1244 			}
   1245 		}
   1246 		
   1247 		// If we found a better extension, use that
   1248 		if (ext && (!nsIURL.fileExtension || nsIURL.fileExtension != ext)) {
   1249 			nsIURL.fileExtension = ext;
   1250 		}
   1251 		
   1252 		// If we replaced the TLD (which would've been interpreted as the extension), add it back
   1253 		if (tld && tld != nsIURL.fileExtension) {
   1254 			nsIURL.fileBaseName = nsIURL.fileBaseName + '.' + tld;
   1255 		}
   1256 		
   1257 		// Test unencoding fileBaseName
   1258 		try {
   1259 			decodeURIComponent(nsIURL.fileBaseName);
   1260 		}
   1261 		catch (e) {
   1262 			if (e.name == 'URIError') {
   1263 				// If we got a 'malformed URI sequence' while decoding,
   1264 				// use MD5 of fileBaseName
   1265 				nsIURL.fileBaseName = Zotero.Utilities.Internal.md5(nsIURL.fileBaseName, false);
   1266 			}
   1267 			else {
   1268 				throw e;
   1269 			}
   1270 		}
   1271 		
   1272 		// Pass unencoded name to getValidFileName() so that percent-encoded
   1273 		// characters aren't stripped to just numbers
   1274 		return Zotero.File.getValidFileName(decodeURIComponent(nsIURL.fileName));
   1275 	}
   1276 	
   1277 	
   1278 	function _getExtensionFromURL(url, contentType) {
   1279 		var nsIURL = Components.classes["@mozilla.org/network/standard-url;1"]
   1280 					.createInstance(Components.interfaces.nsIURL);
   1281 		nsIURL.spec = url;
   1282 		return Zotero.MIME.getPrimaryExtension(contentType, nsIURL.fileExtension);
   1283 	}
   1284 	
   1285 	
   1286 	/**
   1287 	 * Create a new item of type 'attachment' and add to the itemAttachments table
   1288 	 *
   1289 	 * @param {Object} options - 'file', 'url', 'title', 'linkMode', 'contentType', 'charsetID',
   1290 	 *     'parentItemID', 'saveOptions'
   1291 	 * @return {Promise<Zotero.Item>} - A promise for the new attachment
   1292 	 */
   1293 	function _addToDB(options) {
   1294 		var file = options.file;
   1295 		var url = options.url;
   1296 		var title = options.title;
   1297 		var linkMode = options.linkMode;
   1298 		var contentType = options.contentType;
   1299 		var charset = options.charset;
   1300 		var parentItemID = options.parentItemID;
   1301 		var collections = options.collections;
   1302 		var saveOptions = options.saveOptions;
   1303 		
   1304 		return Zotero.DB.executeTransaction(function* () {
   1305 			var attachmentItem = new Zotero.Item('attachment');
   1306 			if (parentItemID) {
   1307 				let {libraryID: parentLibraryID, key: parentKey} =
   1308 					Zotero.Items.getLibraryAndKeyFromID(parentItemID);
   1309 				if (parentLibraryID != Zotero.Libraries.userLibraryID
   1310 						&& linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   1311 					throw new Error("Cannot save linked file in non-local library");
   1312 				}
   1313 				attachmentItem.libraryID = parentLibraryID;
   1314 			}
   1315 			attachmentItem.setField('title', title);
   1316 			if (linkMode == self.LINK_MODE_IMPORTED_URL || linkMode == self.LINK_MODE_LINKED_URL) {
   1317 				attachmentItem.setField('url', url);
   1318 				attachmentItem.setField('accessDate', "CURRENT_TIMESTAMP");
   1319 			}
   1320 			
   1321 			attachmentItem.parentID = parentItemID;
   1322 			attachmentItem.attachmentLinkMode = linkMode;
   1323 			attachmentItem.attachmentContentType = contentType;
   1324 			attachmentItem.attachmentCharset = charset;
   1325 			if (file) {
   1326 				attachmentItem.attachmentPath = file.path;
   1327 			}
   1328 			
   1329 			if (collections) {
   1330 				attachmentItem.setCollections(collections);
   1331 			}
   1332 			yield attachmentItem.save(saveOptions);
   1333 			
   1334 			return attachmentItem;
   1335 		}.bind(this));
   1336 	}
   1337 	
   1338 	
   1339 	/**
   1340 	 * If necessary/possible, detect the file charset and index the file
   1341 	 *
   1342 	 * Since we have to load the content into the browser to get the
   1343 	 * character set (at least until we figure out a better way to get
   1344 	 * at the native detectors), we create the item above and update
   1345 	 * asynchronously after the fact
   1346 	 *
   1347 	 * @return {Promise}
   1348 	 */
   1349 	var _postProcessFile = Zotero.Promise.coroutine(function* (item, file, contentType) {
   1350 		// Don't try to process if MIME type is unknown
   1351 		if (!contentType) {
   1352 			return;
   1353 		}
   1354 		
   1355 		// Items with content types that get cached by the fulltext indexer can just be indexed,
   1356 		// since a charset isn't necessary
   1357 		if (Zotero.Fulltext.isCachedMIMEType(contentType)) {
   1358 			return Zotero.Fulltext.indexItems([item.id]);
   1359 		}
   1360 		
   1361 		// Ignore non-text types
   1362 		var ext = Zotero.File.getExtension(file);
   1363 		if (!Zotero.MIME.hasInternalHandler(contentType, ext) || !Zotero.MIME.isTextType(contentType)) {
   1364 			return;
   1365 		}
   1366 		
   1367 		// If the charset is already set, index item directly
   1368 		if (item.attachmentCharset) {
   1369 			return Zotero.Fulltext.indexItems([item.id]);
   1370 		}
   1371 		
   1372 		// Otherwise, load in a hidden browser to get the charset, and then index the document
   1373 		var deferred = Zotero.Promise.defer();
   1374 		var browser = Zotero.Browser.createHiddenBrowser();
   1375 		
   1376 		if (item.attachmentCharset) {
   1377 			var onpageshow = function(){
   1378 				// ignore spurious about:blank loads
   1379 				if(browser.contentDocument.location.href == "about:blank") return;
   1380 				
   1381 				browser.removeEventListener("pageshow", onpageshow, false);
   1382 				
   1383 				Zotero.Fulltext.indexDocument(browser.contentDocument, itemID)
   1384 				.then(deferred.resolve, deferred.reject)
   1385 				.finally(function () {
   1386 					Zotero.Browser.deleteHiddenBrowser(browser);
   1387 				});
   1388 			};
   1389 			browser.addEventListener("pageshow", onpageshow, false);
   1390 		}
   1391 		else {
   1392 			let callback = Zotero.Promise.coroutine(function* (charset, args) {
   1393 				// ignore spurious about:blank loads
   1394 				if(browser.contentDocument.location.href == "about:blank") return;
   1395 				
   1396 				try {
   1397 					if (charset) {
   1398 						charset = Zotero.CharacterSets.toCanonical(charset);
   1399 						if (charset) {
   1400 							item.attachmentCharset = charset;
   1401 							yield item.saveTx({
   1402 								skipNotifier: true
   1403 							});
   1404 						}
   1405 					}
   1406 					
   1407 					yield Zotero.Fulltext.indexDocument(browser.contentDocument, item.id);
   1408 					Zotero.Browser.deleteHiddenBrowser(browser);
   1409 					
   1410 					deferred.resolve();
   1411 				}
   1412 				catch (e) {
   1413 					deferred.reject(e);
   1414 				}
   1415 			});
   1416 			Zotero.File.addCharsetListener(browser, callback, item.id);
   1417 		}
   1418 		
   1419 		var url = Components.classes["@mozilla.org/network/protocol;1?name=file"]
   1420 					.getService(Components.interfaces.nsIFileProtocolHandler)
   1421 					.getURLSpecFromFile(file);
   1422 		browser.loadURI(url);
   1423 		
   1424 		return deferred.promise;
   1425 	});
   1426 	
   1427 	/**
   1428 	 * Determines if a given document is an instance of PDFJS
   1429 	 * @return {Boolean}
   1430 	 */
   1431 	this.isPDFJS = function(doc) {
   1432 		// pdf.js HACK
   1433 		// This may no longer be necessary (as of Fx 23)
   1434 		if(doc.contentType === "text/html") {
   1435 			var win = doc.defaultView;
   1436 			if(win) {
   1437 				win = win.wrappedJSObject;
   1438 				if(win && "PDFJS" in win) {
   1439 					return true;
   1440 				}
   1441 			}
   1442 		}
   1443 		return false;
   1444 	}
   1445 	
   1446 	
   1447 	this.linkModeToName = function (linkMode) {
   1448 		switch (linkMode) {
   1449 		case this.LINK_MODE_IMPORTED_FILE:
   1450 			return 'imported_file';
   1451 		case this.LINK_MODE_IMPORTED_URL:
   1452 			return 'imported_url';
   1453 		case this.LINK_MODE_LINKED_FILE:
   1454 			return 'linked_file';
   1455 		case this.LINK_MODE_LINKED_URL:
   1456 			return 'linked_url';
   1457 		default:
   1458 			throw new Error(`Invalid link mode ${linkMode}`);
   1459 		}
   1460 	}
   1461 	
   1462 	
   1463 	this.linkModeFromName = function (linkModeName) {
   1464 		var prop = "LINK_MODE_" + linkModeName.toUpperCase();
   1465 		if (this[prop] !== undefined) {
   1466 			return this[prop];
   1467 		}
   1468 		throw new Error(`Invalid link mode name '${linkModeName}'`);
   1469 	}
   1470 }