diff --git a/chrome/content/zotero/xpcom/editorInstance.js b/chrome/content/zotero/xpcom/editorInstance.js index 9ca282858f..fbed160690 100644 --- a/chrome/content/zotero/xpcom/editorInstance.js +++ b/chrome/content/zotero/xpcom/editorInstance.js @@ -44,7 +44,7 @@ const DOWNLOADED_IMAGE_TYPE = [ ]; // Schema version here has to be the same as in note-editor! -const SCHEMA_VERSION = 5; +const SCHEMA_VERSION = 6; class EditorInstance { constructor() { @@ -301,7 +301,7 @@ class EditorInstance { walkFormat(doc.body); return doc.body.innerHTML; } - + /** * @param {Object[]} annotations JSON annotations * @param {Boolean} skipEmbeddingItemData Do not add itemData to citation items @@ -325,6 +325,7 @@ class EditorInstance { let citationHTML = ''; let imageHTML = ''; let highlightHTML = ''; + let quotedHighlightHTML = ''; let commentHTML = ''; let storedAnnotation = { @@ -389,23 +390,40 @@ class EditorInstance { // Text if (annotation.text) { let text = this._transformTextToHTML(annotation.text.trim()); - highlightHTML = `“${text}”`; + highlightHTML = `${text}`; + quotedHighlightHTML = `${Zotero.getString('punctuation.openingQMark')}${text}${Zotero.getString('punctuation.closingQMark')}`; } // 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('' : ' ') + comment; + commentHTML = this._transformTextToHTML(annotation.comment.trim()); } - - if (citationHTML) { - // Move citation to the next line if highlight has multiple lines or is after image - citationHTML = ((highlightHTML && highlightHTML.includes('' : '') + citationHTML; + + let template; + if (annotation.type === 'highlight') { + template = Zotero.Prefs.get('annotations.noteTemplates.highlight'); } - - let otherHTML = [highlightHTML, citationHTML, commentHTML].filter(x => x).join(' '); - html += '

' + imageHTML + otherHTML + '

\n'; + else if (annotation.type === 'note') { + template = Zotero.Prefs.get('annotations.noteTemplates.note'); + } + else if (annotation.type === 'image') { + template = '

{{image}}
{{citation}} {{comment}}

'; + } + + let vars = { + color: annotation.color, + highlight: (attrs) => attrs.quotes === 'true' ? quotedHighlightHTML : highlightHTML, + comment: commentHTML, + citation: citationHTML, + image: imageHTML, + tags: (attrs) => annotation.tags && annotation.tags.map(tag => tag.name).join(attrs.join || ' ') + }; + let templateHTML = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + // Remove some spaces at the end of paragraph + templateHTML = templateHTML.replace(/([\s]*)(<\/p)/g, '$2'); + // Remove multiple spaces + templateHTML = templateHTML.replace(/\s\s+/g, ' '); + html += templateHTML; } return { html, citationItems: storedCitationItems }; } @@ -1464,9 +1482,15 @@ class EditorInstance { jsonAnnotation.id = annotation.key; jsonAnnotations.push(jsonAnnotation); } - let html = `

${Zotero.getString('pdfReader.annotations')}
` - + Zotero.getString('noteEditor.annotationsDateLine', new Date().toLocaleString()) - + `

