From 0ba766f2e0cd43bb307805f44f53946c0af526ce Mon Sep 17 00:00:00 2001 From: Tom Najdek Date: Thu, 20 Jul 2023 12:50:34 +0200 Subject: [PATCH] Customizable renaming rules #1413 (#2297) --- chrome/content/zotero/xpcom/attachments.js | 214 ++++++++----- chrome/content/zotero/xpcom/prefs.js | 50 ++- .../zotero/xpcom/utilities_internal.js | 174 ++++++++--- defaults/preferences/zotero.js | 2 +- test/tests/attachmentsTest.js | 292 +++++++++++++++++- test/tests/utilities_internalTest.js | 159 +++++++++- 6 files changed, 766 insertions(+), 125 deletions(-) diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index fe4b38f67a..4034bb8366 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -23,7 +23,7 @@ ***** END LICENSE BLOCK ***** */ -Zotero.Attachments = new function(){ +Zotero.Attachments = new function () { const { HiddenBrowser } = ChromeUtils.import("chrome://zotero/content/HiddenBrowser.jsm"); // Keep in sync with Zotero.Schema.integrityCheck() and this.linkModeToName() @@ -2241,15 +2241,6 @@ Zotero.Attachments = new function(){ * (Optional) |formatString| specifies the format string -- otherwise * the 'attachmentRenameFormatString' pref is used * - * Valid substitution markers: - * - * %c -- firstCreator - * %y -- year (extracted from Date field) - * %t -- title - * - * Fields can be truncated to a certain length by appending an integer - * within curly brackets -- e.g. %t{50} truncates the title to 50 characters - * * @param {Zotero.Item} item * @param {String} formatString */ @@ -2257,79 +2248,160 @@ Zotero.Attachments = new function(){ if (!(item instanceof Zotero.Item)) { throw new Error("'item' must be a Zotero.Item"); } - + if (!formatString) { formatString = Zotero.Prefs.get('attachmentRenameFormatString'); } - - // Replaces the substitution marker with the field value, - // truncating based on the {[0-9]+} modifier if applicable - function rpl(field, str) { - if (!str) { - str = formatString; - } - - switch (field) { - case 'creator': - field = 'firstCreator'; - var rpl = '%c'; + + const getSlicedCreatorsOfType = (creatorType, slice) => { + let creatorTypeIDs; + switch (creatorType) { + case 'author': + case 'authors': + creatorTypeIDs = [Zotero.CreatorTypes.getPrimaryIDForType(item.itemTypeID)]; break; - - case 'year': - var rpl = '%y'; + case 'editor': + case 'editors': + creatorTypeIDs = [Zotero.CreatorTypes.getID('editor'), Zotero.CreatorTypes.getID('seriesEditor')]; break; - - case 'title': - var rpl = '%t'; - break; - } - - var value; - switch (field) { - case 'title': - value = item.getField('title', false, true); - break; - - case 'year': - value = item.getField('date', true, true); - if (value) { - value = Zotero.Date.multipartToSQL(value).substr(0, 4); - if (value == '0000') { - value = ''; - } - } - break; - default: - value = '' + item.getField(field, false, true); + case 'creator': + case 'creators': + creatorTypeIDs = null; + break; } - var re = new RegExp("\{?([^%\{\}]*)" + rpl + "(\{[0-9]+\})?" + "([^%\{\}]*)\}?"); - - // If no value for this field, strip entire conditional block - // (within curly braces) - if (!value) { - if (str.match(re)) { - return str.replace(re, '') + if (slice === 0) { + return []; + } + const matchingCreators = creatorTypeIDs === null + ? item.getCreators() + : item.getCreators().filter(c => creatorTypeIDs.includes(c.creatorTypeID)); + const slicedCreators = slice > 0 + ? matchingCreators.slice(0, slice) + : matchingCreators.slice(slice); + + if (slice < 0) { + slicedCreators.reverse(); + } + return slicedCreators; + }; + + + const common = (value, { truncate = false, prefix = '', suffix = '', case: textCase = '' } = {}) => { + if (value === '' || value === null || typeof value === 'undefined') { + return ''; + } + if (truncate) { + value = value.substr(0, truncate); + } + if (prefix) { + value = prefix + value; + } + if (suffix) { + value += suffix; + } + switch (textCase) { + case 'upper': + value = value.toUpperCase(); + break; + case 'lower': + value = value.toLowerCase(); + break; + case 'sentence': + value = value.slice(0, 1).toUpperCase() + value.slice(1); + break; + case 'title': + value = Zotero.Utilities.capitalizeTitle(value, true); + break; + case 'hyphen': + value = value.toLowerCase().replace(/\s+/g, '-'); + break; + case 'snake': + value = value.toLowerCase().replace(/\s+/g, '_'); + break; + case 'camel': + value = value.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase()); + break; + } + return value; + }; + + const initializeFn = (name, shouldInitialize, initializeWith) => (shouldInitialize ? name.slice(0, 1).toUpperCase() + initializeWith : name); + + const transformName = (creator, { name, namePartSeparator, initialize, initializeWith } = {}) => { + if (creator.name) { + return initializeFn(creator.name, ['full', 'name'].includes(initialize), initializeWith); + } + + const firstLast = ['full', 'given-family', 'first-last']; + const lastFirst = ['full-reversed', 'family-given', 'last-first']; + const first = ['given', 'first']; + const last = ['family', 'last']; + + if (firstLast.includes(name)) { + return initializeFn(creator.firstName, ['full', ...first].includes(initialize), initializeWith) + namePartSeparator + initializeFn(creator.lastName, ['full', ...last].includes(initialize), initializeWith); + } + else if (lastFirst.includes(name)) { + return initializeFn(creator.lastName, ['full', ...last].includes(initialize), initializeWith) + namePartSeparator + initializeFn(creator.firstName, ['full', ...first].includes(initialize), initializeWith); + } + else if (first.includes(name)) { + return initializeFn(creator.firstName, ['full', ...first].includes(initialize), initializeWith); + } + + return initializeFn(creator.lastName, ['full', ...last].includes(initialize), initializeWith); + }; + + const commonCreators = (value, { max = Infinity, name = 'family', namePartSeparator = ' ', join = ', ', initialize = '', initializeWith = '.' } = {}) => { + return getSlicedCreatorsOfType(value, max) + .map(c => transformName(c, { name, namePartSeparator, initialize, initializeWith })) + .join(join); + }; + + const fields = Zotero.ItemFields.getAll() + .map(f => f.name) + .reduce((obj, name) => { + obj[name] = (args) => { + return common(item.getField(name, false, true), args); + }; + return obj; + }, {}); + + const year = (args) => { + let value = item.getField('date', true, true); + if (value) { + value = Zotero.Date.multipartToSQL(value).substr(0, 4); + if (value == '0000') { + value = ''; } } - - var f = function(match, p1, p2, p3) { - var maxChars = p2 ? p2.replace(/[^0-9]+/g, '') : false; - return p1 + (maxChars ? value.substr(0, maxChars) : value) + p3; - } - - return str.replace(re, f); - } - - formatString = rpl('creator'); - formatString = rpl('year'); - formatString = rpl('title'); - + return common(value, args); + }; + + const itemType = ({ localize = false, ...rest }) => common( + localize ? Zotero.ItemTypes.getLocalizedString(item.itemType) : item.itemType, rest + ); + + const creatorFields = ['authors', 'editors', 'creators'].reduce((obj, name) => { + obj[name] = (args) => { + return common(commonCreators(name, args), args); + }; + return obj; + }, {}); + + const firstCreator = args => common( + // 74492e40 adds \u2068 and \u2069 around names in the `firstCreator` field, which we don't want in the filename + // We might actually want to move this replacement to getValidFileName + item.getField('firstCreator', false, true).replaceAll('\u2068', '').replaceAll('\u2069', ''), args + ); + + const vars = { ...fields, ...creatorFields, firstCreator, itemType, year }; + + formatString = Zotero.Utilities.Internal.generateHTMLFromTemplate(formatString, vars); formatString = Zotero.Utilities.cleanTags(formatString); formatString = Zotero.File.getValidFileName(formatString); return formatString; - } + }; this.shouldAutoRenameFile = function (isLink) { @@ -3029,4 +3101,4 @@ Zotero.Attachments = new function(){ } throw new Error(`Invalid link mode name '${linkModeName}'`); } -} +}; diff --git a/chrome/content/zotero/xpcom/prefs.js b/chrome/content/zotero/xpcom/prefs.js index 6a0efc0898..fcb747d735 100644 --- a/chrome/content/zotero/xpcom/prefs.js +++ b/chrome/content/zotero/xpcom/prefs.js @@ -22,7 +22,7 @@ ***** END LICENSE BLOCK ***** */ -Zotero.Prefs = new function(){ +Zotero.Prefs = new function() { // Privileged methods this.get = get; this.set = set; @@ -49,7 +49,7 @@ Zotero.Prefs = new function(){ if (!fromVersion) { fromVersion = 0; } - var toVersion = 7; + var toVersion = 8; if (fromVersion < toVersion) { for (var i = fromVersion + 1; i <= toVersion; i++) { switch (i) { @@ -109,6 +109,14 @@ Zotero.Prefs = new function(){ case 7: this.clear('layers.acceleration.disabled', true); break; + // Convert "attachment rename format string" from old format (e.g. {%c - }{%y - }{%t{50}}) + // to a new format that uses the template engine + case 8: + if (this.prefHasUserValue('attachmentRenameFormatString')) { + this.set('attachmentRenameFormatString', this.convertLegacyAttachmentRenameFormatString( + this.get('attachmentRenameFormatString') || '' + )); + } } } this.set('prefVersion', toVersion); @@ -119,7 +127,7 @@ Zotero.Prefs = new function(){ /** * Retrieve a preference **/ - function get(pref, global){ + function get(pref, global) { try { pref = global ? pref : ZOTERO_CONFIG.PREF_BRANCH + pref; let branch = this.rootBranch; @@ -554,4 +562,38 @@ Zotero.Prefs = new function(){ } Zotero.Prefs.set(prefKey, JSON.stringify(libraries)); }; -} + + /** + * Converts a value of a `attachmentRenameFormatString` pref from a legacy format string + * with % markers to a new format that uses the template engine + * + * @param {string} formatString - The legacy format string to convert. + * @returns {string} The new format string. + */ + this.convertLegacyAttachmentRenameFormatString = function (formatString) { + const markers = { + c: 'firstCreator', + y: 'year', + t: 'title' + }; + + // Regexp contains 4 capture groups all wrapped in {}: + // * Prefix before the wildcard, can be empty string + // * Any recognized marker. % sign marks a wildcard and is required for a match but is + // not part of the capture group. Recognized markers are specified in a `markers` + // lookup. + // * Optionally a maximum number of characters to truncate the value to + // * Suffix after the wildcard, can be empty string + const re = new RegExp(`{([^%{}]*)%(${Object.keys(markers).join('|')})({[0-9]+})?([^%{}]*)}`, 'ig'); + + return formatString.replace(re, (match, prefix, marker, truncate, suffix) => { + const field = markers[marker]; + truncate = truncate ? truncate.replace(/[^0-9]+/g, '') : false; + prefix = prefix ? `prefix="${prefix}"` : null; + suffix = suffix ? `suffix="${suffix}"` : null; + truncate = truncate ? `truncate="${truncate}"` : null; + + return `{{ ${[field, truncate, prefix, suffix].filter(f => f !== null).join(' ')} }}`; + }); + }; +}; diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js index 5873fd6627..ac7a57c5dc 100644 --- a/chrome/content/zotero/xpcom/utilities_internal.js +++ b/chrome/content/zotero/xpcom/utilities_internal.js @@ -2091,11 +2091,47 @@ Zotero.Utilities.Internal = { }; }, + + /** + * Splits a string by outer-most brackets (`{{` and '}}' by default, configurable). + * + * @param {string} input - The input string to split. + * @returns {string[]} An array of strings split by outer-most brackets. + */ + splitByOuterBrackets: function(input, left = '{{', right = '}}') { + const result = []; + let startIndex = 0; + let depth = 0; + + for (let i = 0; i < input.length; i++) { + if (input.slice(i, i + 2) === left) { + if (depth === 0) { + result.push(input.slice(startIndex, i)); + startIndex = i; + } + depth++; + } + else if (input.slice(i, i + 2) === right) { + depth--; + if (depth === 0) { + result.push(input.slice(startIndex, i + 2)); + startIndex = i + 2; + } + } + } + + if (startIndex < input.length) { + result.push(input.slice(startIndex)); + } + + return result; + }, + /** * A basic templating engine * * - 'if' statement does case-insensitive string comparison - * - Spaces around '==' are necessary in 'if' statement + * - functions can be called from if statements but must be wrapped in {{}} if arguments are passed (e.g. {{myFunction arg1="foo" arg2="bar"}}) * * Vars example: * { @@ -2121,44 +2157,108 @@ Zotero.Utilities.Internal = { * @returns {String} HTML */ generateHTMLFromTemplate: function (template, vars) { - let levels = [{ condition: true }]; + const hyphenToCamel = varName => varName.replace(/-(.)/g, (_, g1) => g1.toUpperCase()); + + const getAttributes = (part) => { + let attrsRegexp = new RegExp(/(([\w-]*) *=+ *(['"])((\\\3|[^\3])*?)\3)/g); + let attrs = {}; + let match; + while ((match = attrsRegexp.exec(part))) { + if (match[1]) { // if first alternative (i.e. argument with value wrapped in " or ') matched, even if value is empty + attrs[hyphenToCamel(match[2])] = match[4]; + } + } + return attrs; + }; + + + const evaluateIdentifier = (ident, args = {}) => { + ident = hyphenToCamel(ident); + + if (!(ident in vars)) { + return ''; + } + + const identValue = typeof vars[ident] === 'function' ? vars[ident](args) : vars[ident]; + + if (Array.isArray(identValue)) { + return identValue.length ? identValue.join(',') : ''; + } + + if (typeof identValue !== 'string') { + throw new Error(`Identifier "${ident}" does not evaluate to a string`); + } + + return identValue; + }; + + // evaluates extracted (i.e. without brackets) statement (e.g. `sum a="1" b="2"`) into a string value + const evaluateStatement = (statement) => { + statement = statement.trim(); + const operator = statement.split(' ', 1)[0].trim(); + const args = statement.slice(operator.length).trim(); + + return evaluateIdentifier(operator, getAttributes(args)); + }; + + // splits raw (i.e. bracketed) statement (e.g. `{{ sum a="1" b="2" }}) into operator and arguments (e.g. ['sum', 'a="1" b="2"']) + const splitStatement = (statement) => { + statement = statement.slice(2, -2).trim(); + const operator = statement.split(' ', 1)[0].trim(); + const args = statement.slice(operator.length).trim(); + return [operator, args]; + }; + + // evaluates a condition (e.g. `a == "b"`) into a boolean value + const evaluateCondition = (condition) => { + const comparators = ['==', '!=']; + condition = condition.trim(); + + // match[1] if left is statement, match[3] if left is literal, match[4] if left is identifier + // match[6] if right is statement, match[8] if right is literal, match[9] if right is identifier + // match[2] and match[7] are used to match the quotes around the literal (and then check that the other quote is the same) + const match = condition.match(new RegExp(String.raw`(?:{{(.*?)}}|(?:(['"])(.*?)\2)|([^ ]+)) *(${comparators.join('|')}) *(?:{{(.*?)}}|(?:(['"])(.*?)\7)|([^ ]+))`)); + + if (!match) { + // condition is a statement or identifier without a comparator + if (condition.startsWith('{{')) { + const [operator, args] = splitStatement(condition); + return !!evaluateIdentifier(operator, getAttributes(args)); + } + return !!evaluateIdentifier(condition); + } + + const left = match[1] ? evaluateStatement(match[1]) : match[3] ?? evaluateIdentifier(match[4]) ?? ''; + const comparator = match[5]; + const right = match[6] ? evaluateStatement(match[6]) : match[8] ?? evaluateIdentifier(match[9]) ?? ''; + + switch (comparator) { + default: + case '==': + return left.toLowerCase() == right.toLowerCase(); + case '!=': + return left.toLowerCase() != right.toLowerCase(); + } + }; + let html = ''; - let parts = template.split(/{{|}}/); + const levels = [{ condition: true }]; + const parts = this.splitByOuterBrackets(template); + 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 (part.startsWith('{{')) { + const [operator, args] = splitStatement(part); + if (operator === 'if') { - level = { condition: false, executed: false, parentCondition: levels[levels.length-1].condition }; + 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] - // If string variable is equal to the provided string - ? vars[args[0]].toLowerCase() == args[2].toLowerCase() - : ( - Array.isArray(vars[args[0]]) - // Is array non empty - ? !!vars[args[0]].length - : ( - typeof vars[args[0]] === 'function' - // If function returns a value (only string is supported) - // Note: To keep things simple, this doesn't support function attributes - ? !!vars[args[0]]({}) - // If string variable exists - : !!vars[args[0]] - ) - ) - ); + level.condition = level.parentCondition && evaluateCondition(args); level.executed = level.condition; } else { @@ -2176,18 +2276,8 @@ Zotero.Utilities.Internal = { 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]) || ''; + const attrs = getAttributes(part); + html += evaluateIdentifier(operator, attrs); } } else if (level.condition) { diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index 59793b91ea..9a649f2900 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -36,7 +36,7 @@ pref("extensions.zotero.autoRecognizeFiles", true); pref("extensions.zotero.autoRenameFiles", true); pref("extensions.zotero.autoRenameFiles.linked", false); pref("extensions.zotero.autoRenameFiles.fileTypes", "application/pdf"); -pref("extensions.zotero.attachmentRenameFormatString", "{%c - }{%y - }{%t{50}}"); +pref("extensions.zotero.attachmentRenameFormatString", "{{ firstCreator suffix=\" - \" }}{{ year suffix=\" - \" }}{{ title truncate=\"50\" }}"); pref("extensions.zotero.capitalizeTitles", false); pref("extensions.zotero.launchNonNativeFiles", false); pref("extensions.zotero.sortNotesChronologically", false); diff --git a/test/tests/attachmentsTest.js b/test/tests/attachmentsTest.js index bd71d2be93..df4a45d4a3 100644 --- a/test/tests/attachmentsTest.js +++ b/test/tests/attachmentsTest.js @@ -1293,11 +1293,297 @@ describe("Zotero.Attachments", function() { }); describe("#getFileBaseNameFromItem()", function () { - it("should strip HTML tags from title", async function () { - var item = createUnsavedDataObject('item', { title: 'Foo Bar Foo


