2017-05-31 15:28:47 +00:00
|
|
|
chai.use(chaiAsPromised);
|
|
|
|
|
2015-03-24 04:52:36 +00:00
|
|
|
// Useful "constants"
|
|
|
|
var sqlDateTimeRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
|
|
var isoDateTimeRe = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
2015-06-01 03:59:15 +00:00
|
|
|
var zoteroObjectKeyRe = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/; // based on Zotero.Utilities::generateObjectKey()
|
2020-02-03 04:42:46 +00:00
|
|
|
var browserWindowInitialized = false;
|
2015-03-24 04:52:36 +00:00
|
|
|
|
2015-03-08 22:39:42 +00:00
|
|
|
/**
|
|
|
|
* Waits for a DOM event on the specified node. Returns a promise
|
|
|
|
* resolved with the event.
|
|
|
|
*/
|
|
|
|
function waitForDOMEvent(target, event, capture) {
|
2015-04-13 07:27:51 +00:00
|
|
|
var deferred = Zotero.Promise.defer();
|
2015-03-08 22:39:42 +00:00
|
|
|
var func = function(ev) {
|
2016-07-23 20:27:44 +00:00
|
|
|
target.removeEventListener(event, func, capture);
|
2015-03-08 22:39:42 +00:00
|
|
|
deferred.resolve(ev);
|
|
|
|
}
|
|
|
|
target.addEventListener(event, func, capture);
|
|
|
|
return deferred.promise;
|
|
|
|
}
|
|
|
|
|
2018-03-01 02:43:18 +00:00
|
|
|
async function waitForRecognizer() {
|
2022-05-24 19:52:38 +00:00
|
|
|
var win = await waitForWindow('chrome://zotero/content/progressQueueDialog.xhtml')
|
2018-03-01 02:43:18 +00:00
|
|
|
// Wait for status to show as complete
|
2018-10-05 05:56:46 +00:00
|
|
|
var completeStr = Zotero.getString("general.finished");
|
2018-03-01 02:43:18 +00:00
|
|
|
while (win.document.getElementById("label").value != completeStr) {
|
|
|
|
await Zotero.Promise.delay(20);
|
|
|
|
}
|
|
|
|
return win;
|
|
|
|
}
|
|
|
|
|
2015-03-08 19:59:53 +00:00
|
|
|
/**
|
2015-05-05 06:44:17 +00:00
|
|
|
* Open a chrome window and return a promise for the window
|
|
|
|
*
|
|
|
|
* @return {Promise<ChromeWindow>}
|
2015-03-08 19:59:53 +00:00
|
|
|
*/
|
|
|
|
function loadWindow(winurl, argument) {
|
|
|
|
var win = window.openDialog(winurl, "_blank", "chrome", argument);
|
2015-03-08 22:39:42 +00:00
|
|
|
return waitForDOMEvent(win, "load").then(function() {
|
|
|
|
return win;
|
|
|
|
});
|
2015-03-08 19:59:53 +00:00
|
|
|
}
|
|
|
|
|
2015-04-26 20:48:34 +00:00
|
|
|
/**
|
|
|
|
* Open a browser window and return a promise for the window
|
|
|
|
*
|
|
|
|
* @return {Promise<ChromeWindow>}
|
|
|
|
*/
|
|
|
|
function loadBrowserWindow() {
|
2022-06-09 07:10:25 +00:00
|
|
|
var win = window.openDialog("chrome://browser/content/browser.xhtml", "", "all,height=700,width=1000");
|
|
|
|
return waitForDOMEvent(win, "load").then(function() {
|
|
|
|
return new Zotero.Promise((resolve) => {
|
|
|
|
if (!browserWindowInitialized) {
|
|
|
|
setTimeout(function () {
|
|
|
|
browserWindowInitialized = true;
|
|
|
|
resolve(win);
|
|
|
|
}, 1000);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resolve(win);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Open a Zotero window and return a promise for the window
|
|
|
|
*
|
|
|
|
* @return {Promise<ChromeWindow>}
|
|
|
|
*/
|
|
|
|
function loadZoteroWindow() {
|
|
|
|
var win = window.openDialog("chrome://zotero/content/zoteroPane.xhtml", "", "all,height=700,width=1000");
|
2015-05-05 06:44:17 +00:00
|
|
|
return waitForDOMEvent(win, "load").then(function() {
|
2020-02-03 04:42:46 +00:00
|
|
|
return new Zotero.Promise((resolve) => {
|
|
|
|
if (!browserWindowInitialized) {
|
|
|
|
setTimeout(function () {
|
|
|
|
browserWindowInitialized = true;
|
|
|
|
resolve(win);
|
|
|
|
}, 1000);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resolve(win);
|
|
|
|
});
|
2015-05-05 06:44:17 +00:00
|
|
|
});
|
2015-04-26 20:48:34 +00:00
|
|
|
}
|
|
|
|
|
2015-03-08 19:59:53 +00:00
|
|
|
/**
|
2016-03-21 05:30:16 +00:00
|
|
|
* Opens the Zotero pane and selects My Library. Returns the containing window.
|
|
|
|
*
|
|
|
|
* @param {Window} [win] - Existing window to use; if not specified, a new window is opened
|
2015-03-08 19:59:53 +00:00
|
|
|
*/
|
2019-08-26 04:34:06 +00:00
|
|
|
var loadZoteroPane = async function (win) {
|
2015-11-15 22:46:48 +00:00
|
|
|
if (!win) {
|
2022-06-09 07:10:25 +00:00
|
|
|
var win = await loadZoteroWindow();
|
2015-11-15 22:46:48 +00:00
|
|
|
}
|
2015-05-25 05:43:07 +00:00
|
|
|
Zotero.Prefs.clear('lastViewedFolder');
|
2015-05-04 06:00:52 +00:00
|
|
|
|
2019-08-26 04:34:06 +00:00
|
|
|
while (true) {
|
|
|
|
if (win.ZoteroPane && win.ZoteroPane.collectionsView) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
Zotero.debug("Waiting for ZoteroPane initialization");
|
|
|
|
await Zotero.Promise.delay(50);
|
|
|
|
}
|
|
|
|
|
|
|
|
await waitForItemsLoad(win, 0);
|
2015-05-04 06:00:52 +00:00
|
|
|
|
|
|
|
return win;
|
2019-08-26 04:34:06 +00:00
|
|
|
};
|
2015-03-08 19:59:53 +00:00
|
|
|
|
2017-03-13 22:59:50 +00:00
|
|
|
var loadPrefPane = Zotero.Promise.coroutine(function* (paneName) {
|
|
|
|
var id = 'zotero-prefpane-' + paneName;
|
2022-08-10 22:26:23 +00:00
|
|
|
var win = yield loadWindow("chrome://zotero/content/preferences/preferences.xhtml", {
|
2017-03-13 22:59:50 +00:00
|
|
|
pane: id
|
|
|
|
});
|
|
|
|
var doc = win.document;
|
|
|
|
var defer = Zotero.Promise.defer();
|
|
|
|
var pane = doc.getElementById(id);
|
|
|
|
if (!pane.loaded) {
|
|
|
|
pane.addEventListener('paneload', () => defer.resolve());
|
|
|
|
yield defer.promise;
|
|
|
|
}
|
|
|
|
return win;
|
|
|
|
});
|
|
|
|
|
|
|
|
|
2015-03-08 19:59:53 +00:00
|
|
|
/**
|
2015-06-01 03:07:24 +00:00
|
|
|
* Waits for a window with a specific URL to open. Returns a promise for the window, and
|
|
|
|
* optionally passes the window to a callback immediately for use with modal dialogs,
|
|
|
|
* which prevent async code from continuing
|
2015-03-08 19:59:53 +00:00
|
|
|
*/
|
2015-06-01 03:07:24 +00:00
|
|
|
function waitForWindow(uri, callback) {
|
2015-04-13 07:27:51 +00:00
|
|
|
var deferred = Zotero.Promise.defer();
|
2015-03-08 19:59:53 +00:00
|
|
|
var loadobserver = function(ev) {
|
|
|
|
ev.originalTarget.removeEventListener("load", loadobserver, false);
|
2016-09-06 22:02:54 +00:00
|
|
|
Zotero.debug("Window opened: " + ev.target.location.href);
|
2017-04-13 08:28:13 +00:00
|
|
|
|
|
|
|
if (ev.target.location.href != uri) {
|
2022-06-19 07:44:56 +00:00
|
|
|
Zotero.debug(`Ignoring window ${ev.target.location.href} in waitForWindow()`);
|
2017-04-13 08:28:13 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
Services.ww.unregisterNotification(winobserver);
|
2022-06-19 07:44:56 +00:00
|
|
|
var win = ev.target.ownerGlobal;
|
2017-04-13 08:28:13 +00:00
|
|
|
// Give window code time to run on load
|
2022-06-19 07:44:56 +00:00
|
|
|
win.setTimeout(function () {
|
2017-04-13 08:28:13 +00:00
|
|
|
if (callback) {
|
|
|
|
try {
|
|
|
|
// If callback returns a promise, wait for it
|
|
|
|
let maybePromise = callback(win);
|
|
|
|
if (maybePromise && maybePromise.then) {
|
|
|
|
maybePromise.then(() => deferred.resolve(win)).catch(e => deferred.reject(e));
|
2015-06-04 22:52:47 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
2017-04-13 08:28:13 +00:00
|
|
|
catch (e) {
|
|
|
|
Zotero.logError(e);
|
|
|
|
win.close();
|
|
|
|
deferred.reject(e);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
deferred.resolve(win);
|
|
|
|
});
|
2015-03-08 19:59:53 +00:00
|
|
|
};
|
|
|
|
var winobserver = {"observe":function(subject, topic, data) {
|
|
|
|
if(topic != "domwindowopened") return;
|
2022-06-19 07:44:56 +00:00
|
|
|
subject.addEventListener("load", loadobserver, false);
|
2015-03-08 19:59:53 +00:00
|
|
|
}};
|
2015-03-08 22:39:42 +00:00
|
|
|
Services.ww.registerNotification(winobserver);
|
2015-03-08 19:59:53 +00:00
|
|
|
return deferred.promise;
|
|
|
|
}
|
|
|
|
|
2015-06-04 22:52:47 +00:00
|
|
|
/**
|
|
|
|
* Wait for an alert or confirmation dialog to pop up and then close it
|
|
|
|
*
|
2022-06-20 23:30:28 +00:00
|
|
|
* @param {Function} [onOpen] - Function that is passed the window and dialog once it is opened.
|
2015-06-04 22:52:47 +00:00
|
|
|
* Can be used to make assertions on the dialog contents
|
|
|
|
* (e.g., with dialog.document.documentElement.textContent)
|
|
|
|
* @param {String} [button='accept'] - Button in dialog to press (e.g., 'cancel', 'extra1')
|
|
|
|
* @return {Promise}
|
|
|
|
*/
|
2015-06-23 08:21:54 +00:00
|
|
|
function waitForDialog(onOpen, button='accept', url) {
|
2022-06-19 07:44:56 +00:00
|
|
|
return waitForWindow(url || "chrome://global/content/commonDialog.xhtml", Zotero.Promise.method(function (win) {
|
2022-06-20 23:30:28 +00:00
|
|
|
var dialog = win.document.querySelector('dialog');
|
2015-06-04 22:52:47 +00:00
|
|
|
var failure = false;
|
|
|
|
if (onOpen) {
|
|
|
|
try {
|
2022-06-20 23:30:28 +00:00
|
|
|
onOpen(win, dialog);
|
2015-06-04 22:52:47 +00:00
|
|
|
}
|
|
|
|
catch (e) {
|
|
|
|
failure = e;
|
|
|
|
}
|
|
|
|
}
|
2015-12-10 06:09:40 +00:00
|
|
|
if (button === false) {
|
|
|
|
if (failure) {
|
|
|
|
throw failure;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (button != 'cancel') {
|
2015-06-04 22:52:47 +00:00
|
|
|
let deferred = Zotero.Promise.defer();
|
|
|
|
function acceptWhenEnabled() {
|
2015-06-10 04:32:42 +00:00
|
|
|
// Handle delayed buttons
|
2022-06-20 23:30:28 +00:00
|
|
|
if (dialog.getButton(button).disabled) {
|
2022-06-19 07:44:56 +00:00
|
|
|
win.setTimeout(function () {
|
2015-06-04 22:52:47 +00:00
|
|
|
acceptWhenEnabled();
|
|
|
|
}, 250);
|
|
|
|
}
|
|
|
|
else {
|
2022-06-20 23:30:28 +00:00
|
|
|
dialog.getButton(button).click();
|
2015-06-04 22:52:47 +00:00
|
|
|
if (failure) {
|
|
|
|
deferred.reject(failure);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
deferred.resolve();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
acceptWhenEnabled();
|
|
|
|
return deferred.promise;
|
|
|
|
}
|
|
|
|
else {
|
2022-06-20 23:30:28 +00:00
|
|
|
dialog.getButton(button).click();
|
2015-06-04 22:52:47 +00:00
|
|
|
if (failure) {
|
|
|
|
throw failure;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}))
|
|
|
|
}
|
|
|
|
|
Relations overhaul (requires new DB upgrade from 4.0)
Relations are now properties of collections and items rather than
first-class objects, stored in separate collectionRelations and
itemRelations tables with ids for subjects, with foreign keys to the
associated data objects.
Related items now use dc:relation relations rather than a separate table
(among other reasons, because API syncing won't necessarily sync both
items at the same time, so they can't be stored by id).
The UI assigns related-item relations bidirectionally, and checks for
related-item and linked-object relations are done unidirectionally by
default.
dc:isReplacedBy is now dc:replaces, so that the subject is an existing
object, and the predicate is now named
Zotero.Attachments.replacedItemPredicate.
Some additional work is still needed, notably around following
replaced-item relations, and migration needs to be tested more fully,
but this seems to mostly work.
2015-06-02 00:09:39 +00:00
|
|
|
var selectLibrary = Zotero.Promise.coroutine(function* (win, libraryID) {
|
|
|
|
libraryID = libraryID || Zotero.Libraries.userLibraryID;
|
|
|
|
yield win.ZoteroPane.collectionsView.selectLibrary(libraryID);
|
2015-05-25 02:57:46 +00:00
|
|
|
yield waitForItemsLoad(win);
|
|
|
|
});
|
|
|
|
|
2015-06-02 07:33:05 +00:00
|
|
|
var waitForItemsLoad = Zotero.Promise.coroutine(function* (win, collectionRowToSelect) {
|
2015-05-08 20:01:25 +00:00
|
|
|
var zp = win.ZoteroPane;
|
|
|
|
var cv = zp.collectionsView;
|
2015-06-02 07:33:05 +00:00
|
|
|
|
2017-03-24 09:18:55 +00:00
|
|
|
yield cv.waitForLoad();
|
2015-06-02 07:33:05 +00:00
|
|
|
if (collectionRowToSelect !== undefined) {
|
|
|
|
yield cv.selectWait(collectionRowToSelect);
|
|
|
|
}
|
2017-03-24 09:18:55 +00:00
|
|
|
yield zp.itemsView.waitForLoad();
|
2015-06-02 07:33:05 +00:00
|
|
|
});
|
2015-05-08 20:01:25 +00:00
|
|
|
|
2019-03-18 08:53:30 +00:00
|
|
|
/**
|
|
|
|
* Return a promise that resolves once the tag selector has updated
|
|
|
|
*
|
|
|
|
* Some operations result in two tag selector updates, one from the notify() and another from
|
|
|
|
* onItemViewChanged(). Pass 2 for numUpdates to wait for both.
|
|
|
|
*/
|
|
|
|
var waitForTagSelector = function (win, numUpdates = 1) {
|
|
|
|
var updates = 0;
|
|
|
|
|
2017-03-24 04:51:25 +00:00
|
|
|
var zp = win.ZoteroPane;
|
|
|
|
var deferred = Zotero.Promise.defer();
|
|
|
|
if (zp.tagSelectorShown()) {
|
2018-12-12 10:34:39 +00:00
|
|
|
let tagSelector = zp.tagSelector;
|
|
|
|
let componentDidUpdate = tagSelector.componentDidUpdate;
|
|
|
|
tagSelector.componentDidUpdate = function() {
|
2019-03-18 08:53:30 +00:00
|
|
|
updates++;
|
|
|
|
if (updates == numUpdates) {
|
|
|
|
deferred.resolve();
|
|
|
|
tagSelector.componentDidUpdate = componentDidUpdate;
|
|
|
|
}
|
2018-12-12 10:34:39 +00:00
|
|
|
if (typeof componentDidUpdate == 'function') {
|
|
|
|
componentDidUpdate.call(this, arguments);
|
|
|
|
}
|
|
|
|
}
|
2017-03-24 04:51:25 +00:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
deferred.resolve();
|
|
|
|
}
|
|
|
|
return deferred.promise;
|
|
|
|
};
|
|
|
|
|
XUL -> JS tree megacommit
- Just a single huge commit. This has been developed over too long a
time, required many tiny changes across too many files and has seen too
many iterations to be separated into separate commits.
The original branch with all the messy commits will be kept around for
posterity
https://github.com/zotero/zotero/compare/bb220ad0f2d6bf0eca6df6d225d3d358cb50a27b...adomasven:feature/react-item-tree
- Replaces XUL <tree> element across the whole zotero client codebase
with a custom supermegafast virtualized-table inspired by
react-virtualized yet mimicking old XUL treeview API. The
virtualized-table sits on top on a raw-to-the-metal,
interpreted-at-runtime JS based windowing solution inspired by
react-window. React-based solutions could not be used because they were
slow and Zotero UI needs to be responsive and be able to
display thousands of rows in a treeview without any slowdowns.
- Attempts were made at making this screen-reader friendly, but yet to
be tested with something like JAWS
- RTL-friendly
- Styling and behaviour across all platforms was copied as closely as
possible to the original XUL tree
- Instead of row-based scroll snapping this has smooth-scrolling. If
you're using arrow keys to browse through the tree then it effectively
snap-scrolls. Current CSS snap scroll attributes do not seem to work in
the way we would require even on up-to-date browsers, yet alone the ESR
version of FX that Zotero is on. JS solutions are either terrible for
performance or produce inexcusable jitter.
- When dragging-and-dropping items the initial drag freezes the UI for
a fairly jarring amount of time. Does not seem to be fixable due to
the synchronous code that needs to be run in the dragstart handler.
Used to be possible to run that code async with the XUL tree.
- Item tree column picker no longer has a dedicated button. Just
right-click the columns. The column preferences (width, order, etc) are
no longer handled by XUL, which required a custom serialization and
storage solution that throws warnings in the developer console due to
the amount of data being stored. Might cause temporary freezing on HDDs
upon column resize/reorder/visibility toggling.
- Context menu handling code basically unchanged, but any UI changes
that plugins may have wanted to do (including adding new columns) will
have to be redone by them. No serious thought has gone into how plugin
developers would achieve that yet.
- Opens up the possibility for awesome alternative ways to render the
tree items, including things like multiple-row view for the item tree,
which has been requested for a long while especially by users switching
from other referencing software
2020-06-03 07:29:46 +00:00
|
|
|
var waitForCollectionTree = function(win) {
|
|
|
|
let cv = win.ZoteroPane.collectionsView;
|
|
|
|
return cv._waitForEvent('refresh');
|
|
|
|
}
|
|
|
|
|
2015-03-08 19:59:53 +00:00
|
|
|
/**
|
|
|
|
* Waits for a single item event. Returns a promise for the item ID(s).
|
|
|
|
*/
|
|
|
|
function waitForItemEvent(event) {
|
2015-08-08 21:26:42 +00:00
|
|
|
return waitForNotifierEvent(event, 'item').then(x => x.ids);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Wait for a single notifier event and return a promise for the data
|
2019-06-10 06:19:49 +00:00
|
|
|
*
|
|
|
|
* Tests run after all other handlers (priority 101, since handlers are 100 by default)
|
2015-08-08 21:26:42 +00:00
|
|
|
*/
|
|
|
|
function waitForNotifierEvent(event, type) {
|
2016-03-22 04:40:59 +00:00
|
|
|
if (!event) throw new Error("event not provided");
|
|
|
|
|
2015-04-13 07:27:51 +00:00
|
|
|
var deferred = Zotero.Promise.defer();
|
2015-03-08 19:59:53 +00:00
|
|
|
var notifierID = Zotero.Notifier.registerObserver({notify:function(ev, type, ids, extraData) {
|
|
|
|
if(ev == event) {
|
|
|
|
Zotero.Notifier.unregisterObserver(notifierID);
|
2015-08-08 21:26:42 +00:00
|
|
|
deferred.resolve({
|
|
|
|
ids: ids,
|
|
|
|
extraData: extraData
|
|
|
|
});
|
2015-03-08 19:59:53 +00:00
|
|
|
}
|
2019-06-10 06:19:49 +00:00
|
|
|
}}, [type], 'test', 101);
|
2015-03-08 19:59:53 +00:00
|
|
|
return deferred.promise;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2015-03-08 22:39:42 +00:00
|
|
|
* Looks for windows with a specific URL.
|
|
|
|
*/
|
|
|
|
function getWindows(uri) {
|
|
|
|
var enumerator = Services.wm.getEnumerator(null);
|
|
|
|
var wins = [];
|
|
|
|
while(enumerator.hasMoreElements()) {
|
|
|
|
var win = enumerator.getNext();
|
|
|
|
if(win.location == uri) {
|
|
|
|
wins.push(win);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return wins;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Resolve a promise when a specified callback returns true. interval
|
|
|
|
* specifies the interval between checks. timeout specifies when we
|
|
|
|
* should assume failure.
|
|
|
|
*/
|
|
|
|
function waitForCallback(cb, interval, timeout) {
|
2015-04-13 07:27:51 +00:00
|
|
|
var deferred = Zotero.Promise.defer();
|
2015-03-08 22:39:42 +00:00
|
|
|
if(interval === undefined) interval = 100;
|
|
|
|
if(timeout === undefined) timeout = 10000;
|
|
|
|
var start = Date.now();
|
|
|
|
var id = setInterval(function() {
|
|
|
|
var success = cb();
|
|
|
|
if(success) {
|
|
|
|
clearInterval(id);
|
|
|
|
deferred.resolve(success);
|
|
|
|
} else if(Date.now() - start > timeout*1000) {
|
|
|
|
clearInterval(id);
|
|
|
|
deferred.reject(new Error("Promise timed out"));
|
|
|
|
}
|
|
|
|
}, interval);
|
|
|
|
return deferred.promise;
|
|
|
|
}
|
|
|
|
|
2015-06-02 00:00:25 +00:00
|
|
|
|
XUL -> JS tree megacommit
- Just a single huge commit. This has been developed over too long a
time, required many tiny changes across too many files and has seen too
many iterations to be separated into separate commits.
The original branch with all the messy commits will be kept around for
posterity
https://github.com/zotero/zotero/compare/bb220ad0f2d6bf0eca6df6d225d3d358cb50a27b...adomasven:feature/react-item-tree
- Replaces XUL <tree> element across the whole zotero client codebase
with a custom supermegafast virtualized-table inspired by
react-virtualized yet mimicking old XUL treeview API. The
virtualized-table sits on top on a raw-to-the-metal,
interpreted-at-runtime JS based windowing solution inspired by
react-window. React-based solutions could not be used because they were
slow and Zotero UI needs to be responsive and be able to
display thousands of rows in a treeview without any slowdowns.
- Attempts were made at making this screen-reader friendly, but yet to
be tested with something like JAWS
- RTL-friendly
- Styling and behaviour across all platforms was copied as closely as
possible to the original XUL tree
- Instead of row-based scroll snapping this has smooth-scrolling. If
you're using arrow keys to browse through the tree then it effectively
snap-scrolls. Current CSS snap scroll attributes do not seem to work in
the way we would require even on up-to-date browsers, yet alone the ESR
version of FX that Zotero is on. JS solutions are either terrible for
performance or produce inexcusable jitter.
- When dragging-and-dropping items the initial drag freezes the UI for
a fairly jarring amount of time. Does not seem to be fixable due to
the synchronous code that needs to be run in the dragstart handler.
Used to be possible to run that code async with the XUL tree.
- Item tree column picker no longer has a dedicated button. Just
right-click the columns. The column preferences (width, order, etc) are
no longer handled by XUL, which required a custom serialization and
storage solution that throws warnings in the developer console due to
the amount of data being stored. Might cause temporary freezing on HDDs
upon column resize/reorder/visibility toggling.
- Context menu handling code basically unchanged, but any UI changes
that plugins may have wanted to do (including adding new columns) will
have to be redone by them. No serious thought has gone into how plugin
developers would achieve that yet.
- Opens up the possibility for awesome alternative ways to render the
tree items, including things like multiple-row view for the item tree,
which has been requested for a long while especially by users switching
from other referencing software
2020-06-03 07:29:46 +00:00
|
|
|
function clickOnItemsRow(win, itemsView, row) {
|
|
|
|
itemsView._treebox.scrollToRow(row);
|
|
|
|
let elem = win.document.querySelector(`#${itemsView.id}-row-${row}`);
|
|
|
|
elem.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
|
2016-03-14 00:31:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2016-03-28 06:35:27 +00:00
|
|
|
/**
|
|
|
|
* Synchronous inflate
|
|
|
|
*/
|
|
|
|
function gunzip(gzdata) {
|
|
|
|
return pako.inflate(gzdata, { to: 'string' });
|
|
|
|
}
|
|
|
|
|
2016-03-14 00:31:15 +00:00
|
|
|
|
2015-06-02 00:00:25 +00:00
|
|
|
/**
|
|
|
|
* Get a default group used by all tests that want one, creating one if necessary
|
|
|
|
*/
|
2015-06-07 19:40:04 +00:00
|
|
|
var _defaultGroup;
|
|
|
|
var getGroup = Zotero.Promise.method(function () {
|
|
|
|
// Cleared in resetDB()
|
|
|
|
if (_defaultGroup) {
|
|
|
|
return _defaultGroup;
|
|
|
|
}
|
|
|
|
return _defaultGroup = createGroup({
|
2015-06-02 00:00:25 +00:00
|
|
|
name: "My Group"
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
2015-06-02 04:29:40 +00:00
|
|
|
var createGroup = Zotero.Promise.coroutine(function* (props = {}) {
|
2022-02-19 17:42:42 +00:00
|
|
|
// Create a group item requires the current user to be set
|
|
|
|
if (!Zotero.Users.getCurrentUserID()) {
|
|
|
|
yield Zotero.Users.setCurrentUserID(1);
|
|
|
|
yield Zotero.Users.setName(1, 'Name');
|
|
|
|
}
|
|
|
|
|
2015-06-02 00:00:25 +00:00
|
|
|
var group = new Zotero.Group;
|
2015-06-07 19:40:04 +00:00
|
|
|
group.id = props.id || Zotero.Utilities.rand(10000, 1000000);
|
2015-06-02 00:00:25 +00:00
|
|
|
group.name = props.name || "Test " + Zotero.Utilities.randomString();
|
|
|
|
group.description = props.description || "";
|
2015-06-02 04:29:40 +00:00
|
|
|
group.editable = props.editable === undefined ? true : props.editable;
|
|
|
|
group.filesEditable = props.filesEditable === undefined ? true : props.filesEditable;
|
|
|
|
group.version = props.version === undefined ? Zotero.Utilities.rand(1000, 10000) : props.version;
|
2016-07-19 22:54:37 +00:00
|
|
|
if (props.libraryVersion) {
|
|
|
|
group.libraryVersion = props.libraryVersion;
|
|
|
|
}
|
2017-02-24 05:13:11 +00:00
|
|
|
group.archived = props.archived === undefined ? false : props.archived;
|
2015-06-02 04:29:40 +00:00
|
|
|
yield group.saveTx();
|
2015-06-02 00:00:25 +00:00
|
|
|
return group;
|
|
|
|
});
|
|
|
|
|
2016-01-13 13:13:29 +00:00
|
|
|
var createFeed = Zotero.Promise.coroutine(function* (props = {}) {
|
|
|
|
var feed = new Zotero.Feed;
|
|
|
|
feed.name = props.name || "Test " + Zotero.Utilities.randomString();
|
|
|
|
feed.description = props.description || "";
|
|
|
|
feed.url = props.url || 'http://www.' + Zotero.Utilities.randomString() + '.com/feed.rss';
|
|
|
|
feed.refreshInterval = props.refreshInterval || 12;
|
2016-12-13 14:07:43 +00:00
|
|
|
feed.cleanupReadAfter = props.cleanupReadAfter || 2;
|
|
|
|
feed.cleanupUnreadAfter = props.cleanupUnreadAfter || 30;
|
2017-06-16 09:56:06 +00:00
|
|
|
yield feed.saveTx(props.saveOptions);
|
2016-01-13 13:13:29 +00:00
|
|
|
return feed;
|
|
|
|
});
|
|
|
|
|
|
|
|
var clearFeeds = Zotero.Promise.coroutine(function* () {
|
|
|
|
let feeds = Zotero.Feeds.getAll();
|
|
|
|
for (let i=0; i<feeds.length; i++) {
|
|
|
|
yield feeds[i].eraseTx();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2015-05-20 23:28:35 +00:00
|
|
|
//
|
|
|
|
// Data objects
|
|
|
|
//
|
2015-07-22 09:21:32 +00:00
|
|
|
/**
|
|
|
|
* @param {String} objectType - 'collection', 'item', 'search'
|
|
|
|
* @param {Object} [params]
|
|
|
|
* @param {Integer} [params.libraryID]
|
|
|
|
* @param {String} [params.itemType] - Item type
|
|
|
|
* @param {String} [params.title] - Item title
|
|
|
|
* @param {Boolean} [params.setTitle] - Assign a random item title
|
|
|
|
* @param {String} [params.name] - Collection/search name
|
|
|
|
* @param {Integer} [params.parentID]
|
|
|
|
* @param {String} [params.parentKey]
|
|
|
|
* @param {Boolean} [params.synced]
|
|
|
|
* @param {Integer} [params.version]
|
2015-08-06 08:04:37 +00:00
|
|
|
* @param {Integer} [params.dateAdded] - Allowed for items
|
|
|
|
* @param {Integer} [params.dateModified] - Allowed for items
|
2015-07-22 09:21:32 +00:00
|
|
|
*/
|
|
|
|
function createUnsavedDataObject(objectType, params = {}) {
|
2015-06-02 00:02:10 +00:00
|
|
|
if (!objectType) {
|
|
|
|
throw new Error("Object type not provided");
|
|
|
|
}
|
|
|
|
|
2021-01-13 05:40:13 +00:00
|
|
|
var allowedParams = ['libraryID', 'parentID', 'parentKey', 'synced', 'version', 'deleted'];
|
2015-06-02 04:29:40 +00:00
|
|
|
|
|
|
|
var itemType;
|
|
|
|
if (objectType == 'item' || objectType == 'feedItem') {
|
|
|
|
itemType = params.itemType || 'book';
|
2021-01-13 05:40:13 +00:00
|
|
|
allowedParams.push('dateAdded', 'dateModified');
|
2015-05-20 23:28:35 +00:00
|
|
|
}
|
2017-04-18 01:34:08 +00:00
|
|
|
if (objectType == 'item') {
|
|
|
|
allowedParams.push('inPublications');
|
|
|
|
}
|
2015-06-02 04:29:40 +00:00
|
|
|
if (objectType == 'feedItem') {
|
|
|
|
params.guid = params.guid || Zotero.randomString();
|
|
|
|
allowedParams.push('guid');
|
2015-06-02 00:02:10 +00:00
|
|
|
}
|
2015-06-02 04:29:40 +00:00
|
|
|
|
|
|
|
var obj = new Zotero[Zotero.Utilities.capitalize(objectType)](itemType);
|
2017-02-21 05:03:39 +00:00
|
|
|
if (params.libraryID) {
|
|
|
|
obj.libraryID = params.libraryID;
|
|
|
|
}
|
2015-06-02 04:29:40 +00:00
|
|
|
|
2015-05-20 23:28:35 +00:00
|
|
|
switch (objectType) {
|
2015-06-02 00:02:10 +00:00
|
|
|
case 'item':
|
2015-06-02 04:29:40 +00:00
|
|
|
case 'feedItem':
|
2018-02-03 09:14:33 +00:00
|
|
|
if (params.parentItemID) {
|
|
|
|
params.parentID = params.parentItemID;
|
|
|
|
delete params.parentItemID;
|
|
|
|
}
|
2015-07-22 09:21:32 +00:00
|
|
|
if (params.title !== undefined || params.setTitle) {
|
|
|
|
obj.setField('title', params.title !== undefined ? params.title : Zotero.Utilities.randomString());
|
2015-06-02 00:02:10 +00:00
|
|
|
}
|
2022-03-09 07:37:15 +00:00
|
|
|
if (params.creators !== undefined) {
|
|
|
|
obj.setCreators(params.creators);
|
|
|
|
}
|
2016-01-17 21:55:34 +00:00
|
|
|
if (params.collections !== undefined) {
|
|
|
|
obj.setCollections(params.collections);
|
|
|
|
}
|
2017-04-01 06:54:24 +00:00
|
|
|
if (params.tags !== undefined) {
|
|
|
|
obj.setTags(params.tags);
|
|
|
|
}
|
2017-04-01 18:28:32 +00:00
|
|
|
if (params.note !== undefined) {
|
2020-09-12 20:09:49 +00:00
|
|
|
obj.setNote(params.note);
|
2017-04-01 18:28:32 +00:00
|
|
|
}
|
2015-06-02 00:02:10 +00:00
|
|
|
break;
|
|
|
|
|
2015-05-20 23:28:35 +00:00
|
|
|
case 'collection':
|
|
|
|
case 'search':
|
2015-07-22 09:21:32 +00:00
|
|
|
obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString();
|
2015-05-20 23:28:35 +00:00
|
|
|
break;
|
|
|
|
}
|
2015-06-02 04:29:40 +00:00
|
|
|
|
2016-03-26 06:59:54 +00:00
|
|
|
if (objectType == 'search') {
|
2016-07-06 06:04:53 +00:00
|
|
|
obj.addCondition('title', 'contains', Zotero.Utilities.randomString());
|
2016-07-07 11:55:15 +00:00
|
|
|
obj.addCondition('title', 'isNot', Zotero.Utilities.randomString());
|
2016-03-26 06:59:54 +00:00
|
|
|
}
|
|
|
|
|
2022-02-22 00:29:32 +00:00
|
|
|
Zotero.Utilities.Internal.assignProps(obj, params, allowedParams);
|
2015-06-02 04:29:40 +00:00
|
|
|
|
2015-05-20 23:28:35 +00:00
|
|
|
return obj;
|
|
|
|
}
|
|
|
|
|
2015-07-22 09:21:32 +00:00
|
|
|
var createDataObject = Zotero.Promise.coroutine(function* (objectType, params = {}, saveOptions) {
|
2015-05-20 23:28:35 +00:00
|
|
|
var obj = createUnsavedDataObject(objectType, params);
|
2016-02-11 11:02:38 +00:00
|
|
|
yield obj.saveTx(saveOptions);
|
2015-05-22 01:56:04 +00:00
|
|
|
return obj;
|
2015-05-20 23:28:35 +00:00
|
|
|
});
|
|
|
|
|
2015-07-22 09:21:32 +00:00
|
|
|
function getNameProperty(objectType) {
|
|
|
|
return objectType == 'item' ? 'title' : 'name';
|
|
|
|
}
|
|
|
|
|
Deasyncification :back: :cry:
While trying to get translation and citing working with asynchronously
generated data, we realized that drag-and-drop support was going to
be...problematic. Firefox only supports synchronous methods for
providing drag data (unlike, it seems, the DataTransferItem interface
supported by Chrome), which means that we'd need to preload all relevant
data on item selection (bounded by export.quickCopy.dragLimit) and keep
the translate/cite methods synchronous (or maintain two separate
versions).
What we're trying instead is doing what I said in #518 we weren't going
to do: loading most object data on startup and leaving many more
functions synchronous. Essentially, this takes the various load*()
methods described in #518, moves them to startup, and makes them operate
on entire libraries rather than individual objects.
The obvious downside here (other than undoing much of the work of the
last many months) is that it increases startup time, potentially quite a
lot for larger libraries. On my laptop, with a 3,000-item library, this
adds about 3 seconds to startup time. I haven't yet tested with larger
libraries. But I'm hoping that we can optimize this further to reduce
that delay. Among other things, this is loading data for all libraries,
when it should be able to load data only for the library being viewed.
But this is also fundamentally just doing some SELECT queries and
storing the results, so it really shouldn't need to be that slow (though
performance may be bounded a bit here by XPCOM overhead).
If we can make this fast enough, it means that third-party plugins
should be able to remain much closer to their current designs. (Some
things, including saving, will still need to be made asynchronous.)
2016-03-07 21:05:51 +00:00
|
|
|
var modifyDataObject = function (obj, params = {}, saveOptions) {
|
2015-07-22 09:21:32 +00:00
|
|
|
switch (obj.objectType) {
|
|
|
|
case 'item':
|
|
|
|
obj.setField(
|
|
|
|
'title',
|
|
|
|
params.title !== undefined ? params.title : Zotero.Utilities.randomString()
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
default:
|
|
|
|
obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString();
|
|
|
|
}
|
2015-08-06 08:04:37 +00:00
|
|
|
return obj.saveTx(saveOptions);
|
Deasyncification :back: :cry:
While trying to get translation and citing working with asynchronously
generated data, we realized that drag-and-drop support was going to
be...problematic. Firefox only supports synchronous methods for
providing drag data (unlike, it seems, the DataTransferItem interface
supported by Chrome), which means that we'd need to preload all relevant
data on item selection (bounded by export.quickCopy.dragLimit) and keep
the translate/cite methods synchronous (or maintain two separate
versions).
What we're trying instead is doing what I said in #518 we weren't going
to do: loading most object data on startup and leaving many more
functions synchronous. Essentially, this takes the various load*()
methods described in #518, moves them to startup, and makes them operate
on entire libraries rather than individual objects.
The obvious downside here (other than undoing much of the work of the
last many months) is that it increases startup time, potentially quite a
lot for larger libraries. On my laptop, with a 3,000-item library, this
adds about 3 seconds to startup time. I haven't yet tested with larger
libraries. But I'm hoping that we can optimize this further to reduce
that delay. Among other things, this is loading data for all libraries,
when it should be able to load data only for the library being viewed.
But this is also fundamentally just doing some SELECT queries and
storing the results, so it really shouldn't need to be that slow (though
performance may be bounded a bit here by XPCOM overhead).
If we can make this fast enough, it means that third-party plugins
should be able to remain much closer to their current designs. (Some
things, including saving, will still need to be made asynchronous.)
2016-03-07 21:05:51 +00:00
|
|
|
};
|
2015-07-22 09:21:32 +00:00
|
|
|
|
2015-04-17 04:20:16 +00:00
|
|
|
/**
|
|
|
|
* Return a promise for the error thrown by a promise, or false if none
|
|
|
|
*/
|
2019-04-23 23:49:48 +00:00
|
|
|
async function getPromiseError(promise) {
|
|
|
|
try {
|
|
|
|
await promise;
|
|
|
|
}
|
|
|
|
catch (e) {
|
|
|
|
return e;
|
|
|
|
}
|
|
|
|
return false;
|
2015-04-17 04:20:16 +00:00
|
|
|
}
|
|
|
|
|
2018-01-18 14:48:44 +00:00
|
|
|
/**
|
|
|
|
* Init paths for PDF tools and data
|
|
|
|
*/
|
|
|
|
function initPDFToolsPath() {
|
|
|
|
let pdfConvertedFileName = 'pdftotext';
|
|
|
|
let pdfInfoFileName = 'pdfinfo';
|
|
|
|
|
|
|
|
if (Zotero.isWin) {
|
|
|
|
pdfConvertedFileName += '-win.exe';
|
|
|
|
pdfInfoFileName += '-win.exe';
|
|
|
|
}
|
|
|
|
else if (Zotero.isMac) {
|
|
|
|
pdfConvertedFileName += '-mac';
|
|
|
|
pdfInfoFileName += '-mac';
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
let cpu = Zotero.platform.split(' ')[1];
|
|
|
|
pdfConvertedFileName += '-linux-' + cpu;
|
|
|
|
pdfInfoFileName += '-linux-' + cpu;
|
|
|
|
}
|
|
|
|
|
|
|
|
let pdfToolsPath = OS.Path.join(Zotero.Profile.dir, 'pdftools');
|
|
|
|
let pdfConverterPath = OS.Path.join(pdfToolsPath, pdfConvertedFileName);
|
|
|
|
let pdfInfoPath = OS.Path.join(pdfToolsPath, pdfInfoFileName);
|
|
|
|
let pdfDataPath = OS.Path.join(pdfToolsPath, 'poppler-data');
|
|
|
|
|
|
|
|
Zotero.FullText.setPDFConverterPath(pdfConverterPath);
|
|
|
|
Zotero.FullText.setPDFInfoPath(pdfInfoPath);
|
|
|
|
Zotero.FullText.setPDFDataPath(pdfDataPath);
|
|
|
|
}
|
|
|
|
|
2015-03-08 19:59:53 +00:00
|
|
|
/**
|
2015-03-24 04:52:36 +00:00
|
|
|
* Returns the nsIFile corresponding to the test data directory
|
|
|
|
* (i.e., test/tests/data)
|
2015-03-08 19:59:53 +00:00
|
|
|
*/
|
|
|
|
function getTestDataDirectory() {
|
2022-06-09 07:00:54 +00:00
|
|
|
var file = Zotero.File.pathToFile(Zotero.resourcesDir);
|
|
|
|
file.append('tests');
|
|
|
|
file.append('data');
|
|
|
|
return file;
|
2015-03-09 18:25:49 +00:00
|
|
|
}
|
|
|
|
|
2016-02-11 11:02:38 +00:00
|
|
|
function getTestDataUrl(path) {
|
|
|
|
path = path.split('/');
|
|
|
|
if (path[0].length == 0) {
|
|
|
|
path.splice(0, 1);
|
|
|
|
}
|
|
|
|
return "resource://zotero-unit-tests/data/" + path.join('/');
|
2016-01-12 13:28:15 +00:00
|
|
|
}
|
|
|
|
|
2015-04-26 06:31:03 +00:00
|
|
|
/**
|
|
|
|
* Returns an absolute path to an empty temporary directory
|
|
|
|
*/
|
2015-06-01 03:59:15 +00:00
|
|
|
var getTempDirectory = Zotero.Promise.coroutine(function* getTempDirectory() {
|
2015-04-26 06:31:03 +00:00
|
|
|
Components.utils.import("resource://gre/modules/osfile.jsm");
|
|
|
|
let path,
|
|
|
|
attempts = 3,
|
|
|
|
zoteroTmpDirPath = Zotero.getTempDirectory().path;
|
|
|
|
while (attempts--) {
|
|
|
|
path = OS.Path.join(zoteroTmpDirPath, Zotero.Utilities.randomString());
|
|
|
|
try {
|
|
|
|
yield OS.File.makeDir(path, { ignoreExisting: false });
|
|
|
|
break;
|
|
|
|
} catch (e) {
|
|
|
|
if (!attempts) throw e; // Throw on last attempt
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2015-06-01 03:59:15 +00:00
|
|
|
return path;
|
2015-04-26 06:31:03 +00:00
|
|
|
});
|
|
|
|
|
2016-11-19 23:52:10 +00:00
|
|
|
var removeDir = Zotero.Promise.coroutine(function* (dir) {
|
|
|
|
// OS.File.DirectoryIterator, used by OS.File.removeDir(), isn't reliable on Travis,
|
|
|
|
// returning entry.isDir == false for subdirectories, so use nsIFile instead
|
|
|
|
//yield OS.File.removeDir(zipDir);
|
|
|
|
dir = Zotero.File.pathToFile(dir);
|
|
|
|
if (dir.exists()) {
|
|
|
|
dir.remove(true);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2015-03-09 18:25:49 +00:00
|
|
|
/**
|
|
|
|
* Resets the Zotero DB and restarts Zotero. Returns a promise resolved
|
|
|
|
* when this finishes.
|
2015-07-19 21:58:58 +00:00
|
|
|
*
|
|
|
|
* @param {Object} [options] - Initialization options, as passed to Zotero.init(), overriding
|
|
|
|
* any that were set at startup
|
2015-03-09 18:25:49 +00:00
|
|
|
*/
|
2018-02-06 04:09:17 +00:00
|
|
|
async function resetDB(options = {}) {
|
2016-11-29 08:27:44 +00:00
|
|
|
resetPrefs();
|
2016-05-23 04:59:16 +00:00
|
|
|
|
2015-09-29 08:07:26 +00:00
|
|
|
if (options.thisArg) {
|
|
|
|
options.thisArg.timeout(60000);
|
|
|
|
}
|
2016-11-27 05:06:02 +00:00
|
|
|
var db = Zotero.DataDirectory.getDatabase();
|
2018-02-06 04:09:17 +00:00
|
|
|
await Zotero.reinit(
|
2021-06-24 10:07:33 +00:00
|
|
|
async function () {
|
2021-08-16 23:50:44 +00:00
|
|
|
// Extract a zipped DB file into place as the initial DB
|
|
|
|
if (options.dbFile && options.dbFile.endsWith('.zip')) {
|
|
|
|
let zipReader = Components.classes['@mozilla.org/libjar/zip-reader;1']
|
|
|
|
.createInstance(Components.interfaces.nsIZipReader);
|
|
|
|
zipReader.open(Zotero.File.pathToFile(options.dbFile));
|
|
|
|
zipReader.extract('zotero.sqlite', Zotero.File.pathToFile(db));
|
|
|
|
zipReader.close();
|
|
|
|
}
|
|
|
|
// Otherwise swap in the initial copy we made of the DB, or an alternative non-zip file
|
|
|
|
// if given
|
|
|
|
else {
|
|
|
|
await OS.File.copy(options.dbFile || db + '-test-template', db);
|
|
|
|
}
|
2016-11-27 05:06:02 +00:00
|
|
|
_defaultGroup = null;
|
2021-06-24 10:07:33 +00:00
|
|
|
},
|
2016-11-27 05:06:02 +00:00
|
|
|
false,
|
|
|
|
options
|
2018-02-06 04:09:17 +00:00
|
|
|
);
|
|
|
|
await Zotero.Schema.schemaUpdatePromise;
|
|
|
|
initPDFToolsPath();
|
2015-05-31 21:39:37 +00:00
|
|
|
}
|
|
|
|
|
2015-03-24 04:52:36 +00:00
|
|
|
/**
|
|
|
|
* Equivalent to JSON.stringify, except that object properties are stringified
|
|
|
|
* in a sorted order.
|
|
|
|
*/
|
2015-05-30 00:03:24 +00:00
|
|
|
function stableStringify(obj) {
|
|
|
|
return JSON.stringify(obj, function(k, v) {
|
|
|
|
if (v && typeof v == "object" && !Array.isArray(v)) {
|
|
|
|
let o = {},
|
|
|
|
keys = Object.keys(v).sort();
|
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
|
|
o[keys[i]] = v[keys[i]];
|
|
|
|
}
|
|
|
|
return o;
|
2015-03-24 04:52:36 +00:00
|
|
|
}
|
2015-05-30 00:03:24 +00:00
|
|
|
return v;
|
|
|
|
}, "\t");
|
2015-03-24 04:52:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Loads specified sample data from file
|
|
|
|
*/
|
|
|
|
function loadSampleData(dataName) {
|
|
|
|
let data = Zotero.File.getContentsFromURL('resource://zotero-unit-tests/data/' + dataName + '.js');
|
|
|
|
return JSON.parse(data);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Generates sample item data that is stored in data/sampleItemData.js
|
|
|
|
*/
|
|
|
|
function generateAllTypesAndFieldsData() {
|
|
|
|
let data = {};
|
|
|
|
let itemTypes = Zotero.ItemTypes.getTypes();
|
|
|
|
// For most fields, use the field name as the value, but this doesn't
|
|
|
|
// work well for some fields that expect values in certain formats
|
|
|
|
let specialValues = {
|
|
|
|
date: '1999-12-31',
|
|
|
|
filingDate: '2000-01-02',
|
2015-06-07 22:52:31 +00:00
|
|
|
accessDate: '1997-06-13T23:59:58Z',
|
2015-03-24 04:52:36 +00:00
|
|
|
number: 3,
|
|
|
|
numPages: 4,
|
|
|
|
issue: 5,
|
|
|
|
volume: 6,
|
|
|
|
numberOfVolumes: 7,
|
|
|
|
edition: 8,
|
|
|
|
seriesNumber: 9,
|
|
|
|
ISBN: '978-1-234-56789-7',
|
|
|
|
ISSN: '1234-5679',
|
|
|
|
url: 'http://www.example.com',
|
|
|
|
pages: '1-10',
|
|
|
|
DOI: '10.1234/example.doi',
|
|
|
|
runningTime: '1:22:33',
|
|
|
|
language: 'en-US'
|
|
|
|
};
|
|
|
|
|
|
|
|
// Item types that should not be included in sample data
|
2020-06-20 05:29:32 +00:00
|
|
|
let excludeItemTypes = ['note', 'attachment', 'annotation'];
|
2015-03-24 04:52:36 +00:00
|
|
|
|
|
|
|
for (let i = 0; i < itemTypes.length; i++) {
|
|
|
|
if (excludeItemTypes.indexOf(itemTypes[i].name) != -1) continue;
|
|
|
|
|
|
|
|
let itemFields = data[itemTypes[i].name] = {
|
|
|
|
itemType: itemTypes[i].name
|
|
|
|
};
|
|
|
|
|
|
|
|
let fields = Zotero.ItemFields.getItemTypeFields(itemTypes[i].id);
|
|
|
|
for (let j = 0; j < fields.length; j++) {
|
|
|
|
let field = fields[j];
|
|
|
|
field = Zotero.ItemFields.getBaseIDFromTypeAndField(itemTypes[i].id, field) || field;
|
|
|
|
|
|
|
|
let name = Zotero.ItemFields.getName(field),
|
|
|
|
value;
|
|
|
|
|
|
|
|
// Use field name as field value
|
|
|
|
if (specialValues[name]) {
|
|
|
|
value = specialValues[name];
|
|
|
|
} else {
|
|
|
|
value = name.charAt(0).toUpperCase() + name.substr(1);
|
|
|
|
// Make it look nice (sentence case)
|
|
|
|
value = value.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
|
|
.replace(/ [A-Z](?![A-Z])/g, m => m.toLowerCase()); // not all-caps words
|
|
|
|
}
|
|
|
|
|
|
|
|
itemFields[name] = value;
|
|
|
|
}
|
|
|
|
|
|
|
|
let creatorTypes = Zotero.CreatorTypes.getTypesForItemType(itemTypes[i].id),
|
|
|
|
creators = itemFields.creators = [];
|
|
|
|
for (let j = 0; j < creatorTypes.length; j++) {
|
|
|
|
let typeName = creatorTypes[j].name;
|
|
|
|
creators.push({
|
|
|
|
creatorType: typeName,
|
|
|
|
firstName: typeName + 'First',
|
|
|
|
lastName: typeName + 'Last'
|
|
|
|
});
|
|
|
|
}
|
2015-06-05 21:31:57 +00:00
|
|
|
|
|
|
|
// Also add a single-field mode author, which is valid for all types
|
|
|
|
let primaryCreatorType = Zotero.CreatorTypes.getName(
|
|
|
|
Zotero.CreatorTypes.getPrimaryIDForType(itemTypes[i].id)
|
|
|
|
);
|
|
|
|
creators.push({
|
|
|
|
creatorType: primaryCreatorType,
|
|
|
|
lastName: 'Institutional Author',
|
|
|
|
fieldMode: 1
|
|
|
|
});
|
2015-03-24 04:52:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Populates the database with sample items
|
|
|
|
* The field values should be in the form exactly as they would appear in Zotero
|
|
|
|
*/
|
|
|
|
function populateDBWithSampleData(data) {
|
2020-07-05 10:20:01 +00:00
|
|
|
return Zotero.DB.executeTransaction(async function () {
|
2015-06-01 03:59:15 +00:00
|
|
|
for (let itemName in data) {
|
|
|
|
let item = data[itemName];
|
2015-06-07 22:38:00 +00:00
|
|
|
let zItem = new Zotero.Item;
|
|
|
|
zItem.fromJSON(item);
|
2020-07-05 10:20:01 +00:00
|
|
|
item.id = await zItem.save();
|
2015-03-24 04:52:36 +00:00
|
|
|
}
|
2015-06-01 03:59:15 +00:00
|
|
|
|
|
|
|
return data;
|
|
|
|
});
|
2015-03-24 04:52:36 +00:00
|
|
|
}
|
|
|
|
|
2015-06-01 03:59:15 +00:00
|
|
|
var generateItemJSONData = Zotero.Promise.coroutine(function* generateItemJSONData(options, currentData) {
|
|
|
|
let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')),
|
2015-03-24 04:52:36 +00:00
|
|
|
jsonData = {};
|
|
|
|
|
|
|
|
for (let itemName in items) {
|
2015-06-01 03:59:15 +00:00
|
|
|
let zItem = yield Zotero.Items.getAsync(items[itemName].id);
|
2022-03-05 11:02:57 +00:00
|
|
|
jsonData[itemName] = zItem.toJSON(options || {});
|
2015-05-31 21:02:20 +00:00
|
|
|
|
2015-03-24 04:52:36 +00:00
|
|
|
// Don't replace some fields that _always_ change (e.g. item keys)
|
|
|
|
// as long as it follows expected format
|
|
|
|
// This makes it easier to generate more meaningful diffs
|
|
|
|
if (!currentData || !currentData[itemName]) continue;
|
|
|
|
|
|
|
|
for (let field in jsonData[itemName]) {
|
|
|
|
let oldVal = currentData[itemName][field];
|
|
|
|
if (!oldVal) continue;
|
|
|
|
|
|
|
|
let val = jsonData[itemName][field];
|
|
|
|
switch (field) {
|
|
|
|
case 'dateAdded':
|
|
|
|
case 'dateModified':
|
|
|
|
if (!isoDateTimeRe.test(oldVal) || !isoDateTimeRe.test(val)) continue;
|
|
|
|
break;
|
|
|
|
case 'key':
|
|
|
|
if (!zoteroObjectKeyRe.test(oldVal) || !zoteroObjectKeyRe.test(val)) continue;
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
jsonData[itemName][field] = oldVal;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return jsonData;
|
2015-06-01 03:59:15 +00:00
|
|
|
});
|
2015-03-24 04:52:36 +00:00
|
|
|
|
2015-06-01 03:59:15 +00:00
|
|
|
var generateCiteProcJSExportData = Zotero.Promise.coroutine(function* generateCiteProcJSExportData(currentData) {
|
|
|
|
let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')),
|
2015-03-24 04:52:36 +00:00
|
|
|
cslExportData = {};
|
|
|
|
|
|
|
|
for (let itemName in items) {
|
2015-06-01 03:59:15 +00:00
|
|
|
let zItem = yield Zotero.Items.getAsync(items[itemName].id);
|
2015-03-24 04:52:36 +00:00
|
|
|
cslExportData[itemName] = Zotero.Cite.System.prototype.retrieveItem(zItem);
|
|
|
|
|
|
|
|
if (!currentData || !currentData[itemName]) continue;
|
|
|
|
|
2015-05-14 02:17:30 +00:00
|
|
|
// Don't replace id as long as it follows expected format
|
|
|
|
if (Number.isInteger(currentData[itemName].id)
|
|
|
|
&& Number.isInteger(cslExportData[itemName].id)
|
|
|
|
) {
|
|
|
|
cslExportData[itemName].id = currentData[itemName].id;
|
2015-03-24 04:52:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return cslExportData;
|
2015-06-01 03:59:15 +00:00
|
|
|
});
|
2015-03-24 04:52:36 +00:00
|
|
|
|
2015-06-01 03:59:15 +00:00
|
|
|
var generateTranslatorExportData = Zotero.Promise.coroutine(function* generateTranslatorExportData(legacy, currentData) {
|
|
|
|
let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')),
|
2015-03-24 04:52:36 +00:00
|
|
|
translatorExportData = {};
|
|
|
|
|
|
|
|
let itemGetter = new Zotero.Translate.ItemGetter();
|
|
|
|
itemGetter.legacy = !!legacy;
|
|
|
|
|
|
|
|
for (let itemName in items) {
|
2015-06-01 03:59:15 +00:00
|
|
|
let zItem = yield Zotero.Items.getAsync(items[itemName].id);
|
2015-03-24 04:52:36 +00:00
|
|
|
itemGetter._itemsLeft = [zItem];
|
2016-03-21 05:33:37 +00:00
|
|
|
translatorExportData[itemName] = itemGetter.nextItem();
|
2015-03-24 04:52:36 +00:00
|
|
|
|
|
|
|
// Don't replace some fields that _always_ change (e.g. item keys)
|
|
|
|
if (!currentData || !currentData[itemName]) continue;
|
|
|
|
|
|
|
|
// For simplicity, be more lenient than for item key
|
|
|
|
let uriRe = /^http:\/\/zotero\.org\/users\/local\/\w{8}\/items\/\w{8}$/;
|
|
|
|
let itemIDRe = /^\d+$/;
|
|
|
|
for (let field in translatorExportData[itemName]) {
|
|
|
|
let oldVal = currentData[itemName][field];
|
|
|
|
if (!oldVal) continue;
|
|
|
|
|
|
|
|
let val = translatorExportData[itemName][field];
|
|
|
|
switch (field) {
|
|
|
|
case 'uri':
|
|
|
|
if (!uriRe.test(oldVal) || !uriRe.test(val)) continue;
|
|
|
|
break;
|
|
|
|
case 'itemID':
|
|
|
|
if (!itemIDRe.test(oldVal) || !itemIDRe.test(val)) continue;
|
|
|
|
break;
|
|
|
|
case 'key':
|
|
|
|
if (!zoteroObjectKeyRe.test(oldVal) || !zoteroObjectKeyRe.test(val)) continue;
|
|
|
|
break;
|
|
|
|
case 'dateAdded':
|
|
|
|
case 'dateModified':
|
|
|
|
if (legacy) {
|
|
|
|
if (!sqlDateTimeRe.test(oldVal) || !sqlDateTimeRe.test(val)) continue;
|
|
|
|
} else {
|
|
|
|
if (!isoDateTimeRe.test(oldVal) || !isoDateTimeRe.test(val)) continue;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
translatorExportData[itemName][field] = oldVal;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return translatorExportData;
|
2015-06-01 03:59:15 +00:00
|
|
|
});
|
2015-05-31 21:39:37 +00:00
|
|
|
|
2016-09-18 09:24:55 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Build a dummy translator that can be passed to Zotero.Translate
|
|
|
|
*/
|
2016-09-27 14:43:56 +00:00
|
|
|
function buildDummyTranslator(translatorType, code, info={}) {
|
2017-01-23 13:58:16 +00:00
|
|
|
const TRANSLATOR_TYPES = {"import":1, "export":2, "web":4, "search":8};
|
2016-09-27 14:43:56 +00:00
|
|
|
info = Object.assign({
|
|
|
|
"translatorID":"dummy-translator",
|
2017-01-23 13:58:16 +00:00
|
|
|
"translatorType": Number.isInteger(translatorType) ? translatorType : TRANSLATOR_TYPES[translatorType],
|
2016-09-18 09:24:55 +00:00
|
|
|
"label":"Dummy Translator",
|
|
|
|
"creator":"Simon Kornblith",
|
|
|
|
"target":"",
|
|
|
|
"priority":100,
|
|
|
|
"browserSupport":"g",
|
|
|
|
"inRepository":false,
|
|
|
|
"lastUpdated":"0000-00-00 00:00:00",
|
2016-09-27 14:43:56 +00:00
|
|
|
}, info);
|
2016-09-18 09:24:55 +00:00
|
|
|
let translator = new Zotero.Translator(info);
|
2016-12-12 12:29:59 +00:00
|
|
|
translator.code = JSON.stringify(info) + "\n" + code;
|
2016-09-18 09:24:55 +00:00
|
|
|
return translator;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2015-05-31 21:39:37 +00:00
|
|
|
/**
|
|
|
|
* Imports an attachment from a test file.
|
|
|
|
* @param {string} filename - The filename to import (in data directory)
|
|
|
|
* @return {Promise<Zotero.Item>}
|
|
|
|
*/
|
2016-07-19 22:54:37 +00:00
|
|
|
function importFileAttachment(filename, options = {}) {
|
|
|
|
let file = getTestDataDirectory();
|
|
|
|
filename.split('/').forEach((part) => file.append(part));
|
|
|
|
let importOptions = {
|
2017-08-30 21:23:38 +00:00
|
|
|
file,
|
2020-09-08 08:02:25 +00:00
|
|
|
parentItemID: options.parentID,
|
|
|
|
title: options.title
|
2016-07-19 22:54:37 +00:00
|
|
|
};
|
|
|
|
Object.assign(importOptions, options);
|
|
|
|
return Zotero.Attachments.importFromFile(importOptions);
|
2015-05-31 21:39:37 +00:00
|
|
|
}
|
2015-07-20 21:27:55 +00:00
|
|
|
|
|
|
|
|
2017-07-01 10:20:27 +00:00
|
|
|
function importTextAttachment() {
|
|
|
|
return importFileAttachment('test.txt', { contentType: 'text/plain', charset: 'utf-8' });
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function importHTMLAttachment() {
|
|
|
|
return importFileAttachment('test.html', { contentType: 'text/html', charset: 'utf-8' });
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-09-08 08:02:25 +00:00
|
|
|
async function importPDFAttachment(parentItem, options = {}) {
|
|
|
|
var attachment = await importFileAttachment(
|
|
|
|
'test.pdf',
|
|
|
|
{
|
|
|
|
contentType: 'application/pdf',
|
|
|
|
parentID: parentItem ? parentItem.id : null,
|
|
|
|
title: options.title
|
|
|
|
}
|
|
|
|
);
|
|
|
|
return attachment;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function createAnnotation(type, parentItem, options = {}) {
|
2020-06-20 05:29:32 +00:00
|
|
|
var annotation = new Zotero.Item('annotation');
|
2022-01-29 07:13:13 +00:00
|
|
|
annotation.libraryID = parentItem.libraryID;
|
|
|
|
if (options.version != undefined) {
|
|
|
|
annotation.version = options.version;
|
|
|
|
}
|
2020-06-20 05:29:32 +00:00
|
|
|
annotation.parentID = parentItem.id;
|
|
|
|
annotation.annotationType = type;
|
|
|
|
if (type == 'highlight') {
|
|
|
|
annotation.annotationText = Zotero.Utilities.randomString();
|
|
|
|
}
|
2021-04-26 07:47:46 +00:00
|
|
|
if (options.comment !== undefined) {
|
|
|
|
annotation.annotationComment = options.comment;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
annotation.annotationComment = Zotero.Utilities.randomString();
|
|
|
|
}
|
2021-03-21 18:26:44 +00:00
|
|
|
annotation.annotationColor = '#ffd400';
|
|
|
|
var page = Zotero.Utilities.rand(1, 100);
|
|
|
|
annotation.annotationPageLabel = `${page}`;
|
|
|
|
page = page.toString().padStart(5, '0');
|
2020-09-04 23:39:48 +00:00
|
|
|
var pos = Zotero.Utilities.rand(1, 10000).toString().padStart(6, '0');
|
2020-09-10 10:13:51 +00:00
|
|
|
annotation.annotationSortIndex = `${page}|${pos}|00000`;
|
|
|
|
annotation.annotationPosition = JSON.stringify({
|
2020-06-20 05:29:32 +00:00
|
|
|
pageIndex: 123,
|
|
|
|
rects: [
|
|
|
|
[314.4, 412.8, 556.2, 609.6]
|
|
|
|
]
|
2020-09-10 10:13:51 +00:00
|
|
|
});
|
2022-01-29 07:13:13 +00:00
|
|
|
if (options.createdByUserID) {
|
|
|
|
annotation.createdByUserID = options.createdByUserID;
|
|
|
|
}
|
2021-01-20 03:38:49 +00:00
|
|
|
if (options.isExternal) {
|
|
|
|
annotation.annotationIsExternal = options.isExternal;
|
|
|
|
}
|
2020-09-08 08:02:25 +00:00
|
|
|
if (options.tags) {
|
|
|
|
annotation.setTags(options.tags);
|
|
|
|
}
|
2022-01-29 07:13:13 +00:00
|
|
|
if (options.synced !== undefined) {
|
|
|
|
annotation.synced = options.synced;
|
|
|
|
}
|
|
|
|
await annotation.saveTx({
|
|
|
|
skipEditCheck: true
|
|
|
|
});
|
2020-06-20 05:29:32 +00:00
|
|
|
return annotation;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-09-08 08:02:25 +00:00
|
|
|
async function createEmbeddedImage(parentItem, options = {}) {
|
|
|
|
var attachment = await Zotero.Attachments.importEmbeddedImage({
|
|
|
|
blob: await File.createFromFileName(
|
|
|
|
OS.Path.join(getTestDataDirectory().path, 'test.png')
|
|
|
|
),
|
|
|
|
parentItemID: parentItem.id
|
|
|
|
});
|
|
|
|
if (options.tags) {
|
|
|
|
attachment.setTags(options.tags);
|
|
|
|
await attachment.saveTx();
|
|
|
|
}
|
|
|
|
return attachment;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2021-03-21 18:36:23 +00:00
|
|
|
async function getImageBlob() {
|
|
|
|
var path = OS.Path.join(getTestDataDirectory().path, 'test.png');
|
|
|
|
var imageData = await Zotero.File.getBinaryContentsAsync(path);
|
|
|
|
var array = new Uint8Array(imageData.length);
|
|
|
|
for (let i = 0; i < imageData.length; i++) {
|
|
|
|
array[i] = imageData.charCodeAt(i);
|
|
|
|
}
|
|
|
|
return new Blob([array], { type: 'image/png' });
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2015-07-20 21:27:55 +00:00
|
|
|
/**
|
|
|
|
* Sets the fake XHR server to response to a given response
|
|
|
|
*
|
|
|
|
* @param {Object} server - Sinon FakeXMLHttpRequest server
|
|
|
|
* @param {Object|String} response - Dot-separated path to predefined response in responses
|
|
|
|
* object (e.g., keyInfo.fullAccess) or a JSON object
|
|
|
|
* that defines the response
|
|
|
|
* @param {Object} responses - Predefined responses
|
|
|
|
*/
|
2018-08-09 22:20:02 +00:00
|
|
|
function setHTTPResponse(server, baseURL, response, responses, username, password) {
|
2015-07-20 21:27:55 +00:00
|
|
|
if (typeof response == 'string') {
|
|
|
|
let [topic, key] = response.split('.');
|
|
|
|
if (!responses[topic]) {
|
|
|
|
throw new Error("Invalid topic");
|
|
|
|
}
|
|
|
|
if (!responses[topic][key]) {
|
|
|
|
throw new Error("Invalid response key");
|
|
|
|
}
|
|
|
|
response = responses[topic][key];
|
|
|
|
}
|
|
|
|
|
2015-12-09 09:07:48 +00:00
|
|
|
var responseArray = [response.status !== undefined ? response.status : 200, {}, ""];
|
2015-07-20 21:27:55 +00:00
|
|
|
if (response.json) {
|
|
|
|
responseArray[1]["Content-Type"] = "application/json";
|
|
|
|
responseArray[2] = JSON.stringify(response.json);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
responseArray[1]["Content-Type"] = "text/plain";
|
|
|
|
responseArray[2] = response.text || "";
|
|
|
|
}
|
2015-12-23 09:52:09 +00:00
|
|
|
|
|
|
|
if (!response.headers) {
|
|
|
|
response.headers = {};
|
|
|
|
}
|
|
|
|
response.headers["Fake-Server-Match"] = 1;
|
2015-07-20 21:27:55 +00:00
|
|
|
for (let i in response.headers) {
|
|
|
|
responseArray[1][i] = response.headers[i];
|
|
|
|
}
|
2015-12-23 09:52:09 +00:00
|
|
|
|
2018-08-09 22:47:29 +00:00
|
|
|
if (username || password) {
|
|
|
|
server.respondWith(function (req) {
|
|
|
|
if (username && req.username != username) return;
|
|
|
|
if (password && req.password != password) return;
|
|
|
|
|
|
|
|
if (req.method == response.method && req.url == baseURL + response.url) {
|
|
|
|
req.respond(...responseArray);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
server.respondWith(response.method, baseURL + response.url, responseArray);
|
|
|
|
}
|
2015-07-20 21:27:55 +00:00
|
|
|
}
|