/* ***** BEGIN LICENSE BLOCK ***** Copyright © 2011 Center for History and New Media George Mason University, Fairfax, Virginia, USA http://zotero.org This file is part of Zotero. Zotero is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Zotero is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with Zotero. If not, see . ***** END LICENSE BLOCK ***** */ var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); var { E10SUtils } = ChromeUtils.import("resource://gre/modules/E10SUtils.jsm"); var { Subprocess } = ChromeUtils.import("resource://gre/modules/Subprocess.jsm"); var { RemoteTranslate } = ChromeUtils.import("chrome://zotero/content/RemoteTranslate.jsm"); var { ContentDOMReference } = ChromeUtils.import("resource://gre/modules/ContentDOMReference.jsm"); var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs"); var { FilePicker } = ChromeUtils.importESModule('chrome://zotero/content/modules/filePicker.mjs'); // Fix JSON stringify 2028/2029 "bug" // Borrowed from http://stackoverflow.com/questions/16686687/json-stringify-and-u2028-u2029-check if (JSON.stringify(["\u2028\u2029"]) !== '["\\u2028\\u2029"]') { JSON.stringify = function (stringify) { return function () { var str = stringify.apply(this, arguments); if (str && str.indexOf('\u2028') != -1) str = str.replace(/\u2028/g, '\\u2028'); if (str && str.indexOf('\u2029') != -1) str = str.replace(/\u2029/g, '\\u2029'); return str; }; }(JSON.stringify); } // To be used elsewhere (e.g. varDump) function fix2028(str) { if (str.indexOf('\u2028') != -1) str = str.replace(/\u2028/g, '\\u2028'); if (str.indexOf('\u2029') != -1) str = str.replace(/\u2029/g, '\\u2029'); return str; } var Scaffold = new function () { var _browser; var _translatorsLoadedPromise; var _translatorProvider = null; var _lastModifiedTime = 0; var _needRebuildTranslatorSuggestions = true; this.browser = () => _browser; var _editors = {}; var _propertyMap = { 'textbox-translatorID': 'translatorID', 'textbox-label': 'label', 'textbox-creator': 'creator', 'textbox-target': 'target', 'textbox-minVersion': 'minVersion', 'textbox-priority': 'priority', 'textbox-target-all': 'targetAll', 'textbox-hidden-prefs': 'hiddenPrefs' }; var _linesOfMetadata = 15; this.onLoad = async function (e) { if (e.target !== document) return; _browser = document.getElementById('browser'); window.messageManager.addMessageListener('Scaffold:Load', ({ data }) => { document.getElementById("browser-url").value = data.url; }); window.messageManager.loadFrameScript('chrome://scaffold/content/content.js', true); let browserUrl = document.getElementById("browser-url"); browserUrl.addEventListener('keydown', function (e) { if (e.key == 'Enter') { Zotero.debug('Scaffold: Loading URL in browser: ' + browserUrl.value); _browser.loadURI(Services.io.newURI(browserUrl.value), { triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal() }); } }); document.getElementById('tabpanels').addEventListener('select', event => Scaffold.handleTabSelect(event)); let lastTranslatorID = Zotero.Prefs.get('scaffold.lastTranslatorID'); if (lastTranslatorID) { document.getElementById("textbox-translatorID").value = lastTranslatorID; document.getElementById("textbox-label").value = 'Loading…'; } else { this.generateTranslatorID(); } // Add List fields help menu entries for all other item types var types = Zotero.ItemTypes.getAll().map(t => t.name).sort(); var morePopup = document.getElementById('mb-help-fields-more-popup'); var primaryTypes = ['book', 'bookSection', 'conferencePaper', 'journalArticle', 'magazineArticle', 'newspaperArticle']; for (let type of types) { if (primaryTypes.includes(type)) continue; var menuitem = document.createXULElement('menuitem'); menuitem.setAttribute('label', type); menuitem.addEventListener('command', () => { Scaffold.addTemplate('templateNewItem', type); }); morePopup.appendChild(menuitem); } if (!Scaffold_Translators.getDirectory()) { if (!await this.promptForTranslatorsDirectory()) { window.close(); return; } } var importWin = document.getElementById("editor-import").contentWindow; var codeWin = document.getElementById("editor-code").contentWindow; var testsWin = document.getElementById("editor-tests").contentWindow; await Promise.all([ importWin.loadMonaco({ language: 'plaintext' }).then(({ monaco, editor }) => { _editors.importGlobal = monaco; _editors.import = editor; }), codeWin.loadMonaco({ language: 'javascript' }).then(({ monaco, editor }) => { _editors.codeGlobal = monaco; _editors.code = editor; }), testsWin.loadMonaco({ language: 'json' }).then(({ monaco, editor }) => { _editors.testsGlobal = monaco; _editors.tests = editor; }), ]); this.initImportEditor(); this.initCodeEditor(); this.initTestsEditor(); // Set font size from general pref Zotero.UIProperties.registerRoot(document.getElementById('scaffold-pane')); // Set font size of code editor var size = Zotero.Prefs.get("scaffold.fontSize"); if (size) { this.setFontSize(size); } // Listen for Scaffold coming to the foreground and reload translators window.addEventListener('activate', () => this.reloadTranslators()); Scaffold_Translators.setLoadListener({ onLoadBegin: () => { document.getElementById('cmd_load').setAttribute('disabled', true); }, onLoadComplete: () => { document.getElementById('cmd_load').removeAttribute('disabled'); _needRebuildTranslatorSuggestions = true; } }); _translatorsLoadedPromise = Scaffold_Translators.load(); _translatorProvider = Scaffold_Translators.getProvider(); if (lastTranslatorID) { this.load(lastTranslatorID).then((success) => { if (!success) { Zotero.Prefs.clear('scaffold.lastTranslatorID'); this.newTranslator(true); } }); } }; this.promptForTranslatorsDirectory = async function () { var ps = Services.prompt; var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING; var index = ps.confirmEx(null, "Scaffold", "To set up Scaffold, select your development directory for Zotero translators.\n\n" + "In most cases, this should be a git clone of the zotero/translators GitHub repository.", buttonFlags, "Choose Directory…", Zotero.getString('general.cancel'), "Open GitHub Repo", null, {} ); // Revert to home directory if (index == 0) { let dir = await this.setTranslatorsDirectory(); if (dir) { return true; } } else if (index == 2) { Zotero.launchURL('https://github.com/zotero/translators'); } return false; }; this.setTranslatorsDirectory = async function () { var fp = new FilePicker(); var oldPath = Zotero.Prefs.get('scaffold.translatorsDir'); if (oldPath) { fp.displayDirectory = oldPath; } fp.init( window, "Select Translators Directory", fp.modeGetFolder ); fp.appendFilters(fp.filterAll); if (await fp.show() != fp.returnOK) { return false; } var path = PathUtils.normalize(fp.file); if (oldPath == path) { return false; } Zotero.Prefs.set('scaffold.translatorsDir', path); Scaffold_Translators.load(true); // async return path; }; this.reloadTranslators = async function () { Zotero.debug('Reloading translators quietly'); let { numLoaded, numDeleted } = await Scaffold_Translators.load(true); if (numLoaded) { _logOutput(`${numLoaded} ${Zotero.Utilities.pluralize(numLoaded, 'translator')} updated.`); } if (numDeleted) { _logOutput(`${numDeleted} ${Zotero.Utilities.pluralize(numDeleted, 'translator')} deleted.`); } let translatorID = document.getElementById('textbox-translatorID').value; let modifiedTime = Scaffold_Translators.getModifiedTime(translatorID); if (modifiedTime && modifiedTime > _lastModifiedTime) { let ps = Services.prompt; let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING; var index = ps.confirmEx(null, "Scaffold", "Translator code changed externally. Discard unsaved changes and reload?", buttonFlags, Zotero.getString('general.no'), Zotero.getString('general.yes'), null, null, {} ); if (index == 1) { await this.load(translatorID); } else { _lastModifiedTime = modifiedTime; } } _updateTitle(); }; this.initImportEditor = function () { let monaco = _editors.importGlobal, editor = _editors.import; // Nothing to do here }; this.initCodeEditor = async function () { let monaco = _editors.codeGlobal, editor = _editors.code; // For some reason, even if we explicitly re-set the default model's language to JavaScript, // Monaco still treats it as TypeScript. Recreating the model manually fixes the issue. let model = monaco.editor.createModel('', 'javascript', monaco.Uri.parse('inmemory:///translator.js')); editor.setModel(model); editor.updateOptions({ lineNumbers: num => num + _linesOfMetadata - 1, }); monaco.languages.registerCodeLensProvider('javascript', this.createRunCodeLensProvider(monaco, editor)); monaco.languages.registerHoverProvider('javascript', this.createHoverProvider(monaco, editor)); monaco.languages.registerCompletionItemProvider('javascript', this.createCompletionProvider(monaco, editor)); let tsLib = await Zotero.File.getContentsAsync( PathUtils.join(Scaffold_Translators.getDirectory(), 'index.d.ts')); let tsLibPath = 'ts:filename/index.d.ts'; monaco.languages.typescript.javascriptDefaults.addExtraLib(tsLib, tsLibPath); // this would allow peeking: // monaco.editor.createModel(tsLib, 'typescript', monaco.Uri.parse(tsLibPath)); // but it doesn't currently seem to work }; this.initTestsEditor = function () { let monaco = _editors.testsGlobal, editor = _editors.tests; monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ validate: true, allowComments: false, trailingCommas: false, schemaValidation: 'error' }); editor.getModel().updateOptions({ insertSpaces: false }); editor.getModel().onDidChangeContent((_) => { this.populateTests(); }); editor.updateOptions({ links: false, stickyScroll: { enabled: false } }); monaco.languages.registerCodeLensProvider('json', this.createTestCodeLensProvider(monaco, editor)); }; this.createRunCodeLensProvider = function (monaco, editor) { let runMethod = editor.addCommand(0, (_ctx, method) => this.run(method), ''); return { provideCodeLenses: (model, _token) => { let methodRe = '(async\\s+)?\\bfunction\\s+(detect|do)(Web|Import|Export|Search)\\s*\\('; let lenses = []; let matches = model.findMatches( methodRe, /* searchOnlyEditableRange: */ false, /* isRegex: */ true, /* matchCase: */ true, /* wordSeparators: */ null, /* captureMatches: */ true ); for (let match of matches) { let line = match.matches[0]; let methodName = line.match(/function\s+(\w*)/)[1]; lenses.push({ range: match.range, command: { id: runMethod, title: `Run ${methodName}`, arguments: [methodName] } }); } return { lenses, dispose() {} }; }, resolveCodeLens: (_model, codeLens, _token) => codeLens }; }; this.createHoverProvider = function (monaco, _editor) { let uuidRe = `(["'])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\\1`; let types = Zotero.ItemTypes.getTypes().map(t => t.name); let itemTypeRe = `(["'])(${types.join('|')})\\1`; return { provideHover: (model, position) => { let lineRange = new monaco.Range( position.lineNumber, model.getLineMinColumn(position.lineNumber), position.lineNumber, model.getLineMaxColumn(position.lineNumber) ); let matches = model.findMatches( uuidRe, /* searchScope: */ lineRange, /* isRegex: */ true, /* matchCase: */ true, /* wordSeparators: */ null, /* captureMatches: */ true ); for (let uuidMatch of matches) { if (!uuidMatch.range.containsPosition(position)) continue; let translator = _translatorProvider.get(uuidMatch.matches[2]); if (translator) { let metadataJSON = JSON.stringify(translator.metadata, null, '\t'); return { range: uuidMatch.range, contents: [ { value: `**${translator.label}**` }, { value: '```json\n' + metadataJSON + '\n```' } ] }; } } matches = model.findMatches( itemTypeRe, /* searchScope: */ lineRange, /* isRegex: */ true, /* matchCase: */ true, /* wordSeparators: */ null, /* captureMatches: */ true ); for (let itemTypeMatch of matches) { if (!itemTypeMatch.range.containsPosition(position)) continue; let fieldsJSON = this.listFieldsForItemType(itemTypeMatch.matches[2]); return { range: itemTypeMatch.range, contents: [ { value: '```json\n' + fieldsJSON + '\n```' } ] }; } return null; } }; }; this.createTestCodeLensProvider = function (monaco, editor) { let runTestsCommand = editor.addCommand( 0, (_ctx, testIndices) => { let tests; try { tests = JSON.parse(editor.getValue()); } catch (e) { _logOutput('Error parsing tests:\n' + e); } if (testIndices) { tests = testIndices.map(index => tests[index]); } this.runTests(tests); }, ''); let updateTestsCommand = editor.addCommand( 0, async (_ctx, testIndices) => { testIndices = testIndices || Object.keys(allTests); try { var allTests = JSON.parse(editor.getValue()); } catch (e) { _logOutput('Error parsing tests:\n' + e); return; } let tests = testIndices.map(index => allTests[index]); await this.updateTests(tests, (newTest) => { allTests[testIndices.shift()] = newTest; }); _writeTestsToPane(allTests); }, ''); return { provideCodeLenses: (model, _token) => { let lenses = []; let firstChar = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }; lenses.push({ range: firstChar, command: { id: runTestsCommand, title: 'Run All' } }); lenses.push({ range: firstChar, command: { id: updateTestsCommand, title: 'Run and Update All' } }); for (let [testIndex, range] of _findTestObjectTops(monaco, model).entries()) { lenses.push({ range: range, command: { id: runTestsCommand, title: 'Run', arguments: [[testIndex]] } }); lenses.push({ range: range, command: { id: updateTestsCommand, title: 'Run and Update', arguments: [[testIndex]] } }); } return { lenses, dispose() {} }; }, resolveCodeLens: (_model, codeLens, _token) => codeLens }; }; this.createCompletionProvider = function (monaco, editor) { let suggestions = null; return { provideCompletionItems(model, position) { let prefixText = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column }); if (/setTranslator\([^)]*$/.test(prefixText)) { let word = model.getWordUntilPosition(position); let range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn }; if (!suggestions || _needRebuildTranslatorSuggestions) { // Cache the suggestions minus the range field suggestions = [...Scaffold_Translators._translators.entries()].map(([id, meta]) => { return { label: `${meta.translator.label}: '${id}'`, kind: monaco.languages.CompletionItemKind.Constant, insertText: `'${id}'` }; }); _needRebuildTranslatorSuggestions = false; } // Add the range to each suggestion before returning return { suggestions: suggestions.map(s => ({ ...s, range })) }; } return { suggestions: [] }; } }; }; this.updateModelMarkers = function (translatorPath) { runESLint(translatorPath) .then(eslintOutputToModelMarkers) .then(markers => _editors.codeGlobal.editor.setModelMarkers(_editors.code.getModel(), 'eslint', markers)); }; this.setFontSize = function (size) { var sizeWithPX = size + 'px'; _editors.import.updateOptions({ fontSize: size + 1 }); // editor font needs to be a little bigger _editors.code.updateOptions({ fontSize: size + 1 }); _editors.tests.updateOptions({ fontSize: size + 1 }); document.getElementById("scaffold-pane").style.fontSize = sizeWithPX; if (size == 11) { // for the default value 11, clear the prefs Zotero.Prefs.clear('scaffold.fontSize'); } else { Zotero.Prefs.set("scaffold.fontSize", size); } }; this.increaseFontSize = function () { var currentSize = Zotero.Prefs.get("scaffold.fontSize") || 11; this.setFontSize(currentSize + 2); }; this.decreaseFontSize = function () { var currentSize = Zotero.Prefs.get("scaffold.fontSize") || 11; this.setFontSize(currentSize - 2); }; this.newTranslator = async function (skipSavePrompt) { if (!skipSavePrompt && _editors.code.getValue()) { let ps = Services.prompt; let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING; let label = document.getElementById('textbox-label').value; let index = ps.confirmEx(null, "Scaffold", `Do you want to save the changes you made to ${label}?`, buttonFlags, Zotero.getString('general.no'), Zotero.getString('general.yes'), null, null, {} ); if (index == 1 && !await this.save()) { return; } } this.generateTranslatorID(); document.getElementById('textbox-label').value = 'Untitled'; document.getElementById('textbox-creator').value = document.getElementById('textbox-target').value = document.getElementById('textbox-target-all').value = document.getElementById('textbox-configOptions').value = document.getElementById('textbox-displayOptions').value = document.getElementById('textbox-hidden-prefs').value = ''; document.getElementById('textbox-minVersion').value = '5.0'; document.getElementById('textbox-priority').value = '100'; document.getElementById('checkbox-import').checked = false; document.getElementById('checkbox-export').checked = false; document.getElementById('checkbox-web').checked = true; document.getElementById('checkbox-search').checked = false; _editors.code.setValue(''); _editors.tests.setValue(''); this.populateTests(); document.getElementById('textbox-label').focus(); _showTab('metadata'); _updateTitle(); }; /* * load translator */ this.load = async function (translatorID) { await _translatorsLoadedPromise; var translator; if (translatorID === undefined) { var io = {}; io.translatorProvider = _translatorProvider; io.url = io.rootUrl = _browser.currentURI.spec; window.openDialog("chrome://scaffold/content/load.xhtml", "_blank", "chrome,centerscreen,modal,resizable=no", io); translator = io.dataOut; } else { translator = _translatorProvider.get(translatorID); } // No translator was selected in the dialog. if (!translator) return false; for (var id in _propertyMap) { document.getElementById(id).value = translator[_propertyMap[id]] || ""; } //Strip JSON metadata var code = await _translatorProvider.getCodeForTranslator(translator); var lastUpdatedIndex = code.indexOf('"lastUpdated"'); var header = code.substr(0, lastUpdatedIndex + 50); var m = /^\s*{[\S\s]*?}\s*?[\r\n]+/.exec(header); var fixedCode = code.substr(m[0].length); // adjust the first line number when there are an unusual number of metadata lines _linesOfMetadata = m[0].split('\n').length; // load tests into test editing pane _loadTestsFromTranslator(fixedCode); // clear selection _editors.tests.setSelection({ startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: 1 }); // Set up the test running pane this.populateTests(); // remove tests from the translator code before loading into the code editor var testStart = fixedCode.indexOf("/** BEGIN TEST CASES **/"); var testEnd = fixedCode.indexOf("/** END TEST CASES **/"); if (testStart !== -1 && testEnd !== -1) fixedCode = fixedCode.substr(0, testStart) + fixedCode.substr(testEnd + 23); // Convert whitespace to tabs _editors.code.setValue(normalizeWhitespace(fixedCode.trimEnd())); // Then go to line 1 _editors.code.setPosition({ lineNumber: 1, column: 1 }); // Set Test Input editor language based on translator metadata let language = 'plaintext'; if (translator.translatorType & Zotero.Translator.TRANSLATOR_TYPES.import) { if (translator.target.includes('json')) { language = 'json'; } else if (translator.target.includes('xml')) { language = 'xml'; } } else if (translator.translatorType & Zotero.Translator.TRANSLATOR_TYPES.search) { language = 'json'; } _editors.importGlobal.editor.setModelLanguage(_editors.import.getModel(), language); _editors.import.setPosition({ lineNumber: 1, column: 1 }); // Reset configOptions and displayOptions before loading document.getElementById('textbox-configOptions').value = ''; document.getElementById('textbox-displayOptions').value = ''; if (translator.configOptions) { let configOptions = JSON.stringify(translator.configOptions); if (configOptions != '{}') { document.getElementById('textbox-configOptions').value = configOptions; } } if (translator.displayOptions) { let displayOptions = JSON.stringify(translator.displayOptions); if (displayOptions != '{}') { document.getElementById('textbox-displayOptions').value = displayOptions; } } // get translator type; might as well have some fun here var type = translator.translatorType; var types = ["import", "export", "web", "search"]; for (var i = 2; i <= 16; i *= 2) { var mod = type % i; document.getElementById('checkbox-' + types.shift()).checked = !!mod; if (mod) type -= mod; } this.updateModelMarkers(translator.path); _lastModifiedTime = new Date().getTime(); Zotero.Prefs.set('scaffold.lastTranslatorID', translator.translatorID); _updateTitle(); return true; }; function _getMetadataObject() { var metadata = { translatorID: document.getElementById('textbox-translatorID').value, label: document.getElementById('textbox-label').value, creator: document.getElementById('textbox-creator').value, target: document.getElementById('textbox-target').value, minVersion: document.getElementById('textbox-minVersion').value, maxVersion: '', priority: parseInt(document.getElementById('textbox-priority').value) }; // optional (hidden) metadata if (document.getElementById('textbox-target-all').value) { metadata.targetAll = document.getElementById('textbox-target-all').value; } if (document.getElementById('textbox-hidden-prefs').value) { metadata.hiddenPrefs = document.getElementById('textbox-hidden-prefs').value; } if (document.getElementById('textbox-configOptions').value) { metadata.configOptions = JSON.parse(document.getElementById('textbox-configOptions').value); } if (document.getElementById('textbox-displayOptions').value) { metadata.displayOptions = JSON.parse(document.getElementById('textbox-displayOptions').value); } // no option for this metadata.inRepository = true; metadata.translatorType = 0; if (document.getElementById('checkbox-import').checked) { metadata.translatorType += 1; } if (document.getElementById('checkbox-export').checked) { metadata.translatorType += 2; } if (document.getElementById('checkbox-web').checked) { metadata.translatorType += 4; } if (document.getElementById('checkbox-search').checked) { metadata.translatorType += 8; } if (document.getElementById('checkbox-web').checked) { // save browserSupport only for web tranlsators metadata.browserSupport = "gcsibv"; } var date = new Date(); metadata.lastUpdated = date.getUTCFullYear() + "-" + Zotero.Utilities.lpad(date.getUTCMonth() + 1, '0', 2) + "-" + Zotero.Utilities.lpad(date.getUTCDate(), '0', 2) + " " + Zotero.Utilities.lpad(date.getUTCHours(), '0', 2) + ":" + Zotero.Utilities.lpad(date.getUTCMinutes(), '0', 2) + ":" + Zotero.Utilities.lpad(date.getUTCSeconds(), '0', 2); return metadata; } /* * save translator to database */ this.save = async function (updateZotero) { var code = _editors.code.getValue(); var tests = _editors.tests.getValue().trim(); if (!tests || tests == '[]') tests = '[\n]'; // eslint wants a line break between the brackets code = code.trimEnd() + '\n\n/** BEGIN TEST CASES **/\nvar testCases = ' + tests + '\n/** END TEST CASES **/'; var metadata = _getMetadataObject(); if (metadata.label === "Untitled") { _logOutput("Can't save an untitled translator."); return; } var path = await _translatorProvider.save(metadata, code); if (updateZotero) { await Zotero.Translators.save(metadata, code); await Zotero.Translators.reinit(); } _lastModifiedTime = new Date().getTime(); this.updateModelMarkers(path); await this.reloadTranslators(); }; /** * If an editor is focused, trigger `editorTrigger` in it. * Otherwise, run `fallbackCommand`. */ this.trigger = function (editorTrigger, fallbackCommand) { let activeEditor = _editors[_getActiveEditorName()]; if (activeEditor) { activeEditor.trigger('Scaffold.trigger', editorTrigger); } else { // editMenuOverlay.js goDoCommand(fallbackCommand); } }; this.handleTabSelect = function (event) { if (event.target.tagName != 'tabpanels') { return; } // Focus editor when switching to tab var tab = document.getElementById('tabs').selectedItem.id.match(/^tab-(.+)$/)[1]; switch (tab) { case 'import': case 'code': case 'tests': // the select event's default behavior is to focus the selected tab. // we don't want to prevent *all* of the event's default behavior, // but we do want to focus the editor instead of the tab. // so this stupid hack waits 10 ms for event processing to finish // before focusing the editor. setTimeout(() => { document.getElementById(`editor-${tab}`).focus(); _editors[tab].focus(); }, 10); break; } let codeTabBroadcaster = document.getElementById('code-tab-only'); if (tab == 'code') { codeTabBroadcaster.removeAttribute('disabled'); } else { codeTabBroadcaster.setAttribute('disabled', true); } }; this.handleTestSelect = function (event) { let selected = event.target.selectedItems[0]; if (!selected) return; let editImport = document.getElementById('testing_editImport'); let openURL = document.getElementById('testing_openURL'); if (selected.dataset.testType == 'web') { editImport.setAttribute('disabled', true); openURL.removeAttribute('disabled'); } else { editImport.removeAttribute('disabled'); openURL.setAttribute('disabled', true); } }; this.listFieldsForItemType = function (itemType) { var outputObject = {}; outputObject.itemType = Zotero.ItemTypes.getName(itemType); var typeID = Zotero.ItemTypes.getID(itemType); var fieldList = Zotero.ItemFields.getItemTypeFields(typeID); for (let field of fieldList) { var key = Zotero.ItemFields.getName(field); let fieldLocalizedName = Zotero.ItemFields.getLocalizedString(field); outputObject[key] = fieldLocalizedName; } var creatorList = Zotero.CreatorTypes.getTypesForItemType(typeID); var creators = []; for (let creatorType of creatorList) { creators.push({ firstName: "", lastName: "", creatorType: creatorType.name, fieldMode: true }); } outputObject.creators = creators; outputObject.attachments = [{ url: "", document: "", title: "", mimeType: "" }]; outputObject.tags = [{ tag: "" }]; outputObject.notes = [{ note: "" }]; outputObject.seeAlso = []; return JSON.stringify(outputObject, null, '\t'); }; /* * add template code */ this.addTemplate = async function (template, second) { switch (template) { case "templateNewItem": document.getElementById('output').value = this.listFieldsForItemType(second); break; case "templateAllTypes": var typeNames = Zotero.ItemTypes.getTypes().map(t => t.name); document.getElementById('output').value = JSON.stringify(typeNames, null, '\t'); break; default: { //newWeb, scrapeEM, scrapeRIS, scrapeBibTeX, scrapeMARC //These names in the XUL file have to match the file names in template folder. let value = Zotero.File.getContentsFromURL(`chrome://scaffold/content/templates/${template}.js`); value = value.replace('$$YEAR$$', new Date().getFullYear()); let cursorOffset = value.indexOf('$$CURSOR$$'); value = value.replace('$$CURSOR$$', ''); var selection = _editors.code.getSelection(); var id = { major: 1, minor: 1 }; var op = { identifier: id, range: selection, text: value, forceMoveMarkers: true }; _editors.code.executeEdits("addTemplate", [op]); if (cursorOffset != -1) { _editors.code.setPosition(_editors.code.getModel().getPositionAt(cursorOffset)); } break; } } }; /* * run translator */ this.run = async function (functionToRun) { if (document.getElementById('textbox-label').value == 'Untitled') { _logOutput("Translator title not set"); return; } _clearOutput(); // Handle generic call run('detect'), run('do') if (functionToRun == "detect" || functionToRun == "do") { if (document.getElementById('checkbox-web').checked && _browser.currentURI.spec != 'about:blank') { functionToRun += 'Web'; } else if (document.getElementById('checkbox-import').checked && _editors.import.getValue().trim()) { functionToRun += 'Import'; } else if (document.getElementById('checkbox-export').checked && functionToRun == 'do') { functionToRun += 'Export'; } else if (document.getElementById('checkbox-search').checked && _editors.import.getValue().trim()) { functionToRun += 'Search'; } else { _logOutput('No appropriate detect/do function to run'); return; } } _logOutput(`Running ${functionToRun}`); let input = await _getInput(functionToRun); if (functionToRun.endsWith('Export')) { let numItems = Zotero.getActiveZoteroPane().getSelectedItems().length; _logOutput(`Exporting ${numItems} item${numItems == 1 ? '' : 's'} selected in library`); _run(functionToRun, input, _selectItems, () => {}, _getTranslatorsHandler(functionToRun), _myExportDone); } else { _run(functionToRun, input, _selectItems, _myItemDone, _getTranslatorsHandler(functionToRun)); } }; /* * run translator in given mode with given input */ async function _run(functionToRun, input, selectItems, itemDone, detectHandler, done) { let translate; let isRemoteWeb = false; if (functionToRun == "detectWeb" || functionToRun == "doWeb") { translate = new RemoteTranslate({ disableErrorReporting: true }); isRemoteWeb = true; if (!_testTargetRegex(input)) { _logOutput("Target did not match " + _getCurrentURI(input)); if (done) { done(); } return; } await translate.setBrowser(input); } else if (functionToRun == "detectImport" || functionToRun == "doImport") { translate = new Zotero.Translate.Import(); translate.setString(input); } else if (functionToRun == "doExport") { translate = new Zotero.Translate.Export(); translate.setItems(input); } else if (functionToRun == "detectSearch" || functionToRun == "doSearch") { translate = new Zotero.Translate.Search(); translate.setSearch(input); } translate.setTranslatorProvider(_translatorProvider); translate.setHandler("error", _error); translate.setHandler("debug", _debug); if (done) { translate.setHandler("done", done); } // get translator var translator = _getTranslatorFromPane(); if (functionToRun.startsWith('detect')) { if (isRemoteWeb) { try { translate.setTranslator(translator); detectHandler(translate, await translate.detect()); } finally { translate.dispose(); } } else { // don't let target prevent translator from operating translator.target = null; // generate sandbox translate.setHandler("translators", detectHandler); // internal hack to call detect on this translator translate._potentialTranslators = [translator]; translate._foundTranslators = []; translate._currentState = "detect"; translate._detect(); } } else if (isRemoteWeb) { try { translate.setHandler("select", selectItems); translate.setTranslator(translator); let items = await translate.translate({ libraryID: false }); if (items) { for (let item of items) { itemDone(translate, item); } } } finally { translate.dispose(); } } else { // don't let the detectCode prevent the translator from operating translator.detectCode = null; translate.setTranslator(translator); translate.setHandler("select", selectItems); translate.clearHandlers("itemDone"); translate.clearHandlers("collectionDone"); translate.setHandler("itemDone", itemDone); translate.setHandler("collectionDone", function (obj, collection) { _logOutput("Collection: " + collection.name + ", " + collection.children.length + " items"); }); translate.translate({ // disable saving to database libraryID: false }); } } this.runTranslatorOrTests = async function () { if (document.getElementById('tabs').selectedItem.id == 'tab-tests' && document.activeElement.id == 'testing-listbox') { this.runSelectedTests(); } else { this.run('do'); } }; /* * generate translator GUID */ this.generateTranslatorID = function () { document.getElementById("textbox-translatorID").value = _generateGUID(); }; /** * Test target regular expression against document URL and log the result */ this.logTargetRegex = async function () { _logOutput(_testTargetRegex(_browser)); }; /** * Test target regular expression against document URL and return the result */ function _testTargetRegex(browser) { var url = _getCurrentURI(browser); try { var targetRe = new RegExp(document.getElementById('textbox-target').value, "i"); } catch (e) { _logOutput("Regex parse error:\n" + JSON.stringify(e, null, "\t")); } return targetRe.test(url); } /* * called to select items */ function _selectItems(obj, itemList) { var io = { dataIn: itemList, dataOut: null }; window.openDialog("chrome://zotero/content/ingester/selectitems.xhtml", "_blank", "chrome,modal,centerscreen,resizable=yes", io); return io.dataOut; } /* * called if an error occurs */ function _error(_obj, _error) { // stub: this handler doesn't actually seem to get called by the current // translation architecture when a translator throws } /* * logs translator output (instead of logging in the console) */ function _debug(obj, string) { _logOutput(string); } /* * logs item output */ function _myItemDone(obj, item) { delete item.id; if (Array.isArray(item.attachments)) { for (let attachment of item.attachments) { if (attachment.document) { attachment.mimeType = 'text/html'; attachment.url = attachment.document.location?.href; delete attachment.document; } delete attachment.complete; } } _logOutput("Returned item:\n" + Zotero_TranslatorTester._generateDiff(item, _sanitizeItemForDisplay(item))); } /* * logs string output */ function _myExportDone({ string }, worked) { if (worked) { Zotero.debug("Export successful"); _logOutput("Returned string:\n" + string); } else { Zotero.debug("Export failed"); } } /* * returns a 'translators' handler that prints information from detectCode to window */ function _getTranslatorsHandler(fnName) { return (obj, translators) => { if (translators && translators.length != 0) { if (translators[0].itemType === true) { _logOutput(`${fnName} matched`); } else { _logOutput(`${fnName} returned type "${translators[0].itemType}"`); } } else { _logOutput(`${fnName} did not match`); } }; } /* * logs debug info (instead of console) */ function _logOutput(string) { var date = new Date(); var output = document.getElementById('output'); if (typeof string != "string") { string = fix2028(Zotero.Utilities.varDump(string)); } // Put off actually building the log message and appending it to the console until the next animation frame // so as not to slow down translation with repeated layout recalculations triggered by appending text // and accessing scrollHeight // requestAnimationFrame() callbacks are guaranteed to be called in the order they were set requestAnimationFrame(() => { if (output.value) output.value += "\n"; output.value += Zotero.Utilities.lpad(date.getHours(), '0', 2) + ":" + Zotero.Utilities.lpad(date.getMinutes(), '0', 2) + ":" + Zotero.Utilities.lpad(date.getSeconds(), '0', 2) + " " + string.replace(/\n/g, "\n "); // move to end output.scrollTop = output.scrollHeight; }); } /* * gets import text for import translator */ function _getImport() { var text = _editors.import.getValue(); return text; } /* * gets items to export for export translator */ function _getExport() { return Zotero.getActiveZoteroPane().getSelectedItems(); } /* * gets search JSON object for search translator */ function _getSearch() { return JSON.parse(_getImport()); } /* * gets appropriate input for the given type/method */ async function _getInput(typeOrMethod) { typeOrMethod = typeOrMethod.toLowerCase(); if (typeOrMethod.endsWith('web')) { return _browser; } else if (typeOrMethod.endsWith('import')) { return _getImport(); } else if (typeOrMethod.endsWith('export')) { return _getExport(); } else if (typeOrMethod.endsWith('search')) { return _getSearch(); } return null; } /* * transfers metadata to the translator object * Replicated from translator.js */ function _metaToTranslator(translator, metadata) { var props = ["translatorID", "translatorType", "label", "creator", "target", "minVersion", "maxVersion", "priority", "lastUpdated", "inRepository", "configOptions", "displayOptions", "browserSupport", "targetAll", "hiddenPrefs"]; for (var i = 0; i < props.length; i++) { translator[props[i]] = metadata[props[i]]; } if (!translator.configOptions) translator.configOptions = {}; if (!translator.displayOptions) translator.displayOptions = {}; if (!translator.browserSupport) translator.browserSupport = "g"; } /* * gets translator data from the metadata pane */ function _getTranslatorFromPane() { //create a barebones translator var translator = {}; var metadata = _getMetadataObject(true); //copy metadata into the translator object _metaToTranslator(translator, metadata); metadata = JSON.stringify(metadata, null, "\t") + ";\n"; translator.code = metadata + "\n" + _editors.code.getValue(); // make sure translator gets run in browser in Zotero >2.1 if (Zotero.Translator.RUN_MODE_IN_BROWSER) { translator.runMode = Zotero.Translator.RUN_MODE_IN_BROWSER; } return translator; } /* * loads the translator's tests from the translator code */ function _loadTestsFromTranslator(code) { var testStart = code.indexOf("/** BEGIN TEST CASES **/"); var testEnd = code.indexOf("/** END TEST CASES **/"); if (testStart !== -1 && testEnd !== -1) { code = code.substring(testStart + 24, testEnd); } code = code.replace(/var testCases = /, '').trim(); // The JSON parser doesn't like final semicolons if (code.lastIndexOf(';') == code.length - 1) { code = code.slice(0, -1); } try { var testObject = JSON.parse(code); } catch (e) { testObject = []; } // We don't use _writeTestsToPane here because we want to avoid _stringifyTests, // which assumes valid test data and will choke on/incorrectly "fix" // weird inputs that the user might want to fix manually. _writeToEditor(_editors.tests, JSON.stringify(testObject, null, "\t")); } /* * loads the translator's tests from the pane */ function _loadTestsFromPane() { try { return JSON.parse(_editors.tests.getValue().trim() || '[]'); } catch (e) { return null; } } /** * Write text to an editor, overwriting its current value. * This operation can be undone. */ function _writeToEditor(editor, text) { editor.executeEdits('_writeToEditor', [{ range: editor.getModel().getFullModelRange(), text }]); } /* * writes tests back into the translator */ function _writeTestsToPane(tests) { _writeToEditor(_editors.tests, _stringifyTests(tests)); } function _confirmCreateExpectedFailTest() { return Services.prompt.confirm(null, 'Detection Failed', 'Add test ensuring that detection always fails on this page?'); } /** * Mimics most of the behavior of Zotero.Item#fromJSON. Most importantly, * extracts valid fields from item.extra and inserts invalid fields into * item.extra. * * For example, * { itemType: 'journalArticle', extra: 'DOI: foo' } * becomes * { itemType: 'journalArticle', DOI: 'foo' } * and * { itemType: 'book', DOI: 'foo' } * becomes * { itemType: 'book', extra: 'DOI: foo' } * * @param {any} item * @return {any} */ function _sanitizeItemForDisplay(item) { // try to convert to JSON and back to get rid of undesirable undeletable elements; this may fail try { item = JSON.parse(JSON.stringify(item)); } catch (e) {} let itemTypeID = Zotero.ItemTypes.getID(item.itemType); var setFields = new Set(); var { itemType, fields: extraFields, /* creators: extraCreators, */ extra } = Zotero.Utilities.Internal.extractExtraFields( item.extra || '', null, Object.keys(item) // TEMP until we move creator lines to real creators .concat('creators') ); // If a different item type was parsed out of Extra, use that instead if (itemType && item.itemType != itemType) { item.itemType = itemType; itemTypeID = Zotero.ItemTypes.getID(itemType); } for (let [field, value] of extraFields) { item[field] = value; setFields.add(field); extraFields.delete(field); } for (let [field, val] of Object.entries(item)) { switch (field) { case 'itemType': case 'accessDate': case 'creators': case 'attachments': case 'notes': case 'seeAlso': break; case 'extra': // We set this later delete item[field]; break; case 'tags': item[field] = Zotero.Translate.Base.prototype._cleanTags(val); break; // Item fields default: { let fieldID = Zotero.ItemFields.getID(field); if (!fieldID) { if (typeof val == 'string') { extraFields.set(field, val); break; } delete item[field]; continue; } // Convert to base-mapped field if necessary, so that setFields has the base-mapped field // when it's checked for values from getUsedFields() below let origFieldID = fieldID; let origField = field; fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID; if (origFieldID != fieldID) { field = Zotero.ItemFields.getName(fieldID); } if (!Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) { extraFields.set(field, val); delete item[field]; continue; } if (origFieldID != fieldID) { item[field] = item[origField]; delete item[origField]; } setFields.add(field); } } } if (extraFields.size) { for (let field of setFields.keys()) { let baseField; if (Zotero.ItemFields.isBaseField(field)) { baseField = field; } else if (Zotero.ItemFields.isValidForType(Zotero.ItemFields.getID(field), itemTypeID)) { let baseFieldID = Zotero.ItemFields.getBaseIDFromTypeAndField(itemTypeID, field); if (baseFieldID) { baseField = baseFieldID; } } if (baseField) { let mappedFieldNames = Zotero.ItemFields.getTypeFieldsFromBase(baseField, true); for (let mappedField of mappedFieldNames) { if (extraFields.has(mappedField)) { extraFields.delete(mappedField); } } } } // // Deduplicate remaining Extra fields // // For each invalid-for-type base field, remove any mapped fields with the same value let baseFields = []; for (let field of extraFields.keys()) { if (Zotero.ItemFields.getID(field) && Zotero.ItemFields.isBaseField(field)) { baseFields.push(field); } } for (let baseField of baseFields) { let value = extraFields.get(baseField); let mappedFieldNames = Zotero.ItemFields.getTypeFieldsFromBase(baseField, true); for (let mappedField of mappedFieldNames) { if (extraFields.has(mappedField) && extraFields.get(mappedField) === value) { extraFields.delete(mappedField); } } } // Remove Type-mapped fields from Extra, since 'Type' is mapped to Item Type by citeproc-js // and Type values mostly aren't going to be useful for item types without a Type-mapped field. let typeFieldNames = Zotero.ItemFields.getTypeFieldsFromBase('type', true) .concat('audioFileType'); for (let typeFieldName of typeFieldNames) { if (extraFields.has(typeFieldName)) { extraFields.delete(typeFieldName); } } } if (extra || extraFields.size) { item.extra = Zotero.Utilities.Internal.combineExtraFields(extra, extraFields); } return item; } /* sanitizes all items in a test */ function _sanitizeItemsInTest(test) { if (test.items && typeof test.items != 'string' && test.items.length) { for (var i = 0, n = test.items.length; i < n; i++) { test.items[i] = Zotero_TranslatorTester._sanitizeItem(test.items[i]); } } return test; } /* stringifies an array of tests * Output is the same as JSON.stringify (with pretty print), except that * Zotero.Item objects are stringified in a deterministic manner (mostly): * * Certain important fields are placed at the top of the object * * Certain less-frequently used fields are placed at the bottom * * Remaining fields are sorted alphabetically * * tags are always sorted alphabetically * * Some fields, like those inside creator objects, notes, etc. are not sorted */ function _stringifyTests(value, level) { function processRow(key, value) { let val = _stringifyTests(value, level + 1); if (val === undefined) return undefined; val = val.replace(/\n/g, "\n\t"); return JSON.stringify('' + key) + ': ' + val; } if (!level) level = 0; if (typeof (value) == 'function' || typeof (value) == 'undefined' || value === null) { return level ? undefined : ''; } if (typeof (value) !== 'object') return JSON.stringify(value, null, "\t"); if (Array.isArray(value)) { let str = '['; for (let i = 0; i < value.length; i++) { let val = _stringifyTests(value[i], level + 1); if (val === undefined) val = 'undefined'; else val = val.replace(/\n/g, "\n\t"); // Indent str += (i ? ',' : '') + "\n\t" + val; } return str + (str.length > 1 ? "\n]" : ']'); } if (!value.itemType) { // Not a Zotero.Item object let str = '{'; if (level < 2 && value.items) { // Test object. Arrange properties in set order let order = ['type', 'url', 'input', 'defer', 'detectedItemType', 'items']; for (let i = 0; i < order.length; i++) { let val = processRow(order[i], value[order[i]]); if (val === undefined) continue; str += (str.length > 1 ? ',' : '') + '\n\t' + val; } } else { for (let i in value) { let val = processRow(i, value[i]); if (val === undefined) continue; str += (str.length > 1 ? ',' : '') + '\n\t' + val; } } return str + (str.length > 1 ? "\n}" : '}'); } // Zotero.Item object const topFields = ['itemType', 'title', 'caseName', 'nameOfAct', 'subject', 'creators', 'date', 'dateDecided', 'issueDate', 'dateEnacted']; const bottomFields = ['attachments', 'tags', 'notes', 'seeAlso']; let otherFields = Object.keys(value); let presetFields = topFields.concat(bottomFields); for (let i = 0; i < presetFields.length; i++) { let j = otherFields.indexOf(presetFields[i]); if (j == -1) continue; otherFields.splice(j, 1); } let fields = topFields.concat(otherFields.sort()).concat(bottomFields); let str = '{'; for (let i = 0; i < fields.length; i++) { let rawVal = value[fields[i]]; if (!rawVal) continue; let val; if (fields[i] == 'tags') { val = _stringifyTests(rawVal.sort(), level + 1); } else { val = _stringifyTests(rawVal, level + 1); } if (val === undefined) continue; val = val.replace(/\n/g, "\n\t"); str += (str.length > 1 ? ',' : '') + "\n\t" + JSON.stringify(fields[i]) + ': ' + val; } return str + "\n}"; } /* * adds a new test from the current input/translator * web or import only for now */ this.saveTestFromCurrent = async function (type) { _logOutput(`Creating ${type} test...`); try { let test = await this.constructTestFromCurrent(type); _writeTestsToPane([..._loadTestsFromPane(), test]); } catch (e) { _logOutput('Creation failed'); return; } _showTab('tests'); let listBox = document.getElementById('testing-listbox'); listBox.selectedIndex = listBox.getRowCount() - 1; listBox.focus(); }; this.constructTestFromCurrent = async function (type) { _clearOutput(); if ((type === "web" && !document.getElementById('checkbox-web').checked) || (type === "import" && !document.getElementById('checkbox-import').checked) || (type === "search" && !document.getElementById('checkbox-search').checked)) { _logOutput(`Translator does not support ${type} tests`); return Promise.reject(new Error()); } if (type == 'export') { return Promise.reject(new Error(`Test of type export cannot be created`)); } let input = await _getInput(type); if (type == "web") { let translate = new RemoteTranslate({ disableErrorReporting: true }); try { await translate.setBrowser(_browser); await translate.setTranslatorProvider(_translatorProvider); translate.setTranslator(_getTranslatorFromPane()); translate.setHandler("debug", _debug); translate.setHandler("error", _error); translate.setHandler("newTestDetectionFailed", _confirmCreateExpectedFailTest); let newTest = await translate.newTest(); if (!newTest) { throw new Error('Creation failed'); } newTest = _sanitizeItemsInTest(newTest); return newTest; } finally { translate.dispose(); } } else if (type == "import" || type == "search") { let test = { type, input: input, items: [] }; // TranslatorTester doesn't handle these correctly, so we do it manually return new Promise( resolve => _run(`do${type == 'import' ? 'Import' : 'Search'}`, input, null, function (obj, item) { if (item) { test.items.push(Zotero_TranslatorTester._sanitizeItem(item)); } }, null, function () { resolve(test); }) ); } return Promise.reject(new Error('Invalid type: ' + type)); }; /* * populate tests pane and url options in browser pane */ this.populateTests = function () { function wrapWithHBox(elem, { flex = undefined, width = undefined } = {}) { let hbox = document.createXULElement('hbox'); hbox.append(elem); if (flex !== undefined) hbox.setAttribute('flex', flex); if (width !== undefined) hbox.style.width = width + 'px'; return hbox; } let tests = _loadTestsFromPane(); let validateTestsBroadcaster = document.getElementById('validate-tests'); if (tests === null) { validateTestsBroadcaster.setAttribute('disabled', true); return; } else { validateTestsBroadcaster.removeAttribute('disabled'); } let browserURL = document.getElementById("browser-url"); let currentURL = browserURL.value; // browserURL.removeAllItems(); browserURL.value = currentURL; let listBox = document.getElementById("testing-listbox"); let count = listBox.getRowCount(); let oldStatuses = {}; for (let i = 0; i < count; i++) { let item = listBox.getItemAtIndex(i); let [, statusCell] = item.children; oldStatuses[item.dataset.testString] = statusCell.getAttribute('value'); } let testIndex = 0; for (let test of tests) { let testString = _stringifyTests(test, 1); // try to reuse old rows let item = testIndex < count ? listBox.getItemAtIndex(testIndex) : document.createXULElement('richlistitem'); item.innerHTML = ''; // clear children/content if reusing let input = document.createXULElement('label'); input.append(getTestLabel(test)); item.appendChild(wrapWithHBox(input, { flex: 1 })); let status = document.createXULElement('label'); status.append(oldStatuses[testString] || 'Not run'); item.appendChild(wrapWithHBox(status, { width: 150 })); let defer = document.createXULElement('checkbox'); defer.checked = test.defer; defer.disabled = true; item.appendChild(wrapWithHBox(defer, { width: 30 })); item.dataset.testString = testString; item.dataset.testType = test.type; if (testIndex >= count) { listBox.appendChild(item); } // if (test.type == 'web') { // browserURL.appendItem(test.url); // } testIndex++; } // remove old rows that we didn't reuse while (listBox.getItemAtIndex(testIndex)) { listBox.getItemAtIndex(testIndex).remove(); } }; /* * Delete selected test(s) */ this.deleteSelectedTests = function () { var listbox = document.getElementById("testing-listbox"); var indicesToRemove = [...listbox.selectedItems].map(item => listbox.getIndexOfItem(item)); let tests = _loadTestsFromPane(); indicesToRemove.forEach(i => tests.splice(i, 1)); _writeTestsToPane(tests); this.populateTests(); }; /* * Load the import input for the first selected test in the import pane, * from the UI. */ this.editImportFromTest = function () { var listbox = document.getElementById("testing-listbox"); var item = listbox.selectedItems[0]; var test = JSON.parse(item.dataset.testString); if (test.input === undefined) { _logOutput("Can't edit input of a non-import/search test."); } _writeToEditor(_editors.import, test.type == 'import' ? test.input : JSON.stringify(test.input, null, '\t')); _editors.import.setSelection({ startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: 1 }); _showTab('import'); }; /* * Copy the url or data of the first selected test to the clipboard. */ this.copyToClipboard = function () { var listbox = document.getElementById("testing-listbox"); var item = listbox.selectedItems[0]; var url = item.getElementsByTagName("label")[0].getAttribute("value"); var test = JSON.parse(item.dataset.testString); var urlOrData = (test.input !== undefined) ? test.input : url; if (typeof urlOrData !== 'string') { urlOrData = JSON.stringify(urlOrData, null, '\t'); } Zotero.Utilities.Internal.copyTextToClipboard(urlOrData); }; /** * Open the url of the first selected test in the browser (Browser tab or * the system's default browser). * @param {boolean} openExternally whether to open in the default browser **/ this.openURL = function (openExternally) { var listbox = document.getElementById("testing-listbox"); var item = listbox.selectedItems[0]; var url = item.getElementsByTagName("label")[0].textContent; if (openExternally) { Zotero.launchURL(url); } else { _browser.loadURI(Services.io.newURI(url), { triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal() }); _showTab('browser'); } }; this.runTests = function (tests, callback) { callback = callback || (() => {}); _clearOutput(); let testsByType = { import: [], export: [], web: [], search: [] }; for (let test of tests) { testsByType[test.type].push(test); } let rememberCookies = document.getElementById('checkbox-remember-cookies').checked; for (let [type, testsOfType] of Object.entries(testsByType)) { if (testsOfType.length) { let tester = new Zotero_TranslatorTester( _getTranslatorFromPane(), type, _debug, _translatorProvider ); if (!rememberCookies) { tester.setCookieSandbox(new Zotero.CookieSandbox()); } tester.setTests(testsOfType); tester.runTests(callback); } } }; /* * Run selected test(s) */ this.runSelectedTests = function () { var listbox = document.getElementById("testing-listbox"); var items = listbox.selectedItems; if (!items || items.length == 0) return; // No action if nothing selected var tests = []; for (let item of items) { item.getElementsByTagName("label")[1].textContent = "Running"; var test = JSON.parse(item.dataset.testString); test["ui-item"] = ContentDOMReference.get(item); tests.push(test); } this.runTests(tests, (obj, test, status, message) => { ContentDOMReference.resolve(test["ui-item"]).getElementsByTagName("label")[1].textContent = message; }); }; this.updateTests = function (tests, testUpdatedCallback) { _clearOutput(); var updater = new TestUpdater(tests); return new Promise(resolve => updater.updateTests( testUpdatedCallback, resolve )); }; /* * Update selected test(s) */ this.updateSelectedTests = async function () { var listbox = document.getElementById("testing-listbox"); var items = [...listbox.selectedItems]; if (!items || items.length == 0) return; // No action if nothing selected var itemIndices = items.map(item => listbox.getIndexOfItem(item)); var tests = []; for (let item of items) { item.getElementsByTagName("label")[1].textContent = "Updating"; var test = JSON.parse(item.dataset.testString); tests.push(test); } var testsDone = 0; await this.updateTests(tests, (newTest) => { let message; // Assume sequential. TODO: handle this properly via test ID of some sort if (newTest) { message = "Test updated"; items[testsDone].dataset.testString = _stringifyTests(newTest, 1); tests[testsDone] = newTest; } else { message = "Update failed"; } items[testsDone].getElementsByTagName("label")[1].textContent = message; testsDone++; }); let allTests = _loadTestsFromPane(); for (let [i, test] of Object.entries(tests)) { allTests[itemIndices[i]] = test; } _writeTestsToPane(allTests); _logOutput("Tests updated."); }; this.populateLinterMenu = function () { let status = 'Path: ' + getDefaultESLintPath(); let toggle = Zotero.Prefs.get('scaffold.eslint.enabled') ? 'Disable' : 'Enable'; document.getElementById('menu_eslintStatus').label = status; document.getElementById('menu_toggleESLint').label = toggle; }; this.toggleESLint = async function () { Zotero.Prefs.set('scaffold.eslint.enabled', !Zotero.Prefs.get('scaffold.eslint.enabled')); await getESLintPath(); }; this.showTabNumbered = function (tabNumber) { let tabBox = document.getElementById('left-tabbox'); let numTabs = tabBox.querySelectorAll('tabs > tab').length; if (tabNumber > numTabs) { tabNumber = numTabs; } tabBox.selectedIndex = tabNumber - 1; }; var TestUpdater = function (tests) { this.testsToUpdate = tests.slice(); this.numTestsTotal = this.testsToUpdate.length; this.newTests = []; }; TestUpdater.prototype.updateTests = function (testDoneCallback, doneCallback) { this.testDoneCallback = testDoneCallback || function () { /* no-op */ }; this.doneCallback = doneCallback || function () { /* no-op */ }; this._updateTests(); }; TestUpdater.prototype._updateTests = async function () { if (!this.testsToUpdate.length) { this.doneCallback(this.newTests); return; } var test = this.testsToUpdate.shift(); _logOutput("Updating test " + (this.numTestsTotal - this.testsToUpdate.length)); if (test.type == 'web') { _logOutput("Loading web page from " + test.url); const { HiddenBrowser } = ChromeUtils.import("chrome://zotero/content/HiddenBrowser.jsm"); let browser = new HiddenBrowser({ docShell: { allowMetaRedirects: true } }); try { await browser.load(test.url, { requireSuccessfulStatus: true }); if (test.defer) { _logOutput("Waiting " + (Zotero_TranslatorTester.DEFER_DELAY / 1000) + " second(s) for page content to settle"); await Zotero.Promise.delay(Zotero_TranslatorTester.DEFER_DELAY); } else { // Wait just a bit for things to settle await Zotero.Promise.delay(1000); } if (browser.currentURI.spec != test.url) { _logOutput("Page URL differs from test. Will be updated. " + browser.currentURI.spec); } let translate = new RemoteTranslate({ disableErrorReporting: true }); try { await translate.setBrowser(browser); await translate.setTranslatorProvider(_translatorProvider); translate.setTranslator(_getTranslatorFromPane()); translate.setHandler("debug", _debug); translate.setHandler("error", _error); translate.setHandler("newTestDetectionFailed", _confirmCreateExpectedFailTest); let newTest = await translate.newTest(); newTest = _sanitizeItemsInTest(newTest); if (test.defer) { newTest.defer = true; } this.newTests.push(newTest); this.testDoneCallback(newTest); this._updateTests(); } finally { translate.dispose(); } } catch (e) { Zotero.logError(e); this.newTests.push(false); this.testDoneCallback(false); this._updateTests(); } finally { if (browser) browser.destroy(); } } else { test.items = []; const methods = { import: 'doImport', export: 'doExport', // not supported, will error search: 'doSearch' }; // Re-runs the test. // TranslatorTester doesn't handle these correctly, so we do it manually _run(methods[test.type], test.input, null, (obj, item) => { if (item) { test.items.push(Zotero_TranslatorTester._sanitizeItem(item)); } }, null, () => { if (!test.items.length) test = false; this.newTests.push(test); this.testDoneCallback(test); this._updateTests(); }); } }; /* * Normalize whitespace to the Zotero norm of tabs */ function normalizeWhitespace(text) { return text.replace(/^[ \t]+/gm, function (str) { return str.replace(/ {4}/g, "\t"); }); } /* * Clear output pane */ function _clearOutput() { document.getElementById('output').value = ''; } /* * generates an RFC 4122 compliant random GUID */ function _generateGUID() { var guid = ""; for (var i = 0; i < 16; i++) { var bite = Math.floor(Math.random() * 255); if (i == 4 || i == 6 || i == 8 || i == 10) { guid += "-"; // version if (i == 6) bite = bite & 0x0f | 0x40; // variant if (i == 8) bite = bite & 0x3f | 0x80; } var str = bite.toString(16); guid += str.length == 1 ? '0' + str : str; } return guid; } function _getCurrentURI(browser) { return Zotero.Proxies.proxyToProper(browser.currentURI.spec); } function _findTestObjectTops(monaco, model) { let tokenization = monaco.editor.tokenize(model.getValue(), 'json'); let arrayLevel = 0; let objectLevel = 0; let ranges = []; for (let line in tokenization) { line = +line; // string keys for (let token of tokenization[line]) { if (token.type == 'delimiter.array.json' || token.type == 'delimiter.bracket.json') { let range = { startLineNumber: line + 1, startColumn: token.offset + 1, endLineNumber: line + 1, endColumn: token.offset + 2 }; let bracket = model.getValueInRange(range); if (bracket == '[') { arrayLevel++; } else if (bracket == ']') { arrayLevel--; continue; // we only want to record opening brackets } else if (bracket == '{') { objectLevel++; } else if (bracket == '}') { objectLevel--; continue; } if (arrayLevel == 1 && objectLevel == 1) { ranges.push(range); } } } } return ranges; } function getDefaultESLintPath() { return PathUtils.join(Scaffold_Translators.getDirectory(), 'node_modules', '.bin', 'teslint'); } async function getESLintPath() { if (!Zotero.Prefs.get('scaffold.eslint.enabled')) { return null; } let eslintPath = getDefaultESLintPath(); while (!await IOUtils.exists(eslintPath)) { let ps = Services.prompt; let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_2 * ps.BUTTON_TITLE_CANCEL; let index = ps.confirmEx(null, "Scaffold", "Zotero uses ESLint to enable code suggestions and error checking, " + "but it wasn't found in the selected translators directory.\n\n" + "You can install it from the command line:\n\n" + ` cd ${Scaffold_Translators.getDirectory()}\n` + " npm install\n\n", buttonFlags, "Try Again", "Disable Error Checking", null, null, {} ); if (index == 1) { Zotero.Prefs.set('scaffold.eslint.enabled', false); return null; } else if (index == 2) { return null; } } return eslintPath; } async function runESLint(translatorPath) { if (!translatorPath) return []; let eslintPath = await getESLintPath(); if (!eslintPath) return []; Zotero.debug('Running ESLint'); try { let proc = await Subprocess.call({ command: eslintPath, arguments: ['--format', 'json', '--', translatorPath], }); let lintOutput = ''; let chunk; while ((chunk = await proc.stdout.readString())) { lintOutput += chunk; } return JSON.parse(lintOutput); } catch (e) { Zotero.logError(e); } return []; } function eslintOutputToModelMarkers(output) { let result = output[0]; if (!result) return []; return result.messages.map(message => ({ startLineNumber: message.line - _linesOfMetadata + 1, startColumn: message.column, endLineNumber: message.endLine - _linesOfMetadata + 1, endColumn: message.endColumn, message: message.message, severity: message.severity * 4, source: 'ESLint', tags: [ message.ruleId || '-' ] })); } function getTestLabel(test) { switch (test.type) { case 'import': return test.input.substr(0, 80); case 'web': return test.url; case 'search': return JSON.stringify(test.input).substr(0, 80); default: return `Unknown type: ${test.type}`; } } function _showTab(tab) { document.getElementById('tabs').selectedItem = document.getElementById(`tab-${tab}`); } function _getActiveEditorName() { let activeElement = document.activeElement; if (activeElement && activeElement.id && activeElement.id.startsWith('editor-')) { return activeElement.id.substring(7); } return null; } async function _getGitBranchName() { let gitPath = await Subprocess.pathSearch('git'); if (!gitPath) return null; let dir = Scaffold_Translators.getDirectory(); if (!dir) return null; let proc = await Subprocess.call({ command: gitPath, arguments: ['rev-parse', '--abbrev-ref', 'HEAD'], workdir: dir, }); let output = ''; let chunk; while ((chunk = await proc.stdout.readString())) { output += chunk; } return output.trim(); } async function _updateTitle() { let title = 'Scaffold'; let label = document.getElementById('textbox-label').value; if (label) { title += ' - ' + label; } try { let branch = await _getGitBranchName(); if (branch) { title += ' (' + branch + ')'; } } catch (e) { Zotero.logError(e); } document.title = title; } }; window.addEventListener("load", function (e) { Scaffold.onLoad(e); }, false);