mirror of
https://github.com/zotero/zotero.git
synced 2026-10-08 03:08:19 +00:00
2391 lines
70 KiB
JavaScript
2391 lines
70 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 { Subprocess } = ChromeUtils.importESModule("resource://gre/modules/Subprocess.sys.mjs");
|
|
var { RemoteTranslate } = ChromeUtils.importESModule("chrome://zotero/content/RemoteTranslate.mjs");
|
|
|
|
var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
|
|
var { FilePicker } = ChromeUtils.importESModule('chrome://zotero/content/modules/filePicker.mjs');
|
|
var { TranslatorTester } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/translate/testTranslators/translatorTester.mjs');
|
|
var { Test } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/translate/testTranslators/test.mjs');
|
|
var { ZoteroWebTranslationEnvironment } = ChromeUtils.importESModule('chrome://scaffold/content/zoteroWebTranslationEnvironment.mjs');
|
|
|
|
var { CompletionCopilot, registerCompletion } = ChromeUtils.importESModule('resource://zotero/monacopilot.mjs');
|
|
|
|
var lazy = {};
|
|
ChromeUtils.defineLazyGetter(lazy, 'shellPathPromise', () => {
|
|
return Zotero.Utilities.Internal.subprocess(Services.env.get('SHELL'), ['-c', 'echo $PATH'])
|
|
.then(s => s.trimEnd());
|
|
});
|
|
|
|
// Text to display in monaco editors when they are empty
|
|
const CODE_TAB_INFO = `1. Click the "+" toolbar button above to choose a template for the translator.
|
|
2. Use Run detect* and Run do* buttons in the toolbar to run your translator on the URL loaded in the Browser tab.
|
|
3. Use Help → List All Item Types and Help → List All Fields for Item Type for information on the item types and fields your translator can return.`;
|
|
const TESTS_TAB_INFO = ` After the translator is implemented in the Code tab, create tests from the Browser (web translator) and Test Input (import/search translators) tabs.`;
|
|
|
|
var Scaffold = new function () {
|
|
var _browser;
|
|
var _translatorsLoadedPromise;
|
|
var _translatorProvider = null;
|
|
var _loadedTranslatorPath = null;
|
|
var _lastModifiedTime = 0;
|
|
var _needRebuildTranslatorSuggestions = true;
|
|
var _invalidateCodeLenses = null;
|
|
var _copilot;
|
|
var _prefsObserverID;
|
|
|
|
var _editors = {};
|
|
|
|
var _browserProgressListener = null;
|
|
var _persistentCookieContext = null;
|
|
|
|
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'
|
|
};
|
|
|
|
/** @type {{
|
|
* test: Test;
|
|
* testString: string;
|
|
* updatedTestString?: string;
|
|
* status?: string;
|
|
* previews: ScaffoldItemPreview[];
|
|
* }[]} */
|
|
var _testData = [];
|
|
var _linesOfMetadata = 15;
|
|
|
|
this.handleLoad = async function () {
|
|
_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', (e) => {
|
|
if (e.key == 'Enter') {
|
|
this.loadURLInBrowser();
|
|
}
|
|
});
|
|
// Progress listener to record when the webpage is loaded
|
|
_browserProgressListener = {
|
|
QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener", "nsISupportsWeakReference"]),
|
|
onStateChange(webProgress, request, stateFlags, status) {
|
|
let done = stateFlags & Ci.nsIWebProgressListener.STATE_STOP;
|
|
// Hide the spinner when done
|
|
if (done) {
|
|
_setBrowserLoadingIndicator(false);
|
|
}
|
|
},
|
|
};
|
|
_browser.addProgressListener(_browserProgressListener, Ci.nsIWebProgress.NOTIFY_STATE_ALL);
|
|
|
|
document.getElementById('tabpanels').addEventListener('select', event => this.handleTabSelect(event));
|
|
document.getElementById('tabs').addEventListener('mousedown', (event) => {
|
|
// Record if tab selection will happen due to a mouse click vs keyboard nav.
|
|
if (event.clientX === 0 && event.clientY === 0) return;
|
|
document.getElementById('tabs').setAttribute("clicked", true);
|
|
}, true);
|
|
// Record that click has happened for better focus-ring handling in the stylesheet
|
|
document.addEventListener("mouseup", (_) => {
|
|
document.getElementById('tabs').removeAttribute("clicked");
|
|
});
|
|
|
|
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', emptyOverlayMessage: CODE_TAB_INFO }).then(({ monaco, editor }) => {
|
|
_editors.codeGlobal = monaco;
|
|
_editors.code = editor;
|
|
}),
|
|
testsWin.loadMonaco({
|
|
language: 'json',
|
|
// Tests might contain \u2028/\u2029 - don't pop up a warning
|
|
unusualLineTerminators: 'off',
|
|
emptyOverlayMessage: TESTS_TAB_INFO
|
|
}).then(({ monaco, editor }) => {
|
|
_editors.testsGlobal = monaco;
|
|
_editors.tests = editor;
|
|
}),
|
|
]);
|
|
|
|
this.initImportEditor();
|
|
this.initCodeEditor();
|
|
this.initTestsEditor();
|
|
|
|
this.addEditorKeydownHandlers(_editors.import);
|
|
this.addEditorKeydownHandlers(_editors.code);
|
|
this.addEditorKeydownHandlers(_editors.tests);
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
_prefsObserverID = Zotero.Prefs.registerObserver('scaffold.completions.mistralAPIKey', _handleAPIKeyChange);
|
|
_handleAPIKeyChange();
|
|
};
|
|
|
|
this.handleUnload = function () {
|
|
Zotero.Prefs.unregisterObserver(_prefsObserverID);
|
|
if (_persistentCookieContext) {
|
|
_persistentCookieContext.dispose();
|
|
_persistentCookieContext = null;
|
|
}
|
|
};
|
|
|
|
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) {
|
|
_lastModifiedTime = modifiedTime;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
_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));
|
|
model.onDidChangeContent(() => this.updateModelMarkers());
|
|
|
|
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
|
|
|
|
registerCompletion(monaco, editor, {
|
|
language: 'javascript',
|
|
endpoint: 'dummy',
|
|
async requestHandler({ body }) {
|
|
if (!_copilot) {
|
|
return { completion: null };
|
|
}
|
|
|
|
let metadata = _getMetadataObject();
|
|
Object.assign(body.completionMetadata, {
|
|
filename: `${metadata.label}.js [body]`,
|
|
technologies: ['zotero/translators'],
|
|
relatedFiles: [
|
|
{
|
|
path: 'index.d.ts',
|
|
content: tsLib
|
|
},
|
|
{
|
|
path: `${metadata.label}.js [metadata]`,
|
|
content: JSON.stringify(metadata, null, '\t')
|
|
},
|
|
metadata.translatorType & Zotero.Translator.TRANSLATOR_TYPES.import && {
|
|
path: `example_import.${metadata.target || 'dat'}`,
|
|
content: _getImport()
|
|
},
|
|
metadata.translatorType & Zotero.Translator.TRANSLATOR_TYPES.search && {
|
|
path: `example_search.json`,
|
|
content: _getImport()
|
|
},
|
|
{
|
|
path: _browser.currentURI.spec,
|
|
content: await _browser.browsingContext.currentWindowGlobal.getActor('PageData')
|
|
.sendQuery('documentHTML')
|
|
}
|
|
].filter(Boolean)
|
|
});
|
|
return _copilot.complete({ body });
|
|
},
|
|
});
|
|
};
|
|
|
|
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 runCommand = editor.addCommand(
|
|
0,
|
|
async (_ctx, testIndices) => {
|
|
testIndices = testIndices || [..._loadTestsFromPane().keys()];
|
|
await this.runTests(testIndices);
|
|
},
|
|
'');
|
|
|
|
let applyUpdatesCommand = editor.addCommand(
|
|
0,
|
|
(_ctx, testIndices) => {
|
|
testIndices = testIndices || [..._loadTestsFromPane().keys()];
|
|
this.updateTests(testIndices);
|
|
},
|
|
'');
|
|
|
|
return {
|
|
onDidChange: (callback) => {
|
|
_invalidateCodeLenses = callback;
|
|
return { dispose() {} };
|
|
},
|
|
|
|
provideCodeLenses: (model, _token) => {
|
|
let testsWithUpdates = new Set();
|
|
for (let [testIndex, { updatedTestString }] of _testData.entries()) {
|
|
if (updatedTestString) {
|
|
testsWithUpdates.add(testIndex);
|
|
}
|
|
}
|
|
|
|
let lenses = [];
|
|
|
|
let firstChar = {
|
|
startLineNumber: 1,
|
|
startColumn: 1,
|
|
endLineNumber: 1,
|
|
endColumn: 1
|
|
};
|
|
if (testsWithUpdates.size) {
|
|
lenses.push({
|
|
range: firstChar,
|
|
command: {
|
|
id: runCommand,
|
|
title: 'Run All'
|
|
}
|
|
});
|
|
}
|
|
|
|
if (testsWithUpdates.size) {
|
|
lenses.push({
|
|
range: firstChar,
|
|
command: {
|
|
id: applyUpdatesCommand,
|
|
title: 'Apply All Updates'
|
|
}
|
|
});
|
|
}
|
|
|
|
for (let [testIndex, range] of _findTestObjectTops(monaco, model).entries()) {
|
|
lenses.push({
|
|
range: range,
|
|
command: {
|
|
id: runCommand,
|
|
title: 'Run',
|
|
arguments: [[testIndex]]
|
|
}
|
|
});
|
|
|
|
if (testsWithUpdates.has(testIndex)) {
|
|
lenses.push({
|
|
range: range,
|
|
command: {
|
|
id: applyUpdatesCommand,
|
|
title: 'Apply Updates',
|
|
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 = Zotero.Utilities.debounce(async function () {
|
|
let modelVersionId = _editors.code.getModel().getVersionId();
|
|
let output = await runESLint();
|
|
let markers = eslintOutputToModelMarkers(output, modelVersionId);
|
|
_editors.codeGlobal.editor.setModelMarkers(_editors.code.getModel(), 'eslint', markers);
|
|
}, 200);
|
|
|
|
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 == 13) {
|
|
// for the default value 13, 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") || 13;
|
|
this.setFontSize(currentSize + 2);
|
|
};
|
|
this.decreaseFontSize = function () {
|
|
var currentSize = Zotero.Prefs.get("scaffold.fontSize") || 13;
|
|
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;
|
|
// Use a sample regex as a default target
|
|
document.getElementById("textbox-target").value = "^https?://www\\.example\\.org/";
|
|
|
|
_editors.code.setValue('');
|
|
_editors.tests.setValue('');
|
|
|
|
this.populateTests();
|
|
|
|
document.getElementById('textbox-label').focus();
|
|
_showTab('metadata');
|
|
|
|
_lastModifiedTime = new Date().getTime();
|
|
_loadedTranslatorPath = null;
|
|
|
|
_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 (let id in _propertyMap) {
|
|
document.getElementById(id).value = translator[_propertyMap[id]] || "";
|
|
}
|
|
|
|
let rawCode = await _translatorProvider.getCodeForTranslator(translator);
|
|
let { metadata, code, testCases } = _splitTranslator(rawCode);
|
|
// Adjust the first line number
|
|
_linesOfMetadata = metadata.split('\n').length;
|
|
|
|
// Convert whitespace to tabs
|
|
_editors.code.setValue(code);
|
|
// Then go to line 1
|
|
_editors.code.setPosition({ lineNumber: 1, column: 1 });
|
|
|
|
// 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, testCases);
|
|
// Clear selection
|
|
_editors.tests.setSelection({
|
|
startLineNumber: 1,
|
|
endLineNumber: 1,
|
|
startColumn: 1,
|
|
endColumn: 1
|
|
});
|
|
// Set up the test running pane
|
|
this.populateTests();
|
|
|
|
// 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;
|
|
}
|
|
|
|
_loadedTranslatorPath = 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;
|
|
}
|
|
|
|
function _getCode() {
|
|
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 **/';
|
|
return code;
|
|
}
|
|
|
|
/*
|
|
* save translator to database
|
|
*/
|
|
this.save = async function (updateZotero) {
|
|
var metadata = _getMetadataObject();
|
|
var code = _getCode();
|
|
if (metadata.label === "Untitled") {
|
|
_logOutput("Can't save an untitled translator.");
|
|
return;
|
|
}
|
|
|
|
let newPath = _translatorProvider.getSavePath(metadata);
|
|
if (_loadedTranslatorPath && newPath !== _loadedTranslatorPath) {
|
|
try {
|
|
await Zotero.File.removeIfExists(_loadedTranslatorPath);
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
}
|
|
_loadedTranslatorPath = newPath;
|
|
|
|
_logOutput(`Renamed to ${PathUtils.filename(newPath)}.`);
|
|
}
|
|
|
|
await _translatorProvider.save(metadata, code);
|
|
|
|
if (updateZotero) {
|
|
await Zotero.Translators.save(metadata, code);
|
|
await Zotero.Translators.reinit();
|
|
}
|
|
|
|
_lastModifiedTime = new Date().getTime();
|
|
|
|
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;
|
|
}
|
|
|
|
var tabs = document.getElementById('tabs');
|
|
var tab = tabs.selectedItem.id.match(/^tab-(.+)$/)[1];
|
|
let tabPanel = tabs.tabbox.selectedPanel;
|
|
// 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 an element inside of tabpanel instead of the tab
|
|
// (unless tabs are being navigated via keyboard)
|
|
// so this stupid hack focuses the desired element after skipping a tick
|
|
if (tabs.hasAttribute('clicked')) {
|
|
setTimeout(() => {
|
|
let toFocus = tabPanel.querySelector('[focus-on-tab-select]');
|
|
if (toFocus) {
|
|
toFocus.focus();
|
|
// Activate editor that is being focused, if any
|
|
if (toFocus.id.startsWith('editor-')) {
|
|
_editors[tab].focus();
|
|
}
|
|
}
|
|
else {
|
|
// If no specific element set, just tab into the panel
|
|
setTimeout(() => {
|
|
Services.focus.moveFocus(window, tabs.selectedItem, Services.focus.MOVEFOCUS_FORWARD, 0);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
let codeTabBroadcaster = document.getElementById('code-tab-only');
|
|
if (tab == 'code') {
|
|
codeTabBroadcaster.removeAttribute('disabled');
|
|
}
|
|
else {
|
|
codeTabBroadcaster.setAttribute('disabled', true);
|
|
}
|
|
|
|
if (tab == 'tests') {
|
|
this.handleTestingListboxSelect();
|
|
}
|
|
};
|
|
|
|
this.handleTestingContextMenuShowing = function () {
|
|
let listbox = document.getElementById('testing-listbox');
|
|
let selectedItems = Array.from(listbox.selectedItems);
|
|
if (!selectedItems.length) return;
|
|
let selectedIndices = selectedItems.map(item => listbox.getIndexOfItem(item));
|
|
|
|
let editImport = document.getElementById('testing-editImport');
|
|
let openURL = document.getElementById('testing-openURL');
|
|
let applyUpdates = document.getElementById('testing-applyUpdates');
|
|
if (selectedItems.length > 1) {
|
|
editImport.disabled = true;
|
|
openURL.disabled = true;
|
|
}
|
|
else {
|
|
let { test } = _testData[selectedIndices[0]];
|
|
editImport.disabled = test.type === 'web';
|
|
openURL.disabled = test.type !== 'web';
|
|
}
|
|
applyUpdates.disabled = selectedIndices.some(idx => !_testData[idx].updatedTestString);
|
|
};
|
|
|
|
this.handleTestingListboxSelect = function () {
|
|
let listbox = document.querySelector('#testing-listbox');
|
|
let itemPreviews = document.querySelector('#item-previews');
|
|
let previews = [];
|
|
for (let item of listbox.selectedItems) {
|
|
let index = listbox.getIndexOfItem(item);
|
|
let testData = _testData[index];
|
|
previews.push(...testData.previews);
|
|
}
|
|
itemPreviews.setPreviews(previews);
|
|
};
|
|
|
|
this.handleTestingListboxDblClick = function () {
|
|
this.runSelectedTests();
|
|
};
|
|
|
|
// Add special keydown handling for the editors
|
|
this.addEditorKeydownHandlers = function (editor) {
|
|
let doc = editor.getDomNode().ownerDocument;
|
|
// On shift-tab from the start of the first line, tab out of the editor.
|
|
// Use capturing listener, since Shift-Tab keydown events do not propagate to the document.
|
|
doc.addEventListener("keydown", (event) => {
|
|
if (event.key == "Tab" && event.shiftKey) {
|
|
let position = editor.getPosition();
|
|
if (position.column == 1 && position.lineNumber == 1) {
|
|
Services.focus.moveFocus(window, event.target, Services.focus.MOVEFOCUS_BACKWARD, 0);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
}, 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: 1 });
|
|
}
|
|
outputObject.creators = creators;
|
|
outputObject.attachments = [{ url: "", document: {}, title: "", mimeType: "", snapshot: false }];
|
|
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;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Load the typed URL in the browser tab.
|
|
*/
|
|
this.loadURLInBrowser = async function () {
|
|
let url = document.getElementById("browser-url").value;
|
|
Zotero.debug('Scaffold: Loading URL in browser: ' + url);
|
|
|
|
_setBrowserLoadingIndicator(true);
|
|
|
|
_browser.fixupAndLoadURIString(url, {
|
|
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal()
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Check if there is a webpage loaded in the browser tab when translator type is 'web'.
|
|
* If not, alert the user and navigate to the browser tab.
|
|
* @returns {boolean} True if a webpage is loaded for web translators, false otherwise.
|
|
*/
|
|
this.ensureWebpageLoadedIfNeeded = function () {
|
|
if (_browser.currentURI.spec == 'about:blank' && document.getElementById('checkbox-web').checked) {
|
|
Services.prompt.alert(null, "Webpage Not Loaded", "You need to load a webpage in the Browser tab first.");
|
|
_showTab('browser');
|
|
document.getElementById('browser-url').focus();
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/*
|
|
* 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 {
|
|
// If no function can be properly run, alert the user
|
|
// if the translator type is web and no webpage is loaded
|
|
if (!this.ensureWebpageLoadedIfNeeded()) return;
|
|
_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`);
|
|
await _run(functionToRun, input, _selectItems, () => {}, _getTranslatorsHandler(functionToRun), _myExportDone);
|
|
}
|
|
else {
|
|
await _run(functionToRun, input, _selectItems, _myItemDone, _getTranslatorsHandler(functionToRun));
|
|
}
|
|
|
|
_logOutput('Done');
|
|
};
|
|
|
|
/*
|
|
* 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 () {
|
|
if (!this.ensureWebpageLoadedIfNeeded()) return;
|
|
_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, callback) {
|
|
var io = { dataIn: itemList, dataOut: null };
|
|
window.openDialog("chrome://scaffold/content/select.xhtml",
|
|
"_blank", "chrome,modal,centerscreen,resizable=yes", io);
|
|
callback(io.dataOut);
|
|
}
|
|
|
|
/*
|
|
* called if an error occurs
|
|
*/
|
|
function _error(obj, error) {
|
|
_logOutput(String(error));
|
|
}
|
|
|
|
/*
|
|
* logs translator output (instead of logging in the console)
|
|
*/
|
|
function _debug(obj, string) {
|
|
_logOutput(string);
|
|
}
|
|
|
|
/*
|
|
* logs item output
|
|
*/
|
|
function _myItemDone(obj, jsonItem) {
|
|
let itemPreviews = document.querySelector('#item-previews');
|
|
itemPreviews.addItemPair(jsonItem, null);
|
|
}
|
|
|
|
/*
|
|
* 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 = 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" + _getCode();
|
|
|
|
// 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 _splitTranslator(code) {
|
|
let metadata = code.substr(0, code.indexOf('"lastUpdated"') + 50)
|
|
.match(/^\s*\{[\S\s]*?}\s*?[\r\n]+/)[0];
|
|
code = code.substring(metadata.length);
|
|
|
|
var testStart = code.indexOf("/** BEGIN TEST CASES **/");
|
|
var testEnd = code.indexOf("/** END TEST CASES **/");
|
|
|
|
let testCases;
|
|
if (testStart !== -1 && testEnd !== -1) {
|
|
testCases = code.substring(testStart + 24, testEnd);
|
|
code = code.substring(0, testStart).trimEnd();
|
|
}
|
|
else {
|
|
testCases = 'var testCases = [];';
|
|
}
|
|
|
|
testCases = testCases.replace(/^\s*var testCases = /, '').trim();
|
|
// The JSON parser doesn't like final semicolons
|
|
if (testCases.endsWith(';')) {
|
|
testCases = testCases.slice(0, -1);
|
|
}
|
|
|
|
try {
|
|
testCases = JSON.stringify(JSON.parse(testCases), null, '\t');
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
}
|
|
|
|
code = normalizeWhitespace(code);
|
|
|
|
return { metadata, code, testCases };
|
|
}
|
|
|
|
/*
|
|
* loads the translator's tests from the pane
|
|
*/
|
|
function _loadTestsFromPane() {
|
|
try {
|
|
return JSON.parse(_editors.tests.getValue().trim() || '[]')
|
|
.map(test => new Test(test));
|
|
}
|
|
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));
|
|
}
|
|
|
|
/* 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) {
|
|
if (value instanceof Test) {
|
|
value = value.toJSON();
|
|
}
|
|
|
|
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...`);
|
|
|
|
if (type === 'web') {
|
|
if (!this.ensureWebpageLoadedIfNeeded()) return;
|
|
}
|
|
|
|
try {
|
|
let test = await this.constructTestFromCurrent(type);
|
|
_writeTestsToPane([..._loadTestsFromPane(), test]);
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(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`);
|
|
throw new Error();
|
|
}
|
|
|
|
if (type == 'export') {
|
|
throw new Error(`Test of type export cannot be created`);
|
|
}
|
|
|
|
let input = await _getInput(type);
|
|
if (input === _browser) {
|
|
input = _getCurrentURI(_browser);
|
|
}
|
|
|
|
let testDraft = new Test({ type, input, items: [] });
|
|
let [{ updatedTest: test }] = await Array.fromAsync(
|
|
this.runTestsInternal([testDraft])
|
|
);
|
|
|
|
// If we didn't get an item the first time, try again with defer: true
|
|
if (!test) {
|
|
testDraft.defer = true;
|
|
let [{ updatedTest }] = await Array.fromAsync(
|
|
this.runTestsInternal([testDraft])
|
|
);
|
|
test = updatedTest;
|
|
}
|
|
|
|
// But give up after that
|
|
if (!test) {
|
|
if (Services.prompt.confirm(
|
|
null,
|
|
'Detection Failed',
|
|
`Add test ensuring that detection always fails on this ${type === 'web' ? 'page' : 'input'}?`
|
|
)) {
|
|
testDraft.detectedItemType = false;
|
|
testDraft.defer = false;
|
|
return testDraft.toJSON();
|
|
}
|
|
else {
|
|
throw new Error('Not creating expected-fail test');
|
|
}
|
|
}
|
|
|
|
return test.toJSON();
|
|
};
|
|
|
|
/*
|
|
* populate tests pane and url options in browser pane
|
|
*/
|
|
this.populateTests = function () {
|
|
let tests = _loadTestsFromPane();
|
|
let validateTestsBroadcaster = document.getElementById('validate-tests');
|
|
if (tests === null) {
|
|
validateTestsBroadcaster.setAttribute('disabled', true);
|
|
return;
|
|
}
|
|
else {
|
|
validateTestsBroadcaster.removeAttribute('disabled');
|
|
}
|
|
|
|
let listbox = document.getElementById("testing-listbox");
|
|
|
|
for (let [testIndex, test] of tests.entries()) {
|
|
let testString = _stringifyTests(test, 1);
|
|
let testData = _testData[testIndex];
|
|
if (!testData || testData.testString !== testString) {
|
|
_testData[testIndex] = testData = {
|
|
test,
|
|
testString,
|
|
previews: [],
|
|
};
|
|
}
|
|
testData.test = test;
|
|
testData.testString = testString;
|
|
// Remove updatedTestString if updates have been applied
|
|
if (testData.updatedTestString === testString) {
|
|
delete testData.updatedTestString;
|
|
}
|
|
let needsUpdate = !!testData.updatedTestString;
|
|
|
|
let item = testIndex < listbox.getRowCount()
|
|
? listbox.getItemAtIndex(testIndex)
|
|
: document.createXULElement('richlistitem');
|
|
item.replaceChildren();
|
|
|
|
let inputCell = document.createXULElement('hbox');
|
|
inputCell.classList.add('cell', 'col-input');
|
|
|
|
let input = document.createXULElement('label');
|
|
input.append(getTestLabel(test));
|
|
inputCell.append(input);
|
|
item.append(inputCell);
|
|
|
|
let statusCell = document.createXULElement('hbox');
|
|
statusCell.classList.add('cell', 'col-status');
|
|
statusCell.classList.toggle('needs-update', needsUpdate);
|
|
let statusLabel = document.createXULElement('label');
|
|
statusLabel.classList.add('status');
|
|
|
|
let statusText = _testData.find(test => test.testString === testString)?.status ?? '';
|
|
if (needsUpdate) {
|
|
let totalStats = { added: 0, removed: 0 };
|
|
for (let preview of testData.previews) {
|
|
let statsHere = preview.diffStats;
|
|
totalStats.added += statsHere.added;
|
|
totalStats.removed += statsHere.removed;
|
|
}
|
|
|
|
let textElem = document.createElement('span');
|
|
textElem.classList.add('text');
|
|
textElem.textContent = statusText;
|
|
let addedElem = document.createElement('span');
|
|
addedElem.classList.add('added');
|
|
addedElem.textContent = `+${totalStats.added}`;
|
|
let removedElem = document.createElement('span');
|
|
removedElem.classList.add('removed');
|
|
removedElem.textContent = `-${totalStats.removed}`;
|
|
|
|
statusLabel.replaceChildren(textElem, addedElem, removedElem);
|
|
}
|
|
else {
|
|
statusLabel.textContent = statusText;
|
|
}
|
|
|
|
statusCell.append(statusLabel);
|
|
let updateButton = document.createXULElement('toolbarbutton');
|
|
updateButton.classList.add('update');
|
|
updateButton.setAttribute('tooltiptext', 'Update Test');
|
|
updateButton.addEventListener('command', () => this.updateTests([testIndex]));
|
|
statusCell.append(updateButton);
|
|
item.append(statusCell);
|
|
|
|
let deferCell = document.createXULElement('hbox');
|
|
deferCell.classList.add('cell', 'col-defer');
|
|
let defer = document.createXULElement('checkbox');
|
|
if (typeof test.defer === 'number' && test.defer) {
|
|
defer.setAttribute('indeterminate', 'true');
|
|
}
|
|
else {
|
|
defer.checked = test.defer;
|
|
}
|
|
defer.setAttribute('native', 'true');
|
|
defer.addEventListener('command', () => {
|
|
test.defer = defer.checked;
|
|
_writeTestsToPane(tests);
|
|
});
|
|
deferCell.append(defer);
|
|
item.append(deferCell);
|
|
|
|
if (!item.parentElement) {
|
|
listbox.append(item);
|
|
}
|
|
}
|
|
|
|
// Remove unused _testData entries and listbox rows
|
|
_testData = _testData.slice(0, tests.length);
|
|
while (listbox.getRowCount() > tests.length) {
|
|
listbox.getItemAtIndex(listbox.getRowCount() - 1).remove();
|
|
}
|
|
|
|
// Update UI that depends on the selection
|
|
this.handleTestingListboxSelect();
|
|
};
|
|
|
|
/*
|
|
* 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();
|
|
tests = tests.filter((_, i) => !indicesToRemove.includes(i));
|
|
_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 { test } = _testData[listbox.selectedIndex];
|
|
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 { test } = _testData[listbox.selectedIndex];
|
|
var input = test.input; // URL or input object
|
|
if (typeof input !== 'string') {
|
|
input = JSON.stringify(input, null, '\t');
|
|
}
|
|
Zotero.Utilities.Internal.copyTextToClipboard(input);
|
|
};
|
|
|
|
/**
|
|
* 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 { test } = _testData[listbox.selectedIndex];
|
|
var url = test.url;
|
|
if (openExternally) {
|
|
Zotero.launchURL(url);
|
|
}
|
|
else {
|
|
_browser.loadURI(Services.io.newURI(url), {
|
|
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal()
|
|
});
|
|
_showTab('browser');
|
|
}
|
|
};
|
|
|
|
this.runTests = async function (testIndices) {
|
|
let itemPreviews = document.querySelector('#item-previews');
|
|
let testDatas = testIndices.map(index => _testData[index]);
|
|
|
|
for (let testData of testDatas) {
|
|
testData.status = 'Running';
|
|
delete testData.updatedTestString;
|
|
}
|
|
this.populateTests();
|
|
|
|
let numTests = testDatas.length;
|
|
let testIndex = 0;
|
|
_logOutput(`Running ${numTests} ${Zotero.Utilities.pluralize(numTests, 'test')}`);
|
|
for await (let { test, status, reason, updatedTest } of this.runTestsInternal(testDatas.map(d => d.test))) {
|
|
let statusText;
|
|
let needsUpdate = false;
|
|
|
|
let logPrefix = `Test ${testIndex + 1}/${numTests}: `;
|
|
if (status === 'success') {
|
|
statusText = 'Succeeded';
|
|
_logOutput(logPrefix + statusText);
|
|
}
|
|
else {
|
|
statusText = reason;
|
|
needsUpdate = !!updatedTest;
|
|
_logOutput(logPrefix + statusText);
|
|
}
|
|
|
|
// Create previews as long as we got new item data,
|
|
// even if there weren't substantive changes
|
|
let previews = [];
|
|
if (Array.isArray(test.items) && Array.isArray(updatedTest?.items)) {
|
|
for (let i = 0; i < test.items.length || i < updatedTest.items.length; i++) {
|
|
let preItem = i < test.items.length ? test.items[i] : {};
|
|
let postItem = i < updatedTest.items.length ? updatedTest.items[i] : {};
|
|
previews.push(itemPreviews.createPreviewForItemPair(preItem, postItem));
|
|
}
|
|
}
|
|
|
|
let testData = testDatas[testIndex];
|
|
testData.status = statusText;
|
|
testData.previews = previews;
|
|
if (needsUpdate) {
|
|
testData.updatedTestString = _stringifyTests(updatedTest.toJSON(), 1);
|
|
}
|
|
else {
|
|
delete testData.updatedTestString;
|
|
}
|
|
this.populateTests();
|
|
|
|
testIndex++;
|
|
}
|
|
|
|
_invalidateCodeLenses?.();
|
|
};
|
|
|
|
this.runSelectedTests = async function () {
|
|
let listbox = document.getElementById('testing-listbox');
|
|
let selectedItems = [...listbox.selectedItems];
|
|
await this.runTests(
|
|
[...selectedItems].map(item => listbox.getIndexOfItem(item))
|
|
);
|
|
};
|
|
|
|
this.runTestsInternal = async function* (tests) {
|
|
_clearOutput();
|
|
|
|
let rememberCookies = document.getElementById('checkbox-remember-cookies').checked;
|
|
let cookieContext;
|
|
if (rememberCookies) {
|
|
if (!_persistentCookieContext) {
|
|
_persistentCookieContext = Zotero.HTTP.newCookieContext();
|
|
}
|
|
cookieContext = _persistentCookieContext;
|
|
}
|
|
else {
|
|
cookieContext = Zotero.HTTP.newCookieContext();
|
|
}
|
|
|
|
let tester = new TranslatorTester(_getTranslatorFromPane(), {
|
|
translatorProvider: _translatorProvider,
|
|
cookieSandbox: cookieContext.id,
|
|
debug: _logOutput,
|
|
webTranslationEnvironment: new ZoteroWebTranslationEnvironment(),
|
|
});
|
|
|
|
try {
|
|
for (let test of tests) {
|
|
yield { test, ...await tester.run(test) };
|
|
}
|
|
}
|
|
finally {
|
|
if (!rememberCookies) {
|
|
cookieContext.dispose();
|
|
}
|
|
}
|
|
};
|
|
|
|
this.updateTests = function (testIndices) {
|
|
let tests = _loadTestsFromPane();
|
|
for (let testIndex of testIndices) {
|
|
let testData = _testData[testIndex];
|
|
let updatedTestString = testData.updatedTestString;
|
|
if (!updatedTestString) {
|
|
continue;
|
|
}
|
|
|
|
let updatedTest = JSON.parse(updatedTestString);
|
|
tests[testIndex] = updatedTest;
|
|
testData.test = new Test(updatedTest);
|
|
testData.testString = _stringifyTests(updatedTest, 1);
|
|
delete testData.updatedTestString;
|
|
testData.status = 'Updated';
|
|
}
|
|
_writeTestsToPane(tests);
|
|
_logOutput('Tests updated.');
|
|
};
|
|
|
|
this.updateSelectedTests = async function () {
|
|
let listbox = document.getElementById('testing-listbox');
|
|
let selectedItems = [...listbox.selectedItems];
|
|
this.updateTests(
|
|
[...selectedItems].map(item => listbox.getIndexOfItem(item))
|
|
);
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
/*
|
|
* 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 = '';
|
|
let itemPreviews = document.querySelector('#item-previews');
|
|
itemPreviews.clearPreviews();
|
|
}
|
|
|
|
/**
|
|
* Toggle the indicator of browser loading by hiding the load button
|
|
* and displaying the spinner, or vice versa.
|
|
*/
|
|
function _setBrowserLoadingIndicator(isLoading) {
|
|
if (isLoading) {
|
|
document.getElementById("load-url-button").hidden = true;
|
|
document.getElementById("browser-loading").setAttribute("status", "animate");
|
|
}
|
|
else {
|
|
document.getElementById("load-url-button").hidden = false;
|
|
document.getElementById("browser-loading").removeAttribute("status");
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 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', 'eslint');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
let eslintSubprocess = null;
|
|
async function runESLint() {
|
|
let eslintPath = await getESLintPath();
|
|
if (!eslintPath) return [];
|
|
|
|
try {
|
|
let metadata = _getMetadataObject();
|
|
let code = _getCode();
|
|
let translatorString = _translatorProvider.stringify(metadata, code);
|
|
|
|
let subprocessOptions = {
|
|
command: eslintPath,
|
|
arguments: [
|
|
'--format',
|
|
'json',
|
|
'--stdin',
|
|
'--stdin-filename',
|
|
_translatorProvider.getSavePath(metadata)
|
|
],
|
|
};
|
|
|
|
// ESLint needs to find node on the PATH, but macOS doesn't forward
|
|
// the login shell's PATH to GUI processes by default. There's a
|
|
// launchctl command that fixes it, but we can't expect people
|
|
// to do that. Pass the login shell's PATH as a workaround.
|
|
if (Zotero.isMac) {
|
|
subprocessOptions.environment = { PATH: await lazy.shellPathPromise };
|
|
subprocessOptions.environmentAppend = true;
|
|
}
|
|
|
|
let proc = await Subprocess.call(subprocessOptions);
|
|
if (eslintSubprocess) {
|
|
eslintSubprocess.kill(0);
|
|
}
|
|
eslintSubprocess = proc;
|
|
|
|
await proc.stdin.write(translatorString);
|
|
await proc.stdin.close();
|
|
let lintOutput = '';
|
|
let chunk;
|
|
while ((chunk = await proc.stdout.readString())) {
|
|
lintOutput += chunk;
|
|
}
|
|
proc.kill(); // Shouldn't be necessary, but make sure we don't leak
|
|
return JSON.parse(lintOutput);
|
|
}
|
|
catch (e) {
|
|
if (!(e instanceof SyntaxError)) {
|
|
Zotero.logError(e);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function eslintOutputToModelMarkers(output, modelVersionId) {
|
|
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',
|
|
code: message.ruleId,
|
|
modelVersionId,
|
|
}));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function _handleAPIKeyChange() {
|
|
let apiKey = Zotero.Prefs.get('scaffold.completions.mistralAPIKey');
|
|
if (apiKey) {
|
|
_copilot = new CompletionCopilot(apiKey, {
|
|
provider: 'mistral',
|
|
model: 'codestral'
|
|
});
|
|
}
|
|
else {
|
|
_copilot = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('load', e => e.target === document && Scaffold.handleLoad(e));
|
|
window.addEventListener('unload', e => e.target === document && Scaffold.handleUnload(e));
|