Bar' }); - var str = Zotero.Attachments.getFileBaseNameFromItem(item); + var item, itemManyAuthors, itemPatent, itemIncomplete, itemBookSection; + + before(() => { + item = createUnsavedDataObject('item', { title: 'Lorem Ipsum', itemType: 'journalArticle' }); + item.setCreators([ + { firstName: 'Foocius', lastName: 'Barius', creatorType: 'author' }, + { firstName: 'Bazius', lastName: 'Pixelus', creatorType: 'author' } + ]); + item.setField('date', "1975-10-15"); + item.setField('publicationTitle', 'Best Publications Place'); + item.setField('journalAbbreviation', 'BPP'); + item.setField('issue', '42'); + item.setField('pages', '321'); + + + itemManyAuthors = createUnsavedDataObject('item', { title: 'Has Many Authors', itemType: 'book' }); + itemManyAuthors.setCreators([ + { firstName: 'First', lastName: 'Author', creatorType: 'author' }, + { firstName: 'Second', lastName: 'Creator', creatorType: 'author' }, + { firstName: 'Third', lastName: 'Person', creatorType: 'author' }, + { firstName: 'Final', lastName: 'Writer', creatorType: 'author' }, + { firstName: 'Some', lastName: 'Editor1', creatorType: 'editor' }, + { firstName: 'Other', lastName: 'ProEditor2', creatorType: 'editor' }, + { firstName: 'Last', lastName: 'SuperbEditor3', creatorType: 'editor' }, + ]); + itemManyAuthors.setField('date', "2000-01-02"); + itemManyAuthors.setField('publisher', 'Awesome House'); + itemManyAuthors.setField('volume', '3'); + + itemPatent = createUnsavedDataObject('item', { title: 'Retroencabulator', itemType: 'patent' }); + itemPatent.setCreators([ + { name: 'AcmeCorp', creatorType: 'inventor' }, + { firstName: 'Wile', lastName: 'E', creatorType: 'contributor' }, + { firstName: 'Road', lastName: 'R', creatorType: 'contributor' }, + ]); + itemPatent.setField('date', '1952-05-10'); + itemPatent.setField('number', 'HBK-8539b'); + itemPatent.setField('assignee', 'Fast FooBar'); + itemIncomplete = createUnsavedDataObject('item', { title: 'Incomplete', itemType: 'preprint' }); + itemBookSection = createUnsavedDataObject('item', { title: 'Book Section', itemType: 'bookSection' }); + itemBookSection.setField('bookTitle', 'Book Title'); + }); + + + it('should strip HTML tags from title', function () { + var htmlItem = createUnsavedDataObject('item', { title: 'Foo Bar Foo


