quickCopy.js (15950B)
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 "use strict"; 27 28 Zotero.QuickCopy = new function() { 29 this.lastActiveURL = null; 30 31 var _initTimeoutID 32 var _initPromise; 33 var _initialized = false; 34 var _initCancelled = false; 35 var _siteSettings; 36 var _formattedNames; 37 38 this.init = Zotero.Promise.coroutine(function* () { 39 Zotero.debug("Initializing Quick Copy"); 40 41 if (!_initialized) { 42 // Make sure export translator code is loaded whenever the output format changes 43 Zotero.Prefs.registerObserver("export.quickCopy.setting", _loadOutputFormat); 44 _initialized = true; 45 } 46 47 // Load code for selected export translators ahead of time 48 // (in the background, because it requires translator initialization) 49 Zotero.Schema.schemaUpdatePromise 50 .then(function () { 51 if (_initCancelled) return; 52 53 // Avoid random translator initialization during tests, which can result in timeouts, 54 // if an export format is selected 55 if (Zotero.test) return; 56 57 _initPromise = Zotero.Promise.each( 58 [ 59 () => _loadOutputFormat(), 60 () => this.loadSiteSettings() 61 ], 62 f => f() 63 ); 64 }.bind(this)); 65 }); 66 67 68 this.uninit = function () { 69 _initCancelled = true; 70 // Cancel load if in progress 71 if (_initPromise) { 72 _initPromise.cancel(); 73 } 74 Zotero.Prefs.unregisterObserver("export.quickCopy.setting", _loadOutputFormat); 75 }; 76 77 78 this.loadSiteSettings = Zotero.Promise.coroutine(function* () { 79 var sql = "SELECT key AS domainPath, value AS format FROM settings " 80 + "WHERE setting='quickCopySite'"; 81 var rows = yield Zotero.DB.queryAsync(sql); 82 // Unproxify storage row 83 _siteSettings = rows.map(row => { 84 return { 85 domainPath: row.domainPath, 86 format: row.format 87 }; 88 }); 89 yield Zotero.Promise.map(rows, row => _preloadFormat(row.format)); 90 }); 91 92 93 this.hasSiteSettings = function () { 94 return _siteSettings && _siteSettings.length > 0; 95 }; 96 97 98 /* 99 * Return Quick Copy setting object from string, stringified object, or object 100 * 101 * Example string format: "bibliography/html=http://www.zotero.org/styles/apa" 102 * 103 * Quick Copy setting object has the following properties: 104 * - "mode": "bibliography" (for styles) or "export" (for export translators) 105 * - "contentType: "" (plain text output) or "html" (HTML output; for styles 106 * only) 107 * - "id": style ID or export translator ID 108 * - "locale": locale code (for styles only) 109 */ 110 this.unserializeSetting = function (setting) { 111 var settingObject = {}; 112 113 if (typeof setting === 'string') { 114 try { 115 // First test if string input is a stringified object 116 settingObject = JSON.parse(setting); 117 } catch (e) { 118 // Try parsing as formatted string 119 var parsedSetting = setting.match(/(bibliography|export)(?:\/([^=]+))?=(.+)$/); 120 if (parsedSetting) { 121 settingObject.mode = parsedSetting[1]; 122 settingObject.contentType = parsedSetting[2] || ''; 123 settingObject.id = parsedSetting[3]; 124 settingObject.locale = ''; 125 } 126 } 127 } else { 128 // Return input if not a string; it might already be an object 129 return setting; 130 } 131 132 return settingObject; 133 }; 134 135 136 this.getFormattedNameFromSetting = Zotero.Promise.coroutine(function* (setting) { 137 if (!_formattedNames) { 138 yield _loadFormattedNames(); 139 } 140 var format = this.unserializeSetting(setting); 141 142 var name = _formattedNames[format.mode + "=" + format.id]; 143 return name ? name : ''; 144 }); 145 146 this.getSettingFromFormattedName = Zotero.Promise.coroutine(function* (name) { 147 if (!_formattedNames) { 148 yield _loadFormattedNames(); 149 } 150 for (var setting in _formattedNames) { 151 if (_formattedNames[setting] == name) { 152 return setting; 153 } 154 } 155 return ''; 156 }); 157 158 159 this.getFormatFromURL = function(url) { 160 var quickCopyPref = Zotero.Prefs.get("export.quickCopy.setting"); 161 quickCopyPref = JSON.stringify(this.unserializeSetting(quickCopyPref)); 162 163 if (!url) { 164 return quickCopyPref; 165 } 166 167 var ioService = Components.classes["@mozilla.org/network/io-service;1"] 168 .getService(Components.interfaces.nsIIOService); 169 var nsIURI; 170 try { 171 nsIURI = ioService.newURI(url, null, null); 172 // Accessing some properties may throw for URIs that do not support those 173 // parts. E.g. hostPort throws NS_ERROR_FAILURE for about:blank 174 var urlHostPort = nsIURI.hostPort; 175 var urlPath = nsIURI.path; 176 } 177 catch (e) {} 178 179 // Skip non-HTTP URLs 180 if (!nsIURI || !/^https?$/.test(nsIURI.scheme)) { 181 return quickCopyPref; 182 } 183 184 if (!_siteSettings) { 185 throw new Zotero.Exception.UnloadedDataException("Quick Copy site settings not loaded"); 186 } 187 188 var matches = []; 189 for (let i=0; i<_siteSettings.length; i++) { 190 let row = _siteSettings[i]; 191 let domain = row.domainPath.split('/',1)[0]; 192 let path = row.domainPath.substr(domain.length) || '/'; 193 if (urlHostPort.endsWith(domain) && urlPath.startsWith(path)) { 194 matches.push({ 195 format: JSON.stringify(this.unserializeSetting(row.format)), 196 domainLength: domain.length, 197 pathLength: path.length 198 }); 199 } 200 } 201 202 // Give priority to longer domains, then longer paths 203 var sort = function(a, b) { 204 if (a.domainLength > b.domainLength) { 205 return -1; 206 } 207 else if (a.domainLength < b.domainLength) { 208 return 1; 209 } 210 211 if (a.pathLength > b.pathLength) { 212 return -1; 213 } 214 else if (a.pathLength < b.pathLength) { 215 return 1; 216 } 217 218 return -1; 219 }; 220 221 if (matches.length) { 222 matches.sort(sort); 223 return matches[0].format; 224 } else { 225 return quickCopyPref; 226 } 227 }; 228 229 230 /* 231 * Get text and (when applicable) HTML content from items 232 * 233 * |items| is an array of Zotero.Item objects 234 * 235 * |format| may be a Quick Copy format string 236 * (e.g. "bibliography=http://www.zotero.org/styles/apa") 237 * or an Quick Copy format object 238 * 239 * |callback| is only necessary if using an export format and should be 240 * a function suitable for Zotero.Translate.setHandler, taking parameters 241 * |obj| and |worked|. The generated content should be placed in obj.string 242 * and |worked| should be true if the operation is successful. 243 * 244 * If bibliography format, the process is synchronous and an object 245 * contain properties 'text' and 'html' is returned. 246 */ 247 this.getContentFromItems = function (items, format, callback, modified) { 248 if (items.length > Zotero.Prefs.get('export.quickCopy.dragLimit')) { 249 Zotero.debug("Skipping quick copy for " + items.length + " items"); 250 return false; 251 } 252 253 format = this.unserializeSetting(format); 254 255 if (format.mode == 'export') { 256 var translation = new Zotero.Translate.Export; 257 translation.noWait = true; // needed not to break drags 258 translation.setItems(items); 259 translation.setTranslator(format.id); 260 translation.setHandler("done", callback); 261 translation.translate(); 262 return true; 263 } 264 else if (format.mode == 'bibliography') { 265 // Move notes to separate array 266 var allNotes = true; 267 var notes = []; 268 for (var i=0; i<items.length; i++) { 269 if (items[i].isNote()) { 270 notes.push(items.splice(i, 1)[0]); 271 i--; 272 } 273 else { 274 allNotes = false; 275 } 276 } 277 278 // If all notes, export full content 279 if (allNotes) { 280 var content = [], 281 parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] 282 .createInstance(Components.interfaces.nsIDOMParser), 283 doc = parser.parseFromString('<div class="zotero-notes"/>', 'text/html'), 284 textDoc = parser.parseFromString('<div class="zotero-notes"/>', 'text/html'), 285 container = doc.documentElement, 286 textContainer = textDoc.documentElement; 287 for (var i=0; i<notes.length; i++) { 288 var div = doc.createElement("div"); 289 div.className = "zotero-note"; 290 // AMO reviewer: This documented is never rendered (and the inserted markup 291 // is sanitized anyway) 292 div.insertAdjacentHTML('afterbegin', notes[i].getNote()); 293 container.appendChild(div); 294 textContainer.appendChild(textDoc.importNode(div, true)); 295 } 296 297 // Raw HTML output 298 var html = container.outerHTML; 299 300 // Add placeholders for newlines between notes 301 if (notes.length > 1) { 302 var divs = Zotero.Utilities.xpath(container, "div"), 303 textDivs = Zotero.Utilities.xpath(textContainer, "div"); 304 for (var i=1, len=divs.length; i<len; i++) { 305 var p = doc.createElement("p"); 306 p.appendChild(doc.createTextNode("--------------------------------------------------")); 307 container.insertBefore(p, divs[i]); 308 textContainer.insertBefore(textDoc.importNode(p, true), textDivs[i]); 309 } 310 } 311 312 const BLOCKQUOTE_PREFS = { 313 'export.quickCopy.quoteBlockquotes.richText':doc, 314 'export.quickCopy.quoteBlockquotes.plainText':textDoc 315 }; 316 for(var pref in BLOCKQUOTE_PREFS) { 317 if (Zotero.Prefs.get(pref)) { 318 var currentDoc = BLOCKQUOTE_PREFS[pref]; 319 // Add quotes around blockquote paragraphs 320 var addOpenQuote = Zotero.Utilities.xpath(currentDoc, "//blockquote/p[1]"), 321 addCloseQuote = Zotero.Utilities.xpath(currentDoc, "//blockquote/p[last()]"); 322 for(var i=0; i<addOpenQuote.length; i++) { 323 addOpenQuote[i].insertBefore(currentDoc.createTextNode("\u201c"), 324 addOpenQuote[i].firstChild); 325 } 326 for(var i=0; i<addCloseQuote.length; i++) { 327 addCloseQuote[i].appendChild(currentDoc.createTextNode("\u201d")); 328 } 329 } 330 } 331 332 // 333 // Text-only adjustments 334 // 335 336 // Replace span styles with characters 337 var spans = textDoc.getElementsByTagName("span"); 338 for(var i=0; i<spans.length; i++) { 339 var span = spans[i]; 340 if(span.style.textDecoration == "underline") { 341 span.insertBefore(textDoc.createTextNode("_"), span.firstChild); 342 span.appendChild(textDoc.createTextNode("_")); 343 } 344 } 345 346 // 347 // And add spaces for indents 348 // 349 // Placeholder for 4 spaces in final output 350 const ZTAB = "%%ZOTEROTAB%%"; 351 var ps = textDoc.getElementsByTagName("p"); 352 for(var i=0; i<ps.length; i++) { 353 var p = ps[i], 354 paddingLeft = p.style.paddingLeft; 355 if(paddingLeft && paddingLeft.substr(paddingLeft.length-2) === "px") { 356 var paddingPx = parseInt(paddingLeft, 10), 357 ztabs = ""; 358 for (let j = 30; j <= paddingPx; j += 30) ztabs += ZTAB; 359 p.insertBefore(textDoc.createTextNode(ztabs), p.firstChild); 360 } 361 } 362 363 // Use plaintext serializer to output formatted text 364 var docEncoder = Components.classes["@mozilla.org/layout/documentEncoder;1?type=text/html"] 365 .createInstance(Components.interfaces.nsIDocumentEncoder); 366 docEncoder.init(textDoc, "text/plain", docEncoder.OutputFormatted); 367 var text = docEncoder.encodeToString().trim().replace(ZTAB, " ", "g"); 368 369 // 370 // Adjustments for the HTML copied to the clipboard 371 // 372 373 // Everything seems to like margin-left better than padding-left 374 var ps = Zotero.Utilities.xpath(doc, "p"); 375 for(var i=0; i<ps.length; i++) { 376 var p = ps[i]; 377 if(p.style.paddingLeft) { 378 p.style.marginLeft = p.style.paddingLeft; 379 p.style.paddingLeft = ""; 380 } 381 } 382 383 // Word and TextEdit don't indent blockquotes on their own and need this 384 // 385 // OO gets it right, so this results in an extra indent 386 if (Zotero.Prefs.get('export.quickCopy.compatibility.indentBlockquotes')) { 387 var ps = Zotero.Utilities.xpath(doc, "//blockquote/p"); 388 for(var i=0; i<ps.length; i++) ps[i].style.marginLeft = "30px"; 389 } 390 391 // Add Word Normal style to paragraphs and add double-spacing 392 // 393 // OO inserts the conditional style code as a document comment 394 if (Zotero.Prefs.get('export.quickCopy.compatibility.word')) { 395 var ps = doc.getElementsByTagName("p"); 396 for (var i=0; i<ps.length; i++) ps[i].className = "msoNormal"; 397 var copyHTML = "<!--[if gte mso 0]>" 398 + "<style>" 399 + "p { margin-top:.1pt;margin-right:0in;margin-bottom:.1pt;margin-left:0in; line-height: 200%; }" 400 + "li { margin-top:.1pt;margin-right:0in;margin-bottom:.1pt;margin-left:0in; line-height: 200%; }" 401 + "blockquote p { margin-left: 11px; margin-right: 11px }" 402 + "</style>" 403 + "<![endif]-->\n" 404 + container.outerHTML; 405 } 406 else { 407 var copyHTML = container.outerHTML; 408 } 409 410 var content = { 411 text: format.contentType == "html" ? html : text, 412 html: copyHTML 413 }; 414 415 return content; 416 } 417 418 // determine locale preference 419 var locale = format.locale ? format.locale : Zotero.Prefs.get('export.quickCopy.locale'); 420 421 // Copy citations if shift key pressed 422 if (modified) { 423 var csl = Zotero.Styles.get(format.id).getCiteProc(locale); 424 csl.updateItems(items.map(item => item.id)); 425 var citation = { 426 citationItems: items.map(item => ({ id: item.id })), 427 properties: {} 428 }; 429 var html = csl.previewCitationCluster(citation, [], [], "html"); 430 var text = csl.previewCitationCluster(citation, [], [], "text"); 431 } else { 432 var style = Zotero.Styles.get(format.id); 433 var cslEngine = style.getCiteProc(locale); 434 var html = Zotero.Cite.makeFormattedBibliographyOrCitationList(cslEngine, items, "html"); 435 cslEngine = style.getCiteProc(locale); 436 var text = Zotero.Cite.makeFormattedBibliographyOrCitationList(cslEngine, items, "text"); 437 } 438 439 return {text:(format.contentType == "html" ? html : text), html:html}; 440 } 441 442 throw ("Invalid mode '" + format.mode + "' in Zotero.QuickCopy.getContentFromItems()"); 443 }; 444 445 446 /** 447 * If an export translator is the selected output format, load its code (which must be done 448 * asynchronously) ahead of time, since drag-and-drop requires synchronous operation 449 * 450 * @return {Promise} 451 */ 452 var _loadOutputFormat = Zotero.Promise.coroutine(function* () { 453 var format = Zotero.Prefs.get("export.quickCopy.setting"); 454 return _preloadFormat(format); 455 }); 456 457 458 var _preloadFormat = async function (format) { 459 format = Zotero.QuickCopy.unserializeSetting(format); 460 if (format.mode == 'export') { 461 Zotero.debug(`Preloading ${format.id} for Quick Copy`); 462 await Zotero.Translators.init(); 463 let translator = Zotero.Translators.get(format.id); 464 translator.cacheCode = true; 465 await translator.getCode(); 466 } 467 }; 468 469 470 var _loadFormattedNames = Zotero.Promise.coroutine(function* () { 471 var t = new Date; 472 Zotero.debug("Loading formatted names for Quick Copy"); 473 474 var translation = new Zotero.Translate.Export; 475 var translators = yield translation.getTranslators(); 476 477 // add styles to list 478 _formattedNames = {}; 479 var styles = Zotero.Styles.getVisible(); 480 for (let style of styles) { 481 _formattedNames['bibliography=' + style.styleID] = style.title; 482 } 483 484 for (var i=0; i<translators.length; i++) { 485 // Skip RDF formats 486 switch (translators[i].translatorID) { 487 case '6e372642-ed9d-4934-b5d1-c11ac758ebb7': 488 case '14763d24-8ba0-45df-8f52-b8d1108e7ac9': 489 continue; 490 } 491 _formattedNames['export=' + translators[i].translatorID] = translators[i].label; 492 } 493 494 Zotero.debug("Loaded formatted names for Quick Copy in " + (new Date - t) + " ms"); 495 }); 496 }