Improve importing from Mendeley

- Make the annotations importer more resilient to missing data
- Fix notebooks not being imported in some cases
- Fix an instance of a hidden browser leaked by the Mendeley Importer
This commit is contained in:
Tom Najdek 2024-12-06 19:14:09 +01:00
parent 6c437e3df2
commit d3ef6cfebd
No known key found for this signature in database
GPG key ID: EEC61A7B4C667D77
6 changed files with 161 additions and 34 deletions

View file

@ -117,7 +117,7 @@ const apiFetchUrl = async (tokens, url, headers = {}, options = {}) => {
const apiFetch = async (tokens, endPoint, params = {}, headers = {}, options = {}) => {
const stringParams = Object.entries(params).map(p => p.join('=')).join('&');
const url = MENDELEY_API_URL + '/' + endPoint + '?' + stringParams;
const url = `${MENDELEY_API_URL}/${endPoint}${stringParams.length ? '?' + stringParams : ''}`;
return apiFetchUrl(tokens, url, headers, options);
};
@ -183,8 +183,8 @@ const obtainReferenceManagerToken = async (login, password) => {
hasEnteredLogin = await browser.browsingContext.currentWindowGlobal
.getActor("MendeleyAuth")
.sendQuery("login", { login });
Zotero.debug(`hasEnteredLogin: ${hasEnteredLogin}`);
if (!hasEnteredLogin) {
browser.destroy();
reject(new Error("Failed to enter login"));
}
}
@ -195,8 +195,8 @@ const obtainReferenceManagerToken = async (login, password) => {
hasEnteredPassword = await browser.browsingContext.currentWindowGlobal
.getActor("MendeleyAuth")
.sendQuery("password", { password });
Zotero.debug(`hasEnteredPassword: ${hasEnteredPassword}`);
if (!hasEnteredPassword) {
browser.destroy();
reject(new Error("Failed to enter password"));
}
}
@ -205,6 +205,7 @@ const obtainReferenceManagerToken = async (login, password) => {
const cookies = cookieSandbox.getCookiesForURI(
Services.io.newURI("https://www.mendeley.com/reference-manager/library")
);
browser.destroy();
if (!cookies.accessToken) {
reject(new Error("Failed to obtain Mendeley access token"));
}
@ -218,6 +219,7 @@ const obtainReferenceManagerToken = async (login, password) => {
browser.load("https://www.mendeley.com/sign-in?routeTo=https://www.mendeley.com/reference-manager/library/");
Zotero.Promise.delay(ACCESS_TOKEN_TIMEOUT).then(() => {
browser.destroy();
reject(new Error("Timed out while obtaining Mendeley access token"));
});
});

View file

@ -38,6 +38,7 @@ var Zotero_Import_Mendeley = function () {
this._tokens = null;
this._credentials = null;
this._refManagerToken = null;
this._db = null;
this._file = null;
this._saveOptions = null;
@ -327,15 +328,18 @@ Zotero_Import_Mendeley.prototype.translate = async function (options = {}) {
}
if (this._credentials && !this.skipNotebooks) {
const token = await obtainReferenceManagerTokenWithRetry(this._credentials.username, this._credentials.password);
this._progress += 1; // progress one arbitrary "tick" assigned to importing notebooks task, we have 4 more left
if (token) {
if (!this._refManagerToken) {
const token = await obtainReferenceManagerTokenWithRetry(this._credentials.username, this._credentials.password);
this._refManagerToken = {
kind: 'referenceManager',
accessToken: token,
username: this._credentials.username,
password: this._credentials.password
};
}
this._progress += 1; // progress one arbitrary "tick" assigned to importing notebooks task, we have 4 more left
if (this._refManagerToken) {
const notebooks = await this._getNotebooksAPI();
const notesContent = await Promise.all(notebooks.map(notebook => this._translateNotebookToNoteContent(libraryID, notebook)));
this._progress += 1; // progress one arbitrary "tick" assigned to importing notebooks task, we have 1 more left
@ -353,7 +357,7 @@ Zotero_Import_Mendeley.prototype.translate = async function (options = {}) {
const isMappedToExisting = !!existingItem;
Zotero.debug(isMappedToExisting ? `Updating existing notebook "${uuid}" -> "${existingItem.key}"` : `Importing new notebook "${uuid}"`, 5);
let item = existingItem ?? new Zotero.Item('note');
let item = isMappedToExisting ? existingItem : new Zotero.Item('note');
item.libraryID = libraryID;
item.setNote(noteContent);
item.addRelation(predicate, uuid);
@ -1085,14 +1089,15 @@ Zotero_Import_Mendeley.prototype._translateNotebookToNoteContent = async functio
'mendeleyDB:annotationUUID',
annotationUUID
);
let attachmentItem = Zotero.Items.get(annotation.parentID);
let jsonAnnotation = await Zotero.Annotations.toJSON(annotation);
jsonAnnotation.attachmentItemID = attachmentItem.id;
jsonAnnotation.id = annotation.key;
if (annotation) {
let attachmentItem = Zotero.Items.get(annotation.parentID);
let jsonAnnotation = await Zotero.Annotations.toJSON(annotation);
jsonAnnotation.attachmentItemID = attachmentItem.id;
jsonAnnotation.id = annotation.key;
const { html } = Zotero.EditorInstanceUtilities.serializeAnnotations([jsonAnnotation]);
return html;
const { html } = Zotero.EditorInstanceUtilities.serializeAnnotations([jsonAnnotation]);
return html;
}
}
}
}
@ -1744,6 +1749,11 @@ Zotero_Import_Mendeley.prototype._saveAnnotations = async function (annotations,
// do not attempt to import annotations for non-PDF files
return;
}
// Mendeley produces empty, type-less annotations that confuse the PDF worker
annotations = annotations.filter(
annotation => ['highlight', 'note', 'image', 'ink', 'underline', 'text'].includes(annotation.type)
);
let annotationMap = new Map();
for (let annotation of annotations) {
@ -1766,6 +1776,7 @@ Zotero_Import_Mendeley.prototype._saveAnnotations = async function (annotations,
for (let annotation of annotations) {
// Ignore empty highlights
if (annotation.type == 'highlight' && !annotation.text) {
Zotero.debug(`Skipping empty highlight with uuid ${annotation.uuid}`, 5);
continue;
}

View file

@ -2,25 +2,43 @@
{
"id": "339d0202-d99f-48a2-aa0d-9b0c5631af26",
"type": "highlight",
"color":
{
"color": {
"r": 255,
"g": 245,
"b": 173
},
"profile_id": "8dbf0832-8723-4c48-b532-20c0b7f6e01a",
"positions":
[
"positions": [
{
"top_left":
{
"x": 429.74257247464135,
"y": 770.1238821199208
"top_left": {
"x": 191.5049,
"y": 588.5418
},
"bottom_right":
{
"x": 572.5401236420382,
"y": 680.1292839565452
"bottom_right": {
"x": 461.6047999999994,
"y": 576.1542
},
"page": 1
},
{
"top_left": {
"x": 146.3,
"y": 576.8651
},
"bottom_right": {
"x": 461.3467999999992,
"y": 564.6542
},
"page": 1
},
{
"top_left": {
"x": 146.3,
"y": 565.3651
},
"bottom_right": {
"x": 220.06760000000003,
"y": 553.1542
},
"page": 1
}

View file

@ -18,7 +18,7 @@
],
"created": "2024-11-04T13:49:03.274Z",
"modified": "2024-11-04T13:49:03.274Z",
"id": "https://api.mendeley.com/annotations/v2/84f12446-3b49-4052-bbdc-832d28e1e072",
"id": "https://api.mendeley.com/annotations/v2/339d0202-d99f-48a2-aa0d-9b0c5631af26",
"body": [
{
"purpose": "highlighting",

View file

@ -0,0 +1,8 @@
[
{
"id": "8041a43b-f740-41ec-be42-a24cc5106d68",
"created": "2024-07-09T09:56:04.349Z",
"modified": "2024-12-06T17:41:37.200Z",
"title": "TEST"
}
]

View file

@ -3,10 +3,18 @@
describe('Zotero_Import_Mendeley', function () {
var server, httpd, httpdURL, importers;
const getImporter = () => {
const getImporter = ({ importNotes = false } = {}) => {
const importer = new Zotero_Import_Mendeley();
importer.mendeleyAuth = { kind: 'direct', tokens: { accessToken: 'access_token', refreshToken: 'refresh_token' } };
importer.skipNotebooks = true;
importer.skipNotebooks = !importNotes;
if (importNotes) {
importer._refManagerToken = {
kind: 'referenceManager',
accessToken: 'refman_token',
username: 'username',
password: 'password'
};
}
importers.push(importer);
return importer;
};
@ -14,7 +22,7 @@ describe('Zotero_Import_Mendeley', function () {
before(async () => {
Components.utils.import('chrome://zotero/content/import/mendeley/mendeleyImport.js');
// real http server is used to deliver an empty pdf so that annotations can be processed during import
// A real HTTP server is used to deliver a Bitcoin PDF so that annotations can be processed during import.
Components.utils.import("resource://zotero-unit/httpd.js");
const port = 16213;
httpd = new HttpServer();
@ -22,7 +30,7 @@ describe('Zotero_Import_Mendeley', function () {
httpd.start(port);
httpd.registerFile(
'/file1.pdf',
Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'empty.pdf'))
Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'recognizePDF_test_title.pdf'))
);
});
@ -84,7 +92,7 @@ describe('Zotero_Import_Mendeley', function () {
setHTTPResponse(server, 'https://api.mendeley.com/', {
method: 'GET',
url: `profiles/v2/me?`,
url: `profiles/v2/me`,
status: 200,
headers: {},
json: JSON.parse(
@ -104,7 +112,7 @@ describe('Zotero_Import_Mendeley', function () {
setHTTPResponse(server, 'https://api.mendeley.com/', {
method: 'GET',
url: `files/19fb5e5b-1a39-4851-b513-d48441a670e1?`,
url: `files/19fb5e5b-1a39-4851-b513-d48441a670e1`,
status: 200, // ideally would be 303 but mock http doesn't like it
headers: {
Location: `${httpdURL}/file1.pdf`
@ -526,7 +534,7 @@ describe('Zotero_Import_Mendeley', function () {
annotation.annotationText = 'Highlight text';
annotation.annotationComment = 'Highlight comment';
annotation.annotationPageLabel = '57';
annotation.addRelation('mendeleyDB:annotationUUID', '84f12446-3b49-4052-bbdc-832d28e1e072');
annotation.addRelation('mendeleyDB:annotationUUID', '339d0202-d99f-48a2-aa0d-9b0c5631af26');
await annotation.saveTx();
let mendeleyNotebook = JSON.parse(
@ -544,5 +552,85 @@ describe('Zotero_Import_Mendeley', function () {
await attachment.eraseTx();
await item.eraseTx();
});
it('should import a notebook from Mendeley', async () => {
const importer = getImporter({ importNotes: true });
setHTTPResponse(server, 'https://api.mendeley.com/', {
method: 'GET',
url: `notes/v1?limit=500`,
status: 200,
headers: {},
json: JSON.parse(
await Zotero.File.getContentsFromURLAsync('resource://zotero-unit-tests/data/mendeleyMock/notebooks.json')
)
});
setHTTPResponse(server, 'https://api.mendeley.com/', {
method: 'GET',
url: 'notes/v1/8041a43b-f740-41ec-be42-a24cc5106d68',
status: 200,
headers: {},
json: JSON.parse(
await Zotero.File.getContentsFromURLAsync('resource://zotero-unit-tests/data/mendeleyMock/notebook.json')
)
});
await importer.translate({
libraryID: Zotero.Libraries.userLibraryID,
collections: null,
linkFiles: false,
});
const note = (await Zotero.Relations
.getByPredicateAndObject('item', 'mendeleyDB:notebookUUID', '8041a43b-f740-41ec-be42-a24cc5106d68'))
.filter(item => item.libraryID == Zotero.Libraries.userLibraryID && !item.deleted)
.shift();
assert.match(
note.note,
/^<h1>TEST<\/h1>\n<p><span class="highlight" data-annotation="((?:(?:%[0-9A-F]{2}|[^<>'" %])+))">“A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution”<\/span> <span class="citation" data-citation="((?:(?:%[0-9A-F]{2}|[^<>'" %])+))">\(<span class="citation-item">“Item with PDF”, 2005, p. 1<\/span>\)<\/span><\/p>\n<p>Lorem Ipsum<\/p>$/i
);
});
it('should handle incorrect annotations', async () => {
let annotationsResponse = JSON.parse(
await Zotero.File.getContentsFromURLAsync('resource://zotero-unit-tests/data/mendeleyMock/annotations.json')
);
delete annotationsResponse.find(a => a.id == '885615a7-170e-4613-af80-0227ea76ae55').type;
delete annotationsResponse.find(a => a.id == 'bfbdb972-171d-4b21-8ae6-f156ac9a2b41').color;
annotationsResponse.push({ type: 'note', id: 'invalid' });
setHTTPResponse(server, 'https://api.mendeley.com/', {
method: 'GET',
url: `annotations?limit=200`,
status: 200,
headers: {},
json: annotationsResponse
});
const importer = getImporter();
await importer.translate({
libraryID: Zotero.Libraries.userLibraryID,
collections: null,
linkFiles: false,
});
const pdf = (await Zotero.Relations
.getByPredicateAndObject('item', 'mendeleyDB:fileHash', 'cc22c6611277df346ff8dc7386ba3880b2bafa15'))
.filter(item => item.libraryID == Zotero.Libraries.userLibraryID && !item.deleted)
.shift();
const annotations = await pdf.getAnnotations();
// Typeless notes and invalid annotations should be discarded.
assert.lengthOf(annotations, 5);
assert.lengthOf(await Zotero.Relations.getByPredicateAndObject('item', 'mendeleyDB:annotationUUID', '885615a7-170e-4613-af80-0227ea76ae55'), 0);
assert.lengthOf(await Zotero.Relations.getByPredicateAndObject('item', 'mendeleyDB:annotationUUID', 'invalid'), 0);
// Colorless note annotations should have a default colour.
const colorlessNote = (await Zotero.Relations
.getByPredicateAndObject('item', 'mendeleyDB:annotationUUID', 'bfbdb972-171d-4b21-8ae6-f156ac9a2b41'))
.filter(item => item.libraryID == Zotero.Libraries.userLibraryID && !item.deleted)
.shift();
assert.equal(colorlessNote.annotationColor, '#ffd400');
});
});
});