zotero/chrome/content/scaffold/scaffold.js
Abe Jellinek ba0475810c Scaffold: Implement new toolbar
New icons and new scaffold.scss go into chrome://zotero/skin. The separate root
for Scaffold is mostly a historical relic, and adding special cases to build
scripts, SCSS mixins, and so on would just make things complicated.
2024-01-24 04:03:36 -05:00

2320 lines
66 KiB
JavaScript

/*
***** 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 <http://www.gnu.org/licenses/>.
***** 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");
import FilePicker from 'zotero/modules/filePicker';
var Zotero = Components.classes["@zotero.org/Zotero;1"]
// Currently uses only nsISupports
//.getService(Components.interfaces.chnmIZoteroService).
.getService(Components.interfaces.nsISupports)
.wrappedJSObject;
// 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, _frames = [], _document;
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;
_document = document;
_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(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 = OS.Path.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;
}
}
};
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.
editor.setModel(monaco.editor.createModel('', 'javascript', monaco.Uri.parse('inmemory:///translator.js')));
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(
OS.Path.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
});
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');
};
/*
* 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,modal", 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));
// Then go to line 1
_editors.code.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);
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 += '/** 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`);
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) {
if (Array.isArray(item.attachments)) {
for (let attachment of item.attachments) {
if (attachment.document) {
attachment.document = '[object Document]';
attachment.mimeType = 'text/html';
}
delete attachment.url;
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.setAttribute('width', width);
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.firstElementChild.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 hbox = document.createXULElement('hbox');
hbox.setAttribute('flex', 1);
hbox.setAttribute('align', 'center');
let input = document.createXULElement('label');
input.value = getTestLabel(test);
hbox.appendChild(wrapWithHBox(input, { flex: 1 }));
let status = document.createXULElement('label');
status.value = oldStatuses[testString] || 'Not run';
hbox.appendChild(wrapWithHBox(status, { width: 150 }));
let defer = document.createXULElement('checkbox');
defer.checked = test.defer;
defer.disabled = true;
hbox.appendChild(wrapWithHBox(defer, { width: 30 }));
item.appendChild(hbox);
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].getAttribute("value");
if (openExternally) {
Zotero.launchURL(url);
}
else {
_browser.loadURI(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);
}
for (let [type, testsOfType] of Object.entries(testsByType)) {
if (testsOfType.length) {
let tester = new Zotero_TranslatorTester(
_getTranslatorFromPane(),
type,
_debug,
_translatorProvider
);
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].setAttribute("value", "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].setAttribute("value", 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].setAttribute("value", "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].setAttribute("value", 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 = [];
this.tester = new Zotero_TranslatorTester(
_getTranslatorFromPane(),
"web",
_debug,
_translatorProvider
);
};
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;
try {
browser = await HiddenBrowser.create(test.url, {
requireSuccessfulStatus: true,
docShell: { allowMetaRedirects: 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);
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) HiddenBrowser.destroy(browser);
}
}
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 OS.Path.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 OS.File.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;
}
};
window.addEventListener("load", function (e) {
Scaffold.onLoad(e);
}, false);