\n`; + + let vars = { + title: Zotero.getString('pdfReader.annotations'), + date: new Date().toLocaleString() + }; + let html = Zotero.Utilities.Internal.generateHTMLFromTemplate(Zotero.Prefs.get('annotations.noteTemplates.title'), vars); + // New line is needed for note title parser + html += '\n'; + let { html: serializedHTML, citationItems } = await editorInstance._serializeAnnotations(jsonAnnotations, true); html += serializedHTML; citationItems = encodeURIComponent(JSON.stringify(citationItems)); diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js index 6f29059bcb..6e1e5c8ec1 100644 --- a/chrome/content/zotero/xpcom/utilities_internal.js +++ b/chrome/content/zotero/xpcom/utilities_internal.js @@ -2167,6 +2167,95 @@ Zotero.Utilities.Internal = { this._addListener(event, () => resolve(), true); }); }; + }, + + /** + * A basic templating engine + * + * - 'if' statement does case-insensitive string comparison + * - Spaces around '==' are necessary in 'if' statement + * + * Vars example: + * { + * color: '#ff6666', + * highlight: 'This is a highlight, + * comment: 'This is a comment', + * citation: '(Author, 1900)', + * image: '', + * tags: (attrs) => ['tag1', 'tag2'].map(tag => tag.name).join(attrs.join || ' ') + * } + * + * Template example: + * {{if color == '#ff6666'}} + *

{{highlight}}

+ * {{elseif color == '#2ea8e5'}} + * {{if comment}}

{{comment}}:

{{endif}}
{{highlight}}

{{citation}}

+ * {{else}} + *

{{highlight}} {{citation}} {{comment}} {{if tags}} #{{tags join=' #'}}{{endif}}

+ * {{endif}} + * + * @param {String} template + * @param {Object} vars + * @returns {String} HTML + */ + generateHTMLFromTemplate: function (template, vars) { + let levels = [{ condition: true }]; + let html = ''; + let parts = template.split(/{{|}}/); + for (let i = 0; i < parts.length; i++) { + let part = parts[i]; + let level = levels[levels.length - 1]; + if (i % 2 === 1) { + let operator = part.split(' ').filter(x => x)[0]; + // Get arguments that are used for 'if' + let args = []; + let match = part.match(/(["'][^"|^']+["']|[^\s"']+)/g); + if (match) { + args = match.map(x => x.replace(/['"](.*)['"]/, '$1')).slice(1); + } + if (operator === 'if') { + level = { condition: false, executed: false, parentCondition: levels[levels.length-1].condition }; + levels.push(level); + } + if (['if', 'elseif'].includes(operator)) { + if (!level.executed) { + level.condition = level.parentCondition && (args[2] ? vars[args[0]].toLowerCase() == args[2].toLowerCase() : !!vars[args[0]]); + level.executed = level.condition; + } + else { + level.condition = false; + } + continue; + } + else if (operator === 'else') { + level.condition = level.parentCondition && !level.executed; + level.executed = level.condition; + continue; + } + else if (operator === 'endif') { + levels.pop(); + continue; + } + if (level.condition) { + // Get attributes i.e. join=" #" + let attrsRegexp = new RegExp(/((\w*) *=+ *(['"])((\\\3|[^\3])*?)\3)|((\w*) *=+ *(\w*))/g); + let attrs = {}; + while ((match = attrsRegexp.exec(part))) { + if (match[4]) { + attrs[match[2]] = match[4]; + } + else { + attrs[match[7]] = match[8]; + } + } + html += (typeof vars[operator] === 'function' ? vars[operator](attrs) : vars[operator]) || ''; + } + } + else if (level.condition) { + html += part; + } + } + return html; } } diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index 39927f1a0e..924978b208 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -108,8 +108,8 @@ about.createdBy = %1$S is a project of %2$S and is developed by a [global commun about.openSource = %S is [open-source software] and depends on many [exceptional open-source projects]. about.getInvolved = Want to help? [Get involved] today! -punctuation.openingQMark = " -punctuation.closingQMark = " +punctuation.openingQMark = “ +punctuation.closingQMark = ” punctuation.colon = : punctuation.ellipsis = … diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index 832db2deb6..4e63e598d5 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -193,3 +193,8 @@ pref("extensions.zotero.translators.RIS.import.keepID", false); // Retracted Items pref("extensions.zotero.retractions.enabled", true); pref("extensions.zotero.retractions.recentItems", "[]"); + +// Annotations +pref("extensions.zotero.annotations.noteTemplates.title", "

{{title}}
({{date}})

"); +pref("extensions.zotero.annotations.noteTemplates.highlight", "

{{highlight quotes='true'}} {{citation}} {{comment}}

"); +pref("extensions.zotero.annotations.noteTemplates.note", "

{{citation}} {{comment}}

"); diff --git a/note-editor b/note-editor index 6ba8de80ff..a5ba343501 160000 --- a/note-editor +++ b/note-editor @@ -1 +1 @@ -Subproject commit 6ba8de80ff64558f7cc727355b69eaca0ae3dcad +Subproject commit a5ba3435011938a68799f6b169e2e89b574cfded diff --git a/pdf-reader b/pdf-reader index 4f14537a31..c5b1bc4dfd 160000 --- a/pdf-reader +++ b/pdf-reader @@ -1 +1 @@ -Subproject commit 4f14537a31ed66152d99a949d554b8e7ef7f1260 +Subproject commit c5b1bc4dfd88001f0e92ff82dc264f514eb00984 diff --git a/test/tests/utilities_internalTest.js b/test/tests/utilities_internalTest.js index 70fb73351b..f8122036f9 100644 --- a/test/tests/utilities_internalTest.js +++ b/test/tests/utilities_internalTest.js @@ -522,4 +522,26 @@ describe("Zotero.Utilities.Internal", function () { }); }); }); + + describe("#generateHTMLFromTemplate()", function () { + it("should support variables with attributes", function () { + var vars = { + v1: '1', + v2: (pars) => pars.a1 + pars.a2 + pars.a3, + v3: () => undefined, + }; + var template = `{{ v1}}{{v2 a1= 1 a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}`; + var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(html, '11 23 '); + }); + it("should support nested 'if' statements", function () { + var vars = { + v1: '1', + v2: 'H', + }; + var template = `{{if v1 == '1'}}yes1{{if x}}no{{elseif v2 == h }}yes2{{endif}}{{elseif v2 == 2}}no{{else}}no{{endif}} {{if v2 == 1}}not{{elseif x}}not{{else}}yes3{{ endif}}`; + var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(html, 'yes1yes2 yes3'); + }); + }); })