zotero/chrome/content/zotero/xpcom/editorInstance.js
Martynas Bagdonas e0bc873bce Improve embedded note image loading and deletion:
- Delete unused embedded images when note is closed.
- Load images as soon as they are downloaded.
- Introduce new notification for download event, and a test for it.
- Prevent simultaneous downloads of the same attachment.
2021-07-28 13:49:04 +03:00

1475 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2020 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://digitalscholar.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 *****
*/
Components.utils.import("resource://gre/modules/InlineSpellChecker.jsm");
// Note: TinyMCE is automatically doing some meaningless corrections to
// note-editor produced HTML. Which might result to more
// conflicts, especially in group libraries
// Note: Synchronous save can still affect dateModified
// When changing this update in `note-editor` as well.
// This only filters images that are being imported from a URL.
// In all other cases `note-editor` should decide what
// image types can be imported, and if not then
// Zotero.Attachments.importEmbeddedImage does.
// Additionally, the already imported images should never be
// affected
const DOWNLOADED_IMAGE_TYPE = [
'image/jpeg',
'image/png'
];
// Schema version here has to be the same as in note-editor!
const SCHEMA_VERSION = 3;
class EditorInstance {
constructor() {
this.instanceID = Zotero.Utilities.randomString();
}
async init(options) {
Zotero.Notes.registerEditorInstance(this);
this.onNavigate = options.onNavigate;
// TODO: Consider to use only itemID instead of loaded item
this._item = options.item;
this._viewMode = options.viewMode;
this._readOnly = options.readOnly || this._isReadOnly();
this._disableUI = options.disableUI;
this._onReturn = options.onReturn;
this._iframeWindow = options.iframeWindow;
this._popup = options.popup;
this._state = options.state;
this._disableSaving = false;
this._subscriptions = [];
this._quickFormatWindow = null;
this._isAttachment = this._item.isAttachment();
this._citationItemsList = [];
this._initPromise = new Promise((resolve, reject) => {
this._resolveInitPromise = resolve;
this._rejectInitPromise = reject;
});
this._prefObserverIDs = [
Zotero.Prefs.registerObserver('note.fontSize', this._handleFontChange),
Zotero.Prefs.registerObserver('note.fontFamily', this._handleFontChange),
Zotero.Prefs.registerObserver('layout.spellcheckDefault', this._handleSpellCheckChange, true)
];
// Run Cut/Copy/Paste with chrome privileges
this._iframeWindow.wrappedJSObject.zoteroExecCommand = function (doc, command, ui, value) {
// Is that safe enough?
if (!['cut', 'copy', 'paste'].includes(command)) {
return;
}
return doc.execCommand(command, ui, value);
};
this._iframeWindow.addEventListener('message', this._messageHandler);
this._iframeWindow.addEventListener('error', (event) => {
Zotero.logError(event.error);
});
let note = this._item.note;
this._postMessage({
action: 'init',
value: this._state || this._item.note,
viewMode: this._viewMode,
readOnly: this._readOnly,
unsaved: !this._item.id,
disableUI: this._disableUI,
enableReturnButton: !!this._onReturn,
placeholder: options.placeholder,
dir: Zotero.dir,
font: this._getFont(),
hasBackup: note && !Zotero.Notes.hasSchemaVersion(note)
|| !!await Zotero.NoteBackups.getNote(this._item.id),
localizedStrings: {
// Figure out a better way to pass this
'zotero.appName': Zotero.appName,
...Zotero.Intl.getPrefixedStrings('general.'),
...Zotero.Intl.getPrefixedStrings('noteEditor.')
}
});
if (!this._item.isAttachment()) {
Zotero.Notes.ensureEmbeddedImagesAreAvailable(this._item);
}
}
uninit() {
this._prefObserverIDs.forEach(id => Zotero.Prefs.unregisterObserver(id));
if (this._quickFormatWindow) {
this._quickFormatWindow.close();
this._quickFormatWindow = null;
}
// TODO: Allow editor instance to finish its work before
// the uninitialization. I.e. to finish image importing
// As long as the message listeners are attached on
// both sides, editor instance can continue its work
// in the backstage. Although the danger here is that
// multiple editor instances of the same note can start
// competing
this._iframeWindow.removeEventListener('message', this._messageHandler);
Zotero.Notes.unregisterEditorInstance(this);
this.saveSync();
if (!this._item.isAttachment()) {
Zotero.Notes.deleteUnusedEmbeddedImages(this._item);
}
}
focus() {
this._postMessage({ action: 'focus' });
}
async notify(event, type, ids, extraData) {
if (type === 'file' && event === 'download') {
let items = await Zotero.Items.getAsync(ids);
for (let item of items) {
if (item.isAttachment() && await item.getFilePathAsync()) {
let subscription = this._subscriptions.find(x => x.data.attachmentKey === item.key);
if (subscription) {
await this._feedSubscription(subscription);
}
}
}
}
if (this._readOnly || !this._item) {
return;
}
// Update citations itemData
let items = await Zotero.Items.getAsync(ids);
let uris = items.map(x => Zotero.URI.getItemURI(x)).filter(x => x);
let citationItemsList = this._citationItemsList
.filter(ci => ci.uris && uris.some(uri => ci.uris.includes(uri)));
await this._updateCitationItems(citationItemsList);
}
saveSync() {
if (!this._readOnly && !this._disableSaving && this._iframeWindow) {
let noteData = this._iframeWindow.wrappedJSObject.getDataSync(true);
if (noteData) {
noteData = JSON.parse(JSON.stringify(noteData));
}
this._save(noteData);
}
}
async insertAnnotations(annotations) {
await this._ensureNoteCreated();
let { html } = await this._serializeAnnotations(annotations);
if (html) {
this._postMessage({ action: 'insertHTML', pos: -1, html });
}
}
_postMessage(message) {
this._iframeWindow.postMessage({ instanceID: this.instanceID, message }, '*');
}
_isReadOnly() {
let item = this._item;
return !item.isEditable()
|| item.deleted
|| item.parentItem && item.parentItem.deleted;
}
_getFont() {
let fontSize = Zotero.Prefs.get('note.fontSize');
// Fix empty old font prefs before a value was enforced
if (fontSize < 6) {
fontSize = 11;
}
let fontFamily = Zotero.Prefs.get('note.fontFamily');
return { fontSize, fontFamily };
}
_handleFontChange = () => {
this._postMessage({ action: 'updateFont', font: this._getFont() });
}
_handleSpellCheckChange = () => {
try {
let spellChecker = this._getSpellChecker();
let value = Zotero.Prefs.get('layout.spellcheckDefault', true);
if (!value && spellChecker.enabled
|| value && !spellChecker.enabled) {
spellChecker.toggleEnabled();
}
}
catch (e) {
Zotero.logError(e);
}
}
_showInLibrary(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
let win = Zotero.getMainWindow();
if (win) {
win.ZoteroPane.selectItems(ids);
win.Zotero_Tabs.select('zotero-pane');
win.focus();
}
}
/**
* Transform plain text, containing some supported HTML tags, into actual HTML.
* A similar code is also used in pdf-reader mini editor for annotation text and comments.
* It basically creates a text node and then parses and wraps specific parts
* of it into supported HTML tags
*
* @param text Plain text flavored with some HTML tags
* @returns {string} HTML
* @private
*/
_transformTextToHTML(text) {
const supportedFormats = ['i', 'b', 'sub', 'sup'];
function getFormatter(str) {
let results = supportedFormats.map(format => str.toLowerCase().indexOf('<' + format + '>'));
results = results.map((offset, idx) => [supportedFormats[idx], offset]);
results.sort((a, b) => a[1] - b[1]);
for (let result of results) {
let format = result[0];
let offset = result[1];
if (offset < 0) continue;
let lastIndex = str.toLowerCase().indexOf('</' + format + '>', offset);
if (lastIndex >= 0) {
let parts = [];
parts.push(str.slice(0, offset));
parts.push(str.slice(offset + format.length + 2, lastIndex));
parts.push(str.slice(lastIndex + format.length + 3));
return {
format,
parts
};
}
}
return null;
}
function walkFormat(parent) {
let child = parent.firstChild;
while (child) {
if (child.nodeType === 3) {
let text = child.nodeValue;
let formatter = getFormatter(text);
if (formatter) {
let nodes = [];
nodes.push(doc.createTextNode(formatter.parts[0]));
let midNode = doc.createElement(formatter.format);
midNode.appendChild(doc.createTextNode(formatter.parts[1]));
nodes.push(midNode);
nodes.push(doc.createTextNode(formatter.parts[2]));
child.replaceWith(...nodes);
child = midNode;
}
}
walkFormat(child);
child = child.nextSibling;
}
}
let parser = Components.classes['@mozilla.org/xmlextras/domparser;1']
.createInstance(Components.interfaces.nsIDOMParser);
let doc = parser.parseFromString('', 'text/html');
// innerText transforms \n into <br>
doc.body.innerText = text;
walkFormat(doc.body);
return doc.body.innerHTML;
}
/**
* @param {Zotero.Item[]} annotations
* @param {Boolean} skipEmbeddingItemData Do not add itemData to citation items
* @return {Object} Object with `html` string and `citationItems` array to embed into metadata container
*/
async _serializeAnnotations(annotations, skipEmbeddingItemData) {
let storedCitationItems = [];
let html = '';
for (let annotation of annotations) {
let attachmentItem = await Zotero.Items.getAsync(annotation.attachmentItemID);
if (!attachmentItem) {
continue;
}
if (!annotation.text
&& !annotation.comment
&& !annotation.image) {
continue;
}
let citationHTML = '';
let imageHTML = '';
let highlightHTML = '';
let commentHTML = '';
let storedAnnotation = {
uri: Zotero.URI.getItemURI(attachmentItem),
color: annotation.color,
pageLabel: annotation.pageLabel,
position: annotation.position
};
// Citation
let parentItem = attachmentItem.parentID && await Zotero.Items.getAsync(attachmentItem.parentID);
if (parentItem) {
let uris = [Zotero.URI.getItemURI(parentItem)];
let citationItem = {
uris,
locator: annotation.pageLabel
};
// Note: integration.js` uses `Zotero.Cite.System.prototype.retrieveItem`,
// which produces a little bit different CSL JSON
let itemData = Zotero.Utilities.itemToCSLJSON(parentItem);
if (!skipEmbeddingItemData) {
citationItem.itemData = itemData;
}
let item = storedCitationItems.find(item => item.uris.some(uri => uris.includes(uri)));
if (!item) {
storedCitationItems.push({ uris, itemData });
}
storedAnnotation.citationItem = citationItem;
let citation = {
citationItems: [citationItem],
properties: {}
};
let citationWithData = JSON.parse(JSON.stringify(citation));
citationWithData.citationItems[0].itemData = itemData;
let formatted = this._formatCitation(citationWithData);
citationHTML = `<span class="citation" data-citation="${encodeURIComponent(JSON.stringify(citation))}">(${formatted})</span>`;
}
// Image
if (annotation.image) {
// We assume that annotation.image is always PNG
let imageAttachmentKey = await this._importImage(annotation.image);
delete annotation.image;
// Normalize image dimensions to 1.25 of the print size
let rect = annotation.position.rects[0];
let rectWidth = rect[2] - rect[0];
let rectHeight = rect[3] - rect[1];
// Constants from pdf.js
const CSS_UNITS = 96.0 / 72.0;
const PDFJS_DEFAULT_SCALE = 1.25;
let width = Math.round(rectWidth * CSS_UNITS * PDFJS_DEFAULT_SCALE);
let height = Math.round(rectHeight * width / rectWidth);
imageHTML = `<img data-attachment-key="${imageAttachmentKey}" width="${width}" height="${height}" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}"/>`;
}
// Text
if (annotation.text) {
let text = this._transformTextToHTML(annotation.text.trim());
highlightHTML = `<span class="highlight" data-annotation="${encodeURIComponent(JSON.stringify(storedAnnotation))}">“${text}”</span>`;
}
// Note
if (annotation.comment) {
let comment = this._transformTextToHTML(annotation.comment.trim());
// Move comment to the next line if it has multiple lines
commentHTML = (((highlightHTML || imageHTML || citationHTML) && comment.includes('<br')) ? '<br/>' : ' ') + comment;
}
if (citationHTML) {
// Move citation to the next line if highlight has multiple lines or is after image
citationHTML = ((highlightHTML && highlightHTML.includes('<br') || imageHTML) ? '<br>' : '') + citationHTML;
}
let otherHTML = [highlightHTML, citationHTML, commentHTML].filter(x => x).join(' ');
html += '<p>' + imageHTML + otherHTML + '</p>\n';
}
return { html, citationItems: storedCitationItems };
}
async _digestItems(ids) {
let html = '';
let items = await Zotero.Items.getAsync(ids);
for (let item of items) {
if (item.isNote()
&& !await Zotero.Notes.ensureEmbeddedImagesAreAvailable(item)
&& !Zotero.Notes.promptToIgnoreMissingImage()) {
return null;
}
}
for (let item of items) {
if (item.isRegularItem()) {
let itemData = Zotero.Utilities.itemToCSLJSON(item);
let citation = {
citationItems: [{
uris: [Zotero.URI.getItemURI(item)],
itemData
}],
properties: {}
};
let formatted = this._formatCitation(citation);
html += `<p><span class="citation" data-citation="${encodeURIComponent(JSON.stringify(citation))}">(${formatted})</span></p>`;
}
else if (item.isNote()) {
let note = item.note;
let parser = Components.classes['@mozilla.org/xmlextras/domparser;1']
.createInstance(Components.interfaces.nsIDOMParser);
let doc = parser.parseFromString(note, 'text/html');
// Get citationItems with itemData from note metadata
let storedCitationItems = [];
let containerNode = doc.querySelector('body > div[data-schema-version]');
if (containerNode) {
try {
let data = JSON.parse(decodeURIComponent(containerNode.getAttribute('data-citation-items')));
if (Array.isArray(data)) {
storedCitationItems = data;
}
}
catch (e) {
}
}
if (storedCitationItems.length) {
let fillWithItemData = (citationItems) => {
for (let citationItem of citationItems) {
let item = storedCitationItems.find(item => item.uris.some(uri => citationItem.uris.includes(uri)));
if (item) {
citationItem.itemData = item.itemData;
}
}
};
let nodes = doc.querySelectorAll('.citation[data-citation]');
for (let node of nodes) {
let citation = node.getAttribute('data-citation');
try {
citation = JSON.parse(decodeURIComponent(citation));
fillWithItemData(citation.citationItems);
citation = encodeURIComponent(JSON.stringify(citation));
node.setAttribute('data-citation', citation);
}
catch (e) {
Zotero.logError(e);
}
}
// img[data-annotation] and div.highlight[data-annotation]
nodes = doc.querySelectorAll('*[data-annotation]');
for (let node of nodes) {
let annotation = node.getAttribute('data-annotation');
try {
annotation = JSON.parse(decodeURIComponent(annotation));
// citationItem is allowed to not exist in annotation
if (annotation.citationItem) {
fillWithItemData([annotation.citationItem]);
annotation = encodeURIComponent(JSON.stringify(annotation));
node.setAttribute('data-annotation', annotation);
}
}
catch (e) {
Zotero.logError(e);
}
}
}
// Clone all note image attachments and replace keys in the new note
let attachments = Zotero.Items.get(item.getAttachments());
for (let attachment of attachments) {
if (!await attachment.fileExists()) {
continue;
}
await Zotero.DB.executeTransaction(async () => {
let copiedAttachment = await Zotero.Attachments.copyEmbeddedImage({
attachment,
note: this._item,
saveOptions: {
notifierData: {
noteEditorID: this.instanceID
}
}
});
let node = doc.querySelector(`img[data-attachment-key="${attachment.key}"]`);
if (node) {
node.setAttribute('data-attachment-key', copiedAttachment.key);
}
});
}
html += `<p></p>${doc.body.innerHTML}<p></p>`;
}
}
return html;
}
_messageHandler = async (e) => {
if (e.source !== this._iframeWindow
|| e.data.instanceID !== this.instanceID) {
return;
}
let message = e.data.message;
try {
switch (message.action) {
case 'initialized': {
this._resolveInitPromise();
return;
}
case 'insertObject': {
let { type, data, pos } = message;
if (this._readOnly) {
return;
}
let html = '';
await this._ensureNoteCreated();
if (type === 'zotero/item') {
let ids = data.split(',').map(id => parseInt(id));
html = await this._digestItems(ids);
if (!html) {
return;
}
}
else if (type === 'zotero/annotation') {
let annotations = JSON.parse(data);
let { html: serializedHTML } = await this._serializeAnnotations(annotations);
html = serializedHTML;
}
if (html) {
this._postMessage({ action: 'insertHTML', pos, html });
}
return;
}
case 'openAnnotation': {
let { uri, position } = message;
if (this.onNavigate) {
this.onNavigate(uri, { position });
}
else {
let zp = Zotero.getActiveZoteroPane();
if (zp) {
let item = await Zotero.URI.getURIItem(uri);
if (item) {
zp.viewPDF(item.id, { position });
}
}
}
return;
}
case 'openCitationPage': {
let { citation } = message;
if (!citation.citationItems.length) {
return;
}
let citationItem = citation.citationItems[0];
let item = await Zotero.EditorInstance.getItemFromURIs(citationItem.uris);
if (!item) {
return;
}
let attachments = Zotero.Items.get(item.getAttachments()).filter(x => x.isPDFAttachment());
if (citationItem.locator && attachments.length === 1) {
let zp = Zotero.getActiveZoteroPane();
if (zp) {
zp.viewPDF(attachments[0].id, { pageLabel: citationItem.locator });
}
}
else {
this._showInLibrary(item.id);
}
return;
}
case 'showCitationItem': {
let { citation } = message;
let items = [];
for (let citationItem of citation.citationItems) {
let item = await Zotero.EditorInstance.getItemFromURIs(citationItem.uris);
if (item) {
items.push(item);
}
}
if (items.length) {
this._showInLibrary(items.map(item => item.id));
}
return;
}
case 'openURL': {
let { url } = message;
let zp = Zotero.getActiveZoteroPane();
if (zp) {
zp.loadURI(url);
}
return;
}
case 'showNote': {
this._showInLibrary(this._item.id);
return;
}
case 'openWindow': {
// TODO: Can we can avoid creating empty note just to open it in a new window?
await this._ensureNoteCreated();
let zp = Zotero.getActiveZoteroPane();
zp.openNoteWindow(this._item.id);
return;
}
case 'openBackup': {
let zp = Zotero.getActiveZoteroPane();
if (zp) {
zp.openBackupNoteWindow(this._item.id);
}
return;
}
case 'update': {
let { noteData, system } = message;
if (this._readOnly) {
return;
}
await this._save(noteData, system);
return;
}
case 'subscribe': {
let { subscription } = message;
this._subscriptions.push(subscription);
if (subscription.type === 'image') {
await this._feedSubscription(subscription);
}
return;
}
case 'unsubscribe': {
let { id } = message;
this._subscriptions.splice(this._subscriptions.findIndex(s => s.id === id), 1);
return;
}
case 'updateCitationItemsList': {
let { list } = message;
let newList = [];
for (let item of list) {
let existingItem = this._citationItemsList
.find(ci => ci.uris.some(uri => item.uris.includes(uri)));
if (!existingItem) {
newList.push(item);
}
}
await this._updateCitationItems(newList);
this._citationItemsList = list;
return;
}
case 'openCitationPopup': {
let { nodeID, citation } = message;
if (this._readOnly) {
return;
}
citation = JSON.parse(JSON.stringify(citation));
for (let citationItem of citation.citationItems) {
let item = await Zotero.EditorInstance.getItemFromURIs(citationItem.uris);
if (item) {
citationItem.id = item.id;
}
}
let openedEmpty = !citation.citationItems.length;
if (!citation.citationItems.length) {
let win = Zotero.getMainWindow();
if (win) {
let reader = Zotero.Reader.getByTabID(win.Zotero_Tabs.selectedID);
if (reader) {
let item = Zotero.Items.get(reader.itemID);
if (item && item.parentItem) {
item = item.parentItem;
let citationItem = {};
citationItem.id = item.id;
citationItem.uris = [Zotero.URI.getItemURI(item)];
citationItem.itemData = Zotero.Utilities.itemToCSLJSON(item);
citation.citationItems.push(citationItem);
}
}
}
}
let libraryID = this._item.libraryID;
this._openQuickFormatDialog(nodeID, citation, [libraryID], openedEmpty);
return;
}
case 'importImages': {
let { images } = message;
if (this._readOnly) {
return;
}
if (this._isAttachment) {
return;
}
for (let image of images) {
let { nodeID, src } = image;
let attachmentKey = await this._importImage(src, true);
// TODO: Inform editor about the failed to import images
if (attachmentKey) {
this._postMessage({ action: 'attachImportedImage', nodeID, attachmentKey });
}
}
return;
}
case 'openContextMenu': {
let { x, y, pos, itemGroups } = message;
this._openPopup(x, y, pos, itemGroups);
return;
}
case 'return': {
this._onReturn();
return;
}
}
}
catch (e) {
if (message && ['update', 'importImages'].includes(message.action)) {
this._postMessage({ action: 'crash' });
}
throw e;
}
}
async _updateCitationItems(citationItemsList) {
let citationItems = [];
for (let { uris } of citationItemsList) {
let item = await Zotero.EditorInstance.getItemFromURIs(uris);
if (item) {
let itemData = Zotero.Utilities.itemToCSLJSON(item);
citationItems.push({ uris, itemData });
}
}
if (citationItems.length) {
this._postMessage({ action: 'updateCitationItems', citationItems });
}
}
async _feedSubscription(subscription) {
let { id, type, data } = subscription;
if (type === 'image') {
let { attachmentKey } = data;
let item = Zotero.Items.getByLibraryAndKey(this._item.libraryID, attachmentKey);
// Note: Images aren't visible in merge dialog because:
// - Attachments aren't downloaded at the time
// - We are checking if attachments belong to the current note
if (item.parentID === this._item.id) {
if (await item.getFilePathAsync()) {
let src = await this._getDataURL(item);
this._postMessage({ action: 'notifySubscription', id, data: { src } });
}
else {
await Zotero.Notes.ensureEmbeddedImagesAreAvailable(this._item);
// this._postMessage({ action: 'notifySubscription', id, data: { src: 'error' } });
}
}
}
}
async _importImage(src, download) {
let blob;
if (src.startsWith('data:')) {
blob = this._dataURLtoBlob(src);
}
else if (download) {
let res;
try {
res = await Zotero.HTTP.request('GET', src, { responseType: 'blob' });
}
catch (e) {
return;
}
blob = res.response;
if (!DOWNLOADED_IMAGE_TYPE.includes(blob.type)) {
return;
}
}
else {
return;
}
let attachment = await Zotero.Attachments.importEmbeddedImage({
blob,
parentItemID: this._item.id,
saveOptions: {
notifierData: {
noteEditorID: this.instanceID
}
}
});
return attachment.key;
}
async _openPopup(x, y, pos, itemGroups) {
let appendItems = (parentNode, itemGroups) => {
for (let itemGroup of itemGroups) {
for (let item of itemGroup) {
if (item.groups) {
let menu = parentNode.ownerDocument.createElement('menu');
menu.setAttribute('label', item.label);
let menupopup = parentNode.ownerDocument.createElement('menupopup');
menu.append(menupopup);
appendItems(menupopup, item.groups);
parentNode.appendChild(menu);
}
else {
let menuitem = parentNode.ownerDocument.createElement('menuitem');
menuitem.setAttribute('value', item.name);
menuitem.setAttribute('label', item.label);
menuitem.setAttribute('disabled', !item.enabled);
menuitem.setAttribute('checked', item.checked);
menuitem.addEventListener('command', () => {
this._postMessage({
action: 'contextMenuAction',
ctxAction: item.name,
pos
});
});
parentNode.appendChild(menuitem);
}
}
if (itemGroups.indexOf(itemGroup) !== itemGroups.length - 1) {
let separator = parentNode.ownerDocument.createElement('menuseparator');
parentNode.appendChild(separator);
}
}
};
this._popup.hidePopup();
while (this._popup.firstChild) {
this._popup.removeChild(this._popup.firstChild);
}
appendItems(this._popup, itemGroups);
// Spell checker
let spellChecker = this._getSpellChecker();
// If `contenteditable` area wasn't focused before, the spell checker
// might not be fully initialized on right-click.
// The wait time depends on system performance/load
let n = 0;
// Wait for 200ms
while (n++ < 20) {
try {
if (spellChecker.mInlineSpellChecker.spellChecker.GetCurrentDictionary()) {
break;
}
}
catch (e) {
break;
}
await Zotero.Promise.delay(10);
}
// Separator
var separator = this._popup.ownerDocument.createElement('menuseparator');
this._popup.appendChild(separator);
// Check Spelling
var menuitem = this._popup.ownerDocument.createElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('spellCheck.checkSpelling'));
menuitem.setAttribute('checked', spellChecker.enabled);
menuitem.addEventListener('command', () => {
// Possible values: 0 - off, 1 - only multi-line, 2 - multi and single line input boxes
Zotero.Prefs.set('layout.spellcheckDefault', spellChecker.enabled ? 0 : 1, true);
});
this._popup.append(menuitem);
if (spellChecker.enabled) {
// Languages menu
var menu = this._popup.ownerDocument.createElement('menu');
menu.setAttribute('label', Zotero.getString('general.languages'));
this._popup.append(menu);
// Languages menu popup
var menupopup = this._popup.ownerDocument.createElement('menupopup');
menu.append(menupopup);
spellChecker.addDictionaryListToMenu(menupopup, null);
// The menu is prepopulated with names from InlineSpellChecker::getDictionaryDisplayName(),
// which will be in English, so swap in native locale names where we have them
for (var menuitem of menupopup.children) {
// 'spell-check-dictionary-en-US'
let locale = menuitem.id.slice(23);
let label = Zotero.Dictionaries.getBestDictionaryName(locale);
if (label && label != locale) {
menuitem.setAttribute('label', label);
}
}
// Separator
var separator = this._popup.ownerDocument.createElement('menuseparator');
menupopup.appendChild(separator);
// Add Dictionaries
var menuitem = this._popup.ownerDocument.createElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('spellCheck.addRemoveDictionaries'));
menuitem.addEventListener('command', () => {
Services.ww.openWindow(null, "chrome://zotero/content/dictionaryManager.xul",
"dictionary-manager", "chrome,centerscreen", {});
});
menupopup.append(menuitem);
let selection = this._iframeWindow.getSelection();
if (selection) {
spellChecker.initFromEvent(
selection.anchorNode,
selection.anchorOffset
);
}
let firstElementChild = this._popup.firstElementChild;
let suggestionCount = spellChecker.addSuggestionsToMenu(this._popup, firstElementChild, 5);
if (suggestionCount) {
let separator = this._popup.ownerDocument.createElement('menuseparator');
this._popup.insertBefore(separator, firstElementChild);
}
}
this._popup.openPopupAtScreen(x, y, true);
}
_getSpellChecker() {
let spellChecker = new InlineSpellChecker();
let editingSession = this._iframeWindow
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIEditingSession);
spellChecker.init(editingSession.getEditorForWindow(this._iframeWindow));
return spellChecker;
}
async _ensureNoteCreated() {
if (!this._item.id) {
return this._item.saveTx();
}
}
async _save(noteData, skipDateModifiedUpdate) {
if (!noteData) return;
let { state, html } = noteData;
if (html === undefined) return;
try {
if (this._disableSaving) {
Zotero.debug('Saving is disabled');
return;
}
if (this._readOnly) {
Zotero.debug('Not saving read-only note');
return;
}
if (html === null) {
Zotero.debug('Note value not available -- not saving', 2);
return;
}
// Update note
if (this._item) {
await Zotero.NoteBackups.ensureBackup(this._item);
await Zotero.DB.executeTransaction(async () => {
let changed = this._item.setNote(html);
if (changed && !this._disableSaving) {
await this._item.save({
skipDateModifiedUpdate,
notifierData: {
// Use a longer timeout to avoid repeated syncing during typing
autoSyncDelay: Zotero.Notes.AUTO_SYNC_DELAY,
noteEditorID: this.instanceID,
state
}
});
}
});
}
// Create a new note
else {
var item = new Zotero.Item('note');
if (this.parentItem) {
item.libraryID = this.parentItem.libraryID;
}
item.setNote(html);
if (this.parentItem) {
item.parentKey = this.parentItem.key;
}
if (!this._disableSaving) {
var id = await item.saveTx({
notifierData: {
autoSyncDelay: Zotero.Notes.AUTO_SYNC_DELAY
}
});
if (!this.parentItem && this.collection) {
this.collection.addItem(id);
}
this._item = item;
}
}
}
catch (e) {
Zotero.logError(e);
Zotero.crash(true);
throw e;
}
// Reset spell checker as ProseMirror DOM modifications are
// often ignored otherwise
try {
let spellChecker = this._getSpellChecker();
spellChecker.toggleEnabled();
spellChecker.toggleEnabled();
} catch(e) {
Zotero.logError(e);
}
}
/**
* Build citation item preview string (based on _buildBubbleString in quickFormat.js)
* TODO: Try to avoid duplicating this code here and inside note-editor
*/
_formatCitationItemPreview(citationItem) {
const STARTSWITH_ROMANESQUE_REGEXP = /^[&a-zA-Z\u0e01-\u0e5b\u00c0-\u017f\u0370-\u03ff\u0400-\u052f\u0590-\u05d4\u05d6-\u05ff\u1f00-\u1fff\u0600-\u06ff\u200c\u200d\u200e\u0218\u0219\u021a\u021b\u202a-\u202e]/;
const ENDSWITH_ROMANESQUE_REGEXP = /[.;:&a-zA-Z\u0e01-\u0e5b\u00c0-\u017f\u0370-\u03ff\u0400-\u052f\u0590-\u05d4\u05d6-\u05ff\u1f00-\u1fff\u0600-\u06ff\u200c\u200d\u200e\u0218\u0219\u021a\u021b\u202a-\u202e]$/;
let { itemData } = citationItem;
let str = '';
// Authors
let authors = itemData.author;
if (authors) {
if (authors.length === 1) {
str = authors[0].family || authors[0].literal;
}
else if (authors.length === 2) {
let a = authors[0].family || authors[0].literal;
let b = authors[1].family || authors[1].literal;
str = a + ' ' + Zotero.getString('general.and') + ' ' + b;
}
else if (authors.length >= 3) {
str = (authors[0].family || authors[0].literal) + ' ' + Zotero.getString('general.etAl');
}
}
// Title
if (!str && itemData.title) {
str = `${itemData.title}`;
}
// Date
if (itemData.issued
&& itemData.issued['date-parts']
&& itemData.issued['date-parts'][0]) {
let year = itemData.issued['date-parts'][0][0];
if (year && year != '0000') {
str += ', ' + year;
}
}
// Locator
if (citationItem.locator) {
if (citationItem.label) {
// TODO: Localize and use short forms
var label = citationItem.label;
}
else if (/[\-,]/.test(citationItem.locator)) {
var label = 'pp.';
}
else {
var label = 'p.';
}
str += ', ' + label + ' ' + citationItem.locator;
}
// Prefix
if (citationItem.prefix && ENDSWITH_ROMANESQUE_REGEXP) {
str = citationItem.prefix
+ (ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? ' ' : '')
+ str;
}
// Suffix
if (citationItem.suffix && STARTSWITH_ROMANESQUE_REGEXP) {
str += (STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? ' ' : '')
+ citationItem.suffix;
}
return str;
}
_formatCitation(citation) {
return citation.citationItems.map(x => this._formatCitationItemPreview(x)).join('; ');
}
_arrayBufferToBase64(buffer) {
var binary = '';
var bytes = new Uint8Array(buffer);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
_dataURLtoBlob(dataurl) {
let parts = dataurl.split(',');
let mime = parts[0].match(/:(.*?);/)[1];
if (parts[0].indexOf('base64') !== -1) {
let bstr = atob(parts[1]);
let n = bstr.length;
let u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new (Zotero.getMainWindow()).Blob([u8arr], { type: mime });
}
return null;
}
async _getDataURL(item) {
let path = await item.getFilePathAsync();
let buf = await OS.File.read(path, {});
buf = new Uint8Array(buf).buffer;
return 'data:' + item.attachmentContentType + ';base64,' + this._arrayBufferToBase64(buf);
}
// TODO: Allow only one quickFormat dialog
async _openQuickFormatDialog(nodeID, citationData, filterLibraryIDs, openedEmpty) {
await Zotero.Styles.init();
let that = this;
let win;
/**
* Citation editing functions and properties accessible to quickFormat.js and addCitationDialog.js
*/
let CI = function (citation, sortable, fieldIndexPromise, citationsByItemIDPromise, previewFn) {
this.citation = citation;
this.sortable = sortable;
this.filterLibraryIDs = filterLibraryIDs;
this.disableClassicDialog = true;
// Cited items updated in `getItems`
this.citedItems = [];
};
CI.prototype = {
/**
* 1) Provide `quickFormat` dialog with items created from
* `itemData`, without dealing with `Zotero.Integration.sessions`
*
* 2) Allow to pick already cited item from `quickFormat` dropdown
*
* @param citationItem
* @returns {Zotero.Item|undefined}
*/
customGetItem(citationItem) {
// Using `id` as cited item index from `getItems` below
let citedItem = typeof citationItem.id === 'string'
&& this.citedItems[parseInt(citationItem.id.split('cited:')[1])];
// Return cited item picked in `quickFormat` dropdown
if (citedItem) {
return citedItem.item;
}
// Provide an item created from `itemData`
else if (!citationItem.id && citationItem.itemData) {
let item = new Zotero.Item();
Zotero.Utilities.itemFromCSLJSON(item, citationItem.itemData);
return item;
}
// Otherwise returns `undefined` which makes this function to be
},
/**
* Execute a callback with a preview of the given citation
* @return {Promise} A promise resolved with the previewed citation string
*/
preview: async function () {
// Zotero.debug('CI: preview');
},
/**
* Sort the citationItems within citation (depends on this.citation.properties.unsorted)
* @return {Promise} A promise resolved with the previewed citation string
*/
sort: async function () {
// Zotero.debug('CI: sort');
// Normally `this.citation.citationItems` should be sorted by
// citation preview, but in our editor it doesn't make sense
// to do so, because we don't have a real style here and
// it's not the final document
},
/**
* Accept changes to the citation
* @param {Function} [progressCallback] A callback to be run when progress has changed.
* Receives a number from 0 to 100 indicating current status.
*/
accept: async function (progressCallback) {
// Zotero.debug('CI: accept');
if (progressCallback) progressCallback(100);
if (win) {
win.close();
}
let citation = {
citationItems: this.citation.citationItems,
properties: this.citation.properties
};
for (let citationItem of citation.citationItems) {
let citedItem = typeof citationItem.id === 'string'
&& this.citedItems[parseInt(citationItem.id.split('cited:')[1])];
// Cited item
if (citedItem) {
let ci = citedItem.citationItem;
citationItem.uris = ci.uris;
citationItem.itemData = ci.itemData;
}
// New item
else if (citationItem.id) {
let item = await Zotero.Items.getAsync(parseInt(citationItem.id));
citationItem.uris = [Zotero.URI.getItemURI(item)];
citationItem.itemData = Zotero.Utilities.itemToCSLJSON(item);
}
// Otherwise it's existing item, so just passing untouched citationItem
delete citationItem.id;
}
if (progressCallback || !citationData.citationItems.length || openedEmpty) {
that._postMessage({ action: 'setCitation', nodeID, citation });
}
},
/**
* Get a list of items used in the current document
* @return {Promise} A promise resolved by the items
*/
getItems: async function () {
// Zotero.debug('CI: getItems');
let note = that._item.note;
let parser = Components.classes['@mozilla.org/xmlextras/domparser;1']
.createInstance(Components.interfaces.nsIDOMParser);
let doc = parser.parseFromString(note, 'text/html');
let metadataContainer = doc.querySelector('body > div[data-schema-version]');
if (metadataContainer) {
let citationItems = metadataContainer.getAttribute('data-citation-items');
if (citationItems) {
try {
citationItems = JSON.parse(decodeURIComponent(citationItems));
let items = [];
for (let citationItem of citationItems) {
let item = new Zotero.Item();
Zotero.Utilities.itemFromCSLJSON(item, citationItem.itemData);
// This is the only way to pass our custom id for already cited
// items, without modifying `quickFormat` dialog too much.
// Must not contain `/`
item.cslItemID = 'cited:' + items.length;
items.push({ item, citationItem });
}
this.citedItems = items;
return items.map(x => x.item);
}
catch (e) {
Zotero.logError(e);
}
}
}
return [];
}
};
let Citation = class {
constructor(citationField, data, noteIndex) {
if (!data) {
data = { citationItems: [], properties: {} };
}
this.citationID = data.citationID;
this.citationItems = data.citationItems;
this.properties = data.properties;
this.properties.noteIndex = noteIndex;
this._field = citationField;
}
/**
* Load citation item data
* @param {Boolean} [promptToReselect=true] - will throw a MissingItemException if false
* @returns {Promise{Number}}
* - Zotero.Integration.NO_ACTION
* - Zotero.Integration.UPDATE
* - Zotero.Integration.REMOVE_CODE
* - Zotero.Integration.DELETE
*/
loadItemData() {
// Zotero.debug('Citation: loadItemData');
}
async handleMissingItem(idx) {
// Zotero.debug('Citation: handleMissingItem');
}
async prepareForEditing() {
// Zotero.debug('Citation: prepareForEditing');
}
toJSON() {
// Zotero.debug('Citation: toJSON');
}
/**
* Serializes the citation into CSL code representation
* @returns {string}
*/
serialize() {
// Zotero.debug('Citation: serialize');
}
};
if (that._quickFormatWindow) {
that._quickFormatWindow.close();
that._quickFormatWindow = null;
}
let citation = new Citation();
citation.citationItems = citationData.citationItems;
citation.properties = citationData.properties;
let styleID = Zotero.Prefs.get('export.lastStyle');
let locale = Zotero.Prefs.get('export.lastLocale');
let csl = Zotero.Styles.get(styleID).getCiteProc(locale);
var io = new CI(citation, csl.opt.sort_citations);
var allOptions = 'chrome,centerscreen';
// without this, Firefox gets raised with our windows under Compiz
if (Zotero.isLinux) allOptions += ',dialog=no';
// if(options) allOptions += ','+options;
var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')
? 'popup' : 'alwaysRaised') + ',resizable=false,centerscreen';
win = that._quickFormatWindow = Components.classes['@mozilla.org/embedcomp/window-watcher;1']
.getService(Components.interfaces.nsIWindowWatcher)
.openWindow(null, 'chrome://zotero/content/integration/quickFormat.xul', '', mode, {
wrappedJSObject: io
});
}
// TODO: This should be moved to utilities
static async getItemFromURIs(uris) {
for (let uri of uris) {
// Try getting URI directly
try {
let item = await Zotero.URI.getURIItem(uri);
if (item) {
// Ignore items in the trash
if (!item.deleted) {
return item;
}
}
}
catch (e) {
}
// Try merged item mapping
var replacer = await Zotero.Relations.getByPredicateAndObject(
'item', Zotero.Relations.replacedItemPredicate, uri
);
if (replacer.length && !replacer[0].deleted) {
return replacer[0];
}
}
}
/**
* Create note from annotations
*
* @param {Zotero.Item[]} annotations
* @param {Integer} parentID Creates standalone note if not provided
* @returns {Promise<Zotero.Item>}
*/
static async createNoteFromAnnotations(annotations, parentID) {
if (!annotations.length) {
throw new Error("No annotations provided");
}
for (let annotation of annotations) {
if (annotation.annotationType === 'image'
&& !await Zotero.Annotations.hasCacheImage(annotation)) {
try {
await Zotero.PDFRenderer.renderAttachmentAnnotations(annotation.parentID);
}
catch (e) {
Zotero.debug(e);
throw e;
}
break;
}
}
let note = new Zotero.Item('note');
note.libraryID = annotations[0].libraryID;
note.parentID = parentID;
await note.saveTx();
let editorInstance = new EditorInstance();
editorInstance._item = note;
let jsonAnnotations = [];
for (let annotation of annotations) {
let attachmentItem = Zotero.Items.get(annotation.parentID);
let jsonAnnotation = await Zotero.Annotations.toJSON(annotation);
jsonAnnotation.attachmentItemID = attachmentItem.id;
jsonAnnotations.push(jsonAnnotation);
}
let html = `<h1>${Zotero.getString('pdfReader.annotations')}<br/>`
+ Zotero.getString('noteEditor.annotationsDateLine', new Date().toLocaleString())
+ `</h1>\n`;
let { html: serializedHTML, citationItems } = await editorInstance._serializeAnnotations(jsonAnnotations, true);
html += serializedHTML;
citationItems = encodeURIComponent(JSON.stringify(citationItems));
html = `<div data-citation-items="${citationItems}" data-schema-version="${SCHEMA_VERSION}">${html}</div>`;
note.setNote(html);
await note.saveTx();
return note;
}
}
Zotero.EditorInstance = EditorInstance;
Zotero.EditorInstance.SCHEMA_VERSION = SCHEMA_VERSION;