Bar' }); + var str = Zotero.Attachments.getFileBaseNameFromItem(htmlItem, '{{ title }}'); assert.equal(str, 'Foo Bar Foo Bar'); }); + + it('should accept basic formating options', function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, 'FOO{{year}}BAR'), + 'FOO1975BAR' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{firstCreator suffix=" - "}}{{year suffix=" - "}}{{title truncate="50" }}'), + 'Barius and Pixelus - 1975 - Lorem Ipsum' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{year suffix="-"}}{{firstCreator truncate="10" suffix="-"}}{{title truncate="5" }}'), + '1975-Barius and-Lorem' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, 'foo {{year}} bar {{year prefix="++" truncate="2" suffix="++"}}'), + 'foo 1975 bar ++19++' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{firstCreator suffix=" - "}}{{year suffix=" - "}}{{title}}'), + 'Author et al. - 2000 - Has Many Authors' + ); + }); + + it('should offer a range of options for composing creators', function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" }}'), + 'Barius' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" truncate="3" }}'), + 'Bar' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="5" join=" " }}'), + 'Barius Pixelus' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="3" join=" " }}'), + 'Author Creator Person' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ authors }}'), + 'AcmeCorp' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="2" name="family" initialize="family" join=" " initialize-with="" }}'), + 'A C' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ authors max="2" name="family" initialize="family" initialize-with="" }}'), + 'A' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" name="full" initialize="full" name-part-separator="" initialize-with="" }}'), + 'FB' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="3" name="full" initialize="full" name-part-separator="" join=" " initialize-with="" }}'), + 'FA SC TP' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" name="family-given" initialize="given" name-part-separator="" initialize-with="" }}'), + 'BariusF' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="2" name="family-given" initialize="given" join=" " name-part-separator="" initialize-with="" }}'), + 'AuthorF CreatorS' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ editors }}test'), + 'test' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="1" }}'), + 'Editor1' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="5" join=" " }}'), + 'Editor1 ProEditor2 SuperbEditor3' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="2" name="family" initialize="family" join=" " initialize-with="" }}'), + 'E P' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="1" name="full" initialize="full" name-part-separator="" initialize-with="" }}'), + 'SE' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="1" name="family-given" initialize="given" name-part-separator="" initialize-with="" }}'), + 'Editor1S' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="3" name="full" initialize="given" }}'), + 'F. Barius, B. Pixelus' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ creators case="upper" }}'), + 'BARIUS, PIXELUS' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="2" }}'), + 'Author, Creator' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ creators max="3" join=" " name="given" }}'), + 'First Second Third' + ); + }); + + it('should accept case parameter', async function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="upper" }}'), + 'BEST PUBLICATIONS PLACE' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="lower" }}'), + 'best publications place' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="title" }}'), + 'Best Publications Place' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="hyphen" }}'), + 'best-publications-place' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="camel" }}'), + 'bestPublicationsPlace' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="snake" }}'), + 'best_publications_place' + ); + }); + + it('should accept itemType or any other field', function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ itemType localize="true" }}'), + 'Journal Article' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle }}'), + 'Best Publications Place' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ journalAbbreviation }}'), + 'BPP' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ publisher }}'), + 'Awesome House' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ volume }}'), + '3' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ issue }}'), + '42' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, '{{ pages }}'), + '321' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ number }}'), + 'HBK-8539b' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ assignee }}'), + 'Fast FooBar' + ); + }); + + it("should support simple logic in template syntax", function () { + const template = '{{ if itemType == "journalArticle" }}j-{{ publicationTitle case="hyphen" }}{{ elseif itemType == "patent" }}p-{{ number case="hyphen" }}{{ else }}o-{{ title case="hyphen" }}{{ endif }}'; + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, template), 'j-best-publications-place' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemPatent, template), 'p-hbk-8539b' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, template), 'o-has-many-authors' + ); + }); + + it("should skip missing fields", function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemIncomplete, '{{ authors prefix = "a" suffix="-" }}{{ publicationTitle case="hyphen" suffix="-" }}{{ title }}'), + 'Incomplete' + ); + }); + + it("should recognized base-mapped fields", function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemBookSection, '{{ bookTitle case="snake" }}'), + 'book_title' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemBookSection, '{{ publicationTitle case="snake" }}'), + 'book_title' + ); + }); + + it("should convert formatString attachmentRenameFormatString to use template syntax", function () { + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%c - }{%y - }{%t{50}}'), + '{{ firstCreator suffix=" - " }}{{ year suffix=" - " }}{{ title truncate="50" }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{ - %y - }'), + '{{ year prefix=" - " suffix=" - " }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%y{2}00}'), + '{{ year truncate="2" suffix="00" }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%c5 - }'), + '{{ firstCreator suffix="5 - " }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%c-2 - }'), + '{{ firstCreator suffix="-2 - " }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%t5 - }'), + '{{ title suffix="5 - " }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{++%t{10}--}'), + '{{ title truncate="10" prefix="++" suffix="--" }}' + ); + assert.equal( + Zotero.Prefs.convertLegacyAttachmentRenameFormatString('foo{%c}-{%t{10}}-{%y{2}00}'), + 'foo{{ firstCreator }}-{{ title truncate="10" }}-{{ year truncate="2" suffix="00" }}' + ); + }); }); describe("#getBaseDirectoryRelativePath()", function () { diff --git a/test/tests/utilities_internalTest.js b/test/tests/utilities_internalTest.js index 2497fb88c2..f7f0c4d2cd 100644 --- a/test/tests/utilities_internalTest.js +++ b/test/tests/utilities_internalTest.js @@ -549,25 +549,176 @@ describe("Zotero.Utilities.Internal", function () { it("should support variables with attributes", function () { var vars = { v1: '1', - v2: (pars) => pars.a1 + pars.a2 + pars.a3, + v2: pars => `${pars.a1 ?? ''}${pars.a2 ?? ''}${pars.a3 ?? ''}`, v3: () => '', v5: () => 'something', ar1: [], ar2: [1, 2] }; - var template = `{{ v1}}{{v2 a1= 1 a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no{{endif}}{{if v2}}no{{endif}}`; + var template = `{{ v1}}{{v2 a1= "1" a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no1{{endif}}{{if v2}}{{v2}}{{endif}}`; var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); assert.equal(html, '11 23 1,2yes'); }); + it("should support empty string as attribute value and correctly render returned false-ish values", function () { + const vars = { + length: ({ string }) => string.length.toString(), + }; + const template = `"" has a length of {{ length string="" }} and "hello" has a length of {{ length string="hello" }}`; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out, '"" has a length of 0 and "hello" has a length of 5'); + }); + + it("should support functions in comparison statements", function () { + const vars = { + sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(), + fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'), + false: 'false', + twoWords: 'two words', + onlyOne: 'actually == 1' + }; + const template = `{{if {{ sum a="1" b="2" }} == "3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no speak math{{endif}}`; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out, '1 + 2 = 3'); + + const template2 = '{{if false != "false"}}no{{elseif false == "false"}}yes{{else}}no{{endif}}'; + const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars); + assert.equal(out2, 'yes'); + + const template3 = '{{ if twoWords == "two words" }}yes{{else}}no{{endif}}'; + const out3 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template3, vars); + assert.equal(out3, 'yes'); + + const template4 = '{{ if onlyOne == \'actually == 1\' }}yes{{else}}no{{endif}}'; + const out4 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template4, vars); + assert.equal(out4, 'yes'); + + const template5 = '{{ if "3" == {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}'; + const out5 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template5, vars); + assert.equal(out5, 'yes'); + + const template6 = '{{ if {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}'; + const out6 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template6, vars); + assert.equal(out6, 'yes'); + + const template7 = '{{ if {{ twoWords }} }}yes{{else}}no{{endif}}'; + const out7 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template7, vars); + assert.equal(out7, 'yes'); + + const template8 = '{{ if twoWords }}yes{{else}}no{{endif}}'; + const out8 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template8, vars); + assert.equal(out8, 'yes'); + + const template9 = '{{ if missing }}no{{else}}yes{{endif}}'; + const out9 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template9, vars); + assert.equal(out9, 'yes'); + + const template10 = '{{ if {{ missing foo="bar" }} }}no{{else}}yes{{endif}}'; + const out10 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template10, vars); + assert.equal(out10, 'yes'); + + const template11 = '{{ if {{ missing foo="bar" }} == "" }}yes{{else}}no{{endif}}'; + const out11 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template11, vars); + assert.equal(out11, 'yes'); + + const template12 = '{{ if fooBar == "bar" }}yes{{else}}no{{endif}}'; + const out12 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template12, vars); + assert.equal(out12, 'yes'); + + const template13 = '{{ if {{ fooBar }} == "bar" }}yes{{else}}no{{endif}}'; + const out13 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template13, vars); + assert.equal(out13, 'yes'); + + const template14 = `{{if {{ sum a="1" b="2" }}=="3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no{{endif}}`; + const out14 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template14, vars); + assert.equal(out14, '1 + 2 = 3'); + + const template15 = `{{if "two words"==twoWords}}yes{{else}}no{{endif}}`; + const out15 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template15, vars); + assert.equal(out15, 'yes'); + }); + + it("should accept hyphen-case variables and attributes", function () { + const vars = { + fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'), + }; + const template = '{{ foo-bar is-foo="true" }}{{ if {{ foo-bar is-foo="false" }} == "bar" }}{{ foo-bar is-foo="false" }}{{ endif }}'; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out, 'foobar'); + }); + + it("should work with a condition in the middle", function () { + const vars = { + v1: '1', + }; + const template = 'test {{ if v1 == "1" }}yes{{ else }}no{{ endif }} foobar'; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out, 'test yes foobar'); + }); + + it("missing identifiers are evaluted as empty string", function () { + const vars = { + foo: 'foo', + }; + const template = '{{bar}}{{ if foo == "" }}no{{elseif foo}}{{foo}}{{else}}no{{endif}}'; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out, 'foo'); + + const template2 = 'test: {{ if bar == "" }}yes{{else}}no{{endif}}'; + const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars); + assert.equal(out2, 'test: yes'); + }); + + it("should preserve whitespace outside of brackets", function () { + const template = ' starts }} with {{ whitespace {"test"} == \'foobar\' '; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, {}); + assert.equal(out, template); + const vars = { + space: ' ', + spaceFn: () => ' ', + }; + + const whitespace = ' {{if spaceFn}}{{else}} {{endif}}{{space}} {{space-fn}}'; + const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(whitespace, vars); + assert.equal(out2, ' '); + }); + + it("should accept array values in logic statements", function () { + let someTags = ['foo', 'bar']; + const vars = { + tags: ({ join }) => (join ? someTags.join(join) : someTags), + }; + const template = '{{ if tags }}#{{ tags join=" #" }}{{else}}no tags{{endif}}'; + const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out, '#foo #bar'); + + someTags = []; + const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + assert.equal(out2, 'no tags'); + }); + + + it("should throw if function returns anything else than a string (or an array which is always joined into string)", function () { + const vars = { + number: () => 1, + logic: () => true, + array: () => [], + fn: () => 1, + }; + assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ number }}', vars), /Identifier "number" does not evaluate to a string/); + assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ logic }}', vars), /Identifier "logic" does not evaluate to a string/); + assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ if fn }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/); + assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ if {{ fn foo="bar" }} }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/); + }); + 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 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'); }); }); -}) +});